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

sile-typesetter / sile / 11613475099

31 Oct 2024 02:07PM UTC coverage: 60.798% (+3.5%) from 57.261%
11613475099

push

github

web-flow
Merge cbdbfbf2e into 5dffc31aa

114 of 179 new or added lines in 5 files covered. (63.69%)

163 existing lines in 5 files now uncovered.

11047 of 18170 relevant lines covered (60.8%)

3094.47 hits per line

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

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

8
local atomType = syms.atomType
10✔
9
local symbolDefaults = syms.symbolDefaults
10✔
10

11
local elements = {}
10✔
12

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

24
local function isDisplayMode (mode)
25
   return mode <= 1
1,881✔
26
end
27

28
local function isCrampedMode (mode)
29
   return mode % 2 == 1
57✔
30
end
31

32
local function isScriptMode (mode)
33
   return mode == mathMode.script or mode == mathMode.scriptCramped
2,530✔
34
end
35

36
local function isScriptScriptMode (mode)
37
   return mode == mathMode.scriptScript or mode == mathMode.scriptScriptCramped
2,056✔
38
end
39

40
local mathCache = {}
10✔
41

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

59
            The math table in '%s' could not be %s.
60
         ]]):format(face.filename, fontHasMathTable and "parsed" or "loaded"))
×
61
      end
62
      local upem = ot.parseHead(hb.get_table(face, "head")).unitsPerEm
64✔
63
      local constants = {}
32✔
64
      for k, v in pairs(mathTable.mathConstants) do
1,824✔
65
         if type(v) == "table" then
1,792✔
66
            v = v.value
1,632✔
67
         end
68
         if k:sub(-9) == "ScaleDown" then
3,584✔
69
            constants[k] = v / 100
64✔
70
         else
71
            constants[k] = v * font.size / upem
1,728✔
72
         end
73
      end
74
      local italicsCorrection = {}
32✔
75
      for k, v in pairs(mathTable.mathItalicsCorrection) do
13,568✔
76
         italicsCorrection[k] = v.value * font.size / upem
13,536✔
77
      end
78
      mathCache[key] = {
32✔
79
         constants = constants,
32✔
80
         italicsCorrection = italicsCorrection,
32✔
81
         mathVariants = mathTable.mathVariants,
32✔
82
         unitsPerEm = upem,
32✔
83
      }
32✔
84
   end
85
   return mathCache[key]
3,514✔
86
end
87

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

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

154
local function getRightMostGlyphId (node)
155
   while node and node:is_a(elements.stackbox) and node.direction == "H" do
280✔
156
      node = node.children[#node.children]
15✔
157
   end
158
   if node and node:is_a(elements.text) then
250✔
159
      return node.value.glyphString[#node.value.glyphString]
123✔
160
   else
161
      return 0
4✔
162
   end
163
end
164

165
-- Compares two SILE.types.length, without considering shrink or stretch values, and
166
-- returns the biggest.
167
local function maxLength (...)
168
   local arg = { ... }
2,411✔
169
   local m
170
   for i, v in ipairs(arg) do
7,477✔
171
      if i == 1 then
5,066✔
172
         m = v
2,411✔
173
      else
174
         if v.length:tonumber() > m.length:tonumber() then
7,965✔
175
            m = v
599✔
176
         end
177
      end
178
   end
179
   return m
2,411✔
180
end
181

182
local function scaleWidth (length, line)
183
   local number = length.length
1,052✔
184
   if line.ratio and line.ratio < 0 and length.shrink:tonumber() > 0 then
1,052✔
185
      number = number + length.shrink * line.ratio
×
186
   elseif line.ratio and line.ratio > 0 and length.stretch:tonumber() > 0 then
2,104✔
187
      number = number + length.stretch * line.ratio
1,174✔
188
   end
189
   return number
1,052✔
190
end
191

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

202
function elements.mbox:__tostring ()
20✔
203
   return self._type
×
204
end
205

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

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

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

236
function elements.mbox.output (_, _, _, _)
20✔
237
   SU.error("output is a virtual function that need to be overridden by its child classes")
×
238
end
239

240
function elements.mbox:getMathMetrics ()
20✔
241
   return retrieveMathTable(self.font)
3,514✔
242
end
243

244
function elements.mbox:getScaleDown ()
20✔
245
   local constants = self:getMathMetrics().constants
4,442✔
246
   local scaleDown
247
   if isScriptMode(self.mode) then
4,442✔
248
      scaleDown = constants.scriptPercentScaleDown
416✔
249
   elseif isScriptScriptMode(self.mode) then
3,610✔
250
      scaleDown = constants.scriptScriptPercentScaleDown
181✔
251
   else
252
      scaleDown = 1
1,624✔
253
   end
254
   return scaleDown
2,221✔
255
end
256

257
-- Determine the mode of its descendants
258
function elements.mbox:styleDescendants ()
20✔
259
   self:styleChildren()
1,783✔
260
   for _, n in ipairs(self.children) do
3,504✔
261
      if n then
1,721✔
262
         n:styleDescendants()
1,721✔
263
      end
264
   end
265
end
266

267
-- shapeTree shapes the mbox and all its descendants in a recursive fashion
268
-- The inner-most leaf nodes determine their shape first, and then propagate to their parents
269
-- During the process, each node will determine its size by (width, height, depth)
270
-- and (relX, relY) which the relative position to its parent
271
function elements.mbox:shapeTree ()
20✔
272
   for _, n in ipairs(self.children) do
3,504✔
273
      if n then
1,721✔
274
         n:shapeTree()
1,721✔
275
      end
276
   end
277
   self:shape()
1,783✔
278
end
279

280
-- Output the node and all its descendants
281
function elements.mbox:outputTree (x, y, line)
20✔
282
   self:output(x, y, line)
1,783✔
283
   local debug = SILE.settings:get("math.debug.boxes")
1,783✔
284
   if debug and not (self:is_a(elements.space)) then
1,783✔
285
      SILE.outputter:setCursor(scaleWidth(x, line), y.length)
×
286
      SILE.outputter:debugHbox({ height = self.height.length, depth = self.depth.length }, scaleWidth(self.width, line))
×
287
   end
288
   for _, n in ipairs(self.children) do
3,504✔
289
      if n then
1,721✔
290
         n:outputTree(x + n.relX, y + n.relY, line)
5,163✔
291
      end
292
   end
293
end
294

295
local spaceKind = {
10✔
296
   thin = "thin",
297
   med = "med",
298
   thick = "thick",
299
}
300

301
-- Indexed by left atom
302
local spacingRules = {
10✔
303
   [atomType.ordinary] = {
10✔
304
      [atomType.bigOperator] = { spaceKind.thin },
10✔
305
      [atomType.binaryOperator] = { spaceKind.med, notScript = true },
10✔
306
      [atomType.relationalOperator] = { spaceKind.thick, notScript = true },
10✔
307
      [atomType.inner] = { spaceKind.thin, notScript = true },
10✔
308
   },
10✔
309
   [atomType.bigOperator] = {
10✔
310
      [atomType.ordinary] = { spaceKind.thin },
10✔
311
      [atomType.bigOperator] = { spaceKind.thin },
10✔
312
      [atomType.relationalOperator] = { spaceKind.thick, notScript = true },
10✔
313
      [atomType.inner] = { spaceKind.thin, notScript = true },
10✔
314
   },
10✔
315
   [atomType.binaryOperator] = {
10✔
316
      [atomType.ordinary] = { spaceKind.med, notScript = true },
10✔
317
      [atomType.bigOperator] = { spaceKind.med, notScript = true },
10✔
318
      [atomType.openingSymbol] = { spaceKind.med, notScript = true },
10✔
319
      [atomType.inner] = { spaceKind.med, notScript = true },
10✔
320
   },
10✔
321
   [atomType.relationalOperator] = {
10✔
322
      [atomType.ordinary] = { spaceKind.thick, notScript = true },
10✔
323
      [atomType.bigOperator] = { spaceKind.thick, notScript = true },
10✔
324
      [atomType.openingSymbol] = { spaceKind.thick, notScript = true },
10✔
325
      [atomType.inner] = { spaceKind.thick, notScript = true },
10✔
326
   },
10✔
327
   [atomType.closeSymbol] = {
10✔
328
      [atomType.bigOperator] = { spaceKind.thin },
10✔
329
      [atomType.binaryOperator] = { spaceKind.med, notScript = true },
10✔
330
      [atomType.relationalOperator] = { spaceKind.thick, notScript = true },
10✔
331
      [atomType.inner] = { spaceKind.thin, notScript = true },
10✔
332
   },
10✔
333
   [atomType.punctuationSymbol] = {
10✔
334
      [atomType.ordinary] = { spaceKind.thin, notScript = true },
10✔
335
      [atomType.bigOperator] = { spaceKind.thin, notScript = true },
10✔
336
      [atomType.relationalOperator] = { spaceKind.thin, notScript = true },
10✔
337
      [atomType.openingSymbol] = { spaceKind.thin, notScript = true },
10✔
338
      [atomType.closeSymbol] = { spaceKind.thin, notScript = true },
10✔
339
      [atomType.punctuationSymbol] = { spaceKind.thin, notScript = true },
10✔
340
      [atomType.inner] = { spaceKind.thin, notScript = true },
10✔
341
   },
10✔
342
   [atomType.inner] = {
10✔
343
      [atomType.ordinary] = { spaceKind.thin, notScript = true },
10✔
344
      [atomType.bigOperator] = { spaceKind.thin },
10✔
345
      [atomType.binaryOperator] = { spaceKind.med, notScript = true },
10✔
346
      [atomType.relationalOperator] = { spaceKind.thick, notScript = true },
10✔
347
      [atomType.openingSymbol] = { spaceKind.thin, notScript = true },
10✔
348
      [atomType.punctuationSymbol] = { spaceKind.thin, notScript = true },
10✔
349
      [atomType.inner] = { spaceKind.thin, notScript = true },
10✔
350
   },
10✔
351
}
352

353
-- _stackbox stacks its content one, either horizontally or vertically
354
elements.stackbox = pl.class(elements.mbox)
20✔
355
elements.stackbox._type = "Stackbox"
10✔
356

357
function elements.stackbox:__tostring ()
20✔
358
   local result = self.direction .. "Box("
×
359
   for i, n in ipairs(self.children) do
×
360
      result = result .. (i == 1 and "" or ", ") .. tostring(n)
×
361
   end
362
   result = result .. ")"
×
363
   return result
×
364
end
365

366
function elements.stackbox:_init (direction, children)
20✔
367
   elements.mbox._init(self)
383✔
368
   if not (direction == "H" or direction == "V") then
383✔
369
      SU.error("Wrong direction '" .. direction .. "'; should be H or V")
×
370
   end
371
   self.direction = direction
383✔
372
   self.children = children
383✔
373
end
374

375
function elements.stackbox:styleChildren ()
20✔
376
   for _, n in ipairs(self.children) do
1,448✔
377
      n.mode = self.mode
1,065✔
378
   end
379
   if self.direction == "H" then
383✔
380
      -- Insert spaces according to the atom type, following Knuth's guidelines
381
      -- in the TeXbook
382
      local spaces = {}
321✔
383
      for i = 1, #self.children - 1 do
1,009✔
384
         local v = self.children[i]
688✔
385
         local v2 = self.children[i + 1]
688✔
386
         if spacingRules[v.atom] and spacingRules[v.atom][v2.atom] then
688✔
387
            local rule = spacingRules[v.atom][v2.atom]
334✔
388
            if not (rule.notScript and (isScriptMode(self.mode) or isScriptScriptMode(self.mode))) then
894✔
389
               spaces[i + 1] = rule[1]
238✔
390
            end
391
         end
392
      end
393
      local spaceIdx = {}
321✔
394
      for i, _ in pairs(spaces) do
559✔
395
         table.insert(spaceIdx, i)
238✔
396
      end
397
      table.sort(spaceIdx, function (a, b)
642✔
398
         return a > b
340✔
399
      end)
400
      for _, idx in ipairs(spaceIdx) do
559✔
401
         local hsp = elements.space(spaces[idx], 0, 0)
238✔
402
         table.insert(self.children, idx, hsp)
238✔
403
      end
404
   end
405
end
406

407
function elements.stackbox:shape ()
20✔
408
   -- For a horizontal stackbox (i.e. mrow):
409
   -- 1. set self.height and self.depth to max element height & depth
410
   -- 2. handle stretchy operators
411
   -- 3. set self.width
412
   -- For a vertical stackbox:
413
   -- 1. set self.width to max element width
414
   -- 2. set self.height
415
   -- And finally set children's relative coordinates
416
   self.height = SILE.types.length(0)
766✔
417
   self.depth = SILE.types.length(0)
766✔
418
   if self.direction == "H" then
383✔
419
      for i, n in ipairs(self.children) do
1,562✔
420
         n.relY = SILE.types.length(0)
2,482✔
421
         self.height = i == 1 and n.height or maxLength(self.height, n.height)
2,167✔
422
         self.depth = i == 1 and n.depth or maxLength(self.depth, n.depth)
2,167✔
423
      end
424
      -- Handle stretchy operators
425
      for _, elt in ipairs(self.children) do
1,562✔
426
         if elt.is_a(elements.text) and elt.kind == "operator" and SU.boolean(elt.stretchy, false) then
2,787✔
427
            elt:_vertStretchyReshape(self.depth, self.height)
78✔
428
         end
429
      end
430
      -- Set self.width
431
      self.width = SILE.types.length(0)
642✔
432
      for i, n in ipairs(self.children) do
1,562✔
433
         n.relX = self.width
1,241✔
434
         self.width = i == 1 and n.width or self.width + n.width
2,167✔
435
      end
436
   else -- self.direction == "V"
437
      for i, n in ipairs(self.children) do
124✔
438
         n.relX = SILE.types.length(0)
124✔
439
         self.width = i == 1 and n.width or maxLength(self.width, n.width)
62✔
440
      end
441
      -- Set self.height and self.depth
442
      for i, n in ipairs(self.children) do
124✔
443
         self.depth = i == 1 and n.depth or self.depth + n.depth
62✔
444
      end
445
      for i = 1, #self.children do
124✔
446
         local n = self.children[i]
62✔
447
         if i == 1 then
62✔
448
            self.height = n.height
62✔
449
            self.depth = n.depth
62✔
450
         elseif i > 1 then
×
451
            n.relY = self.children[i - 1].relY + self.children[i - 1].depth + n.height
×
452
            self.depth = self.depth + n.height + n.depth
×
453
         end
454
      end
455
   end
456
end
457

458
-- Despite of its name, this function actually output the whole tree of nodes recursively.
459
function elements.stackbox:outputYourself (typesetter, line)
20✔
460
   local mathX = typesetter.frame.state.cursorX
62✔
461
   local mathY = typesetter.frame.state.cursorY
62✔
462
   self:outputTree(self.relX + mathX, self.relY + mathY, line)
186✔
463
   typesetter.frame:advanceWritingDirection(scaleWidth(self.width, line))
124✔
464
end
465

466
function elements.stackbox.output (_, _, _, _) end
393✔
467

468
elements.phantom = pl.class(elements.stackbox) -- inherit from stackbox
20✔
469
elements.phantom._type = "Phantom"
10✔
470

471
function elements.phantom:_init (children)
20✔
472
   -- MathML core 3.3.7:
473
   -- "Its layout algorithm is the same as the mrow element".
474
   -- Also not the MathML states that <mphantom> is sort of legacy, "implemented
475
   -- for compatibility with full MathML. Authors whose only target is MathML
476
   -- Core are encouraged to use CSS for styling."
477
   -- The thing is that we don't have CSS in SILE, so supporting <mphantom> is
478
   -- a must.
479
   elements.stackbox._init(self, "H", children)
×
480
end
481

482
function elements.phantom:output (_, _, _)
20✔
483
   -- Note the trick here: when the tree is rendered, the node's output
484
   -- function is invoked, then all its children's output functions.
485
   -- So we just cancel the list of children here, before it's rendered.
UNCOV
486
   self.children = {}
×
487
end
488

489
elements.subscript = pl.class(elements.mbox)
20✔
490
elements.subscript._type = "Subscript"
10✔
491

492
function elements.subscript:__tostring ()
20✔
UNCOV
493
   return (self.sub and "Subscript" or "Superscript")
×
UNCOV
494
      .. "("
×
UNCOV
495
      .. tostring(self.base)
×
UNCOV
496
      .. ", "
×
UNCOV
497
      .. tostring(self.sub or self.super)
×
UNCOV
498
      .. ")"
×
499
end
500

501
function elements.subscript:_init (base, sub, sup)
20✔
502
   elements.mbox._init(self)
102✔
503
   self.base = base
102✔
504
   self.sub = sub
102✔
505
   self.sup = sup
102✔
506
   if self.base then
102✔
507
      table.insert(self.children, self.base)
102✔
508
   end
509
   if self.sub then
102✔
510
      table.insert(self.children, self.sub)
74✔
511
   end
512
   if self.sup then
102✔
513
      table.insert(self.children, self.sup)
42✔
514
   end
515
   self.atom = self.base.atom
102✔
516
end
517

518
function elements.subscript:styleChildren ()
20✔
519
   if self.base then
102✔
520
      self.base.mode = self.mode
102✔
521
   end
522
   if self.sub then
102✔
523
      self.sub.mode = getSubscriptMode(self.mode)
148✔
524
   end
525
   if self.sup then
102✔
526
      self.sup.mode = getSuperscriptMode(self.mode)
84✔
527
   end
528
end
529

530
function elements.subscript:calculateItalicsCorrection ()
20✔
531
   local lastGid = getRightMostGlyphId(self.base)
102✔
532
   if lastGid > 0 then
102✔
533
      local mathMetrics = self:getMathMetrics()
98✔
534
      if mathMetrics.italicsCorrection[lastGid] then
98✔
535
         return mathMetrics.italicsCorrection[lastGid]
71✔
536
      end
537
   end
538
   return 0
31✔
539
end
540

541
function elements.subscript:shape ()
20✔
542
   local mathMetrics = self:getMathMetrics()
117✔
543
   local constants = mathMetrics.constants
117✔
544
   local scaleDown = self:getScaleDown()
117✔
545
   if self.base then
117✔
546
      self.base.relX = SILE.types.length(0)
234✔
547
      self.base.relY = SILE.types.length(0)
234✔
548
      -- Use widthForSubscript of base, if available
549
      self.width = self.base.widthForSubscript or self.base.width
117✔
550
   else
UNCOV
551
      self.width = SILE.types.length(0)
×
552
   end
553
   local itCorr = self:calculateItalicsCorrection() * scaleDown
234✔
554
   local subShift
555
   local supShift
556
   if self.sub then
117✔
557
      if self.isUnderOver or SU.boolean(self.base.largeop, false) then
163✔
558
         -- Ad hoc correction on integral limits, following LuaTeX's
559
         -- `\mathnolimitsmode=0` (see LuaTeX Reference Manual).
560
         subShift = -itCorr
33✔
561
      else
562
         subShift = 0
56✔
563
      end
564
      self.sub.relX = self.width + subShift
178✔
565
      self.sub.relY = SILE.types.length(math.max(
178✔
566
         constants.subscriptShiftDown * scaleDown,
89✔
567
         --self.base.depth + constants.subscriptBaselineDropMin * scaleDown,
568
         (self.sub.height - constants.subscriptTopMax * scaleDown):tonumber()
178✔
569
      ))
89✔
570
      if self:is_a(elements.underOver) or self:is_a(elements.stackbox) or SU.boolean(self.base.largeop, false) then
326✔
571
         self.sub.relY = maxLength(self.sub.relY, self.base.depth + constants.subscriptBaselineDropMin * scaleDown)
99✔
572
      end
573
   end
574
   if self.sup then
117✔
575
      if self.isUnderOver or SU.boolean(self.base.largeop, false) then
99✔
576
         -- Ad hoc correction on integral limits, following LuaTeX's
577
         -- `\mathnolimitsmode=0` (see LuaTeX Reference Manual).
578
         supShift = 0
21✔
579
      else
580
         supShift = itCorr
36✔
581
      end
582
      self.sup.relX = self.width + supShift
114✔
583
      self.sup.relY = SILE.types.length(math.max(
114✔
584
         isCrampedMode(self.mode) and constants.superscriptShiftUpCramped * scaleDown
114✔
585
            or constants.superscriptShiftUp * scaleDown, -- or cramped
57✔
586
         --self.base.height - constants.superscriptBaselineDropMax * scaleDown,
587
         (self.sup.depth + constants.superscriptBottomMin * scaleDown):tonumber()
114✔
588
      )) * -1
114✔
589
      if self:is_a(elements.underOver) or self:is_a(elements.stackbox) or SU.boolean(self.base.largeop, false) then
198✔
590
         self.sup.relY = maxLength(
42✔
591
            (0 - self.sup.relY),
21✔
592
            self.base.height - constants.superscriptBaselineDropMax * scaleDown
21✔
593
         ) * -1
42✔
594
      end
595
   end
596
   if self.sub and self.sup then
117✔
597
      local gap = self.sub.relY - self.sub.height - self.sup.relY - self.sup.depth
87✔
598
      if gap.length:tonumber() < constants.subSuperscriptGapMin * scaleDown then
58✔
599
         -- The following adjustment comes directly from Appendix G of he
600
         -- TeXbook (rule 18e).
601
         self.sub.relY = constants.subSuperscriptGapMin * scaleDown + self.sub.height + self.sup.relY + self.sup.depth
32✔
602
         local psi = constants.superscriptBottomMaxWithSubscript * scaleDown + self.sup.relY + self.sup.depth
16✔
603
         if psi:tonumber() > 0 then
16✔
604
            self.sup.relY = self.sup.relY - psi
16✔
605
            self.sub.relY = self.sub.relY - psi
16✔
606
         end
607
      end
608
   end
UNCOV
609
   self.width = self.width
×
610
      + maxLength(
234✔
611
         self.sub and self.sub.width + subShift or SILE.types.length(0),
206✔
612
         self.sup and self.sup.width + supShift or SILE.types.length(0)
174✔
613
      )
117✔
614
      + constants.spaceAfterScript * scaleDown
234✔
615
   self.height = maxLength(
234✔
616
      self.base and self.base.height or SILE.types.length(0),
117✔
617
      self.sub and (self.sub.height - self.sub.relY) or SILE.types.length(0),
206✔
618
      self.sup and (self.sup.height - self.sup.relY) or SILE.types.length(0)
174✔
619
   )
117✔
620
   self.depth = maxLength(
234✔
621
      self.base and self.base.depth or SILE.types.length(0),
117✔
622
      self.sub and (self.sub.depth + self.sub.relY) or SILE.types.length(0),
206✔
623
      self.sup and (self.sup.depth + self.sup.relY) or SILE.types.length(0)
174✔
624
   )
117✔
625
end
626

627
function elements.subscript.output (_, _, _, _) end
112✔
628

629
elements.underOver = pl.class(elements.subscript)
20✔
630
elements.underOver._type = "UnderOver"
10✔
631

632
function elements.underOver:__tostring ()
20✔
UNCOV
633
   return self._type .. "(" .. tostring(self.base) .. ", " .. tostring(self.sub) .. ", " .. tostring(self.sup) .. ")"
×
634
end
635

636
function elements.underOver:_init (base, sub, sup)
20✔
637
   elements.mbox._init(self)
25✔
638
   self.atom = base.atom
25✔
639
   self.base = base
25✔
640
   self.sub = sub
25✔
641
   self.sup = sup
25✔
642
   if self.sup then
25✔
643
      table.insert(self.children, self.sup)
24✔
644
   end
645
   if self.base then
25✔
646
      table.insert(self.children, self.base)
25✔
647
   end
648
   if self.sub then
25✔
649
      table.insert(self.children, self.sub)
25✔
650
   end
651
end
652

653
function elements.underOver:styleChildren ()
20✔
654
   if self.base then
25✔
655
      self.base.mode = self.mode
25✔
656
   end
657
   if self.sub then
25✔
658
      self.sub.mode = getSubscriptMode(self.mode)
50✔
659
   end
660
   if self.sup then
25✔
661
      self.sup.mode = getSuperscriptMode(self.mode)
48✔
662
   end
663
end
664

665
function elements.underOver:shape ()
20✔
666
   if not (self.mode == mathMode.display or self.mode == mathMode.displayCramped) and SU.boolean(self.base.largeop, false) then
40✔
667
      -- FIXME
668
      -- Added the "largeop" condition, but it's kind of a workaround:
669
      -- It should rather be the "moveablelimits" propery in MathML, but we do not have that yet.
670
      -- When the base is a moveable limit, the under/over scripts are not placed under/over the base,
671
      -- but ather to the right of it, when display mode is not used.
672
      -- Notable effects:
673
      --   Mozilla MathML test 19 (on "k times" > overbrace > base)
674
      --   Maxwell's Equations in MathML3 Test Suite "complex1" (on the vectors in fractions)
675
      -- For now, go with the "largeop" property, but this is not correct.
676
      self.isUnderOver = true
15✔
677
      elements.subscript.shape(self)
15✔
678
      return
15✔
679
   end
680
   local constants = self:getMathMetrics().constants
20✔
681
   local scaleDown = self:getScaleDown()
10✔
682
   -- Determine relative Ys
683
   if self.base then
10✔
684
      self.base.relY = SILE.types.length(0)
20✔
685
   end
686
   if self.sub then
10✔
687
      self.sub.relY = self.base.depth
10✔
688
         + SILE.types.length(
20✔
689
            math.max(
20✔
690
               (self.sub.height + constants.lowerLimitGapMin * scaleDown):tonumber(),
20✔
691
               constants.lowerLimitBaselineDropMin * scaleDown
10✔
692
            )
693
         )
20✔
694
   end
695
   if self.sup then
10✔
696
      self.sup.relY = 0
9✔
697
         - self.base.height
9✔
698
         - SILE.types.length(
18✔
699
            math.max(
18✔
700
               (constants.upperLimitGapMin * scaleDown + self.sup.depth):tonumber(),
18✔
701
               constants.upperLimitBaselineRiseMin * scaleDown
9✔
702
            )
703
         )
18✔
704
   end
705
   -- Determine relative Xs based on widest symbol
706
   local widest, a, b
707
   if self.sub and self.sub.width > self.base.width then
10✔
708
      if self.sup and self.sub.width > self.sup.width then
6✔
709
         widest = self.sub
6✔
710
         a = self.base
6✔
711
         b = self.sup
6✔
712
      elseif self.sup then
×
UNCOV
713
         widest = self.sup
×
UNCOV
714
         a = self.base
×
UNCOV
715
         b = self.sub
×
716
      else
UNCOV
717
         widest = self.sub
×
UNCOV
718
         a = self.base
×
719
         b = nil
×
720
      end
721
   else
722
      if self.sup and self.base.width > self.sup.width then
4✔
723
         widest = self.base
3✔
724
         a = self.sub
3✔
725
         b = self.sup
3✔
726
      elseif self.sup then
1✔
UNCOV
727
         widest = self.sup
×
UNCOV
728
         a = self.base
×
UNCOV
729
         b = self.sub
×
730
      else
731
         widest = self.base
1✔
732
         a = self.sub
1✔
733
         b = nil
1✔
734
      end
735
   end
736
   widest.relX = SILE.types.length(0)
20✔
737
   local c = widest.width / 2
10✔
738
   if a then
10✔
739
      a.relX = c - a.width / 2
30✔
740
   end
741
   if b then
10✔
742
      b.relX = c - b.width / 2
27✔
743
   end
744
   local itCorr = self:calculateItalicsCorrection() * scaleDown
20✔
745
   if self.sup then
10✔
746
      self.sup.relX = self.sup.relX + itCorr / 2
18✔
747
   end
748
   if self.sub then
10✔
749
      self.sub.relX = self.sub.relX - itCorr / 2
20✔
750
   end
751
   -- Determine width and height
752
   self.width = maxLength(
20✔
753
      self.base and self.base.width or SILE.types.length(0),
10✔
754
      self.sub and self.sub.width or SILE.types.length(0),
10✔
755
      self.sup and self.sup.width or SILE.types.length(0)
10✔
756
   )
10✔
757
   if self.sup then
10✔
758
      self.height = 0 - self.sup.relY + self.sup.height
27✔
759
   else
760
      self.height = self.base and self.base.height or 0
1✔
761
   end
762
   if self.sub then
10✔
763
      self.depth = self.sub.relY + self.sub.depth
20✔
764
   else
UNCOV
765
      self.depth = self.base and self.base.depth or 0
×
766
   end
767
end
768

769
function elements.underOver:calculateItalicsCorrection ()
20✔
770
   local lastGid = getRightMostGlyphId(self.base)
25✔
771
   if lastGid > 0 then
25✔
772
      local mathMetrics = self:getMathMetrics()
25✔
773
      if mathMetrics.italicsCorrection[lastGid] then
25✔
774
         local c = mathMetrics.italicsCorrection[lastGid]
×
775
         -- If this is a big operator, and we are in display style, then the
776
         -- base glyph may be bigger than the font size. We need to adjust the
777
         -- italic correction accordingly.
UNCOV
778
         if self.base.atom == atomType.bigOperator and isDisplayMode(self.mode) then
×
UNCOV
779
            c = c * (self.base and self.base.font.size / self.font.size or 1.0)
×
780
         end
UNCOV
781
         return c
×
782
      end
783
   end
784
   return 0
25✔
785
end
786

787
function elements.underOver.output (_, _, _, _) end
35✔
788

789
-- terminal is the base class for leaf node
790
elements.terminal = pl.class(elements.mbox)
20✔
791
elements.terminal._type = "Terminal"
10✔
792

793
function elements.terminal:_init ()
20✔
794
   elements.mbox._init(self)
1,220✔
795
end
796

797
function elements.terminal.styleChildren (_) end
1,230✔
798

799
function elements.terminal.shape (_) end
10✔
800

801
elements.space = pl.class(elements.terminal)
20✔
802
elements.space._type = "Space"
10✔
803

804
function elements.space:_init ()
20✔
805
   elements.terminal._init(self)
×
806
end
807

808
function elements.space:__tostring ()
20✔
809
   return self._type
×
UNCOV
810
      .. "(width="
×
UNCOV
811
      .. tostring(self.width)
×
UNCOV
812
      .. ", height="
×
UNCOV
813
      .. tostring(self.height)
×
UNCOV
814
      .. ", depth="
×
UNCOV
815
      .. tostring(self.depth)
×
UNCOV
816
      .. ")"
×
817
end
818

819
local function getStandardLength (value)
820
   if type(value) == "string" then
888✔
821
      local direction = 1
296✔
822
      if value:sub(1, 1) == "-" then
592✔
823
         value = value:sub(2, -1)
20✔
824
         direction = -1
10✔
825
      end
826
      if value == "thin" then
296✔
827
         return SILE.types.length("3mu") * direction
195✔
828
      elseif value == "med" then
231✔
829
         return SILE.types.length("4mu plus 2mu minus 4mu") * direction
288✔
830
      elseif value == "thick" then
135✔
831
         return SILE.types.length("5mu plus 5mu") * direction
333✔
832
      end
833
   end
834
   return SILE.types.length(value)
616✔
835
end
836

837
function elements.space:_init (width, height, depth)
20✔
838
   elements.terminal._init(self)
296✔
839
   self.width = getStandardLength(width)
592✔
840
   self.height = getStandardLength(height)
592✔
841
   self.depth = getStandardLength(depth)
592✔
842
end
843

844
function elements.space:shape ()
20✔
845
   self.width = self.width:absolute() * self:getScaleDown()
1,184✔
846
   self.height = self.height:absolute() * self:getScaleDown()
1,184✔
847
   self.depth = self.depth:absolute() * self:getScaleDown()
1,184✔
848
end
849

850
function elements.space.output (_) end
306✔
851

852
-- text node. For any actual text output
853
elements.text = pl.class(elements.terminal)
20✔
854
elements.text._type = "Text"
10✔
855

856
function elements.text:__tostring ()
20✔
857
   return self._type
×
858
      .. "(atom="
×
859
      .. tostring(self.atom)
×
860
      .. ", kind="
×
861
      .. tostring(self.kind)
×
UNCOV
862
      .. ", script="
×
UNCOV
863
      .. tostring(self.script)
×
NEW
864
      .. (SU.boolean(self.stretchy, false) and ", stretchy" or "")
×
NEW
865
      .. (SU.boolean(self.largeop, false) and ", largeop" or "")
×
866
      .. ', text="'
×
867
      .. (self.originalText or self.text)
×
UNCOV
868
      .. '")'
×
869
end
870

871
function elements.text:_init (kind, attributes, script, text)
20✔
872
   elements.terminal._init(self)
924✔
873
   if not (kind == "number" or kind == "identifier" or kind == "operator" or kind == "string") then
924✔
UNCOV
874
      SU.error("Unknown text node kind '" .. kind .. "'; should be one of: number, identifier, operator, string")
×
875
   end
876
   self.kind = kind
924✔
877
   self.script = script
924✔
878
   self.text = text
924✔
879
   if self.script ~= "upright" then
924✔
880
      local converted = convertMathVariantScript(self.text, self.script)
924✔
881
      self.originalText = self.text
924✔
882
      self.text = converted
924✔
883
   end
884
   if self.kind == "operator" then
924✔
885
      if self.text == "-" then
368✔
886
         self.text = "−"
4✔
887
      end
888
   end
889
   for attribute, value in pairs(attributes) do
1,376✔
890
      self[attribute] = value
452✔
891
   end
892
end
893

894
function elements.text:shape ()
20✔
895
   self.font.size = self.font.size * self:getScaleDown()
1,848✔
896
   local face = SILE.font.cache(self.font, SILE.shaper.getFace)
924✔
897
   local mathMetrics = self:getMathMetrics()
924✔
898
   local glyphs = SILE.shaper:shapeToken(self.text, self.font)
924✔
899
   -- Use bigger variants for big operators in display style
900
   if isDisplayMode(self.mode) and SU.boolean(self.largeop, false) then
2,157✔
901
      -- We copy the glyph list to avoid modifying the shaper's cache. Yes.
902
      glyphs = pl.tablex.deepcopy(glyphs)
38✔
903
      local constructions = mathMetrics.mathVariants.vertGlyphConstructions[glyphs[1].gid]
19✔
904
      if constructions then
19✔
905
         local displayVariants = constructions.mathGlyphVariantRecord
19✔
906
         -- We select the biggest variant. TODO: we should probably select the
907
         -- first variant that is higher than displayOperatorMinHeight.
908
         local biggest
909
         local m = 0
19✔
910
         for _, v in ipairs(displayVariants) do
57✔
911
            if v.advanceMeasurement > m then
38✔
912
               biggest = v
38✔
913
               m = v.advanceMeasurement
38✔
914
            end
915
         end
916
         if biggest then
19✔
917
            glyphs[1].gid = biggest.variantGlyph
19✔
918
            local dimen = hb.get_glyph_dimensions(face, self.font.size, biggest.variantGlyph)
19✔
919
            glyphs[1].width = dimen.width
19✔
920
            glyphs[1].glyphAdvance = dimen.glyphAdvance
19✔
921
            --[[ I am told (https://github.com/alif-type/xits/issues/90) that,
922
        in fact, the relative height and depth of display-style big operators
923
        in the font is not relevant, as these should be centered around the
924
        axis. So the following code does that, while conserving their
925
        vertical size (distance from top to bottom). ]]
926
            local axisHeight = mathMetrics.constants.axisHeight * self:getScaleDown()
38✔
927
            local y_size = dimen.height + dimen.depth
19✔
928
            glyphs[1].height = y_size / 2 + axisHeight
19✔
929
            glyphs[1].depth = y_size / 2 - axisHeight
19✔
930
            -- We still need to store the font's height and depth somewhere,
931
            -- because that's what will be used to draw the glyph, and we will need
932
            -- to artificially compensate for that.
933
            glyphs[1].fontHeight = dimen.height
19✔
934
            glyphs[1].fontDepth = dimen.depth
19✔
935
         end
936
      end
937
   end
938
   SILE.shaper:preAddNodes(glyphs, self.value)
924✔
939
   self.value.items = glyphs
924✔
940
   self.value.glyphString = {}
924✔
941
   if glyphs and #glyphs > 0 then
924✔
942
      for i = 1, #glyphs do
2,095✔
943
         table.insert(self.value.glyphString, glyphs[i].gid)
1,171✔
944
      end
945
      self.width = SILE.types.length(0)
1,848✔
946
      self.widthForSubscript = SILE.types.length(0)
1,848✔
947
      for i = #glyphs, 1, -1 do
2,095✔
948
         self.width = self.width + glyphs[i].glyphAdvance
2,342✔
949
      end
950
      -- Store width without italic correction somewhere
951
      self.widthForSubscript = self.width
924✔
952
      local itCorr = mathMetrics.italicsCorrection[glyphs[#glyphs].gid]
924✔
953
      if itCorr then
924✔
954
         self.width = self.width + itCorr * self:getScaleDown()
666✔
955
      end
956
      for i = 1, #glyphs do
2,095✔
957
         self.height = i == 1 and SILE.types.length(glyphs[i].height)
1,171✔
958
            or SILE.types.length(math.max(self.height:tonumber(), glyphs[i].height))
1,665✔
959
         self.depth = i == 1 and SILE.types.length(glyphs[i].depth)
1,171✔
960
            or SILE.types.length(math.max(self.depth:tonumber(), glyphs[i].depth))
1,665✔
961
      end
962
   else
UNCOV
963
      self.width = SILE.types.length(0)
×
UNCOV
964
      self.height = SILE.types.length(0)
×
UNCOV
965
      self.depth = SILE.types.length(0)
×
966
   end
967
end
968

969
function elements.text.findClosestVariant(_, variants, requiredAdvance, currentAdvance)
20✔
970
   local closest
971
   local closestI
972
   local m = requiredAdvance - currentAdvance
78✔
973
   for i, variant in ipairs(variants) do
1,092✔
974
      local diff = math.abs(variant.advanceMeasurement - requiredAdvance)
1,014✔
975
      SU.debug("math", "stretch: diff =", diff)
1,014✔
976
      if diff < m then
1,014✔
977
         closest = variant
86✔
978
         closestI = i
86✔
979
         m = diff
86✔
980
      end
981
   end
982
   return closest, closestI
78✔
983
end
984

985
function elements.text:_reshapeGlyph(glyph, closestVariant, sz)
20✔
986
   local face = SILE.font.cache(self.font, SILE.shaper.getFace)
46✔
987
   local dimen = hb.get_glyph_dimensions(face, sz, closestVariant.variantGlyph)
46✔
988
   glyph.gid = closestVariant.variantGlyph
46✔
989
   glyph.width, glyph.height, glyph.depth, glyph.glyphAdvance = dimen.width, dimen.height, dimen.depth, dimen.glyphAdvance
46✔
990
   return dimen
46✔
991
end
992

993
function elements.text:_stretchyReshape(target, direction)
20✔
994
   -- direction is the required direction of stretching: true for vertical, false for horizontal
995
   -- target is the required dimension of the stretched glyph, in font units
996
   local mathMetrics = self:getMathMetrics()
78✔
997
   local upem = mathMetrics.unitsPerEm
78✔
998
   local sz = self.font.size
78✔
999
   local requiredAdvance = target:tonumber() * upem / sz
156✔
1000
   SU.debug("math", "stretch: rA =", requiredAdvance)
78✔
1001
   -- Choose variant of the closest size. The criterion we use is to have
1002
   -- an advance measurement as close as possible as the required one.
1003
   -- The advance measurement is simply the dimension of the glyph.
1004
   -- Therefore, the selected glyph may be smaller or bigger than
1005
   -- required.
1006
   -- TODO: implement assembly of stretchable glyphs from their parts for cases
1007
   -- when the biggest variant is not big enough.
1008
   -- We copy the glyph list to avoid modifying the shaper's cache. Yes.
1009
   local glyphs = pl.tablex.deepcopy(self.value.items)
78✔
NEW
1010
   local glyphConstructions = direction
×
1011
      and mathMetrics.mathVariants.vertGlyphConstructions
78✔
1012
      or mathMetrics.mathVariants.horizGlyphConstructions
78✔
1013
   local constructions = glyphConstructions[glyphs[1].gid]
78✔
1014
   if constructions then
78✔
1015
      local variants = constructions.mathGlyphVariantRecord
78✔
1016
      SU.debug("math", "stretch: variants =", variants)
78✔
1017
      local currentAdvance = (direction and (self.depth + self.height):tonumber() or self.width:tonumber()) * upem / sz
234✔
1018
      local closest, closestI = self:findClosestVariant(variants, requiredAdvance, currentAdvance)
78✔
1019
      SU.debug("math", "stretch: closestI =", closestI)
78✔
1020
      if closest then
78✔
1021
         -- Now we have to re-shape the glyph chain. We will assume there
1022
         -- is only one glyph.
1023
         -- TODO: this code is probably wrong when the vertical
1024
         -- variants have a different width than the original, because
1025
         -- the shaping phase is already done. Need to do better.
1026
         local dimen = self:_reshapeGlyph(glyphs[1], closest, sz)
46✔
1027
         self.width, self.depth, self.height = SILE.types.length(dimen.glyphAdvance), SILE.types.length(dimen.depth), SILE.types.length(dimen.height)
184✔
1028
         SILE.shaper:preAddNodes(glyphs, self.value)
46✔
1029
         self.value.items = glyphs
46✔
1030
         self.value.glyphString = { glyphs[1].gid }
46✔
1031
         return true
46✔
1032
      end
1033
   end
1034
   return false
32✔
1035
end
1036

1037
function elements.text:_vertStretchyReshape(depth, height)
20✔
1038
   local hasStretched = self:_stretchyReshape(depth + height, true)
156✔
1039
   if hasStretched then
78✔
1040
      -- HACK: see output routine
1041
      self.vertExpectedSz = height + depth
92✔
1042
      self.vertScalingRatio = (depth + height):tonumber() / (self.height:tonumber() + self.depth:tonumber())
230✔
1043
      self.height = height
46✔
1044
      self.depth = depth
46✔
1045
   end
1046
   return hasStretched
78✔
1047
end
1048

1049
function elements.text:_horizStretchyReshape(width)
20✔
NEW
1050
   local hasStretched = self:_stretchyReshape(width, false)
×
NEW
1051
   if hasStretched then
×
1052
      -- HACK: see output routine
NEW
1053
      self.horizScalingRatio = width:tonumber() / self.width:tonumber()
×
NEW
1054
      self.width = width
×
1055
   end
NEW
1056
   return hasStretched
×
1057
end
1058

1059
function elements.text:output (x, y, line)
20✔
1060
   if not self.value.glyphString then
924✔
UNCOV
1061
      return
×
1062
   end
1063
   local compensatedY
1064
   if isDisplayMode(self.mode) and SU.boolean(self.largeop, false) and self.value.items[1].fontDepth then
2,157✔
1065
      compensatedY = SILE.types.length(y.length + self.value.items[1].depth - self.value.items[1].fontDepth)
76✔
1066
   else
1067
      compensatedY = y
905✔
1068
   end
1069
   SILE.outputter:setCursor(scaleWidth(x, line), compensatedY.length)
1,848✔
1070
   SILE.outputter:setFont(self.font)
924✔
1071
   -- There should be no stretch or shrink on the width of a text
1072
   -- element.
1073
   local width = self.width.length
924✔
1074
   -- HACK: For stretchy operators, MathML Core and OpenType define how to build large glyphs
1075
   -- from an assembly of smaller ones. It's fairly complex and idealistic...
1076
   -- Anyhow, we do not have that yet, so we just stretch the glyph artificially.
1077
   -- There are cases where this will not look very good.
1078
   -- Call that a compromise, so that long vectors or large matrices look "decent" without assembly.
1079
   if SILE.outputter.scaleFn and (self.horizScalingRatio or self.vertScalingRatio) then
924✔
1080
      local xratio = self.horizScalingRatio or 1
46✔
1081
      local yratio = self.vertScalingRatio or 1
46✔
1082
      SU.debug("math", "fake glyph stretch: xratio =", xratio, "yratio =", yratio)
46✔
1083
      SILE.outputter:scaleFn(x, y, xratio, yratio, function ()
92✔
1084
         SILE.outputter:drawHbox(self.value, width)
46✔
1085
      end)
1086
   else
1087
      SILE.outputter:drawHbox(self.value, width)
878✔
1088
   end
1089
end
1090

1091
elements.fraction = pl.class(elements.mbox)
20✔
1092
elements.fraction._type = "Fraction"
10✔
1093

1094
function elements.fraction:__tostring ()
20✔
1095
   return self._type .. "(" .. tostring(self.numerator) .. ", " .. tostring(self.denominator) .. ")"
×
1096
end
1097

1098
function elements.fraction:_init (attributes, numerator, denominator)
20✔
1099
   elements.mbox._init(self)
33✔
1100
   self.numerator = numerator
33✔
1101
   self.denominator = denominator
33✔
1102
   self.attributes = attributes
33✔
1103
   table.insert(self.children, numerator)
33✔
1104
   table.insert(self.children, denominator)
33✔
1105
end
1106

1107
function elements.fraction:styleChildren ()
20✔
1108
   self.numerator.mode = getNumeratorMode(self.mode)
66✔
1109
   self.denominator.mode = getDenominatorMode(self.mode)
66✔
1110
end
1111

1112
function elements.fraction:shape ()
20✔
1113
   -- MathML Core 3.3.2: "To avoid visual confusion between the fraction bar
1114
   -- and another adjacent items (e.g. minus sign or another fraction's bar),
1115
   -- a default 1-pixel space is added around the element."
1116
   -- Note that PlainTeX would likely use \nulldelimiterspace (default 1.2pt)
1117
   -- but it would depend on the surrounding context, and might be far too
1118
   -- much in some cases, so we stick to MathML's suggested padding.
1119
   self.padding = SILE.types.length("1px"):absolute()
99✔
1120

1121
   -- Determine relative abscissas and width
1122
   local widest, other
1123
   if self.denominator.width > self.numerator.width then
33✔
1124
      widest, other = self.denominator, self.numerator
25✔
1125
   else
1126
      widest, other = self.numerator, self.denominator
8✔
1127
   end
1128
   widest.relX = self.padding
33✔
1129
   other.relX = self.padding + (widest.width - other.width) / 2
132✔
1130
   self.width = widest.width + 2 * self.padding
99✔
1131
   -- Determine relative ordinates and height
1132
   local constants = self:getMathMetrics().constants
66✔
1133
   local scaleDown = self:getScaleDown()
33✔
1134
   self.axisHeight = constants.axisHeight * scaleDown
33✔
1135
   self.ruleThickness = self.attributes.linethickness
33✔
1136
      and SU.cast("measurement", self.attributes.linethickness):tonumber()
33✔
1137
      or constants.fractionRuleThickness * scaleDown
33✔
1138

1139
   -- MathML Core 3.3.2.2 ("Fraction with zero line thickness") uses
1140
   -- stack(DisplayStyle)GapMin, stackTop(DisplayStyle)ShiftUp and stackBottom(DisplayStyle)ShiftDown.
1141
   -- TODO not implemented
1142
   -- The most common use cases for zero line thickness are:
1143
   --  - Binomial coefficients
1144
   --  - Stacked subscript/superscript on big operators such as sums.
1145

1146
   local numeratorGapMin, denominatorGapMin, numeratorShiftUp, denominatorShiftDown
1147
   if isDisplayMode(self.mode) then
66✔
1148
      numeratorGapMin = constants.fractionNumDisplayStyleGapMin * scaleDown
10✔
1149
      denominatorGapMin = constants.fractionDenomDisplayStyleGapMin * scaleDown
10✔
1150
      numeratorShiftUp = constants.fractionNumeratorDisplayStyleShiftUp * scaleDown
10✔
1151
      denominatorShiftDown = constants.fractionDenominatorDisplayStyleShiftDown * scaleDown
10✔
1152
   else
1153
      numeratorGapMin = constants.fractionNumeratorGapMin * scaleDown
23✔
1154
      denominatorGapMin = constants.fractionDenominatorGapMin * scaleDown
23✔
1155
      numeratorShiftUp = constants.fractionNumeratorShiftUp * scaleDown
23✔
1156
      denominatorShiftDown = constants.fractionDenominatorShiftDown * scaleDown
23✔
1157
   end
1158

1159
   self.numerator.relY = -self.axisHeight
33✔
1160
      - self.ruleThickness / 2
33✔
1161
      - SILE.types.length(
66✔
1162
         math.max(
66✔
1163
            (numeratorGapMin + self.numerator.depth):tonumber(),
66✔
1164
            numeratorShiftUp - self.axisHeight - self.ruleThickness / 2
33✔
1165
         )
1166
      )
66✔
1167
   self.denominator.relY = -self.axisHeight
33✔
1168
      + self.ruleThickness / 2
33✔
1169
      + SILE.types.length(
66✔
1170
         math.max(
66✔
1171
            (denominatorGapMin + self.denominator.height):tonumber(),
66✔
1172
            denominatorShiftDown + self.axisHeight - self.ruleThickness / 2
33✔
1173
         )
1174
      )
66✔
1175
   self.height = self.numerator.height - self.numerator.relY
66✔
1176
   self.depth = self.denominator.relY + self.denominator.depth
66✔
1177
end
1178

1179
function elements.fraction:output (x, y, line)
20✔
1180
   if self.ruleThickness > 0 then
33✔
1181
      SILE.outputter:drawRule(
66✔
1182
         scaleWidth(x + self.padding, line),
66✔
1183
         y.length - self.axisHeight - self.ruleThickness / 2,
66✔
1184
         scaleWidth(self.width - 2 * self.padding, line),
99✔
1185
         self.ruleThickness
1186
      )
33✔
1187
   end
1188
end
1189

1190
local function newSubscript (spec)
1191
   return elements.subscript(spec.base, spec.sub, spec.sup)
102✔
1192
end
1193

1194
local function newUnderOver (spec)
1195
   return elements.underOver(spec.base, spec.sub, spec.sup)
25✔
1196
end
1197

1198
-- TODO replace with penlight equivalent
1199
local function mapList (f, l)
1200
   local ret = {}
8✔
1201
   for i, x in ipairs(l) do
32✔
1202
      ret[i] = f(i, x)
48✔
1203
   end
1204
   return ret
8✔
1205
end
1206

1207
elements.mtr = pl.class(elements.mbox)
20✔
1208
-- elements.mtr._type = "" -- TODO why not set?
1209

1210
function elements.mtr:_init (children)
20✔
1211
   self.children = children
12✔
1212
end
1213

1214
function elements.mtr:styleChildren ()
20✔
1215
   for _, c in ipairs(self.children) do
48✔
1216
      c.mode = self.mode
36✔
1217
   end
1218
end
1219

1220
function elements.mtr.shape (_) end -- done by parent table
22✔
1221

1222
function elements.mtr.output (_) end
22✔
1223

1224
elements.table = pl.class(elements.mbox)
20✔
1225
elements.table._type = "table" -- TODO why case difference?
10✔
1226

1227
function elements.table:_init (children, options)
20✔
1228
   elements.mbox._init(self)
8✔
1229
   self.children = children
8✔
1230
   self.options = options
8✔
1231
   self.nrows = #self.children
8✔
1232
   self.ncols = math.max(pl.utils.unpack(mapList(function (_, row)
32✔
1233
      return #row.children
24✔
1234
   end, self.children)))
16✔
1235
   SU.debug("math", "self.ncols =", self.ncols)
8✔
1236
   local spacing = SILE.settings:get("math.font.size") * 0.6 -- arbitrary ratio of the current math font size
16✔
1237
   self.rowspacing = self.options.rowspacing and SILE.types.length(self.options.rowspacing) or spacing
8✔
1238
   self.columnspacing = self.options.columnspacing and SILE.types.length(self.options.columnspacing) or spacing
8✔
1239
   -- Pad rows that do not have enough cells by adding cells to the
1240
   -- right.
1241
   for i, row in ipairs(self.children) do
32✔
1242
      for j = 1, (self.ncols - #row.children) do
24✔
UNCOV
1243
         SU.debug("math", "padding i =", i, "j =", j)
×
UNCOV
1244
         table.insert(row.children, elements.stackbox("H", {}))
×
UNCOV
1245
         SU.debug("math", "size", #row.children)
×
1246
      end
1247
   end
1248
   if options.columnalign then
8✔
1249
      local l = {}
4✔
1250
      for w in string.gmatch(options.columnalign, "[^%s]+") do
16✔
1251
         if not (w == "left" or w == "center" or w == "right") then
12✔
UNCOV
1252
            SU.error("Invalid specifier in `columnalign` attribute: " .. w)
×
1253
         end
1254
         table.insert(l, w)
12✔
1255
      end
1256
      -- Pad with last value of l if necessary
1257
      for _ = 1, (self.ncols - #l), 1 do
4✔
UNCOV
1258
         table.insert(l, l[#l])
×
1259
      end
1260
      -- On the contrary, remove excess values in l if necessary
1261
      for _ = 1, (#l - self.ncols), 1 do
4✔
UNCOV
1262
         table.remove(l)
×
1263
      end
1264
      self.options.columnalign = l
4✔
1265
   else
1266
      self.options.columnalign = pl.List.range(1, self.ncols):map(function (_)
14✔
1267
         return "center"
12✔
1268
      end)
1269
   end
1270
end
1271

1272
function elements.table:styleChildren ()
20✔
1273
   if self.mode == mathMode.display and self.options.displaystyle ~= "false" then
8✔
1274
      for _, c in ipairs(self.children) do
16✔
1275
         c.mode = mathMode.display
12✔
1276
      end
1277
   else
1278
      for _, c in ipairs(self.children) do
16✔
1279
         c.mode = mathMode.text
12✔
1280
      end
1281
   end
1282
end
1283

1284
function elements.table:shape ()
20✔
1285
   -- Determine the height (resp. depth) of each row, which is the max
1286
   -- height (resp. depth) among its elements. Then we only need to add it to
1287
   -- the table's height and center every cell vertically.
1288
   for _, row in ipairs(self.children) do
32✔
1289
      row.height = SILE.types.length(0)
48✔
1290
      row.depth = SILE.types.length(0)
48✔
1291
      for _, cell in ipairs(row.children) do
96✔
1292
         row.height = maxLength(row.height, cell.height)
144✔
1293
         row.depth = maxLength(row.depth, cell.depth)
144✔
1294
      end
1295
   end
1296
   self.vertSize = SILE.types.length(0)
16✔
1297
   for i, row in ipairs(self.children) do
32✔
1298
      self.vertSize = self.vertSize
×
1299
         + row.height
24✔
1300
         + row.depth
24✔
1301
         + (i == self.nrows and SILE.types.length(0) or self.rowspacing) -- Spacing
56✔
1302
   end
1303
   local rowHeightSoFar = SILE.types.length(0)
8✔
1304
   for i, row in ipairs(self.children) do
32✔
1305
      row.relY = rowHeightSoFar + row.height - self.vertSize
72✔
UNCOV
1306
      rowHeightSoFar = rowHeightSoFar
×
1307
         + row.height
24✔
1308
         + row.depth
24✔
1309
         + (i == self.nrows and SILE.types.length(0) or self.rowspacing) -- Spacing
32✔
1310
   end
1311
   self.width = SILE.types.length(0)
16✔
1312
   local thisColRelX = SILE.types.length(0)
8✔
1313
   -- For every column...
1314
   for i = 1, self.ncols do
32✔
1315
      -- Determine its width
1316
      local columnWidth = SILE.types.length(0)
24✔
1317
      for j = 1, self.nrows do
96✔
1318
         if self.children[j].children[i].width > columnWidth then
72✔
1319
            columnWidth = self.children[j].children[i].width
32✔
1320
         end
1321
      end
1322
      -- Use it to align the contents of every cell as required.
1323
      for j = 1, self.nrows do
96✔
1324
         local cell = self.children[j].children[i]
72✔
1325
         if self.options.columnalign[i] == "left" then
72✔
1326
            cell.relX = thisColRelX
12✔
1327
         elseif self.options.columnalign[i] == "center" then
60✔
1328
            cell.relX = thisColRelX + (columnWidth - cell.width) / 2
192✔
1329
         elseif self.options.columnalign[i] == "right" then
12✔
1330
            cell.relX = thisColRelX + (columnWidth - cell.width)
36✔
1331
         else
UNCOV
1332
            SU.error("invalid columnalign parameter")
×
1333
         end
1334
      end
1335
      thisColRelX = thisColRelX + columnWidth + (i == self.ncols and SILE.types.length(0) or self.columnspacing) -- Spacing
56✔
1336
   end
1337
   self.width = thisColRelX
8✔
1338
   -- Center myself vertically around the axis, and update relative Ys of rows accordingly
1339
   local axisHeight = self:getMathMetrics().constants.axisHeight * self:getScaleDown()
24✔
1340
   self.height = self.vertSize / 2 + axisHeight
24✔
1341
   self.depth = self.vertSize / 2 - axisHeight
24✔
1342
   for _, row in ipairs(self.children) do
32✔
1343
      row.relY = row.relY + self.vertSize / 2 - axisHeight
96✔
1344
      -- Also adjust width
1345
      row.width = self.width
24✔
1346
   end
1347
end
1348

1349
function elements.table.output (_) end
18✔
1350

1351
local function getRadicandMode (mode)
1352
   -- Not too sure if we should do something special/
UNCOV
1353
   return mode
×
1354
end
1355

1356
local function getDegreeMode (mode)
1357
   -- 2 levels smaller, up to scriptScript evntually.
1358
   -- Not too sure if we should do something else.
1359
   if mode == mathMode.display then
×
UNCOV
1360
      return mathMode.scriptScript
×
UNCOV
1361
   elseif mode == mathMode.displayCramped then
×
UNCOV
1362
      return mathMode.scriptScriptCramped
×
UNCOV
1363
   elseif mode == mathMode.text or mode == mathMode.script or mode == mathMode.scriptScript then
×
UNCOV
1364
      return mathMode.scriptScript
×
1365
   end
UNCOV
1366
   return mathMode.scriptScriptCramped
×
1367
end
1368

1369
elements.sqrt = pl.class(elements.mbox)
20✔
1370
elements.sqrt._type = "Sqrt"
10✔
1371

1372
function elements.sqrt:__tostring ()
20✔
UNCOV
1373
   return self._type .. "(" .. tostring(self.radicand) .. (self.degree and ", " .. tostring(self.degree) or "") .. ")"
×
1374
end
1375

1376
function elements.sqrt:_init (radicand, degree)
20✔
UNCOV
1377
   elements.mbox._init(self)
×
UNCOV
1378
   self.radicand = radicand
×
UNCOV
1379
   if degree then
×
UNCOV
1380
      self.degree = degree
×
UNCOV
1381
      table.insert(self.children, degree)
×
1382
   end
UNCOV
1383
   table.insert(self.children, radicand)
×
UNCOV
1384
   self.relX = SILE.types.length()
×
1385
   self.relY = SILE.types.length()
×
1386
end
1387

1388
function elements.sqrt:styleChildren ()
20✔
UNCOV
1389
   self.radicand.mode = getRadicandMode(self.mode)
×
UNCOV
1390
   if self.degree then
×
UNCOV
1391
      self.degree.mode = getDegreeMode(self.mode)
×
1392
   end
1393
end
1394

1395
function elements.sqrt:shape ()
20✔
UNCOV
1396
   local mathMetrics = self:getMathMetrics()
×
UNCOV
1397
   local scaleDown = self:getScaleDown()
×
UNCOV
1398
   local constants = mathMetrics.constants
×
1399

UNCOV
1400
   self.radicalRuleThickness = constants.radicalRuleThickness * scaleDown
×
UNCOV
1401
   if self.mode == mathMode.display or self.mode == mathMode.displayCramped then
×
UNCOV
1402
      self.radicalVerticalGap = constants.radicalDisplayStyleVerticalGap * scaleDown
×
1403
   else
UNCOV
1404
      self.radicalVerticalGap = constants.radicalVerticalGap * scaleDown
×
1405
   end
1406
   self.extraAscender = constants.radicalExtraAscender * scaleDown
×
1407

1408
   -- HACK: We draw own own radical sign in the output() method.
1409
   -- Derive dimensions for the radical sign (more or less ad hoc).
1410
   -- Note: In TeX, the radical sign extends a lot below the baseline,
1411
   -- and MathML Core also has a lot of layout text about it.
1412
   -- Not only it doesn't look good, but it's not very clear vs. OpenType.
1413
   local radicalGlyph = SILE.shaper:measureChar("√")
×
1414
   local ratio = (self.radicand.height:tonumber() + self.radicand.depth:tonumber())
×
1415
      / (radicalGlyph.height + radicalGlyph.depth)
×
1416
   local vertAdHocOffset = (ratio > 1 and math.log(ratio) or 0) * self.radicalVerticalGap
×
1417
   self.symbolHeight = SILE.types.length(radicalGlyph.height) * scaleDown
×
UNCOV
1418
   self.symbolDepth = (SILE.types.length(radicalGlyph.depth) + vertAdHocOffset) * scaleDown
×
1419
   self.symbolWidth = (SILE.types.length(radicalGlyph.width) + vertAdHocOffset) * scaleDown
×
1420

1421
   -- Adjust the height of the radical sign if the radicand is higher
UNCOV
1422
   self.symbolHeight = self.radicand.height > self.symbolHeight and self.radicand.height or self.symbolHeight
×
1423
   -- Compute the (max-)height of the short leg of the radical sign
UNCOV
1424
   self.symbolShortHeight = self.symbolHeight * constants.radicalDegreeBottomRaisePercent
×
1425

1426
   self.offsetX = SILE.types.length()
×
UNCOV
1427
   if self.degree then
×
1428
      -- Position the degree
UNCOV
1429
      self.degree.relY = -constants.radicalDegreeBottomRaisePercent * self.symbolHeight
×
1430
      -- Adjust the height of the short leg of the radical sign to ensure the degree is not too close
1431
      -- (empirically use radicalExtraAscender)
1432
      self.symbolShortHeight = self.symbolShortHeight - constants.radicalExtraAscender * scaleDown
×
1433
      -- Compute the width adjustment for the degree
1434
      self.offsetX = self.degree.width
×
UNCOV
1435
         + constants.radicalKernBeforeDegree * scaleDown
×
1436
         + constants.radicalKernAfterDegree * scaleDown
×
1437
   end
1438
   -- Position the radicand
UNCOV
1439
   self.radicand.relX = self.symbolWidth + self.offsetX
×
1440
   -- Compute the dimentions of the whole radical
UNCOV
1441
   self.width = self.radicand.width + self.symbolWidth + self.offsetX
×
1442
   self.height = self.symbolHeight + self.radicalVerticalGap + self.extraAscender
×
1443
   self.depth = self.radicand.depth
×
1444
end
1445

1446
local function _r (number)
1447
   -- Lua 5.3+ formats floats as 1.0 and integers as 1
1448
   -- Also some PDF readers do not like double precision.
1449
   return math.floor(number) == number and math.floor(number) or tonumber(string.format("%.5f", number))
×
1450
end
1451

1452
function elements.sqrt:output (x, y, line)
20✔
1453
   -- HACK:
1454
   -- OpenType might say we need to assemble the radical sign from parts.
1455
   -- Frankly, it's much easier to just draw it as a graphic :-)
1456
   -- Hence, here we use a PDF graphic operators to draw a nice radical sign.
1457
   -- Some values here are ad hoc, but they look good.
UNCOV
1458
   local h = self.height:tonumber()
×
1459
   local d = self.depth:tonumber()
×
UNCOV
1460
   local s0 = scaleWidth(self.offsetX, line):tonumber()
×
UNCOV
1461
   local sw = scaleWidth(self.symbolWidth, line):tonumber()
×
UNCOV
1462
   local dsh = h - self.symbolShortHeight:tonumber()
×
UNCOV
1463
   local dsd = self.symbolDepth:tonumber()
×
UNCOV
1464
   local symbol = {
×
UNCOV
1465
      _r(self.radicalRuleThickness),
×
1466
      "w", -- line width
1467
      2,
1468
      "j", -- round line joins
1469
      _r(sw + s0),
×
1470
      _r(self.extraAscender),
×
1471
      "m",
1472
      _r(s0 + sw * 0.90),
×
UNCOV
1473
      _r(self.extraAscender),
×
1474
      "l",
1475
      _r(s0 + sw * 0.4),
×
UNCOV
1476
      _r(h + d + dsd),
×
1477
      "l",
UNCOV
1478
      _r(s0 + sw * 0.2),
×
1479
      _r(dsh),
×
1480
      "l",
UNCOV
1481
      s0 + sw * 0.1,
×
1482
      _r(dsh + 0.5),
×
1483
      "l",
1484
      "S",
1485
   }
UNCOV
1486
   local svg = table.concat(symbol, " ")
×
1487
   local xscaled = scaleWidth(x, line)
×
1488
   SILE.outputter:drawSVG(svg, xscaled, y, sw, h, 1)
×
1489
   -- And now we just need to draw the bar over the radicand
UNCOV
1490
   SILE.outputter:drawRule(
×
UNCOV
1491
      s0 + self.symbolWidth + xscaled,
×
1492
      y.length - self.height + self.extraAscender - self.radicalRuleThickness / 2,
×
UNCOV
1493
      scaleWidth(self.radicand.width, line),
×
1494
      self.radicalRuleThickness
1495
   )
1496
end
1497

1498
elements.padded = pl.class(elements.mbox)
20✔
1499
elements.padded._type = "Padded"
10✔
1500

1501
function elements.padded:__tostring ()
20✔
NEW
1502
   return self._type .. "(" .. tostring(self.impadded) .. ")"
×
1503
end
1504

1505
function elements.padded:_init (attributes, impadded)
20✔
NEW
1506
   elements.mbox._init(self)
×
NEW
1507
   self.impadded = impadded
×
NEW
1508
   self.attributes = attributes or {}
×
NEW
1509
   table.insert(self.children, impadded)
×
1510
end
1511

1512
function elements.padded:styleChildren ()
20✔
NEW
1513
   self.impadded.mode = self.mode
×
1514
end
1515

1516
function elements.padded:shape ()
20✔
1517
   -- TODO MathML allows percentages font-relative units (em, ex) for padding
1518
   -- But our units work with font.size, not math.font.size (possibly adjusted by scaleDown)
1519
   -- so the expectations might not be met.
NEW
1520
   local width = self.attributes.width and SU.cast("measurement", self.attributes.width)
×
NEW
1521
   local height = self.attributes.height and SU.cast("measurement", self.attributes.height)
×
NEW
1522
   local depth = self.attributes.depth and SU.cast("measurement", self.attributes.depth)
×
NEW
1523
   local lspace = self.attributes.lspace and SU.cast("measurement", self.attributes.lspace)
×
NEW
1524
   local voffset = self.attributes.voffset and SU.cast("measurement", self.attributes.voffset)
×
1525
   -- Clamping for width, height, depth, lspace
NEW
1526
   width = width and (width:tonumber() > 0 and width or SILE.types.measurement())
×
NEW
1527
   height = height and (height:tonumber() > 0 and height or SILE.types.measurement())
×
NEW
1528
   depth = depth and (depth:tonumber() > 0 and depth or SILE.types.measurement())
×
NEW
1529
   lspace = lspace and (lspace:tonumber() > 0 and lspace or SILE.types.measurement())
×
1530
   -- No clamping for voffset
NEW
1531
   voffset = voffset or SILE.types.measurement(0)
×
1532
   -- Compute the dimensions
NEW
1533
   self.width = width and SILE.types.length(width) or self.impadded.width
×
NEW
1534
   self.height = height and SILE.types.length(height) or self.impadded.height
×
NEW
1535
   self.depth = depth and SILE.types.length(depth) or self.impadded.depth
×
NEW
1536
   self.impadded.relX = lspace and SILE.types.length(lspace) or SILE.types.length()
×
NEW
1537
   self.impadded.relY = voffset and SILE.types.length(voffset):negate() or SILE.types.length()
×
1538
end
1539

1540
function elements.padded.output (_, _, _, _) end
10✔
1541

1542
elements.mathMode = mathMode
10✔
1543
elements.atomType = atomType
10✔
1544
elements.symbolDefaults = symbolDefaults
10✔
1545
elements.newSubscript = newSubscript
10✔
1546
elements.newUnderOver = newUnderOver
10✔
1547

1548
return elements
10✔
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