• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sile-typesetter / sile / 11880790858

17 Nov 2024 05:21PM UTC coverage: 65.762% (+9.6%) from 56.187%
11880790858

push

github

web-flow
Merge 7870e0c07 into 7411744d9

4048 of 4057 new or added lines in 6 files covered. (99.78%)

105 existing lines in 4 files now uncovered.

12986 of 19747 relevant lines covered (65.76%)

3525.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.79
/packages/math/base-elements.lua
1
local nodefactory = require("types.node")
12✔
2
local hb = require("justenoughharfbuzz")
12✔
3
local ot = require("core.opentype-parser")
12✔
4
local atoms = require("packages.math.atoms")
12✔
5
local mathvariants = require("packages.math.unicode-mathvariants")
12✔
6
local convertMathVariantScript = mathvariants.convertMathVariantScript
12✔
7

8
local elements = {}
12✔
9

10
local mathMode = {
12✔
11
   display = 0,
12
   displayCramped = 1,
13
   text = 2,
14
   textCramped = 3,
15
   script = 4,
16
   scriptCramped = 5,
17
   scriptScript = 6,
18
   scriptScriptCramped = 7,
19
}
20

21
local function isDisplayMode (mode)
22
   return mode <= 1
2,198✔
23
end
24

25
local function isCrampedMode (mode)
26
   return mode % 2 == 1
63✔
27
end
28

29
local function isScriptMode (mode)
30
   return mode == mathMode.script or mode == mathMode.scriptCramped
3,021✔
31
end
32

33
local function isScriptScriptMode (mode)
34
   return mode == mathMode.scriptScript or mode == mathMode.scriptScriptCramped
2,492✔
35
end
36

37
local mathCache = {}
12✔
38

39
local function retrieveMathTable (font)
40
   local key = SILE.font._key(font)
4,149✔
41
   if not mathCache[key] then
4,149✔
42
      SU.debug("math", "Loading math font", key)
36✔
43
      local face = SILE.font.cache(font, SILE.shaper.getFace)
36✔
44
      if not face then
36✔
45
         SU.error("Could not find requested font " .. font .. " or any suitable substitutes")
×
46
      end
47
      local fontHasMathTable, rawMathTable, mathTableParsable, mathTable
48
      fontHasMathTable, rawMathTable = pcall(hb.get_table, face, "MATH")
36✔
49
      if fontHasMathTable then
36✔
50
         mathTableParsable, mathTable = pcall(ot.parseMath, rawMathTable)
72✔
51
      end
52
      if not fontHasMathTable or not mathTableParsable then
36✔
53
         SU.error(([[
×
54
            You must use a math font for math rendering
55

56
            The math table in '%s' could not be %s.
57
         ]]):format(face.filename, fontHasMathTable and "parsed" or "loaded"))
×
58
      end
59
      local upem = ot.parseHead(hb.get_table(face, "head")).unitsPerEm
72✔
60
      local constants = {}
36✔
61
      for k, v in pairs(mathTable.mathConstants) do
2,052✔
62
         if type(v) == "table" then
2,016✔
63
            v = v.value
1,836✔
64
         end
65
         if k:sub(-9) == "ScaleDown" then
4,032✔
66
            constants[k] = v / 100
72✔
67
         else
68
            constants[k] = v * font.size / upem
1,944✔
69
         end
70
      end
71
      local italicsCorrection = {}
36✔
72
      for k, v in pairs(mathTable.mathItalicsCorrection) do
15,264✔
73
         italicsCorrection[k] = v.value * font.size / upem
15,228✔
74
      end
75
      mathCache[key] = {
36✔
76
         constants = constants,
36✔
77
         italicsCorrection = italicsCorrection,
36✔
78
         mathVariants = mathTable.mathVariants,
36✔
79
         unitsPerEm = upem,
36✔
80
      }
36✔
81
   end
82
   return mathCache[key]
4,149✔
83
end
84

85
-- Style transition functions for superscript and subscript
86
local function getSuperscriptMode (mode)
87
   -- D, T -> S
88
   if mode == mathMode.display or mode == mathMode.text then
74✔
89
      return mathMode.script
37✔
90
   -- D', T' -> S'
91
   elseif mode == mathMode.displayCramped or mode == mathMode.textCramped then
37✔
92
      return mathMode.scriptCramped
31✔
93
   -- S, SS -> SS
94
   elseif mode == mathMode.script or mode == mathMode.scriptScript then
6✔
95
      return mathMode.scriptScript
3✔
96
   -- S', SS' -> SS'
97
   else
98
      return mathMode.scriptScriptCramped
3✔
99
   end
100
end
101
local function getSubscriptMode (mode)
102
   -- D, T, D', T' -> S'
103
   if
104
      mode == mathMode.display
106✔
105
      or mode == mathMode.text
60✔
106
      or mode == mathMode.displayCramped
50✔
107
      or mode == mathMode.textCramped
50✔
108
   then
109
      return mathMode.scriptCramped
87✔
110
   -- S, SS, S', SS' -> SS'
111
   else
112
      return mathMode.scriptScriptCramped
19✔
113
   end
114
end
115

116
-- Style transition functions for fraction (numerator and denominator)
117
local function getNumeratorMode (mode)
118
   -- D -> T
119
   if mode == mathMode.display then
44✔
120
      return mathMode.text
21✔
121
   -- D' -> T'
122
   elseif mode == mathMode.displayCramped then
23✔
123
      return mathMode.textCramped
×
124
   -- T -> S
125
   elseif mode == mathMode.text then
23✔
126
      return mathMode.script
×
127
   -- T' -> S'
128
   elseif mode == mathMode.textCramped then
23✔
129
      return mathMode.scriptCramped
13✔
130
   -- S, SS -> SS
131
   elseif mode == mathMode.script or mode == mathMode.scriptScript then
10✔
132
      return mathMode.scriptScript
×
133
   -- S', SS' -> SS'
134
   else
135
      return mathMode.scriptScriptCramped
10✔
136
   end
137
end
138
local function getDenominatorMode (mode)
139
   -- D, D' -> T'
140
   if mode == mathMode.display or mode == mathMode.displayCramped then
44✔
141
      return mathMode.textCramped
21✔
142
   -- T, T' -> S'
143
   elseif mode == mathMode.text or mode == mathMode.textCramped then
23✔
144
      return mathMode.scriptCramped
13✔
145
   -- S, SS, S', SS' -> SS'
146
   else
147
      return mathMode.scriptScriptCramped
10✔
148
   end
149
end
150

151
local function getRightMostGlyphId (node)
152
   while node and node:is_a(elements.stackbox) and node.direction == "H" do
