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

sile-typesetter / sile / 11987615580

23 Nov 2024 01:36PM UTC coverage: 68.563% (+8.0%) from 60.594%
11987615580

push

github

web-flow
Merge pull request #2167 from Omikhleia/feat-operator-table

feat(math): Support the MathML operator dictionary and many TeX-like aliases

4115 of 4123 new or added lines in 6 files covered. (99.81%)

214 existing lines in 5 files now uncovered.

13622 of 19868 relevant lines covered (68.56%)

3653.44 hits per line

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

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

8
local elements = {}
16✔
9

10
local mathMode = {
16✔
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,610✔
23
end
24

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

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

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

37
local mathCache = {}
16✔
38

39
local function retrieveMathTable (font)
40
   local key = SILE.font._key(font)
4,981✔
41
   if not mathCache[key] then
4,981✔
42
      SU.debug("math", "Loading math font", key)
44✔
43
      local face = SILE.font.cache(font, SILE.shaper.getFace)
44✔
44
      if not face then
44✔
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")
44✔
49
      if fontHasMathTable then
44✔
50
         mathTableParsable, mathTable = pcall(ot.parseMath, rawMathTable)
88✔
51
      end
52
      if not fontHasMathTable or not mathTableParsable then
44✔
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
88✔
60
      local constants = {}
44✔
61
      for k, v in pairs(mathTable.mathConstants) do
2,508✔
62
         if type(v) == "table" then
2,464✔
63
            v = v.value
2,244✔
64
         end
65
         if k:sub(-9) == "ScaleDown" then
4,928✔
66
            constants[k] = v / 100
88✔
67
         else
68
            constants[k] = v * font.size / upem
2,376✔
69
         end
70
      end
71
      local italicsCorrection = {}
44✔
72
      for k, v in pairs(mathTable.mathItalicsCorrection) do
18,656✔
73
         italicsCorrection[k] = v.value * font.size / upem
18,612✔
74
      end
75
      mathCache[key] = {
44✔
76
         constants = constants,
44✔
77
         italicsCorrection = italicsCorrection,
44✔
78
         mathVariants = mathTable.mathVariants,
44✔
79
         unitsPerEm = upem,
44✔
80
      }
44✔
81
   end
82
   return mathCache[key]
4,981✔
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
120✔
89
      return mathMode.script
82✔
90
   -- D', T' -> S'
91
   elseif mode == mathMode.displayCramped or mode == mathMode.textCramped then
38✔
92
      return mathMode.scriptCramped
31✔
93
   -- S, SS -> SS
94
   elseif mode == mathMode.script or mode == mathMode.scriptScript then
7✔
95
      return mathMode.scriptScript
4✔
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
127✔
105
      or mode == mathMode.text
60✔
106
      or mode == mathMode.displayCramped
50✔
107
      or mode == mathMode.textCramped
50✔
108
   then
109
      return mathMode.scriptCramped
108✔
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
404✔
153
      node = node.children[#node.children]
15✔
154
   end
155
   if node and node:is_a(elements.text) then
374✔
156
      return node.value.glyphString[#node.value.glyphString]
185✔
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 = { ... }
3,341✔
166
   local m
167
   for i, v in ipairs(arg) do
10,386✔
168
      if i == 1 then
7,045✔
169
         m = v
3,341✔
170
      else
171
         if v.length:tonumber() > m.length:tonumber() then
11,112✔
172
            m = v
855✔
173
         end
174
      end
175
   end
176
   return m
3,341✔
177
end
178

179
local function scaleWidth (length, line)
180
   local number = length.length
1,460✔
181
   if line.ratio and line.ratio < 0 and length.shrink:tonumber() > 0 then
1,460✔
182
      number = number + length.shrink * line.ratio
×
183
   elseif line.ratio and line.ratio > 0 and length.stretch:tonumber() > 0 then
2,920✔
184
      number = number + length.stretch * line.ratio
1,640✔
185
   end
186
   return number
1,460✔
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)
32✔
197
elements.mbox._type = "Mbox"
16✔
198

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

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

225
function elements.mbox.styleChildren (_)
32✔
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 (_, _, _)
32✔
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 (_, _, _, _)
32✔
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 ()
32✔
238
   return retrieveMathTable(self.font)
4,981✔
239
end
240

241
function elements.mbox:getScaleDown ()
32✔
242
   local constants = self:getMathMetrics().constants
6,338✔
243
   local scaleDown
244
   if isScriptMode(self.mode) then
6,338✔
245
      scaleDown = constants.scriptPercentScaleDown
565✔
246
   elseif isScriptScriptMode(self.mode) then
5,208✔
247
      scaleDown = constants.scriptScriptPercentScaleDown
182✔
248
   else
249
      scaleDown = 1
2,422✔
250
   end
251
   return scaleDown
3,169✔
252
end
253

254
-- Determine the mode of its descendants
255
function elements.mbox:styleDescendants ()
32✔
256
   self:styleChildren()
2,506✔
257
   for _, n in ipairs(self.children) do
4,923✔
258
      if n then
2,417✔
259
         n:styleDescendants()
2,417✔
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 ()
32✔
269
   for _, n in ipairs(self.children) do
4,923✔
270
      if n then
2,417✔
271
         n:shapeTree()
2,417✔
272
      end
273
   end
274
   self:shape()
2,506✔
275
end
276

277
-- Output the node and all its descendants
278
function elements.mbox:outputTree (x, y, line)
32✔
279
   self:output(x, y, line)
2,506✔
280
   local debug = SILE.settings:get("math.debug.boxes")
2,506✔
281
   if debug and not (self:is_a(elements.space)) then
2,506✔
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,923✔
286
      if n then
2,417✔
287
         n:outputTree(x + n.relX, y + n.relY, line)
7,251✔
288
      end
289
   end
290
end
291

292
local spaceKind = {
16✔
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 = {
16✔
312
   [atoms.types.ord] = {
16✔
313
      -- [atoms.types.ord] = nil
314
      [atoms.types.op] = { spaceKind.thin },
16✔
315
      [atoms.types.bin] = { spaceKind.med, notScript = true },
16✔
316
      [atoms.types.rel] = { spaceKind.thick, notScript = true },
16✔
317
      -- [atoms.types.open] = nil
318
      -- [atoms.types.close] = nil
319
      -- [atoms.types.punct] = nil
320
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
16✔
321
   },
16✔
322
   [atoms.types.op] = {
16✔
323
      [atoms.types.ord] = { spaceKind.thin },
16✔
324
      [atoms.types.op] = { spaceKind.thin },
16✔
325
      [atoms.types.bin] = { impossible = true },
16✔
326
      [atoms.types.rel] = { spaceKind.thick, notScript = true },
16✔
327
      -- [atoms.types.open] = nil
328
      -- [atoms.types.close] = nil
329
      -- [atoms.types.punct] = nil
330
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
16✔
331
   },
16✔
332
   [atoms.types.bin] = {
16✔
333
      [atoms.types.ord] = { spaceKind.med, notScript = true },
16✔
334
      [atoms.types.op] = { spaceKind.med, notScript = true },
16✔
335
      [atoms.types.bin] = { impossible = true },
16✔
336
      [atoms.types.rel] = { impossible = true },
16✔
337
      [atoms.types.open] = { spaceKind.med, notScript = true },
16✔
338
      [atoms.types.close] = { impossible = true },
16✔
339
      [atoms.types.punct] = { impossible = true },
16✔
340
      [atoms.types.inner] = { spaceKind.med, notScript = true },
16✔
341
   },
16✔
342
   [atoms.types.rel] = {
16✔
343
      [atoms.types.ord] = { spaceKind.thick, notScript = true },
16✔
344
      [atoms.types.op] = { spaceKind.thick, notScript = true },
16✔
345
      [atoms.types.bin] = { impossible = true },
16✔
346
      -- [atoms.types.rel] = nil
347
      [atoms.types.open] = { spaceKind.thick, notScript = true },
16✔
348
      -- [atoms.types.close] = nil
349
      -- [atoms.types.punct] = nil
350
      [atoms.types.inner] = { spaceKind.thick, notScript = true },
16✔
351
   },
16✔
352
   [atoms.types.open] = {
16✔
353
      -- [atoms.types.ord] = nil
354
      -- [atoms.types.op] = nil
355
      [atoms.types.bin] = { impossible = true },
16✔
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
   },
16✔
362
   [atoms.types.close] = {
16✔
363
      -- [atoms.types.ord] = nil
364
      [atoms.types.op] = { spaceKind.thin },
16✔
365
      [atoms.types.bin] = { spaceKind.med, notScript = true },
16✔
366
      [atoms.types.rel] = { spaceKind.thick, notScript = true },
16✔
367
      -- [atoms.types.open] = nil
368
      -- [atoms.types.close] = nil
369
      -- [atoms.types.punct] = nil
370
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
16✔
371
   },
16✔
372
   [atoms.types.punct] = {
16✔
373
      [atoms.types.ord] = { spaceKind.thin, notScript = true },
16✔
374
      [atoms.types.op] = { spaceKind.thin, notScript = true },
16✔
375
      [atoms.types.bin] = { impossible = true },
16✔
376
      [atoms.types.rel] = { spaceKind.thin, notScript = true },
16✔
377
      [atoms.types.open] = { spaceKind.thin, notScript = true },
16✔
378
      [atoms.types.close] = { spaceKind.thin, notScript = true },
16✔
379
      [atoms.types.punct] = { spaceKind.thin, notScript = true },
16✔
380
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
16✔
381
   },
16✔
382
   [atoms.types.inner] = {
16✔
383
      [atoms.types.ord] = { spaceKind.thin, notScript = true },
16✔
384
      [atoms.types.op] = { spaceKind.thin },
16✔
385
      [atoms.types.bin] = { spaceKind.med, notScript = true },
16✔
386
      [atoms.types.rel] = { spaceKind.thick, notScript = true },
16✔
387
      [atoms.types.open] = { spaceKind.thin, notScript = true },
16✔
388
      [atoms.types.punct] = { spaceKind.thin, notScript = true },
16✔
389
      -- [atoms.types.close] = nil
390
      [atoms.types.inner] = { spaceKind.thin, notScript = true },
16✔
391
   },
16✔
392
}
393

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

398
function elements.stackbox:__tostring ()
32✔
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)
32✔
408
   elements.mbox._init(self)
540✔
409
   if not (direction == "H" or direction == "V") then
540✔
UNCOV
410
      SU.error("Wrong direction '" .. direction .. "'; should be H or V")
×
411
   end
412
   self.direction = direction
540✔
413
   self.children = children
540✔
414
end
415

416
function elements.stackbox:styleChildren ()
32✔
417
   for _, n in ipairs(self.children) do
2,024✔
418
      n.mode = self.mode
1,484✔
419
   end
420
   if self.direction == "H" then
540✔
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 = {}
540✔
426
      if #self.children >= 1 then
540✔
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]
534✔
431
         if v.atom == atoms.types.bin then
534✔
NEW
432
            v.atom = atoms.types.ord
×
433
         end
434
      end
435
      for i = 1, #self.children - 1 do
1,490✔
436
         local v = self.children[i]
950✔
437
         local v2 = self.children[i + 1]
950✔
438
         -- Handle re-wrapped paired open/close symbols
439
         v = v.is_paired and v.children[#v.children] or v
950✔
440
         v2 = v2.is_paired and v2.children[1] or v2
950✔
441
         if spacingRules[v.atom] and spacingRules[v.atom][v2.atom] then
950✔
442
            local rule = spacingRules[v.atom][v2.atom]
468✔
443
            if rule.impossible then
468✔
444
               -- Another interpretation of the TeXbook p. 133 for binary operator exceptions:
445
               if v2.atom == atoms.types.bin then
16✔
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
12✔
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]
16✔
461
               if rule and rule.impossible then
16✔
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,224✔
470
               spaces[i + 1] = rule[1]
347✔
471
            end
472
         end
473
      end
474
      local spaceIdx = {}
540✔
475
      for i, _ in pairs(spaces) do
887✔
476
         table.insert(spaceIdx, i)
347✔
477
      end
478
      table.sort(spaceIdx, function (a, b)
1,080✔
479
         return a > b
468✔
480
      end)
481
      for _, idx in ipairs(spaceIdx) do
887✔
482
         local hsp = elements.space(spaces[idx], 0, 0)
347✔
483
         table.insert(self.children, idx, hsp)
347✔
484
      end
485
   end
486
end
487

488
function elements.stackbox:shape ()
32✔
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)
1,080✔
498
   self.depth = SILE.types.length(0)
1,080✔
499
   if self.direction == "H" then
540✔
500
      for i, n in ipairs(self.children) do
2,371✔
501
         n.relY = SILE.types.length(0)
3,662✔
502
         self.height = i == 1 and n.height or maxLength(self.height, n.height)
3,128✔
503
         self.depth = i == 1 and n.depth or maxLength(self.depth, n.depth)
3,128✔
504
      end
505
      -- Handle stretchy operators
506
      for _, elt in ipairs(self.children) do
2,371✔
507
         if elt:is_a(elements.text) and elt.kind == "operator" and SU.boolean(elt.stretchy, false) then
4,084✔
508
            elt:_vertStretchyReshape(self.depth, self.height)
102✔
509
         end
510
      end
511
      -- Set self.width
512
      self.width = SILE.types.length(0)
1,080✔
513
      for i, n in ipairs(self.children) do
2,371✔
514
         n.relX = self.width
1,831✔
515
         self.width = i == 1 and n.width or self.width + n.width
3,128✔
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)
32✔
541
   local mathX = typesetter.frame.state.cursorX
89✔
542
   local mathY = typesetter.frame.state.cursorY
89✔
543
   self:outputTree(self.relX + mathX, self.relY + mathY, line)
267✔
544
   typesetter.frame:advanceWritingDirection(scaleWidth(self.width, line))
178✔
545
end
546

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

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

552
function elements.phantom:_init (children)
32✔
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 (_, _, _)
32✔
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)
32✔
571
elements.subscript._type = "Subscript"
16✔
572

573
function elements.subscript:__tostring ()
32✔
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)
32✔
583
   elements.mbox._init(self)
159✔
584
   self.base = base
159✔
585
   self.sub = sub
159✔
586
   self.sup = sup
159✔
587
   if self.base then
159✔
588
      table.insert(self.children, self.base)
159✔
589
   end
590
   if self.sub then
159✔
591
      table.insert(self.children, self.sub)
97✔
592
   end
593
   if self.sup then
159✔
594
      table.insert(self.children, self.sup)
94✔
595
   end
596
   self.atom = self.base.atom
159✔
597
end
598

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

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

622
function elements.subscript:shape ()
32✔
623
   local mathMetrics = self:getMathMetrics()
174✔
624
   local constants = mathMetrics.constants
174✔
625
   local scaleDown = self:getScaleDown()
174✔
626
   if self.base then
174✔
627
      self.base.relX = SILE.types.length(0)
348✔
628
      self.base.relY = SILE.types.length(0)
348✔
629
      -- Use widthForSubscript of base, if available
630
      self.width = self.base.widthForSubscript or self.base.width
174✔
631
   else
UNCOV
632
      self.width = SILE.types.length(0)
×
633
   end
634
   local itCorr = self:calculateItalicsCorrection() * scaleDown
348✔
635
   local isBaseSymbol = not self.base or self.base:is_a(elements.terminal)
348✔
636
   local isBaseLargeOp = SU.boolean(self.base and self.base.largeop, false)
174✔
637
   local subShift
638
   local supShift
639
   if self.sub then
174✔
640
      if self.isUnderOver or isBaseLargeOp then
112✔
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
79✔
646
      end
647
      self.sub.relX = self.width + subShift
224✔
648
      self.sub.relY = SILE.types.length(
224✔
649
         math.max(
224✔
650
            constants.subscriptShiftDown * scaleDown,
112✔
651
            isBaseSymbol and 0 -- TeX (σ19) is more finicky than MathML Core
112✔
652
               or (self.base.depth + constants.subscriptBaselineDropMin * scaleDown):tonumber(),
116✔
653
            (self.sub.height - constants.subscriptTopMax * scaleDown):tonumber()
224✔
654
         )
655
      )
112✔
656
      if self:is_a(elements.underOver) or self:is_a(elements.stackbox) or isBaseLargeOp then
321✔
657
         self.sub.relY = maxLength(self.sub.relY, self.base.depth + constants.subscriptBaselineDropMin * scaleDown)
99✔
658
      end
659
   end
660
   if self.sup then
174✔
661
      if self.isUnderOver or isBaseLargeOp then
109✔
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
88✔
667
      end
668
      self.sup.relX = self.width + supShift
218✔
669
      self.sup.relY = SILE.types.length(
218✔
670
         math.max(
218✔
671
            isCrampedMode(self.mode) and constants.superscriptShiftUpCramped * scaleDown
218✔
672
               or constants.superscriptShiftUp * scaleDown,
109✔
673
            isBaseSymbol and 0 -- TeX (σ18) is more finicky than MathML Core
109✔
674
               or (self.base.height - constants.superscriptBaselineDropMax * scaleDown):tonumber(),
121✔
675
            (self.sup.depth + constants.superscriptBottomMin * scaleDown):tonumber()
218✔
676
         )
677
      ) * -1
218✔
678
      if self:is_a(elements.underOver) or self:is_a(elements.stackbox) or isBaseLargeOp then
312✔
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
174✔
686
      local gap = self.sub.relY - self.sub.height - self.sup.relY - self.sup.depth
141✔
687
      if gap.length:tonumber() < constants.subSuperscriptGapMin * scaleDown then
94✔
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
104✔
691
         local psi = constants.superscriptBottomMaxWithSubscript * scaleDown + self.sup.relY + self.sup.depth
52✔
692
         if psi:tonumber() > 0 then
52✔
693
            self.sup.relY = self.sup.relY - psi
30✔
694
            self.sub.relY = self.sub.relY - psi
30✔
695
         end
696
      end
697
   end
UNCOV
698
   self.width = self.width
×
699
      + maxLength(
348✔
700
         self.sub and self.sub.width + subShift or SILE.types.length(0),
286✔
701
         self.sup and self.sup.width + supShift or SILE.types.length(0)
283✔
702
      )
174✔
703
      + constants.spaceAfterScript * scaleDown
348✔
704
   self.height = maxLength(
348✔
705
      self.base and self.base.height or SILE.types.length(0),
174✔
706
      self.sub and (self.sub.height - self.sub.relY) or SILE.types.length(0),
286✔
707
      self.sup and (self.sup.height - self.sup.relY) or SILE.types.length(0)
283✔
708
   )
174✔
709
   self.depth = maxLength(
348✔
710
      self.base and self.base.depth or SILE.types.length(0),
174✔
711
      self.sub and (self.sub.depth + self.sub.relY) or SILE.types.length(0),
286✔
712
      self.sup and (self.sup.depth + self.sup.relY) or SILE.types.length(0)
283✔
713
   )
174✔
714
end
715

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

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

721
function elements.underOver:__tostring ()
32✔
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)
32✔
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 ()
32✔
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)
32✔
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✔
UNCOV
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 ()
32✔
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✔
UNCOV
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 ()
32✔
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✔
UNCOV
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
46✔
923

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

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

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

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

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

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

943
function elements.space:__tostring ()
32✔
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,287✔
956
      local direction = 1
429✔
957
      if value:sub(1, 1) == "-" then
858✔
958
         value = value:sub(2, -1)
20✔
959
         direction = -1
10✔
960
      end
961
      if value == "thin" then
429✔
962
         return SILE.types.length("3mu") * direction
267✔
963
      elseif value == "med" then
340✔
964
         return SILE.types.length("4mu plus 2mu minus 4mu") * direction
414✔
965
      elseif value == "thick" then
202✔
966
         return SILE.types.length("5mu plus 5mu") * direction
465✔
967
      end
968
   end
969
   return SILE.types.length(value)
905✔
970
end
971

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

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

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

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

991
function elements.text:__tostring ()
32✔
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)
32✔
1007
   elements.terminal._init(self)
1,283✔
1008
   if not (kind == "number" or kind == "identifier" or kind == "operator" or kind == "string") then
1,283✔
UNCOV
1009
      SU.error("Unknown text node kind '" .. kind .. "'; should be one of: number, identifier, operator, string")
×
1010
   end
1011
   self.kind = kind
1,283✔
1012
   self.script = script
1,283✔
1013
   self.text = text
1,283✔
1014
   if self.script ~= "upright" then
1,283✔
1015
      local converted = convertMathVariantScript(self.text, self.script)
1,283✔
1016
      self.originalText = self.text
1,283✔
1017
      self.text = converted
1,283✔
1018
   end
1019
   if self.kind == "operator" then
1,283✔
1020
      if self.text == "-" then
517✔
1021
         self.text = "−"
18✔
1022
      end
1023
   end
1024
   for attribute, value in pairs(attributes) do
4,149✔
1025
      self[attribute] = value
2,866✔
1026
   end
1027
end
1028

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

1115
function elements.text.findClosestVariant (_, variants, requiredAdvance, currentAdvance)
32✔
1116
   local closest
1117
   local closestI
1118
   local m = requiredAdvance - currentAdvance
101✔
1119
   for i, variant in ipairs(variants) do
1,414✔
1120
      local diff = math.abs(variant.advanceMeasurement - requiredAdvance)
1,313✔
1121
      SU.debug("math", "stretch: diff =", diff)
1,313✔
1122
      if diff < m then
1,313✔
1123
         closest = variant
95✔
1124
         closestI = i
95✔
1125
         m = diff
95✔
1126
      end
1127
   end
1128
   return closest, closestI
101✔
1129
end
1130

1131
function elements.text:_reshapeGlyph (glyph, closestVariant, sz)
32✔
1132
   local face = SILE.font.cache(self.font, SILE.shaper.getFace)
41✔
1133
   local dimen = hb.get_glyph_dimensions(face, sz, closestVariant.variantGlyph)
41✔
1134
   glyph.gid = closestVariant.variantGlyph
41✔
UNCOV
1135
   glyph.width, glyph.height, glyph.depth, glyph.glyphAdvance =
×
1136
      dimen.width, dimen.height, dimen.depth, dimen.glyphAdvance
41✔
1137
   return dimen
41✔
1138
end
1139

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

1184
function elements.text:_vertStretchyReshape (depth, height)
32✔
1185
   local hasStretched = self:_stretchyReshape(depth + height, true)
204✔
1186
   if hasStretched then
102✔
1187
      -- HACK: see output routine
1188
      self.vertExpectedSz = height + depth
82✔
1189
      self.vertScalingRatio = (depth + height):tonumber() / (self.height:tonumber() + self.depth:tonumber())
205✔
1190
      self.height = height
41✔
1191
      self.depth = depth
41✔
1192
   end
1193
   return hasStretched
102✔
1194
end
1195

1196
function elements.text:_horizStretchyReshape (width)
32✔
UNCOV
1197
   local hasStretched = self:_stretchyReshape(width, false)
×
1198
   if hasStretched then
×
1199
      -- HACK: see output routine
UNCOV
1200
      self.horizScalingRatio = width:tonumber() / self.width:tonumber()
×
1201
      self.width = width
×
1202
   end
UNCOV
1203
   return hasStretched
×
1204
end
1205

1206
function elements.text:output (x, y, line)
32✔
1207
   if not self.value.glyphString then
1,283✔
UNCOV
1208
      return
×
1209
   end
1210
   local compensatedY
1211
   if isDisplayMode(self.mode) and SU.boolean(self.largeop, false) and self.value.items[1].fontDepth then
3,062✔
1212
      compensatedY = SILE.types.length(y.length + self.value.items[1].depth - self.value.items[1].fontDepth)
84✔
1213
   else
1214
      compensatedY = y
1,262✔
1215
   end
1216
   SILE.outputter:setCursor(scaleWidth(x, line), compensatedY.length)
2,566✔
1217
   SILE.outputter:setFont(self.font)
1,283✔
1218
   -- There should be no stretch or shrink on the width of a text
1219
   -- element.
1220
   local width = self.width.length
1,283✔
1221
   -- HACK: For stretchy operators, MathML Core and OpenType define how to build large glyphs
1222
   -- from an assembly of smaller ones. It's fairly complex and idealistic...
1223
   -- Anyhow, we do not have that yet, so we just stretch the glyph artificially.
1224
   -- There are cases where this will not look very good.
1225
   -- Call that a compromise, so that long vectors or large matrices look "decent" without assembly.
1226
   if SILE.outputter.scaleFn and (self.horizScalingRatio or self.vertScalingRatio) then
1,283✔
1227
      local xratio = self.horizScalingRatio or 1
41✔
1228
      local yratio = self.vertScalingRatio or 1
41✔
1229
      SU.debug("math", "fake glyph stretch: xratio =", xratio, "yratio =", yratio)
41✔
1230
      SILE.outputter:scaleFn(x, y, xratio, yratio, function ()
82✔
1231
         SILE.outputter:drawHbox(self.value, width)
41✔
1232
      end)
1233
   else
1234
      SILE.outputter:drawHbox(self.value, width)
1,242✔
1235
   end
1236
end
1237

1238
elements.fraction = pl.class(elements.mbox)
32✔
1239
elements.fraction._type = "Fraction"
16✔
1240

1241
function elements.fraction:__tostring ()
32✔
UNCOV
1242
   return self._type .. "(" .. tostring(self.numerator) .. ", " .. tostring(self.denominator) .. ")"
×
1243
end
1244

1245
function elements.fraction:_init (attributes, numerator, denominator)
32✔
1246
   elements.mbox._init(self)
44✔
1247
   self.numerator = numerator
44✔
1248
   self.denominator = denominator
44✔
1249
   self.attributes = attributes
44✔
1250
   table.insert(self.children, numerator)
44✔
1251
   table.insert(self.children, denominator)
44✔
1252
end
1253

1254
function elements.fraction:styleChildren ()
32✔
1255
   self.numerator.mode = getNumeratorMode(self.mode)
88✔
1256
   self.denominator.mode = getDenominatorMode(self.mode)
88✔
1257
end
1258

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

1268
   -- Determine relative abscissas and width
1269
   local widest, other
1270
   if self.denominator.width > self.numerator.width then
44✔
1271
      widest, other = self.denominator, self.numerator
35✔
1272
   else
1273
      widest, other = self.numerator, self.denominator
9✔
1274
   end
1275
   widest.relX = self.padding
44✔
1276
   other.relX = self.padding + (widest.width - other.width) / 2
176✔
1277
   self.width = widest.width + 2 * self.padding
132✔
1278
   -- Determine relative ordinates and height
1279
   local constants = self:getMathMetrics().constants
88✔
1280
   local scaleDown = self:getScaleDown()
44✔
1281
   self.axisHeight = constants.axisHeight * scaleDown
44✔
1282
   self.ruleThickness = self.attributes.linethickness
44✔
1283
         and SU.cast("measurement", self.attributes.linethickness):tonumber()
44✔
1284
      or constants.fractionRuleThickness * scaleDown
44✔
1285

1286
   -- MathML Core 3.3.2.2 ("Fraction with zero line thickness") uses
1287
   -- stack(DisplayStyle)GapMin, stackTop(DisplayStyle)ShiftUp and stackBottom(DisplayStyle)ShiftDown.
1288
   -- TODO not implemented
1289
   -- The most common use cases for zero line thickness are:
1290
   --  - Binomial coefficients
1291
   --  - Stacked subscript/superscript on big operators such as sums.
1292

1293
   local numeratorGapMin, denominatorGapMin, numeratorShiftUp, denominatorShiftDown
1294
   if isDisplayMode(self.mode) then
88✔
1295
      numeratorGapMin = constants.fractionNumDisplayStyleGapMin * scaleDown
21✔
1296
      denominatorGapMin = constants.fractionDenomDisplayStyleGapMin * scaleDown
21✔
1297
      numeratorShiftUp = constants.fractionNumeratorDisplayStyleShiftUp * scaleDown
21✔
1298
      denominatorShiftDown = constants.fractionDenominatorDisplayStyleShiftDown * scaleDown
21✔
1299
   else
1300
      numeratorGapMin = constants.fractionNumeratorGapMin * scaleDown
23✔
1301
      denominatorGapMin = constants.fractionDenominatorGapMin * scaleDown
23✔
1302
      numeratorShiftUp = constants.fractionNumeratorShiftUp * scaleDown
23✔
1303
      denominatorShiftDown = constants.fractionDenominatorShiftDown * scaleDown
23✔
1304
   end
1305

1306
   self.numerator.relY = -self.axisHeight
44✔
1307
      - self.ruleThickness / 2
44✔
1308
      - SILE.types.length(
88✔
1309
         math.max(
88✔
1310
            (numeratorGapMin + self.numerator.depth):tonumber(),
88✔
1311
            numeratorShiftUp - self.axisHeight - self.ruleThickness / 2
44✔
1312
         )
1313
      )
88✔
1314
   self.denominator.relY = -self.axisHeight
44✔
1315
      + self.ruleThickness / 2
44✔
1316
      + SILE.types.length(
88✔
1317
         math.max(
88✔
1318
            (denominatorGapMin + self.denominator.height):tonumber(),
88✔
1319
            denominatorShiftDown + self.axisHeight - self.ruleThickness / 2
44✔
1320
         )
1321
      )
88✔
1322
   self.height = self.numerator.height - self.numerator.relY
88✔
1323
   self.depth = self.denominator.relY + self.denominator.depth
88✔
1324
end
1325

1326
function elements.fraction:output (x, y, line)
32✔
1327
   if self.ruleThickness > 0 then
44✔
1328
      SILE.outputter:drawRule(
88✔
1329
         scaleWidth(x + self.padding, line),
88✔
1330
         y.length - self.axisHeight - self.ruleThickness / 2,
88✔
1331
         scaleWidth(self.width - 2 * self.padding, line),
132✔
1332
         self.ruleThickness
1333
      )
44✔
1334
   end
1335
end
1336

1337
local function newSubscript (spec)
1338
   return elements.subscript(spec.base, spec.sub, spec.sup)
159✔
1339
end
1340

1341
local function newUnderOver (spec)
1342
   return elements.underOver(spec.base, spec.sub, spec.sup)
30✔
1343
end
1344

1345
-- TODO replace with penlight equivalent
1346
local function mapList (f, l)
1347
   local ret = {}
9✔
1348
   for i, x in ipairs(l) do
35✔
1349
      ret[i] = f(i, x)
52✔
1350
   end
1351
   return ret
9✔
1352
end
1353

1354
elements.mtr = pl.class(elements.mbox)
32✔
1355
-- elements.mtr._type = "" -- TODO why not set?
1356

1357
function elements.mtr:_init (children)
32✔
1358
   self.children = children
12✔
1359
end
1360

1361
function elements.mtr:styleChildren ()
32✔
1362
   for _, c in ipairs(self.children) do
48✔
1363
      c.mode = self.mode
36✔
1364
   end
1365
end
1366

1367
function elements.mtr.shape (_) end -- done by parent table
28✔
1368

1369
function elements.mtr.output (_) end
28✔
1370

1371
elements.table = pl.class(elements.mbox)
32✔
1372
elements.table._type = "table" -- TODO why case difference?
16✔
1373

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

1419
function elements.table:styleChildren ()
32✔
1420
   if self.mode == mathMode.display and self.options.displaystyle ~= "false" then
9✔
1421
      for _, c in ipairs(self.children) do
19✔
1422
         c.mode = mathMode.display
14✔
1423
      end
1424
   else
1425
      for _, c in ipairs(self.children) do
16✔
1426
         c.mode = mathMode.text
12✔
1427
      end
1428
   end
1429
end
1430

1431
function elements.table:shape ()
32✔
1432
   -- Determine the height (resp. depth) of each row, which is the max
1433
   -- height (resp. depth) among its elements. Then we only need to add it to
1434
   -- the table's height and center every cell vertically.
1435
   for _, row in ipairs(self.children) do
35✔
1436
      row.height = SILE.types.length(0)
52✔
1437
      row.depth = SILE.types.length(0)
52✔
1438
      for _, cell in ipairs(row.children) do
104✔
1439
         row.height = maxLength(row.height, cell.height)
156✔
1440
         row.depth = maxLength(row.depth, cell.depth)
156✔
1441
      end
1442
   end
1443
   self.vertSize = SILE.types.length(0)
18✔
1444
   for i, row in ipairs(self.children) do
35✔
UNCOV
1445
      self.vertSize = self.vertSize
×
1446
         + row.height
26✔
1447
         + row.depth
26✔
1448
         + (i == self.nrows and SILE.types.length(0) or self.rowspacing) -- Spacing
61✔
1449
   end
1450
   local rowHeightSoFar = SILE.types.length(0)
9✔
1451
   for i, row in ipairs(self.children) do
35✔
1452
      row.relY = rowHeightSoFar + row.height - self.vertSize
78✔
UNCOV
1453
      rowHeightSoFar = rowHeightSoFar
×
1454
         + row.height
26✔
1455
         + row.depth
26✔
1456
         + (i == self.nrows and SILE.types.length(0) or self.rowspacing) -- Spacing
35✔
1457
   end
1458
   self.width = SILE.types.length(0)
18✔
1459
   local thisColRelX = SILE.types.length(0)
9✔
1460
   -- For every column...
1461
   for i = 1, self.ncols do
36✔
1462
      -- Determine its width
1463
      local columnWidth = SILE.types.length(0)
27✔
1464
      for j = 1, self.nrows do
105✔
1465
         if self.children[j].children[i].width > columnWidth then
78✔
1466
            columnWidth = self.children[j].children[i].width
37✔
1467
         end
1468
      end
1469
      -- Use it to align the contents of every cell as required.
1470
      for j = 1, self.nrows do
105✔
1471
         local cell = self.children[j].children[i]
78✔
1472
         if self.options.columnalign[i] == "left" then
78✔
1473
            cell.relX = thisColRelX
14✔
1474
         elseif self.options.columnalign[i] == "center" then
64✔
1475
            cell.relX = thisColRelX + (columnWidth - cell.width) / 2
200✔
1476
         elseif self.options.columnalign[i] == "right" then
14✔
1477
            cell.relX = thisColRelX + (columnWidth - cell.width)
42✔
1478
         else
UNCOV
1479
            SU.error("invalid columnalign parameter")
×
1480
         end
1481
      end
1482
      thisColRelX = thisColRelX + columnWidth + (i == self.ncols and SILE.types.length(0) or self.columnspacing) -- Spacing
63✔
1483
   end
1484
   self.width = thisColRelX
9✔
1485
   -- Center myself vertically around the axis, and update relative Ys of rows accordingly
1486
   local axisHeight = self:getMathMetrics().constants.axisHeight * self:getScaleDown()
27✔
1487
   self.height = self.vertSize / 2 + axisHeight
27✔
1488
   self.depth = self.vertSize / 2 - axisHeight
27✔
1489
   for _, row in ipairs(self.children) do
35✔
1490
      row.relY = row.relY + self.vertSize / 2 - axisHeight
104✔
1491
      -- Also adjust width
1492
      row.width = self.width
26✔
1493
   end
1494
end
1495

1496
function elements.table.output (_) end
25✔
1497

1498
local function getRadicandMode (mode)
1499
   -- Not too sure if we should do something special/
UNCOV
1500
   return mode
×
1501
end
1502

1503
local function getDegreeMode (mode)
1504
   -- 2 levels smaller, up to scriptScript evntually.
1505
   -- Not too sure if we should do something else.
UNCOV
1506
   if mode == mathMode.display then
×
1507
      return mathMode.scriptScript
×
1508
   elseif mode == mathMode.displayCramped then
×
1509
      return mathMode.scriptScriptCramped
×
1510
   elseif mode == mathMode.text or mode == mathMode.script or mode == mathMode.scriptScript then
×
1511
      return mathMode.scriptScript
×
1512
   end
UNCOV
1513
   return mathMode.scriptScriptCramped
×
1514
end
1515

1516
elements.sqrt = pl.class(elements.mbox)
32✔
1517
elements.sqrt._type = "Sqrt"
16✔
1518

1519
function elements.sqrt:__tostring ()
32✔
UNCOV
1520
   return self._type .. "(" .. tostring(self.radicand) .. (self.degree and ", " .. tostring(self.degree) or "") .. ")"
×
1521
end
1522

1523
function elements.sqrt:_init (radicand, degree)
32✔
UNCOV
1524
   elements.mbox._init(self)
×
1525
   self.radicand = radicand
×
1526
   if degree then
×
1527
      self.degree = degree
×
1528
      table.insert(self.children, degree)
×
1529
   end
UNCOV
1530
   table.insert(self.children, radicand)
×
1531
   self.relX = SILE.types.length()
×
1532
   self.relY = SILE.types.length()
×
1533
end
1534

1535
function elements.sqrt:styleChildren ()
32✔
UNCOV
1536
   self.radicand.mode = getRadicandMode(self.mode)
×
1537
   if self.degree then
×
1538
      self.degree.mode = getDegreeMode(self.mode)
×
1539
   end
1540
end
1541

1542
function elements.sqrt:shape ()
32✔
UNCOV
1543
   local mathMetrics = self:getMathMetrics()
×
1544
   local scaleDown = self:getScaleDown()
×
1545
   local constants = mathMetrics.constants
×
1546

UNCOV
1547
   self.radicalRuleThickness = constants.radicalRuleThickness * scaleDown
×
1548
   if self.mode == mathMode.display or self.mode == mathMode.displayCramped then
×
1549
      self.radicalVerticalGap = constants.radicalDisplayStyleVerticalGap * scaleDown
×
1550
   else
UNCOV
1551
      self.radicalVerticalGap = constants.radicalVerticalGap * scaleDown
×
1552
   end
UNCOV
1553
   self.extraAscender = constants.radicalExtraAscender * scaleDown
×
1554

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

1568
   -- Adjust the height of the radical sign if the radicand is higher
UNCOV
1569
   self.symbolHeight = self.radicand.height > self.symbolHeight and self.radicand.height or self.symbolHeight
×
1570
   -- Compute the (max-)height of the short leg of the radical sign
UNCOV
1571
   self.symbolShortHeight = self.symbolHeight * constants.radicalDegreeBottomRaisePercent
×
1572

UNCOV
1573
   self.offsetX = SILE.types.length()
×
1574
   if self.degree then
×
1575
      -- Position the degree
UNCOV
1576
      self.degree.relY = -constants.radicalDegreeBottomRaisePercent * self.symbolHeight
×
1577
      -- Adjust the height of the short leg of the radical sign to ensure the degree is not too close
1578
      -- (empirically use radicalExtraAscender)
UNCOV
1579
      self.symbolShortHeight = self.symbolShortHeight - constants.radicalExtraAscender * scaleDown
×
1580
      -- Compute the width adjustment for the degree
UNCOV
1581
      self.offsetX = self.degree.width
×
1582
         + constants.radicalKernBeforeDegree * scaleDown
×
1583
         + constants.radicalKernAfterDegree * scaleDown
×
1584
   end
1585
   -- Position the radicand
UNCOV
1586
   self.radicand.relX = self.symbolWidth + self.offsetX
×
1587
   -- Compute the dimensions of the whole radical
UNCOV
1588
   self.width = self.radicand.width + self.symbolWidth + self.offsetX
×
1589
   self.height = self.symbolHeight + self.radicalVerticalGap + self.extraAscender
×
1590
   self.depth = self.radicand.depth
×
1591
end
1592

1593
local function _r (number)
1594
   -- Lua 5.3+ formats floats as 1.0 and integers as 1
1595
   -- Also some PDF readers do not like double precision.
UNCOV
1596
   return math.floor(number) == number and math.floor(number) or tonumber(string.format("%.5f", number))
×
1597
end
1598

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

1645
elements.padded = pl.class(elements.mbox)
32✔
1646
elements.padded._type = "Padded"
16✔
1647

1648
function elements.padded:__tostring ()
32✔
UNCOV
1649
   return self._type .. "(" .. tostring(self.impadded) .. ")"
×
1650
end
1651

1652
function elements.padded:_init (attributes, impadded)
32✔
UNCOV
1653
   elements.mbox._init(self)
×
1654
   self.impadded = impadded
×
1655
   self.attributes = attributes or {}
×
1656
   table.insert(self.children, impadded)
×
1657
end
1658

1659
function elements.padded:styleChildren ()
32✔
UNCOV
1660
   self.impadded.mode = self.mode
×
1661
end
1662

1663
function elements.padded:shape ()
32✔
1664
   -- TODO MathML allows percentages font-relative units (em, ex) for padding
1665
   -- But our units work with font.size, not math.font.size (possibly adjusted by scaleDown)
1666
   -- so the expectations might not be met.
UNCOV
1667
   local width = self.attributes.width and SU.cast("measurement", self.attributes.width)
×
1668
   local height = self.attributes.height and SU.cast("measurement", self.attributes.height)
×
1669
   local depth = self.attributes.depth and SU.cast("measurement", self.attributes.depth)
×
1670
   local lspace = self.attributes.lspace and SU.cast("measurement", self.attributes.lspace)
×
1671
   local voffset = self.attributes.voffset and SU.cast("measurement", self.attributes.voffset)
×
1672
   -- Clamping for width, height, depth, lspace
UNCOV
1673
   width = width and (width:tonumber() > 0 and width or SILE.types.measurement())
×
1674
   height = height and (height:tonumber() > 0 and height or SILE.types.measurement())
×
1675
   depth = depth and (depth:tonumber() > 0 and depth or SILE.types.measurement())
×
1676
   lspace = lspace and (lspace:tonumber() > 0 and lspace or SILE.types.measurement())
×
1677
   -- No clamping for voffset
UNCOV
1678
   voffset = voffset or SILE.types.measurement(0)
×
1679
   -- Compute the dimensions
UNCOV
1680
   self.width = width and SILE.types.length(width) or self.impadded.width
×
1681
   self.height = height and SILE.types.length(height) or self.impadded.height
×
1682
   self.depth = depth and SILE.types.length(depth) or self.impadded.depth
×
1683
   self.impadded.relX = lspace and SILE.types.length(lspace) or SILE.types.length()
×
1684
   self.impadded.relY = voffset and SILE.types.length(voffset):negate() or SILE.types.length()
×
1685
end
1686

1687
function elements.padded.output (_, _, _, _) end
16✔
1688

1689
-- Bevelled fractions are not part of MathML Core, and MathML4 does not
1690
-- exactly specify how to compute the layout.
1691
elements.bevelledFraction = pl.class(elements.fraction) -- Inherit from fraction
32✔
1692
elements.fraction._type = "BevelledFraction"
16✔
1693

1694
function elements.bevelledFraction:shape ()
32✔
UNCOV
1695
   local constants = self:getMathMetrics().constants
×
1696
   local scaleDown = self:getScaleDown()
×
1697
   local hSkew = constants.skewedFractionHorizontalGap * scaleDown
×
1698
   -- OpenType has properties which are not totally explicit.
1699
   -- The definition of skewedFractionVerticalGap (and its value in fonts
1700
   -- such as Libertinus Math) seems to imply that it is measured from the
1701
   -- bottom of the numerator to the top of the denominator.
1702
   -- This does not seem to be a nice general layout.
1703
   -- So we will use superscriptShiftUp(Cramped) for the numerator:
UNCOV
1704
   local vSkewUp = isCrampedMode(self.mode) and constants.superscriptShiftUpCramped * scaleDown
×
1705
      or constants.superscriptShiftUp * scaleDown
×
1706
   -- And all good books say that the denominator should not be shifted down:
UNCOV
1707
   local vSkewDown = 0
×
1708

UNCOV
1709
   self.ruleThickness = self.attributes.linethickness
×
1710
         and SU.cast("measurement", self.attributes.linethickness):tonumber()
×
1711
      or constants.fractionRuleThickness * scaleDown
×
1712
   self.numerator.relX = SILE.types.length(0)
×
1713
   self.numerator.relY = SILE.types.length(-vSkewUp)
×
1714
   self.denominator.relX = self.numerator.width + hSkew
×
1715
   self.denominator.relY = SILE.types.length(vSkewDown)
×
1716
   self.width = self.numerator.width + self.denominator.width + hSkew
×
1717
   self.height = maxLength(self.numerator.height + vSkewUp, self.denominator.height - vSkewDown)
×
1718
   self.depth = maxLength(self.numerator.depth - vSkewUp, self.denominator.depth + vSkewDown)
×
1719
   self.barWidth = SILE.types.length(hSkew)
×
1720
   self.barX = self.numerator.relX + self.numerator.width
×
1721
end
1722

1723
function elements.bevelledFraction:output (x, y, line)
32✔
UNCOV
1724
   local h = self.height:tonumber()
×
1725
   local d = self.depth:tonumber()
×
1726
   local barwidth = scaleWidth(self.barWidth, line):tonumber()
×
1727
   local xscaled = scaleWidth(x + self.barX, line)
×
1728
   local rd = self.ruleThickness / 2
×
1729
   local symbol = {
×
1730
      _r(self.ruleThickness),
×
1731
      "w", -- line width
1732
      1,
1733
      "J", -- round line caps
UNCOV
1734
      _r(0),
×
1735
      _r(d + h - rd),
×
1736
      "m",
UNCOV
1737
      _r(barwidth),
×
1738
      _r(rd),
×
1739
      "l",
1740
      "S",
1741
   }
UNCOV
1742
   local svg = table.concat(symbol, " ")
×
1743
   SILE.outputter:drawSVG(svg, xscaled, y, barwidth, h, 1)
×
1744
end
1745

1746
elements.mathMode = mathMode
16✔
1747
elements.newSubscript = newSubscript
16✔
1748
elements.newUnderOver = newUnderOver
16✔
1749

1750
return elements
16✔
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