306✔
153
      node = node.children[#node.children]
15✔
154
   end
155
   if node and node:is_a(elements.text) then
276✔
156
      return node.value.glyphString[#node.value.glyphString]
136✔
157
   else
158
      return 0
4✔
159
   end
160
end
161

162
-- Compares two SILE.types.length, without considering shrink or stretch values, and
163
-- returns the biggest.
164
local function maxLength (...)
165
   local arg = { ... }
2,812✔
166
   local m
167
   for i, v in ipairs(arg) do
8,701✔
168
      if i == 1 then
5,889✔
169
         m = v
2,812✔
170
      else
171
         if v.length:tonumber() > m.length:tonumber() then
9,231✔
172
            m = v
665✔
173
         end
174
      end
175
   end
176
   return m
2,812✔
177
end
178

179
local function scaleWidth (length, line)
180
   local number = length.length
1,237✔
181
   if line.ratio and line.ratio < 0 and length.shrink:tonumber() > 0 then
1,237✔
182
      number = number + length.shrink * line.ratio
×
183
   elseif line.ratio and line.ratio > 0 and length.stretch:tonumber() > 0 then
2,474✔
184
      number = number + length.stretch * line.ratio
1,410✔
185
   end
186
   return number
1,237✔
187
end
188

189
-- math box, box with a horizontal shift value and could contain zero or more
190
-- mbox'es (or its child classes) the entire math environment itself is
191
-- a top-level mbox.
192
-- Typesetting of mbox evolves four steps:
193
--   1. Determine the mode for each mbox according to their parent.
194
--   2. Shape the mbox hierarchy from leaf to top. Get the shape and relative position.
195
--   3. Convert mbox into _nnode's to put in SILE's typesetting framework
196
elements.mbox = pl.class(nodefactory.hbox)
24✔
197
elements.mbox._type = "Mbox"
12✔
198

199
function elements.mbox:__tostring ()
24✔
200
   return self._type
×
201
end
202

203
function elements.mbox:_init ()
24✔
204
   nodefactory.hbox._init(self)
2,103✔
205
   self.font = {}
2,103✔
206
   self.children = {} -- The child nodes
2,103✔
207
   self.relX = SILE.types.length(0) -- x position relative to its parent box
4,206✔
208
   self.relY = SILE.types.length(0) -- y position relative to its parent box
4,206✔
209
   self.value = {}
2,103✔
210
   self.mode = mathMode.display
2,103✔
211
   self.atom = atoms.types.ord
2,103✔
212
   local font = {
2,103✔
213
      family = SILE.settings:get("math.font.family"),
4,206✔
214
      size = SILE.settings:get("math.font.size"),
4,206✔
215
      style = SILE.settings:get("math.font.style"),
4,206✔
216
      weight = SILE.settings:get("math.font.weight"),
4,206✔
217
   }
218
   local filename = SILE.settings:get("math.font.filename")
2,103✔
219
   if filename and filename ~= "" then
2,103✔
220
      font.filename = filename
×
221
   end
222
   self.font = SILE.font.loadDefaults(font)
4,206✔
223
end
224

225
function elements.mbox.styleChildren (_)
24✔
226
   SU.error("styleChildren is a virtual function that need to be overridden by its child classes")
×
227
end
228

229
function elements.mbox.shape (_, _, _)
24✔
230
   SU.error("shape is a virtual function that need to be overridden by its child classes")
×
231
end
232

233
function elements.mbox.output (_, _, _, _)
24✔
234
   SU.error("output is a virtual function that need to be overridden by its child classes")
×
235
end
236

237
function elements.mbox:getMathMetrics ()
24✔
238
   return retrieveMathTable(self.font)
4,149✔
239
end
240

241
function elements.mbox:getScaleDown ()
24✔
242
   local constants = self:getMathMetrics().constants
5,298✔
243
   local scaleDown
244
   if isScriptMode(self.mode) then
5,298✔
245
      scaleDown = constants.scriptPercentScaleDown
453✔
246
   elseif isScriptScriptMode(self.mode) then
4,392✔
247
      scaleDown = constants.scriptScriptPercentScaleDown
181✔
248
   else
249
      scaleDown = 1
2,015✔
250
   end
251
   return scaleDown
2,649✔
252
end
253

254
-- Determine the mode of its descendants
255
function elements.mbox:styleDescendants ()
24✔
256
   self:styleChildren()
2,115✔
257
   for _, n in ipairs(self.children) do
4,158✔
258
      if n then
2,043✔
259
         n:styleDescendants()
2,043✔
260
      end
261
   end
262
end
263

264
-- shapeTree shapes the mbox and all its descendants in a recursive fashion
265
-- The inner-most leaf nodes determine their shape first, and then propagate to their parents
266
-- During the process, each node will determine its size by (width, height, depth)
267
-- and (relX, relY) which the relative position to its parent
268
function elements.mbox:shapeTree ()
24✔
269
   for _, n in ipairs(self.children) do
4,158✔
270
      if n then
2,043✔
271
         n:shapeTree()
2,043✔
272
      end
273
   end
274
   self:shape()
2,115✔
275
end
276

277
-- Output the node and all its descendants
278
function elements.mbox:outputTree (x, y, line)
24✔
279
   self:output(x, y, line)
2,115✔
280
   local debug = SILE.settings:get("math.debug.boxes")
2,115✔
281
   if debug and not (self:is_a(elements.space)) then
2,115✔
282
      SILE.outputter:setCursor(scaleWidth(x, line), y.length)
×
283
      SILE.outputter:debugHbox({ height = self.height.length, depth = self.depth.length }, scaleWidth(self.width, line))
×
284
   end
285
   for _, n in ipairs(self.children) do
4,158✔
286
      if n then
2,043✔
287
         n:outputTree(x + n.relX, y + n.relY, line)
6,129✔
288
      end
289
   end
290
end
291

292
local spaceKind = {
12✔
293
   thin = "thin",
294
   med = "med",
295
   thick = "thick",
296
}
297

298
-- Spacing table indexed by left atom, as in TeXbook p. 170.
299
-- Notes
300
--  - the "notScript" key is used to prevent spaces in script and scriptscript modes
301
--    (= parenthesized non-zero value in The TeXbook's table).
302
--  - Cases commented are as expected, just listed for clarity and completeness.
303
--    (= no space i.e. 0 in in The TeXbook's table)
304
--  - Cases marked as impossible are not expected to happen (= stars in the TeXbook):
305
--    "... such cases never arise, because binary atoms must be preceded and followed
306
--    by atoms compatible with the nature of binary operations."
307
--    This must be understood with the context explained onp. 133:
308
--     "... binary operations are treated as ordinary symbols if they don’t occur
309
--     between two quantities that they can operate on." (a rule which notably helps
310
--     addressing binary atoms used as unary operators.)
311
local spacingRules = {
12✔
312
   [atoms.types.ord] = {
12✔
313
      -- [atoms.types.ord] = nil
314
      [atoms.types.op] = { spaceKind.thin },
12✔
315
      [atoms.types.bin] = { spaceKind.med, notScript = true },
12✔
316
      [atoms.types.rel] = { spaceKind.thick, notScript = true },
12✔
317
      -- [atoms.types.open] = nil
318
      -- [atoms.types.close] = nil
319
      -- [atoms.types.punct] = nil
320
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
12✔
321
   },
12✔
322
   [atoms.types.op] = {
12✔
323
      [atoms.types.ord] = { spaceKind.thin },
12✔
324
      [atoms.types.op] = { spaceKind.thin },
12✔
325
      [atoms.types.bin] = { impossible = true },
12✔
326
      [atoms.types.rel] = { spaceKind.thick, notScript = true },
12✔
327
      -- [atoms.types.open] = nil
328
      -- [atoms.types.close] = nil
329
      -- [atoms.types.punct] = nil
330
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
12✔
331
   },
12✔
332
   [atoms.types.bin] = {
12✔
333
      [atoms.types.ord] = { spaceKind.med, notScript = true },
12✔
334
      [atoms.types.op] = { spaceKind.med, notScript = true },
12✔
335
      [atoms.types.bin] = { impossible = true },
12✔
336
      [atoms.types.rel] = { impossible = true },
12✔
337
      [atoms.types.open] = { spaceKind.med, notScript = true },
12✔
338
      [atoms.types.close] = { impossible = true },
12✔
339
      [atoms.types.punct] = { impossible = true },
12✔
340
      [atoms.types.inner] = { spaceKind.med, notScript = true },
12✔
341
   },
12✔
342
   [atoms.types.rel] = {
12✔
343
      [atoms.types.ord] = { spaceKind.thick, notScript = true },
12✔
344
      [atoms.types.op] = { spaceKind.thick, notScript = true },
12✔
345
      [atoms.types.bin] = { impossible = true },
12✔
346
      -- [atoms.types.rel] = nil
347
      [atoms.types.open] = { spaceKind.thick, notScript = true },
12✔
348
      -- [atoms.types.close] = nil
349
      -- [atoms.types.punct] = nil
350
      [atoms.types.inner] = { spaceKind.thick, notScript = true },
12✔
351
   },
12✔
352
   [atoms.types.open] = {
12✔
353
      -- [atoms.types.ord] = nil
354
      -- [atoms.types.op] = nil
355
      [atoms.types.bin] = { impossible = true },
12✔
356
      -- [atoms.types.rel] = nil
357
      -- [atoms.types.open] = nil
358
      -- [atoms.types.close] = nil
359
      -- [atoms.types.punct] = nil
360
      -- [atoms.types.inner] = nil
361
   },
12✔
362
   [atoms.types.close] = {
12✔
363
      -- [atoms.types.ord] = nil
364
      [atoms.types.op] = { spaceKind.thin },
12✔
365
      [atoms.types.bin] = { spaceKind.med, notScript = true },
12✔
366
      [atoms.types.rel] = { spaceKind.thick, notScript = true },
12✔
367
      -- [atoms.types.open] = nil
368
      -- [atoms.types.close] = nil
369
      -- [atoms.types.punct] = nil
370
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
12✔
371
   },
12✔
372
   [atoms.types.punct] = {
12✔
373
      [atoms.types.ord] = { spaceKind.thin, notScript = true },
12✔
374
      [atoms.types.op] = { spaceKind.thin, notScript = true },
12✔
375
      [atoms.types.bin] = { impossible = true },
12✔
376
      [atoms.types.rel] = { spaceKind.thin, notScript = true },
12✔
377
      [atoms.types.open] = { spaceKind.thin, notScript = true },
12✔
378
      [atoms.types.close] = { spaceKind.thin, notScript = true },
12✔
379
      [atoms.types.punct] = { spaceKind.thin, notScript = true },
12✔
380
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
12✔
381
   },
12✔
382
   [atoms.types.inner] = {
12✔
383
      [atoms.types.ord] = { spaceKind.thin, notScript = true },
12✔
384
      [atoms.types.op] = { spaceKind.thin },
12✔
385
      [atoms.types.bin] = { spaceKind.med, notScript = true },
12✔
386
      [atoms.types.rel] = { spaceKind.thick, notScript = true },
12✔
387
      [atoms.types.open] = { spaceKind.thin, notScript = true },
12✔
388
      [atoms.types.punct] = { spaceKind.thin, notScript = true },
12✔
389
      -- [atoms.types.close] = nil
390
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
12✔
391
   },
12✔
392
}
393

394
-- _stackbox stacks its content one, either horizontally or vertically
395
elements.stackbox = pl.class(elements.mbox)
24✔
396
elements.stackbox._type = "Stackbox"
12✔
397

398
function elements.stackbox:__tostring ()
24✔
UNCOV
399
   local result = self.direction .. "Box("
×
400
   for i, n in ipairs(self.children) do
×
401
      result = result .. (i == 1 and "" or ", ") .. tostring(n)
×
402
   end
UNCOV
403
   result = result .. ")"
×
404
   return result
×
405
end
406

407
function elements.stackbox:_init (direction, children)
24✔
408
   elements.mbox._init(self)
469✔
409
   if not (direction == "H" or direction == "V") then
469✔
UNCOV
410
      SU.error("Wrong direction '" .. direction .. "'; should be H or V")
×
411
   end
412
   self.direction = direction
469✔
413
   self.children = children
469✔
414
end
415

416
function elements.stackbox:styleChildren ()
24✔
417
   for _, n in ipairs(self.children) do
1,738✔
418
      n.mode = self.mode
1,269✔
419
   end
420
   if self.direction == "H" then
469✔
421
      -- Insert spaces according to the atom type, following Knuth's guidelines
422
      -- in The TeXbook, p. 170 (amended with p. 133 for binary operators)
423
      -- FIXME: This implementation is not using the atom form and the MathML logic (lspace/rspace).
424
      -- (This is notably unsatisfactory for <mphantom> elements)
425
      local spaces = {}
469✔
426
      if #self.children >= 1 then
469✔
427
         -- An interpretation of the TeXbook p. 133 for binary operator exceptions:
428
         -- A binary operator at the beginning of the expression is treated as an ordinary atom
429
         -- (so as to be considered as a unary operator, without more context).
430
         local v = self.children[1]
463✔
431
         if v.atom == atoms.types.bin then
463✔
NEW
432
            v.atom = atoms.types.ord
×
433
         end
434
      end
435
      for i = 1, #self.children - 1 do
1,275✔
436
         local v = self.children[i]
806✔
437
         local v2 = self.children[i + 1]
806✔
438
         -- Handle re-wrapped paired open/close symbols
439
         v = v.is_paired and v.children[#v.children] or v
806✔
440
         v2 = v2.is_paired and v2.children[1] or v2
806✔
441
         if spacingRules[v.atom] and spacingRules[v.atom][v2.atom] then
806✔
442
            local rule = spacingRules[v.atom][v2.atom]
421✔
443
            if rule.impossible then
421✔
444
               -- Another interpretation of the TeXbook p. 133 for binary operator exceptions:
445
               if v2.atom == atoms.types.bin then
7✔
446
                  -- If a binary atom follows an atom that is not compatible with it, make it an ordinary.
447
                  -- (so as to be conidered as a unary operator).
448
                  -- Typical case: "a = -b" (ord rel bin ord), "a + -b" (ord bin bin ord)
449
                  v2.atom = atoms.types.ord
3✔
450
               else
451
                  -- If a binary atom precedes an atom that is not compatible with it, make it an ordinary.
452
                  -- Quite unusual case (bin, rel/close/punct) unlikely to happen in practice.
453
                  -- (Not seen in 80+ test formulas)
454
                  -- We might address it a bit late here, the preceding atom has already based its spacing
455
                  -- on the binary atom... but this might not be a big deal.
456
                  -- (i.e. rather than add an extra look-ahead just for this case).
457
                  -- Artificial example: "a + = b" (ord bin rel ord)
458
                  v.atom = atoms.types.ord
4✔
459
               end
460
               rule = spacingRules[v.atom][v2.atom]
7✔
461
               if rule and rule.impossible then
7✔
462
                  -- Should not occur if we did our table based on the TeXbook correctly?
463
                  -- We can still handle it by ignoring the rule: no spacing sounds logical.
464
                  -- But let's have a warning so it might be investigated further.
NEW
465
                  SU.warn("Impossible spacing rule for (" .. v.atom .. ", " .. v2.atom .. "), please report this issue")
×
NEW
466
                  rule = nil
×
467
               end
468
            end
469
            if rule and not (rule.notScript and (isScriptMode(self.mode) or isScriptScriptMode(self.mode))) then
1,089✔
470
               spaces[i + 1] = rule[1]
306✔
471
            end
472
         end
473
      end
474
      local spaceIdx = {}
469✔
475
      for i, _ in pairs(spaces) do
775✔
476
         table.insert(spaceIdx, i)
306✔
477
      end
478
      table.sort(spaceIdx, function (a, b)
938✔
479
         return a > b
412✔
480
      end)
481
      for _, idx in ipairs(spaceIdx) do
775✔
482
         local hsp = elements.space(spaces[idx], 0, 0)
306✔
483
         table.insert(self.children, idx, hsp)
306✔
484
      end
485
   end
486
end
487

488
function elements.stackbox:shape ()
24✔
489
   -- For a horizontal stackbox (i.e. mrow):
490
   -- 1. set self.height and self.depth to max element height & depth
491
   -- 2. handle stretchy operators
492
   -- 3. set self.width
493
   -- For a vertical stackbox:
494
   -- 1. set self.width to max element width
495
   -- 2. set self.height
496
   -- And finally set children's relative coordinates
497
   self.height = SILE.types.length(0)
938✔
498
   self.depth = SILE.types.length(0)
938✔
499
   if self.direction == "H" then
469✔
500
      for i, n in ipairs(self.children) do
2,044✔
501
         n.relY = SILE.types.length(0)
3,150✔
502
         self.height = i == 1 and n.height or maxLength(self.height, n.height)
2,687✔
503
         self.depth = i == 1 and n.depth or maxLength(self.depth, n.depth)
2,687✔
504
      end
505
      -- Handle stretchy operators
506
      for _, elt in ipairs(self.children) do
2,044✔
507
         if elt:is_a(elements.text) and elt.kind == "operator" and SU.boolean(elt.stretchy, false) then
3,513✔
508
            elt:_vertStretchyReshape(self.depth, self.height)
95✔
509
         end
510
      end
511
      -- Set self.width
512
      self.width = SILE.types.length(0)
938✔
513
      for i, n in ipairs(self.children) do
2,044✔
514
         n.relX = self.width
1,575✔
515
         self.width = i == 1 and n.width or self.width + n.width
2,687✔
516
      end
517
   else -- self.direction == "V"
UNCOV
518
      for i, n in ipairs(self.children) do
×
519
         n.relX = SILE.types.length(0)
×
520
         self.width = i == 1 and n.width or maxLength(self.width, n.width)
×
521
      end
522
      -- Set self.height and self.depth
UNCOV
523
      for i, n in ipairs(self.children) do
×
524
         self.depth = i == 1 and n.depth or self.depth + n.depth
×
525
      end
UNCOV
526
      for i = 1, #self.children do
×
527
         local n = self.children[i]
×
528
         if i == 1 then
×
529
            self.height = n.height
×
530
            self.depth = n.depth
×
531
         elseif i > 1 then
×
532
            n.relY = self.children[i - 1].relY + self.children[i - 1].depth + n.height
×
533
            self.depth = self.depth + n.height + n.depth
×
534
         end
535
      end
536
   end
537
end
538

539
-- Despite of its name, this function actually output the whole tree of nodes recursively.
540
function elements.stackbox:outputYourself (typesetter, line)
24✔
541
   local mathX = typesetter.frame.state.cursorX
72✔
542
   local mathY = typesetter.frame.state.cursorY
72✔
543
   self:outputTree(self.relX + mathX, self.relY + mathY, line)
216✔
544
   typesetter.frame:advanceWritingDirection(scaleWidth(self.width, line))
144✔
545
end
546

547
function elements.stackbox.output (_, _, _, _) end
481✔
548

549
elements.phantom = pl.class(elements.stackbox) -- inherit from stackbox
24✔
550
elements.phantom._type = "Phantom"
12✔
551

552
function elements.phantom:_init (children)
24✔
553
   -- MathML core 3.3.7:
554
   -- "Its layout algorithm is the same as the mrow element".
555
   -- Also not the MathML states that <mphantom> is sort of legacy, "implemented
556
   -- for compatibility with full MathML. Authors whose only target is MathML
557
   -- Core are encouraged to use CSS for styling."
558
   -- The thing is that we don't have CSS in SILE, so supporting <mphantom> is
559
   -- a must.
UNCOV
560
   elements.stackbox._init(self, "H", children)
×
561
end
562

563
function elements.phantom:output (_, _, _)
24✔
564
   -- Note the trick here: when the tree is rendered, the node's output
565
   -- function is invoked, then all its children's output functions.
566
   -- So we just cancel the list of children here, before it's rendered.
UNCOV
567
   self.children = {}
×
568
end
569

570
elements.subscript = pl.class(elements.mbox)
24✔
571
elements.subscript._type = "Subscript"
12✔
572

573
function elements.subscript:__tostring ()
24✔
UNCOV
574
   return (self.sub and "Subscript" or "Superscript")
×
575
      .. "("
×
576
      .. tostring(self.base)
×
577
      .. ", "
×
578
      .. tostring(self.sub or self.super)
×
579
      .. ")"
×
580
end
581

582
function elements.subscript:_init (base, sub, sup)
24✔
583
   elements.mbox._init(self)
110✔
584
   self.base = base
110✔
585
   self.sub = sub
110✔
586
   self.sup = sup
110✔
587
   if self.base then
110✔
588
      table.insert(self.children, self.base)
110✔
589
   end
590
   if self.sub then
110✔
591
      table.insert(self.children, self.sub)
76✔
592
   end
593
   if self.sup then
110✔
594
      table.insert(self.children, self.sup)
48✔
595
   end
596
   self.atom = self.base.atom
110✔
597
end
598

599
function elements.subscript:styleChildren ()
24✔
600
   if self.base then
110✔
601
      self.base.mode = self.mode
110✔
602
   end
603
   if self.sub then
110✔
604
      self.sub.mode = getSubscriptMode(self.mode)
152✔
605
   end
606
   if self.sup then
110✔
607
      self.sup.mode = getSuperscriptMode(self.mode)
96✔
608
   end
609
end
610

611
function elements.subscript:calculateItalicsCorrection ()
24✔
612
   local lastGid = getRightMostGlyphId(self.base)
110✔
613
   if lastGid > 0 then
110✔
614
      local mathMetrics = self:getMathMetrics()
106✔
615
      if mathMetrics.italicsCorrection[lastGid] then
106✔
616
         return mathMetrics.italicsCorrection[lastGid]
74✔
617
      end
618
   end
619
   return 0
36✔
620
end
621

622
function elements.subscript:shape ()
24✔
623
   local mathMetrics = self:getMathMetrics()
125✔
624
   local constants = mathMetrics.constants
125✔
625
   local scaleDown = self:getScaleDown()
125✔
626
   if self.base then
125✔
627
      self.base.relX = SILE.types.length(0)
250✔
628
      self.base.relY = SILE.types.length(0)
250✔
629
      -- Use widthForSubscript of base, if available
630
      self.width = self.base.widthForSubscript or self.base.width
125✔
631
   else
UNCOV
632
      self.width = SILE.types.length(0)
×
633
   end
634
   local itCorr = self:calculateItalicsCorrection() * scaleDown
250✔
635
   local isBaseSymbol = not self.base or self.base:is_a(elements.terminal)
250✔
636
   local isBaseLargeOp = SU.boolean(self.base and self.base.largeop, false)
125✔
637
   local subShift
638
   local supShift
639
   if self.sub then
125✔
640
      if self.isUnderOver or isBaseLargeOp then
91✔
641
         -- Ad hoc correction on integral limits, following LuaTeX's
642
         -- `\mathnolimitsmode=0` (see LuaTeX Reference Manual).
643
         subShift = -itCorr
33✔
644
      else
645
         subShift = 0
58✔
646
      end
647
      self.sub.relX = self.width + subShift
182✔
648
      self.sub.relY = SILE.types.length(
182✔
649
         math.max(
182✔
650
            constants.subscriptShiftDown * scaleDown,
91✔
651
            isBaseSymbol and 0 -- TeX (σ19) is more finicky than MathML Core
91✔
652
               or (self.base.depth + constants.subscriptBaselineDropMin * scaleDown):tonumber(),
95✔
653
            (self.sub.height - constants.subscriptTopMax * scaleDown):tonumber()
182✔
654
         )
655
      )
91✔
656
      if self:is_a(elements.underOver) or self:is_a(elements.stackbox) or isBaseLargeOp then
258✔
657
         self.sub.relY = maxLength(self.sub.relY, self.base.depth + constants.subscriptBaselineDropMin * scaleDown)
99✔
658
      end
659
   end
660
   if self.sup then
125✔
661
      if self.isUnderOver or isBaseLargeOp then
63✔
662
         -- Ad hoc correction on integral limits, following LuaTeX's
663
         -- `\mathnolimitsmode=0` (see LuaTeX Reference Manual).
664
         supShift = 0
21✔
665
      else
666
         supShift = itCorr
42✔
667
      end
668
      self.sup.relX = self.width + supShift
126✔
669
      self.sup.relY = SILE.types.length(
126✔
670
         math.max(
126✔
671
            isCrampedMode(self.mode) and constants.superscriptShiftUpCramped * scaleDown
126✔
672
               or constants.superscriptShiftUp * scaleDown,
63✔
673
            isBaseSymbol and 0 -- TeX (σ18) is more finicky than MathML Core
63✔
674
               or (self.base.height - constants.superscriptBaselineDropMax * scaleDown):tonumber(),
75✔
675
            (self.sup.depth + constants.superscriptBottomMin * scaleDown):tonumber()
126✔
676
         )
677
      ) * -1
126✔
678
      if self:is_a(elements.underOver) or self:is_a(elements.stackbox) or isBaseLargeOp then
174✔
679
         self.sup.relY = maxLength(
42✔
680
            (0 - self.sup.relY),
21✔
681
            self.base.height - constants.superscriptBaselineDropMax * scaleDown
21✔
682
         ) * -1
42✔
683
      end
684
   end
685
   if self.sub and self.sup then
125✔
686
      local gap = self.sub.relY - self.sub.height - self.sup.relY - self.sup.depth
87✔
687
      if gap.length:tonumber() < constants.subSuperscriptGapMin * scaleDown then
58✔
688
         -- The following adjustment comes directly from Appendix G of he
689
         -- TeXbook (rule 18e).
690
         self.sub.relY = constants.subSuperscriptGapMin * scaleDown + self.sub.height + self.sup.relY + self.sup.depth
32✔
691
         local psi = constants.superscriptBottomMaxWithSubscript * scaleDown + self.sup.relY + self.sup.depth
16✔
692
         if psi:tonumber() > 0 then
16✔
693
            self.sup.relY = self.sup.relY - psi
16✔
694
            self.sub.relY = self.sub.relY - psi
16✔
695
         end
696
      end
697
   end
UNCOV
698
   self.width = self.width
×
699
      + maxLength(
250✔
700
         self.sub and self.sub.width + subShift or SILE.types.length(0),
216✔
701
         self.sup and self.sup.width + supShift or SILE.types.length(0)
188✔
702
      )
125✔
703
      + constants.spaceAfterScript * scaleDown
250✔
704
   self.height = maxLength(
250✔
705
      self.base and self.base.height or SILE.types.length(0),
125✔
706
      self.sub and (self.sub.height - self.sub.relY) or SILE.types.length(0),
216✔
707
      self.sup and (self.sup.height - self.sup.relY) or SILE.types.length(0)
188✔
708
   )
125✔
709
   self.depth = maxLength(
250✔
710
      self.base and self.base.depth or SILE.types.length(0),
125✔
711
      self.sub and (self.sub.depth + self.sub.relY) or SILE.types.length(0),
216✔
712
      self.sup and (self.sup.depth + self.sup.relY) or SILE.types.length(0)
188✔
713
   )
125✔
714
end
715

716
function elements.subscript.output (_, _, _, _) end
122✔
717

718
elements.underOver = pl.class(elements.subscript)
24✔
719
elements.underOver._type = "UnderOver"
12✔
720

721
function elements.underOver:__tostring ()
24✔
UNCOV
722
   return self._type .. "(" .. tostring(self.base) .. ", " .. tostring(self.sub) .. ", " .. tostring(self.sup) .. ")"
×
723
end
724

725
local function isNotEmpty (element)
726
   -- The MathML test suite uses <munderover> with an empty <mrow> as sub/sup.
727
   -- I don't know why they didn't use a <munder> or <mover> instead...
728
   -- But the expectation is to behave as if the empty element was not there,
729
   -- so that height and depth are not affected by the axis height.
730
   -- See notably:
731
   --   MathML3 "complex1" torture test: Maxwell's Equations (vectors in fractions)
732
   return element and (element:is_a(elements.terminal) or #element.children > 0)
116✔
733
end
734

735
function elements.underOver:_init (base, sub, sup)
24✔
736
   elements.mbox._init(self)
30✔
737
   self.atom = base.atom
30✔
738
   self.base = base
30✔
739
   self.sub = isNotEmpty(sub) and sub or nil
60✔
740
   self.sup = isNotEmpty(sup) and sup or nil
60✔
741
   if self.sup then
30✔
742
      table.insert(self.children, self.sup)
26✔
743
   end
744
   if self.base then
30✔
745
      table.insert(self.children, self.base)
30✔
746
   end
747
   if self.sub then
30✔
748
      table.insert(self.children, self.sub)
30✔
749
   end
750
end
751

752
function elements.underOver:styleChildren ()
24✔
753
   if self.base then
30✔
754
      self.base.mode = self.mode
30✔
755
   end
756
   if self.sub then
30✔
757
      self.sub.mode = getSubscriptMode(self.mode)
60✔
758
   end
759
   if self.sup then
30✔
760
      self.sup.mode = getSuperscriptMode(self.mode)
52✔
761
   end
762
end
763

764
function elements.underOver:_stretchyReshapeToBase (part)
24✔
765
   -- FIXME: Big leap of faith here.
766
   -- MathML Core only mentions stretching along the inline axis in 3.4.2.2,
767
   -- i.e. under the section on <mover>, <munder>, <munderover>.
768
   -- So we are "somewhat" good here, but... the algorithm is totally unclear
769
   -- to me and seems to imply a lot of recursion and reshaping.
770
   -- The implementation below is NOT general and only works for the cases
771
   -- I checked:
772
   --   Mozilla MathML tests: braces in f19, f22
773
   --   Personal tests: vectors in d19, d22, d23
774
   --   Joe Javawaski's tests: braces in 8a, 8b
775
   --   MathML3 "complex1" torture test: Maxwell's Equations (vectors in fractions)
776
   if #part.children == 0 then
26✔
777
      local elt = part
13✔
778
      if elt:is_a(elements.text) and elt.kind == "operator" and SU.boolean(elt.stretchy, false) then
37✔
779
         elt:_horizStretchyReshape(self.base.width)
×
780
      end
781
   elseif part:is_a(elements.underOver) then
26✔
782
      -- Big assumption here: only considering one level of stacked under/over.
UNCOV
783
      local hasStretched = false
×
784
      for _, elt in ipairs(part.children) do
×
NEW
785
         if elt:is_a(elements.text) and elt.kind == "operator" and SU.boolean(elt.stretchy, false) then
×
786
            local stretched = elt:_horizStretchyReshape(self.base.width)
×
787
            if stretched then
×
788
               hasStretched = true
×
789
            end
790
         end
791
      end
UNCOV
792
      if hasStretched then
×
793
         -- We need to re-calculate the shape so positions are re-calculated on each
794
         -- of its own parts.
795
         -- (Added after seeing that Mozilla test f19 was not rendering correctly.)
UNCOV
796
         part:shape()
×
797
      end
798
   end
799
end
800

801
function elements.underOver:shape ()
24✔
802
   local isMovableLimits = SU.boolean(self.base and self.base.movablelimits, false)
30✔
803
   if not (self.mode == mathMode.display or self.mode == mathMode.displayCramped) and isMovableLimits then
30✔
804
      -- When the base is a movable limit, the under/over scripts are not placed under/over the base,
805
      -- but other to the right of it, when display mode is not used.
806
      -- Notable effects:
807
      --   Mozilla MathML test 19 (on "k times" > overbrace > base)
808
      --   Maxwell's Equations in MathML3 Test Suite "complex1" (on the vectors in fractions)
809
      self.isUnderOver = true
15✔
810
      elements.subscript.shape(self)
15✔
811
      return
15✔
812
   end
813
   local constants = self:getMathMetrics().constants
30✔
814
   local scaleDown = self:getScaleDown()
15✔
815
   -- Determine relative Ys
816
   if self.base then
15✔
817
      self.base.relY = SILE.types.length(0)
30✔
818
   end
819
   if self.sub then
15✔
820
      self:_stretchyReshapeToBase(self.sub)
15✔
821
      self.sub.relY = self.base.depth
15✔
822
         + SILE.types.length(
30✔
823
            math.max(
30✔
824
               (self.sub.height + constants.lowerLimitGapMin * scaleDown):tonumber(),
30✔
825
               constants.lowerLimitBaselineDropMin * scaleDown
15✔
826
            )
827
         )
30✔
828
   end
829
   if self.sup then
15✔
830
      self:_stretchyReshapeToBase(self.sup)
11✔
831
      self.sup.relY = 0
11✔
832
         - self.base.height
11✔
833
         - SILE.types.length(
22✔
834
            math.max(
22✔
835
               (constants.upperLimitGapMin * scaleDown + self.sup.depth):tonumber(),
22✔
836
               constants.upperLimitBaselineRiseMin * scaleDown
11✔
837
            )
838
         )
22✔
839
   end
840
   -- Determine relative Xs based on widest symbol
841
   local widest, a, b
842
   if self.sub and self.sub.width > self.base.width then
15✔
843
      if self.sup and self.sub.width > self.sup.width then
10✔
844
         widest = self.sub
8✔
845
         a = self.base
8✔
846
         b = self.sup
8✔
847
      elseif self.sup then
2✔
848
         widest = self.sup
×
849
         a = self.base
×
850
         b = self.sub
×
851
      else
852
         widest = self.sub
2✔
853
         a = self.base
2✔
854
         b = nil
2✔
855
      end
856
   else
857
      if self.sup and self.base.width > self.sup.width then
5✔
858
         widest = self.base
3✔
859
         a = self.sub
3✔
860
         b = self.sup
3✔
861
      elseif self.sup then
2✔
862
         widest = self.sup
×
863
         a = self.base
×
864
         b = self.sub
×
865
      else
866
         widest = self.base
2✔
867
         a = self.sub
2✔
868
         b = nil
2✔
869
      end
870
   end
871
   widest.relX = SILE.types.length(0)
30✔
872
   local c = widest.width / 2
15✔
873
   if a then
15✔
874
      a.relX = c - a.width / 2
45✔
875
   end
876
   if b then
15✔
877
      b.relX = c - b.width / 2
33✔
878
   end
879
   local itCorr = self:calculateItalicsCorrection() * scaleDown
30✔
880
   if self.sup then
15✔
881
      self.sup.relX = self.sup.relX + itCorr / 2
22✔
882
   end
883
   if self.sub then
15✔
884
      self.sub.relX = self.sub.relX - itCorr / 2
30✔
885
   end
886
   -- Determine width and height
887
   self.width = maxLength(
30✔
888
      self.base and self.base.width or SILE.types.length(0),
15✔
889
      self.sub and self.sub.width or SILE.types.length(0),
15✔
890
      self.sup and self.sup.width or SILE.types.length(0)
15✔
891
   )
15✔
892
   if self.sup then
15✔
893
      self.height = 0 - self.sup.relY + self.sup.height
33✔
894
   else
895
      self.height = self.base and self.base.height or 0
4✔
896
   end
897
   if self.sub then
15✔
898
      self.depth = self.sub.relY + self.sub.depth
30✔
899
   else
UNCOV
900
      self.depth = self.base and self.base.depth or 0
×
901
   end
902
end
903

904
function elements.underOver:calculateItalicsCorrection ()
24✔
905
   local lastGid = getRightMostGlyphId(self.base)
30✔
906
   if lastGid > 0 then
30✔
907
      local mathMetrics = self:getMathMetrics()
30✔
908
      if mathMetrics.italicsCorrection[lastGid] then
30✔
909
         local c = mathMetrics.italicsCorrection[lastGid]
×
910
         -- If this is a big operator, and we are in display style, then the
911
         -- base glyph may be bigger than the font size. We need to adjust the
912
         -- italic correction accordingly.
NEW
913
         if SU.boolean(self.base.largeop) and isDisplayMode(self.mode) then
×
914
            c = c * (self.base and self.base.font.size / self.font.size or 1.0)
×
915
         end
UNCOV
916
         return c
×
917
      end
918
   end
919
   return 0
30✔
920
end
921

922
function elements.underOver.output (_, _, _, _) end
42✔
923

924
-- terminal is the base class for leaf node
925
elements.terminal = pl.class(elements.mbox)
24✔
926
elements.terminal._type = "Terminal"
12✔
927

928
function elements.terminal:_init ()
24✔
929
   elements.mbox._init(self)
1,442✔
930
end
931

932
function elements.terminal.styleChildren (_) end
1,454✔
933

934
function elements.terminal.shape (_) end
12✔
935

936
elements.space = pl.class(elements.terminal)
24✔
937
elements.space._type = "Space"
12✔
938

939
function elements.space:_init ()
24✔
UNCOV
940
   elements.terminal._init(self)
×
941
end
942

943
function elements.space:__tostring ()
24✔
UNCOV
944
   return self._type
×
945
      .. "(width="
×
946
      .. tostring(self.width)
×
947
      .. ", height="
×
948
      .. tostring(self.height)
×
949
      .. ", depth="
×
950
      .. tostring(self.depth)
×
951
      .. ")"
×
952
end
953

954
local function getStandardLength (value)
955
   if type(value) == "string" then
1,095✔
956
      local direction = 1
365✔
957
      if value:sub(1, 1) == "-" then
730✔
958
         value = value:sub(2, -1)
20✔
959
         direction = -1
10✔
960
      end
961
      if value == "thin" then
365✔
962
         return SILE.types.length("3mu") * direction
264✔
963
      elseif value == "med" then
277✔
964
         return SILE.types.length("4mu plus 2mu minus 4mu") * direction
378✔
965
      elseif value == "thick" then
151✔
966
         return SILE.types.length("5mu plus 5mu") * direction
381✔
967
      end
968
   end
969
   return SILE.types.length(value)
754✔
970
end
971

972
function elements.space:_init (width, height, depth)
24✔
973
   elements.terminal._init(self)
365✔
974
   self.width = getStandardLength(width)
730✔
975
   self.height = getStandardLength(height)
730✔
976
   self.depth = getStandardLength(depth)
730✔
977
end
978

979
function elements.space:shape ()
24✔
980
   self.width = self.width:absolute() * self:getScaleDown()
1,460✔
981
   self.height = self.height:absolute() * self:getScaleDown()
1,460✔
982
   self.depth = self.depth:absolute() * self:getScaleDown()
1,460✔
983
end
984

985
function elements.space.output (_) end
377✔
986

987
-- text node. For any actual text output
988
elements.text = pl.class(elements.terminal)
24✔
989
elements.text._type = "Text"
12✔
990

991
function elements.text:__tostring ()
24✔
UNCOV
992
   return self._type
×
993
      .. "(atom="
×
994
      .. tostring(self.atom)
×
995
      .. ", kind="
×
996
      .. tostring(self.kind)
×
997
      .. ", script="
×
998
      .. tostring(self.script)
×
999
      .. (SU.boolean(self.stretchy, false) and ", stretchy" or "")
×
1000
      .. (SU.boolean(self.largeop, false) and ", largeop" or "")
×
1001
      .. ', text="'
×
1002
      .. (self.originalText or self.text)
×
1003
      .. '")'
×
1004
end
1005

1006
function elements.text:_init (kind, attributes, script, text)
24✔
1007
   elements.terminal._init(self)
1,077✔
1008
   if not (kind == "number" or kind == "identifier" or kind == "operator" or kind == "string") then
1,077✔
UNCOV
1009
      SU.error("Unknown text node kind '" .. kind .. "'; should be one of: number, identifier, operator, string")
×
1010
   end
1011
   self.kind = kind
1,077✔
1012
   self.script = script
1,077✔
1013
   self.text = text
1,077✔
1014
   if self.script ~= "upright" then
1,077✔
1015
      local converted = convertMathVariantScript(self.text, self.script)
1,077✔
1016
      self.originalText = self.text
1,077✔
1017
      self.text = converted
1,077✔
1018
   end
1019
   if self.kind == "operator" then
1,077✔
1020
      if self.text == "-" then
437✔
1021
         self.text = "−"
9✔
1022
      end
1023
   end
1024
   for attribute, value in pairs(attributes) do
3,520✔
1025
      self[attribute] = value
2,443✔
1026
   end
1027
end
1028

1029
function elements.text:shape ()
24✔
1030
   self.font.size = self.font.size * self:getScaleDown()
2,154✔
1031
   local face = SILE.font.cache(self.font, SILE.shaper.getFace)
1,077✔
1032
   local mathMetrics = self:getMathMetrics()
1,077✔
1033
   local glyphs = SILE.shaper:shapeToken(self.text, self.font)
1,077✔
1034
   -- Use bigger variants for big operators in display style
1035
   if isDisplayMode(self.mode) and SU.boolean(self.largeop, false) then
2,542✔
1036
      -- We copy the glyph list to avoid modifying the shaper's cache. Yes.
1037
      glyphs = pl.tablex.deepcopy(glyphs)
42✔
1038
      local constructions = mathMetrics.mathVariants.vertGlyphConstructions[glyphs[1].gid]
21✔
1039
      if constructions then
21✔
1040
         local displayVariants = constructions.mathGlyphVariantRecord
21✔
1041
         -- We select the biggest variant. TODO: we should probably select the
1042
         -- first variant that is higher than displayOperatorMinHeight.
1043
         local biggest
1044
         local m = 0
21✔
1045
         for _, v in ipairs(displayVariants) do
63✔
1046
            if v.advanceMeasurement > m then
42✔
1047
               biggest = v
42✔
1048
               m = v.advanceMeasurement
42✔
1049
            end
1050
         end
1051
         if biggest then
21✔
1052
            glyphs[1].gid = biggest.variantGlyph
21✔
1053
            local dimen = hb.get_glyph_dimensions(face, self.font.size, biggest.variantGlyph)
21✔
1054
            glyphs[1].width = dimen.width
21✔
1055
            glyphs[1].glyphAdvance = dimen.glyphAdvance
21✔
1056
            --[[ I am told (https://github.com/alif-type/xits/issues/90) that,
1057
        in fact, the relative height and depth of display-style big operators
1058
        in the font is not relevant, as these should be centered around the
1059
        axis. So the following code does that, while conserving their
1060
        vertical size (distance from top to bottom). ]]
1061
            local axisHeight = mathMetrics.constants.axisHeight * self:getScaleDown()
42✔
1062
            local y_size = dimen.height + dimen.depth
21✔
1063
            glyphs[1].height = y_size / 2 + axisHeight
21✔
1064
            glyphs[1].depth = y_size / 2 - axisHeight
21✔
1065
            -- We still need to store the font's height and depth somewhere,
1066
            -- because that's what will be used to draw the glyph, and we will need
1067
            -- to artificially compensate for that.
1068
            glyphs[1].fontHeight = dimen.height
21✔
1069
            glyphs[1].fontDepth = dimen.depth
21✔
1070
         end
1071
      end
1072
   end
1073
   SILE.shaper:preAddNodes(glyphs, self.value)
1,077✔
1074
   self.value.items = glyphs
1,077✔
1075
   self.value.glyphString = {}
1,077✔
1076
   if glyphs and #glyphs > 0 then
1,077✔
1077
      for i = 1, #glyphs do
2,436✔
1078
         table.insert(self.value.glyphString, glyphs[i].gid)
1,359✔
1079
      end
1080
      self.width = SILE.types.length(0)
2,154✔
1081
      self.widthForSubscript = SILE.types.length(0)
2,154✔
1082
      for i = #glyphs, 1, -1 do
2,436✔
1083
         self.width = self.width + glyphs[i].glyphAdvance
2,718✔
1084
      end
1085
      -- Store width without italic correction somewhere
1086
      self.widthForSubscript = self.width
1,077✔
1087
      local itCorr = mathMetrics.italicsCorrection[glyphs[#glyphs].gid]
1,077✔
1088
      if itCorr then
1,077✔
1089
         self.width = self.width + itCorr * self:getScaleDown()
792✔
1090
      end
1091
      for i = 1, #glyphs do
2,436✔
1092
         self.height = i == 1 and SILE.types.length(glyphs[i].height)
1,359✔
1093
            or SILE.types.length(math.max(self.height:tonumber(), glyphs[i].height))
1,923✔
1094
         self.depth = i == 1 and SILE.types.length(glyphs[i].depth)
1,359✔
1095
            or SILE.types.length(math.max(self.depth:tonumber(), glyphs[i].depth))
1,923✔
1096
      end
1097
   else
UNCOV
1098
      self.width = SILE.types.length(0)
×
1099
      self.height = SILE.types.length(0)
×
1100
      self.depth = SILE.types.length(0)
×
1101
   end
1102
end
1103

1104
function elements.text.findClosestVariant (_, variants, requiredAdvance, currentAdvance)
24✔
1105
   local closest
1106
   local closestI
1107
   local m = requiredAdvance - currentAdvance
94✔
1108
   for i, variant in ipairs(variants) do
1,316✔
1109
      local diff = math.abs(variant.advanceMeasurement - requiredAdvance)
1,222✔
1110
      SU.debug("math", "stretch: diff =", diff)
1,222✔
1111
      if diff < m then
1,222✔
1112
         closest = variant
86✔
1113
         closestI = i
86✔
1114
         m = diff
86✔
1115
      end
1116
   end
1117
   return closest, closestI
94✔
1118
end
1119

1120
function elements.text:_reshapeGlyph (glyph, closestVariant, sz)
24✔
1121
   local face = SILE.font.cache(self.font, SILE.shaper.getFace)
38✔
1122
   local dimen = hb.get_glyph_dimensions(face, sz, closestVariant.variantGlyph)
38✔
1123
   glyph.gid = closestVariant.variantGlyph
38✔
UNCOV
1124
   glyph.width, glyph.height, glyph.depth, glyph.glyphAdvance =
×
1125
      dimen.width, dimen.height, dimen.depth, dimen.glyphAdvance
38✔
1126
   return dimen
38✔
1127
end
1128

1129
function elements.text:_stretchyReshape (target, direction)
24✔
1130
   -- direction is the required direction of stretching: true for vertical, false for horizontal
1131
   -- target is the required dimension of the stretched glyph, in font units
1132
   local mathMetrics = self:getMathMetrics()
95✔
1133
   local upem = mathMetrics.unitsPerEm
95✔
1134
   local sz = self.font.size
95✔
1135
   local requiredAdvance = target:tonumber() * upem / sz
190✔
1136
   SU.debug("math", "stretch: rA =", requiredAdvance)
95✔
1137
   -- Choose variant of the closest size. The criterion we use is to have
1138
   -- an advance measurement as close as possible as the required one.
1139
   -- The advance measurement is simply the dimension of the glyph.
1140
   -- Therefore, the selected glyph may be smaller or bigger than
1141
   -- required.
1142
   -- TODO: implement assembly of stretchable glyphs from their parts for cases
1143
   -- when the biggest variant is not big enough.
1144
   -- We copy the glyph list to avoid modifying the shaper's cache. Yes.
1145
   local glyphs = pl.tablex.deepcopy(self.value.items)
95✔
1146
   local glyphConstructions = direction and mathMetrics.mathVariants.vertGlyphConstructions
95✔
1147
      or mathMetrics.mathVariants.horizGlyphConstructions
95✔
1148
   local constructions = glyphConstructions[glyphs[1].gid]
95✔
1149
   if constructions then
95✔
1150
      local variants = constructions.mathGlyphVariantRecord
94✔
1151
      SU.debug("math", "stretch: variants =", variants)
94✔
1152
      local currentAdvance = (direction and (self.depth + self.height):tonumber() or self.width:tonumber()) * upem / sz
282✔
1153
      local closest, closestI = self:findClosestVariant(variants, requiredAdvance, currentAdvance)
94✔
1154
      SU.debug("math", "stretch: closestI =", closestI)
94✔
1155
      if closest then
94✔
1156
         -- Now we have to re-shape the glyph chain. We will assume there
1157
         -- is only one glyph.
1158
         -- TODO: this code is probably wrong when the vertical
1159
         -- variants have a different width than the original, because
1160
         -- the shaping phase is already done. Need to do better.
1161
         local dimen = self:_reshapeGlyph(glyphs[1], closest, sz)
38✔
UNCOV
1162
         self.width, self.depth, self.height =
×
1163
            SILE.types.length(dimen.glyphAdvance), SILE.types.length(dimen.depth), SILE.types.length(dimen.height)
152✔
1164
         SILE.shaper:preAddNodes(glyphs, self.value)
38✔
1165
         self.value.items = glyphs
38✔
1166
         self.value.glyphString = { glyphs[1].gid }
38✔
1167
         return true
38✔
1168
      end
1169
   end
1170
   return false
57✔
1171
end
1172

1173
function elements.text:_vertStretchyReshape (depth, height)
24✔
1174
   local hasStretched = self:_stretchyReshape(depth + height, true)
190✔
1175
   if hasStretched then
95✔
1176
      -- HACK: see output routine
1177
      self.vertExpectedSz = height + depth
76✔
1178
      self.vertScalingRatio = (depth + height):tonumber() / (self.height:tonumber() + self.depth:tonumber())
190✔
1179
      self.height = height
38✔
1180
      self.depth = depth
38✔
1181
   end
1182
   return hasStretched
95✔
1183
end
1184

1185
function elements.text:_horizStretchyReshape (width)
24✔
UNCOV
1186
   local hasStretched = self:_stretchyReshape(width, false)
×
1187
   if hasStretched then
×
1188
      -- HACK: see output routine
UNCOV
1189
      self.horizScalingRatio = width:tonumber() / self.width:tonumber()
×
1190
      self.width = width
×
1191
   end
UNCOV
1192
   return hasStretched
×
1193
end
1194

1195
function elements.text:output (x, y, line)
24✔
1196
   if not self.value.glyphString then
1,077✔
UNCOV
1197
      return
×
1198
   end
1199
   local compensatedY
1200
   if isDisplayMode(self.mode) and SU.boolean(self.largeop, false) and self.value.items[1].fontDepth then
2,542✔
1201
      compensatedY = SILE.types.length(y.length + self.value.items[1].depth - self.value.items[1].fontDepth)
84✔
1202
   else
1203
      compensatedY = y
1,056✔
1204
   end
1205
   SILE.outputter:setCursor(scaleWidth(x, line), compensatedY.length)
2,154✔
1206
   SILE.outputter:setFont(self.font)
1,077✔
1207
   -- There should be no stretch or shrink on the width of a text
1208
   -- element.
1209
   local width = self.width.length
1,077✔
1210
   -- HACK: For stretchy operators, MathML Core and OpenType define how to build large glyphs
1211
   -- from an assembly of smaller ones. It's fairly complex and idealistic...
1212
   -- Anyhow, we do not have that yet, so we just stretch the glyph artificially.
1213
   -- There are cases where this will not look very good.
1214
   -- Call that a compromise, so that long vectors or large matrices look "decent" without assembly.
1215
   if SILE.outputter.scaleFn and (self.horizScalingRatio or self.vertScalingRatio) then
1,077✔
1216
      local xratio = self.horizScalingRatio or 1
38✔
1217
      local yratio = self.vertScalingRatio or 1
38✔
1218
      SU.debug("math", "fake glyph stretch: xratio =", xratio, "yratio =", yratio)
38✔
1219
      SILE.outputter:scaleFn(x, y, xratio, yratio, function ()
76✔
1220
         SILE.outputter:drawHbox(self.value, width)
38✔
1221
      end)
1222
   else
1223
      SILE.outputter:drawHbox(self.value, width)
1,039✔
1224
   end
1225
end
1226

1227
elements.fraction = pl.class(elements.mbox)
24✔
1228
elements.fraction._type = "Fraction"
12✔
1229

1230
function elements.fraction:__tostring ()
24✔
UNCOV
1231
   return self._type .. "(" .. tostring(self.numerator) .. ", " .. tostring(self.denominator) .. ")"
×
1232
end
1233

1234
function elements.fraction:_init (attributes, numerator, denominator)
24✔
1235
   elements.mbox._init(self)
44✔
1236
   self.numerator = numerator
44✔
1237
   self.denominator = denominator
44✔
1238
   self.attributes = attributes
44✔
1239
   table.insert(self.children, numerator)
44✔
1240
   table.insert(self.children, denominator)
44✔
1241
end
1242

1243
function elements.fraction:styleChildren ()
24✔
1244
   self.numerator.mode = getNumeratorMode(self.mode)
88✔
1245
   self.denominator.mode = getDenominatorMode(self.mode)
88✔
1246
end
1247

1248
function elements.fraction:shape ()
24✔
1249
   -- MathML Core 3.3.2: "To avoid visual confusion between the fraction bar
1250
   -- and another adjacent items (e.g. minus sign or another fraction's bar),
1251
   -- a default 1-pixel space is added around the element."
1252
   -- Note that PlainTeX would likely use \nulldelimiterspace (default 1.2pt)
1253
   -- but it would depend on the surrounding context, and might be far too
1254
   -- much in some cases, so we stick to MathML's suggested padding.
1255
   self.padding = SILE.types.length("1px"):absolute()
132✔
1256

1257
   -- Determine relative abscissas and width
1258
   local widest, other
1259
   if self.denominator.width > self.numerator.width then
44✔
1260
      widest, other = self.denominator, self.numerator
35✔
1261
   else
1262
      widest, other = self.numerator, self.denominator
9✔
1263
   end
1264
   widest.relX = self.padding
44✔
1265
   other.relX = self.padding + (widest.width - other.width) / 2
176✔
1266
   self.width = widest.width + 2 * self.padding
132✔
1267
   -- Determine relative ordinates and height
1268
   local constants = self:getMathMetrics().constants
88✔
1269
   local scaleDown = self:getScaleDown()
44✔
1270
   self.axisHeight = constants.axisHeight * scaleDown
44✔
1271
   self.ruleThickness = self.attributes.linethickness
44✔
1272
         and SU.cast("measurement", self.attributes.linethickness):tonumber()
44✔
1273
      or constants.fractionRuleThickness * scaleDown
44✔
1274

1275
   -- MathML Core 3.3.2.2 ("Fraction with zero line thickness") uses
1276
   -- stack(DisplayStyle)GapMin, stackTop(DisplayStyle)ShiftUp and stackBottom(DisplayStyle)ShiftDown.
1277
   -- TODO not implemented
1278
   -- The most common use cases for zero line thickness are:
1279
   --  - Binomial coefficients
1280
   --  - Stacked subscript/superscript on big operators such as sums.
1281

1282
   local numeratorGapMin, denominatorGapMin, numeratorShiftUp, denominatorShiftDown
1283
   if isDisplayMode(self.mode) then
88✔
1284
      numeratorGapMin = constants.fractionNumDisplayStyleGapMin * scaleDown
21✔
1285
      denominatorGapMin = constants.fractionDenomDisplayStyleGapMin * scaleDown
21✔
1286
      numeratorShiftUp = constants.fractionNumeratorDisplayStyleShiftUp * scaleDown
21✔
1287
      denominatorShiftDown = constants.fractionDenominatorDisplayStyleShiftDown * scaleDown
21✔
1288
   else
1289
      numeratorGapMin = constants.fractionNumeratorGapMin * scaleDown
23✔
1290
      denominatorGapMin = constants.fractionDenominatorGapMin * scaleDown
23✔
1291
      numeratorShiftUp = constants.fractionNumeratorShiftUp * scaleDown
23✔
1292
      denominatorShiftDown = constants.fractionDenominatorShiftDown * scaleDown
23✔
1293
   end
1294

1295
   self.numerator.relY = -self.axisHeight
44✔
1296
      - self.ruleThickness / 2
44✔
1297
      - SILE.types.length(
88✔
1298
         math.max(
88✔
1299
            (numeratorGapMin + self.numerator.depth):tonumber(),
88✔
1300
            numeratorShiftUp - self.axisHeight - self.ruleThickness / 2
44✔
1301
         )
1302
      )
88✔
1303
   self.denominator.relY = -self.axisHeight
44✔
1304
      + self.ruleThickness / 2
44✔
1305
      + SILE.types.length(
88✔
1306
         math.max(
88✔
1307
            (denominatorGapMin + self.denominator.height):tonumber(),
88✔
1308
            denominatorShiftDown + self.axisHeight - self.ruleThickness / 2
44✔
1309
         )
1310
      )
88✔
1311
   self.height = self.numerator.height - self.numerator.relY
88✔
1312
   self.depth = self.denominator.relY + self.denominator.depth
88✔
1313
end
1314

1315
function elements.fraction:output (x, y, line)
24✔
1316
   if self.ruleThickness > 0 then
44✔
1317
      SILE.outputter:drawRule(
88✔
1318
         scaleWidth(x + self.padding, line),
88✔
1319
         y.length - self.axisHeight - self.ruleThickness / 2,
88✔
1320
         scaleWidth(self.width - 2 * self.padding, line),
132✔
1321
         self.ruleThickness
1322
      )
44✔
1323
   end
1324
end
1325

1326
local function newSubscript (spec)
1327
   return elements.subscript(spec.base, spec.sub, spec.sup)
110✔
1328
end
1329

1330
local function newUnderOver (spec)
1331
   return elements.underOver(spec.base, spec.sub, spec.sup)
30✔
1332
end
1333

1334
-- TODO replace with penlight equivalent
1335
local function mapList (f, l)
1336
   local ret = {}
8✔
1337
   for i, x in ipairs(l) do
32✔
1338
      ret[i] = f(i, x)
48✔
1339
   end
1340
   return ret
8✔
1341
end
1342

1343
elements.mtr = pl.class(elements.mbox)
24✔
1344
-- elements.mtr._type = "" -- TODO why not set?
1345

1346
function elements.mtr:_init (children)
24✔
1347
   self.children = children
12✔
1348
end
1349

1350
function elements.mtr:styleChildren ()
24✔
1351
   for _, c in ipairs(self.children) do
48✔
1352
      c.mode = self.mode
36✔
1353
   end
1354
end
1355

1356
function elements.mtr.shape (_) end -- done by parent table
24✔
1357

1358
function elements.mtr.output (_) end
24✔
1359

1360
elements.table = pl.class(elements.mbox)
24✔
1361
elements.table._type = "table" -- TODO why case difference?
12✔
1362

1363
function elements.table:_init (children, options)
24✔
1364
   elements.mbox._init(self)
8✔
1365
   self.children = children
8✔
1366
   self.options = options
8✔
1367
   self.nrows = #self.children
8✔
1368
   self.ncols = math.max(pl.utils.unpack(mapList(function (_, row)
32✔
1369
      return #row.children
24✔
1370
   end, self.children)))
16✔
1371
   SU.debug("math", "self.ncols =", self.ncols)
8✔
1372
   local spacing = SILE.settings:get("math.font.size") * 0.6 -- arbitrary ratio of the current math font size
16✔
1373
   self.rowspacing = self.options.rowspacing and SILE.types.length(self.options.rowspacing) or spacing
8✔
1374
   self.columnspacing = self.options.columnspacing and SILE.types.length(self.options.columnspacing) or spacing
8✔
1375
   -- Pad rows that do not have enough cells by adding cells to the
1376
   -- right.
1377
   for i, row in ipairs(self.children) do
32✔
1378
      for j = 1, (self.ncols - #row.children) do
24✔
1379
         SU.debug("math", "padding i =", i, "j =", j)
×
1380
         table.insert(row.children, elements.stackbox("H", {}))
×
1381
         SU.debug("math", "size", #row.children)
×
1382
      end
1383
   end
1384
   if options.columnalign then
8✔
1385
      local l = {}
4✔
1386
      for w in string.gmatch(options.columnalign, "[^%s]+") do
16✔
1387
         if not (w == "left" or w == "center" or w == "right") then
12✔
1388
            SU.error("Invalid specifier in `columnalign` attribute: " .. w)
×
1389
         end
1390
         table.insert(l, w)
12✔
1391
      end
1392
      -- Pad with last value of l if necessary
1393
      for _ = 1, (self.ncols - #l), 1 do
4✔
1394
         table.insert(l, l[#l])
×
1395
      end
1396
      -- On the contrary, remove excess values in l if necessary
1397
      for _ = 1, (#l - self.ncols), 1 do
4✔
1398
         table.remove(l)
×
1399
      end
1400
      self.options.columnalign = l
4✔
1401
   else
1402
      self.options.columnalign = pl.List.range(1, self.ncols):map(function (_)
14✔
1403
         return "center"
12✔
1404
      end)
1405
   end
1406
end
1407

1408
function elements.table:styleChildren ()
24✔
1409
   if self.mode == mathMode.display and self.options.displaystyle ~= "false" then
8✔
1410
      for _, c in ipairs(self.children) do
16✔
1411
         c.mode = mathMode.display
12✔
1412
      end
1413
   else
1414
      for _, c in ipairs(self.children) do
16✔
1415
         c.mode = mathMode.text
12✔
1416
      end
1417
   end
1418
end
1419

1420
function elements.table:shape ()
24✔
1421
   -- Determine the height (resp. depth) of each row, which is the max
1422
   -- height (resp. depth) among its elements. Then we only need to add it to
1423
   -- the table's height and center every cell vertically.
1424
   for _, row in ipairs(self.children) do
32✔
1425
      row.height = SILE.types.length(0)
48✔
1426
      row.depth = SILE.types.length(0)
48✔
1427
      for _, cell in ipairs(row.children) do
96✔
1428
         row.height = maxLength(row.height, cell.height)
144✔
1429
         row.depth = maxLength(row.depth, cell.depth)
144✔
1430
      end
1431
   end
1432
   self.vertSize = SILE.types.length(0)
16✔
1433
   for i, row in ipairs(self.children) do
32✔
1434
      self.vertSize = self.vertSize
×
1435
         + row.height
24✔
1436
         + row.depth
24✔
1437
         + (i == self.nrows and SILE.types.length(0) or self.rowspacing) -- Spacing
56✔
1438
   end
1439
   local rowHeightSoFar = SILE.types.length(0)
8✔
1440
   for i, row in ipairs(self.children) do
32✔
1441
      row.relY = rowHeightSoFar + row.height - self.vertSize
72✔
1442
      rowHeightSoFar = rowHeightSoFar
×
1443
         + row.height
24✔
1444
         + row.depth
24✔
1445
         + (i == self.nrows and SILE.types.length(0) or self.rowspacing) -- Spacing
32✔
1446
   end
1447
   self.width = SILE.types.length(0)
16✔
1448
   local thisColRelX = SILE.types.length(0)
8✔
1449
   -- For every column...
1450
   for i = 1, self.ncols do
32✔
1451
      -- Determine its width
1452
      local columnWidth = SILE.types.length(0)
24✔
1453
      for j = 1, self.nrows do
96✔
1454
         if self.children[j].children[i].width > columnWidth then
72✔
1455
            columnWidth = self.children[j].children[i].width
32✔
1456
         end
1457
      end
1458
      -- Use it to align the contents of every cell as required.
1459
      for j = 1, self.nrows do
96✔
1460
         local cell = self.children[j].children[i]
72✔
1461
         if self.options.columnalign[i] == "left" then
72✔
1462
            cell.relX = thisColRelX
12✔
1463
         elseif self.options.columnalign[i] == "center" then
60✔
1464
            cell.relX = thisColRelX + (columnWidth - cell.width) / 2
192✔
1465
         elseif self.options.columnalign[i] == "right" then
12✔
1466
            cell.relX = thisColRelX + (columnWidth - cell.width)
36✔
1467
         else
UNCOV
1468
            SU.error("invalid columnalign parameter")
×
1469
         end
1470
      end
1471
      thisColRelX = thisColRelX + columnWidth + (i == self.ncols and SILE.types.length(0) or self.columnspacing) -- Spacing
56✔
1472
   end
1473
   self.width = thisColRelX
8✔
1474
   -- Center myself vertically around the axis, and update relative Ys of rows accordingly
1475
   local axisHeight = self:getMathMetrics().constants.axisHeight * self:getScaleDown()
24✔
1476
   self.height = self.vertSize / 2 + axisHeight
24✔
1477
   self.depth = self.vertSize / 2 - axisHeight
24✔
1478
   for _, row in ipairs(self.children) do
32✔
1479
      row.relY = row.relY + self.vertSize / 2 - axisHeight
96✔
1480
      -- Also adjust width
1481
      row.width = self.width
24✔
1482
   end
1483
end
1484

1485
function elements.table.output (_) end
20✔
1486

1487
local function getRadicandMode (mode)
1488
   -- Not too sure if we should do something special/
UNCOV
1489
   return mode
×
1490
end
1491

1492
local function getDegreeMode (mode)
1493
   -- 2 levels smaller, up to scriptScript evntually.
1494
   -- Not too sure if we should do something else.
UNCOV
1495
   if mode == mathMode.display then
×
1496
      return mathMode.scriptScript
×
1497
   elseif mode == mathMode.displayCramped then
×
1498
      return mathMode.scriptScriptCramped
×
1499
   elseif mode == mathMode.text or mode == mathMode.script or mode == mathMode.scriptScript then
×
1500
      return mathMode.scriptScript
×
1501
   end
UNCOV
1502
   return mathMode.scriptScriptCramped
×
1503
end
1504

1505
elements.sqrt = pl.class(elements.mbox)
24✔
1506
elements.sqrt._type = "Sqrt"
12✔
1507

1508
function elements.sqrt:__tostring ()
24✔
UNCOV
1509
   return self._type .. "(" .. tostring(self.radicand) .. (self.degree and ", " .. tostring(self.degree) or "") .. ")"
×
1510
end
1511

1512
function elements.sqrt:_init (radicand, degree)
24✔
UNCOV
1513
   elements.mbox._init(self)
×
1514
   self.radicand = radicand
×
1515
   if degree then
×
1516
      self.degree = degree
×
1517
      table.insert(self.children, degree)
×
1518
   end
UNCOV
1519
   table.insert(self.children, radicand)
×
1520
   self.relX = SILE.types.length()
×
1521
   self.relY = SILE.types.length()
×
1522
end
1523

1524
function elements.sqrt:styleChildren ()
24✔
UNCOV
1525
   self.radicand.mode = getRadicandMode(self.mode)
×
1526
   if self.degree then
×
1527
      self.degree.mode = getDegreeMode(self.mode)
×
1528
   end
1529
end
1530

1531
function elements.sqrt:shape ()
24✔
UNCOV
1532
   local mathMetrics = self:getMathMetrics()
×
1533
   local scaleDown = self:getScaleDown()
×
1534
   local constants = mathMetrics.constants
×
1535

UNCOV
1536
   self.radicalRuleThickness = constants.radicalRuleThickness * scaleDown
×
1537
   if self.mode == mathMode.display or self.mode == mathMode.displayCramped then
×
1538
      self.radicalVerticalGap = constants.radicalDisplayStyleVerticalGap * scaleDown
×
1539
   else
UNCOV
1540
      self.radicalVerticalGap = constants.radicalVerticalGap * scaleDown
×
1541
   end
UNCOV
1542
   self.extraAscender = constants.radicalExtraAscender * scaleDown
×
1543

1544
   -- HACK: We draw own own radical sign in the output() method.
1545
   -- Derive dimensions for the radical sign (more or less ad hoc).
1546
   -- Note: In TeX, the radical sign extends a lot below the baseline,
1547
   -- and MathML Core also has a lot of layout text about it.
1548
   -- Not only it doesn't look good, but it's not very clear vs. OpenType.
UNCOV
1549
   local radicalGlyph = SILE.shaper:measureChar("√")
×
1550
   local ratio = (self.radicand.height:tonumber() + self.radicand.depth:tonumber())
×
1551
      / (radicalGlyph.height + radicalGlyph.depth)
×
1552
   local vertAdHocOffset = (ratio > 1 and math.log(ratio) or 0) * self.radicalVerticalGap
×
1553
   self.symbolHeight = SILE.types.length(radicalGlyph.height) * scaleDown
×
1554
   self.symbolDepth = (SILE.types.length(radicalGlyph.depth) + vertAdHocOffset) * scaleDown
×
1555
   self.symbolWidth = (SILE.types.length(radicalGlyph.width) + vertAdHocOffset) * scaleDown
×
1556

1557
   -- Adjust the height of the radical sign if the radicand is higher
UNCOV
1558
   self.symbolHeight = self.radicand.height > self.symbolHeight and self.radicand.height or self.symbolHeight
×
1559
   -- Compute the (max-)height of the short leg of the radical sign
UNCOV
1560
   self.symbolShortHeight = self.symbolHeight * constants.radicalDegreeBottomRaisePercent
×
1561

UNCOV
1562
   self.offsetX = SILE.types.length()
×
1563
   if self.degree then
×
1564
      -- Position the degree
UNCOV
1565
      self.degree.relY = -constants.radicalDegreeBottomRaisePercent * self.symbolHeight
×
1566
      -- Adjust the height of the short leg of the radical sign to ensure the degree is not too close
1567
      -- (empirically use radicalExtraAscender)
UNCOV
1568
      self.symbolShortHeight = self.symbolShortHeight - constants.radicalExtraAscender * scaleDown
×
1569
      -- Compute the width adjustment for the degree
UNCOV
1570
      self.offsetX = self.degree.width
×
1571
         + constants.radicalKernBeforeDegree * scaleDown
×
1572
         + constants.radicalKernAfterDegree * scaleDown
×
1573
   end
1574
   -- Position the radicand
UNCOV
1575
   self.radicand.relX = self.symbolWidth + self.offsetX
×
1576
   -- Compute the dimensions of the whole radical
UNCOV
1577
   self.width = self.radicand.width + self.symbolWidth + self.offsetX
×
1578
   self.height = self.symbolHeight + self.radicalVerticalGap + self.extraAscender
×
1579
   self.depth = self.radicand.depth
×
1580
end
1581

1582
local function _r (number)
1583
   -- Lua 5.3+ formats floats as 1.0 and integers as 1
1584
   -- Also some PDF readers do not like double precision.
UNCOV
1585
   return math.floor(number) == number and math.floor(number) or tonumber(string.format("%.5f", number))
×
1586
end
1587

1588
function elements.sqrt:output (x, y, line)
24✔
1589
   -- HACK:
1590
   -- OpenType might say we need to assemble the radical sign from parts.
1591
   -- Frankly, it's much easier to just draw it as a graphic :-)
1592
   -- Hence, here we use a PDF graphic operators to draw a nice radical sign.
1593
   -- Some values here are ad hoc, but they look good.
UNCOV
1594
   local h = self.height:tonumber()
×
1595
   local d = self.depth:tonumber()
×
1596
   local s0 = scaleWidth(self.offsetX, line):tonumber()
×
1597
   local sw = scaleWidth(self.symbolWidth, line):tonumber()
×
1598
   local dsh = h - self.symbolShortHeight:tonumber()
×
1599
   local dsd = self.symbolDepth:tonumber()
×
1600
   local symbol = {
×
1601
      _r(self.radicalRuleThickness),
×
1602
      "w", -- line width
1603
      1,
1604
      "j", -- round line joins
UNCOV
1605
      _r(sw + s0),
×
1606
      _r(self.extraAscender),
×
1607
      "m",
UNCOV
1608
      _r(s0 + sw * 0.90),
×
1609
      _r(self.extraAscender),
×
1610
      "l",
UNCOV
1611
      _r(s0 + sw * 0.4),
×
1612
      _r(h + d + dsd),
×
1613
      "l",
UNCOV
1614
      _r(s0 + sw * 0.2),
×
1615
      _r(dsh),
×
1616
      "l",
UNCOV
1617
      s0 + sw * 0.1,
×
1618
      _r(dsh + 0.5),
×
1619
      "l",
1620
      "S",
1621
   }
UNCOV
1622
   local svg = table.concat(symbol, " ")
×
1623
   local xscaled = scaleWidth(x, line)
×
1624
   SILE.outputter:drawSVG(svg, xscaled, y, sw, h, 1)
×
1625
   -- And now we just need to draw the bar over the radicand
UNCOV
1626
   SILE.outputter:drawRule(
×
1627
      s0 + self.symbolWidth + xscaled,
×
1628
      y.length - self.height + self.extraAscender - self.radicalRuleThickness / 2,
×
1629
      scaleWidth(self.radicand.width, line),
×
1630
      self.radicalRuleThickness
1631
   )
1632
end
1633

1634
elements.padded = pl.class(elements.mbox)
24✔
1635
elements.padded._type = "Padded"
12✔
1636

1637
function elements.padded:__tostring ()
24✔
UNCOV
1638
   return self._type .. "(" .. tostring(self.impadded) .. ")"
×
1639
end
1640

1641
function elements.padded:_init (attributes, impadded)
24✔
UNCOV
1642
   elements.mbox._init(self)
×
1643
   self.impadded = impadded
×
1644
   self.attributes = attributes or {}
×
1645
   table.insert(self.children, impadded)
×
1646
end
1647

1648
function elements.padded:styleChildren ()
24✔
UNCOV
1649
   self.impadded.mode = self.mode
×
1650
end
1651

1652
function elements.padded:shape ()
24✔
1653
   -- TODO MathML allows percentages font-relative units (em, ex) for padding
1654
   -- But our units work with font.size, not math.font.size (possibly adjusted by scaleDown)
1655
   -- so the expectations might not be met.
UNCOV
1656
   local width = self.attributes.width and SU.cast("measurement", self.attributes.width)
×
1657
   local height = self.attributes.height and SU.cast("measurement", self.attributes.height)
×
1658
   local depth = self.attributes.depth and SU.cast("measurement", self.attributes.depth)
×
1659
   local lspace = self.attributes.lspace and SU.cast("measurement", self.attributes.lspace)
×
1660
   local voffset = self.attributes.voffset and SU.cast("measurement", self.attributes.voffset)
×
1661
   -- Clamping for width, height, depth, lspace
UNCOV
1662
   width = width and (width:tonumber() > 0 and width or SILE.types.measurement())
×
1663
   height = height and (height:tonumber() > 0 and height or SILE.types.measurement())
×
1664
   depth = depth and (depth:tonumber() > 0 and depth or SILE.types.measurement())
×
1665
   lspace = lspace and (lspace:tonumber() > 0 and lspace or SILE.types.measurement())
×
1666
   -- No clamping for voffset
UNCOV
1667
   voffset = voffset or SILE.types.measurement(0)
×
1668
   -- Compute the dimensions
UNCOV
1669
   self.width = width and SILE.types.length(width) or self.impadded.width
×
1670
   self.height = height and SILE.types.length(height) or self.impadded.height
×
1671
   self.depth = depth and SILE.types.length(depth) or self.impadded.depth
×
1672
   self.impadded.relX = lspace and SILE.types.length(lspace) or SILE.types.length()
×
1673
   self.impadded.relY = voffset and SILE.types.length(voffset):negate() or SILE.types.length()
×
1674
end
1675

1676
function elements.padded.output (_, _, _, _) end
12✔
1677

1678
-- Bevelled fractions are not part of MathML Core, and MathML4 does not
1679
-- exactly specify how to compute the layout.
1680
elements.bevelledFraction = pl.class(elements.fraction) -- Inherit from fraction
24✔
1681
elements.fraction._type = "BevelledFraction"
12✔
1682

1683
function elements.bevelledFraction:shape ()
24✔
UNCOV
1684
   local constants = self:getMathMetrics().constants
×
1685
   local scaleDown = self:getScaleDown()
×
1686
   local hSkew = constants.skewedFractionHorizontalGap * scaleDown
×
1687
   -- OpenType has properties which are not totally explicit.
1688
   -- The definition of skewedFractionVerticalGap (and its value in fonts
1689
   -- such as Libertinus Math) seems to imply that it is measured from the
1690
   -- bottom of the numerator to the top of the denominator.
1691
   -- This does not seem to be a nice general layout.
1692
   -- So we will use superscriptShiftUp(Cramped) for the numerator:
UNCOV
1693
   local vSkewUp = isCrampedMode(self.mode) and constants.superscriptShiftUpCramped * scaleDown
×
1694
      or constants.superscriptShiftUp * scaleDown
×
1695
   -- And all good books say that the denominator should not be shifted down:
UNCOV
1696
   local vSkewDown = 0
×
1697

UNCOV
1698
   self.ruleThickness = self.attributes.linethickness
×
1699
         and SU.cast("measurement", self.attributes.linethickness):tonumber()
×
1700
      or constants.fractionRuleThickness * scaleDown
×
1701
   self.numerator.relX = SILE.types.length(0)
×
1702
   self.numerator.relY = SILE.types.length(-vSkewUp)
×
1703
   self.denominator.relX = self.numerator.width + hSkew
×
1704
   self.denominator.relY = SILE.types.length(vSkewDown)
×
1705
   self.width = self.numerator.width + self.denominator.width + hSkew
×
1706
   self.height = maxLength(self.numerator.height + vSkewUp, self.denominator.height - vSkewDown)
×
1707
   self.depth = maxLength(self.numerator.depth - vSkewUp, self.denominator.depth + vSkewDown)
×
1708
   self.barWidth = SILE.types.length(hSkew)
×
1709
   self.barX = self.numerator.relX + self.numerator.width
×
1710
end
1711

1712
function elements.bevelledFraction:output (x, y, line)
24✔
UNCOV
1713
   local h = self.height:tonumber()
×
1714
   local d = self.depth:tonumber()
×
1715
   local barwidth = scaleWidth(self.barWidth, line):tonumber()
×
1716
   local xscaled = scaleWidth(x + self.barX, line)
×
1717
   local rd = self.ruleThickness / 2
×
1718
   local symbol = {
×
1719
      _r(self.ruleThickness),
×
1720
      "w", -- line width
1721
      1,
1722
      "J", -- round line caps
UNCOV
1723
      _r(0),
×
1724
      _r(d + h - rd),
×
1725
      "m",
UNCOV
1726
      _r(barwidth),
×
1727
      _r(rd),
×
1728
      "l",
1729
      "S",
1730
   }
UNCOV
1731
   local svg = table.concat(symbol, " ")
×
1732
   SILE.outputter:drawSVG(svg, xscaled, y, barwidth, h, 1)
×
1733
end
1734

1735
elements.mathMode = mathMode
12✔
1736
elements.newSubscript = newSubscript
12✔
1737
elements.newUnderOver = newUnderOver
12✔
1738

1739
return elements
12✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc