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

sile-typesetter / sile / 11827188491

13 Nov 2024 10:24PM UTC coverage: 33.179% (-27.8%) from 61.013%
11827188491

push

github

alerque
chore(deps): Bump Nix flake dependencies

5947 of 17924 relevant lines covered (33.18%)

1920.63 hits per line

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

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

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

11
local elements = {}
×
12

13
local mathMode = {
×
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
×
26
end
27

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

32
local function isScriptMode (mode)
33
   return mode == mathMode.script or mode == mathMode.scriptCramped
×
34
end
35

36
local function isScriptScriptMode (mode)
37
   return mode == mathMode.scriptScript or mode == mathMode.scriptScriptCramped
×
38
end
39

40
local mathCache = {}
×
41

42
local function retrieveMathTable (font)
43
   local key = SILE.font._key(font)
×
44
   if not mathCache[key] then
×
45
      SU.debug("math", "Loading math font", key)
×
46
      local face = SILE.font.cache(font, SILE.shaper.getFace)
×
47
      if not face then
×
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")
×
52
      if fontHasMathTable then
×
53
         mathTableParsable, mathTable = pcall(ot.parseMath, rawMathTable)
×
54
      end
55
      if not fontHasMathTable or not mathTableParsable then
×
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
×
63
      local constants = {}
×
64
      for k, v in pairs(mathTable.mathConstants) do
×
65
         if type(v) == "table" then
×
66
            v = v.value
×
67
         end
68
         if k:sub(-9) == "ScaleDown" then
×
69
            constants[k] = v / 100
×
70
         else
71
            constants[k] = v * font.size / upem
×
72
         end
73
      end
74
      local italicsCorrection = {}
×
75
      for k, v in pairs(mathTable.mathItalicsCorrection) do
×
76
         italicsCorrection[k] = v.value * font.size / upem
×
77
      end
78
      mathCache[key] = {
×
79
         constants = constants,
80
         italicsCorrection = italicsCorrection,
81
         mathVariants = mathTable.mathVariants,
82
         unitsPerEm = upem,
83
      }
84
   end
85
   return mathCache[key]
×
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
×
92
      return mathMode.script
×
93
   -- D', T' -> S'
94
   elseif mode == mathMode.displayCramped or mode == mathMode.textCramped then
×
95
      return mathMode.scriptCramped
×
96
   -- S, SS -> SS
97
   elseif mode == mathMode.script or mode == mathMode.scriptScript then
×
98
      return mathMode.scriptScript
×
99
   -- S', SS' -> SS'
100
   else
101
      return mathMode.scriptScriptCramped
×
102
   end
103
end
104
local function getSubscriptMode (mode)
105
   -- D, T, D', T' -> S'
106
   if
107
      mode == mathMode.display
×
108
      or mode == mathMode.text
×
109
      or mode == mathMode.displayCramped
×
110
      or mode == mathMode.textCramped
×
111
   then
112
      return mathMode.scriptCramped
×
113
   -- S, SS, S', SS' -> SS'
114
   else
115
      return mathMode.scriptScriptCramped
×
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
×
123
      return mathMode.text
×
124
   -- D' -> T'
125
   elseif mode == mathMode.displayCramped then
×
126
      return mathMode.textCramped
×
127
   -- T -> S
128
   elseif mode == mathMode.text then
×
129
      return mathMode.script
×
130
   -- T' -> S'
131
   elseif mode == mathMode.textCramped then
×
132
      return mathMode.scriptCramped
×
133
   -- S, SS -> SS
134
   elseif mode == mathMode.script or mode == mathMode.scriptScript then
×
135
      return mathMode.scriptScript
×
136
   -- S', SS' -> SS'
137
   else
138
      return mathMode.scriptScriptCramped
×
139
   end
140
end
141
local function getDenominatorMode (mode)
142
   -- D, D' -> T'
143
   if mode == mathMode.display or mode == mathMode.displayCramped then
×
144
      return mathMode.textCramped
×
145
   -- T, T' -> S'
146
   elseif mode == mathMode.text or mode == mathMode.textCramped then
×
147
      return mathMode.scriptCramped
×
148
   -- S, SS, S', SS' -> SS'
149
   else
150
      return mathMode.scriptScriptCramped
×
151
   end
152
end
153

154
local function getRightMostGlyphId (node)
155
   while node and node:is_a(elements.stackbox) and node.direction == "H" do
×
156
      node = node.children[#node.children]
×
157
   end
158
   if node and node:is_a(elements.text) then
×
159
      return node.value.glyphString[#node.value.glyphString]
×
160
   else
161
      return 0
×
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 = { ... }
×
169
   local m
170
   for i, v in ipairs(arg) do
×
171
      if i == 1 then
×
172
         m = v
×
173
      else
174
         if v.length:tonumber() > m.length:tonumber() then
×
175
            m = v
×
176
         end
177
      end
178
   end
179
   return m
×
180
end
181

182
local function scaleWidth (length, line)
183
   local number = length.length
×
184
   if line.ratio and line.ratio < 0 and length.shrink:tonumber() > 0 then
×
185
      number = number + length.shrink * line.ratio
×
186
   elseif line.ratio and line.ratio > 0 and length.stretch:tonumber() > 0 then
×
187
      number = number + length.stretch * line.ratio
×
188
   end
189
   return number
×
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)
×
200
elements.mbox._type = "Mbox"
×
201

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

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

228
function elements.mbox.styleChildren (_)
×
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 (_, _, _)
×
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 (_, _, _, _)
×
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 ()
×
241
   return retrieveMathTable(self.font)
×
242
end
243

244
function elements.mbox:getScaleDown ()
×
245
   local constants = self:getMathMetrics().constants
×
246
   local scaleDown
247
   if isScriptMode(self.mode) then
×
248
      scaleDown = constants.scriptPercentScaleDown
×
249
   elseif isScriptScriptMode(self.mode) then
×
250
      scaleDown = constants.scriptScriptPercentScaleDown
×
251
   else
252
      scaleDown = 1
×
253
   end
254
   return scaleDown
×
255
end
256

257
-- Determine the mode of its descendants
258
function elements.mbox:styleDescendants ()
×
259
   self:styleChildren()
×
260
   for _, n in ipairs(self.children) do
×
261
      if n then
×
262
         n:styleDescendants()
×
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 ()
×
272
   for _, n in ipairs(self.children) do
×
273
      if n then
×
274
         n:shapeTree()
×
275
      end
276
   end
277
   self:shape()
×
278
end
279

280
-- Output the node and all its descendants
281
function elements.mbox:outputTree (x, y, line)
×
282
   self:output(x, y, line)
×
283
   local debug = SILE.settings:get("math.debug.boxes")
×
284
   if debug and not (self:is_a(elements.space)) then
×
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
×
289
      if n then
×
290
         n:outputTree(x + n.relX, y + n.relY, line)
×
291
      end
292
   end
293
end
294

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

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

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

357
function elements.stackbox:__tostring ()
×
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)
×
367
   elements.mbox._init(self)
×
368
   if not (direction == "H" or direction == "V") then
×
369
      SU.error("Wrong direction '" .. direction .. "'; should be H or V")
×
370
   end
371
   self.direction = direction
×
372
   self.children = children
×
373
end
374

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

407
function elements.stackbox:shape ()
×
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)
×
417
   self.depth = SILE.types.length(0)
×
418
   if self.direction == "H" then
×
419
      for i, n in ipairs(self.children) do
×
420
         n.relY = SILE.types.length(0)
×
421
         self.height = i == 1 and n.height or maxLength(self.height, n.height)
×
422
         self.depth = i == 1 and n.depth or maxLength(self.depth, n.depth)
×
423
      end
424
      -- Handle stretchy operators
425
      for _, elt in ipairs(self.children) do
×
426
         if elt.is_a(elements.text) and elt.kind == "operator" and SU.boolean(elt.stretchy, false) then
×
427
            elt:_vertStretchyReshape(self.depth, self.height)
×
428
         end
429
      end
430
      -- Set self.width
431
      self.width = SILE.types.length(0)
×
432
      for i, n in ipairs(self.children) do
×
433
         n.relX = self.width
×
434
         self.width = i == 1 and n.width or self.width + n.width
×
435
      end
436
   else -- self.direction == "V"
437
      for i, n in ipairs(self.children) do
×
438
         n.relX = SILE.types.length(0)
×
439
         self.width = i == 1 and n.width or maxLength(self.width, n.width)
×
440
      end
441
      -- Set self.height and self.depth
442
      for i, n in ipairs(self.children) do
×
443
         self.depth = i == 1 and n.depth or self.depth + n.depth
×
444
      end
445
      for i = 1, #self.children do
×
446
         local n = self.children[i]
×
447
         if i == 1 then
×
448
            self.height = n.height
×
449
            self.depth = n.depth
×
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)
×
460
   local mathX = typesetter.frame.state.cursorX
×
461
   local mathY = typesetter.frame.state.cursorY
×
462
   self:outputTree(self.relX + mathX, self.relY + mathY, line)
×
463
   typesetter.frame:advanceWritingDirection(scaleWidth(self.width, line))
×
464
end
465

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

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

471
function elements.phantom:_init (children)
×
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 (_, _, _)
×
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.
486
   self.children = {}
×
487
end
488

489
elements.subscript = pl.class(elements.mbox)
×
490
elements.subscript._type = "Subscript"
×
491

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

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

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

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

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

635
function elements.subscript.output (_, _, _, _) end
×
636

637
elements.underOver = pl.class(elements.subscript)
×
638
elements.underOver._type = "UnderOver"
×
639

640
function elements.underOver:__tostring ()
×
641
   return self._type .. "(" .. tostring(self.base) .. ", " .. tostring(self.sub) .. ", " .. tostring(self.sup) .. ")"
×
642
end
643

644
local function isNotEmpty (element)
645
   -- The MathML test suite uses <munderover> with an empty <mrow> as sub/sup.
646
   -- I don't know why they didn't use a <munder> or <mover> instead...
647
   -- But the expectation is to behave as if the empty element was not there,
648
   -- so that height and depth are not affected by the axis height.
649
   -- See notably:
650
   --   MathML3 "complex1" torture test: Maxwell's Equations (vectors in fractions)
651
   return element and (element:is_a(elements.terminal) or #element.children > 0)
×
652
end
653

654
function elements.underOver:_init (base, sub, sup)
×
655
   elements.mbox._init(self)
×
656
   self.atom = base.atom
×
657
   self.base = base
×
658
   self.sub = isNotEmpty(sub) and sub or nil
×
659
   self.sup = isNotEmpty(sup) and sup or nil
×
660
   if self.sup then
×
661
      table.insert(self.children, self.sup)
×
662
   end
663
   if self.base then
×
664
      table.insert(self.children, self.base)
×
665
   end
666
   if self.sub then
×
667
      table.insert(self.children, self.sub)
×
668
   end
669
end
670

671
function elements.underOver:styleChildren ()
×
672
   if self.base then
×
673
      self.base.mode = self.mode
×
674
   end
675
   if self.sub then
×
676
      self.sub.mode = getSubscriptMode(self.mode)
×
677
   end
678
   if self.sup then
×
679
      self.sup.mode = getSuperscriptMode(self.mode)
×
680
   end
681
end
682

683
function elements.underOver:_stretchyReshapeToBase (part)
×
684
   -- FIXME: Big leap of faith here.
685
   -- MathML Core only mentions stretching along the inline axis in 3.4.2.2,
686
   -- i.e. under the section on <mover>, <munder>, <munderover>.
687
   -- So we are "somewhat" good here, but... the algorithm is totally unclear
688
   -- to me and seems to imply a lot of recursion and reshaping.
689
   -- The implementation below is NOT general and only works for the cases
690
   -- I checked:
691
   --   Mozilla MathML tests: braces in f19, f22
692
   --   Personal tests: vectors in d19, d22, d23
693
   --   Joe Javawaski's tests: braces in 8a, 8b
694
   --   MathML3 "complex1" torture test: Maxwell's Equations (vectors in fractions)
695
   if #part.children == 0 then
×
696
      local elt = part
×
697
      if elt.is_a(elements.text) and elt.kind == "operator" and SU.boolean(elt.stretchy, false) then
×
698
         elt:_horizStretchyReshape(self.base.width)
×
699
      end
700
   elseif part:is_a(elements.underOver) then
×
701
      -- Big assumption here: only considering one level of stacked under/over.
702
      local hasStretched = false
×
703
      for _, elt in ipairs(part.children) do
×
704
         if elt.is_a(elements.text) and elt.kind == "operator" and SU.boolean(elt.stretchy, false) then
×
705
            local stretched = elt:_horizStretchyReshape(self.base.width)
×
706
            if stretched then
×
707
               hasStretched = true
×
708
            end
709
         end
710
      end
711
      if hasStretched then
×
712
         -- We need to re-calculate the shape so positions are re-calculated on each
713
         -- of its own parts.
714
         -- (Added after seeing that Mozilla test f19 was not rendering correctly.)
715
         part:shape()
×
716
      end
717
   end
718
end
719

720
function elements.underOver:shape ()
×
721
   local isMovableLimits = SU.boolean(self.base and self.base.movablelimits, false)
×
722
   if not (self.mode == mathMode.display or self.mode == mathMode.displayCramped) and isMovableLimits then
×
723
      -- When the base is a movable limit, the under/over scripts are not placed under/over the base,
724
      -- but other to the right of it, when display mode is not used.
725
      -- Notable effects:
726
      --   Mozilla MathML test 19 (on "k times" > overbrace > base)
727
      --   Maxwell's Equations in MathML3 Test Suite "complex1" (on the vectors in fractions)
728
      self.isUnderOver = true
×
729
      elements.subscript.shape(self)
×
730
      return
×
731
   end
732
   local constants = self:getMathMetrics().constants
×
733
   local scaleDown = self:getScaleDown()
×
734
   -- Determine relative Ys
735
   if self.base then
×
736
      self.base.relY = SILE.types.length(0)
×
737
   end
738
   if self.sub then
×
739
      self:_stretchyReshapeToBase(self.sub)
×
740
      self.sub.relY = self.base.depth
×
741
         + SILE.types.length(
×
742
            math.max(
×
743
               (self.sub.height + constants.lowerLimitGapMin * scaleDown):tonumber(),
×
744
               constants.lowerLimitBaselineDropMin * scaleDown
×
745
            )
746
         )
747
   end
748
   if self.sup then
×
749
      self:_stretchyReshapeToBase(self.sup)
×
750
      self.sup.relY = 0
×
751
         - self.base.height
×
752
         - SILE.types.length(
×
753
            math.max(
×
754
               (constants.upperLimitGapMin * scaleDown + self.sup.depth):tonumber(),
×
755
               constants.upperLimitBaselineRiseMin * scaleDown
×
756
            )
757
         )
758
   end
759
   -- Determine relative Xs based on widest symbol
760
   local widest, a, b
761
   if self.sub and self.sub.width > self.base.width then
×
762
      if self.sup and self.sub.width > self.sup.width then
×
763
         widest = self.sub
×
764
         a = self.base
×
765
         b = self.sup
×
766
      elseif self.sup then
×
767
         widest = self.sup
×
768
         a = self.base
×
769
         b = self.sub
×
770
      else
771
         widest = self.sub
×
772
         a = self.base
×
773
         b = nil
×
774
      end
775
   else
776
      if self.sup and self.base.width > self.sup.width then
×
777
         widest = self.base
×
778
         a = self.sub
×
779
         b = self.sup
×
780
      elseif self.sup then
×
781
         widest = self.sup
×
782
         a = self.base
×
783
         b = self.sub
×
784
      else
785
         widest = self.base
×
786
         a = self.sub
×
787
         b = nil
×
788
      end
789
   end
790
   widest.relX = SILE.types.length(0)
×
791
   local c = widest.width / 2
×
792
   if a then
×
793
      a.relX = c - a.width / 2
×
794
   end
795
   if b then
×
796
      b.relX = c - b.width / 2
×
797
   end
798
   local itCorr = self:calculateItalicsCorrection() * scaleDown
×
799
   if self.sup then
×
800
      self.sup.relX = self.sup.relX + itCorr / 2
×
801
   end
802
   if self.sub then
×
803
      self.sub.relX = self.sub.relX - itCorr / 2
×
804
   end
805
   -- Determine width and height
806
   self.width = maxLength(
×
807
      self.base and self.base.width or SILE.types.length(0),
×
808
      self.sub and self.sub.width or SILE.types.length(0),
×
809
      self.sup and self.sup.width or SILE.types.length(0)
×
810
   )
811
   if self.sup then
×
812
      self.height = 0 - self.sup.relY + self.sup.height
×
813
   else
814
      self.height = self.base and self.base.height or 0
×
815
   end
816
   if self.sub then
×
817
      self.depth = self.sub.relY + self.sub.depth
×
818
   else
819
      self.depth = self.base and self.base.depth or 0
×
820
   end
821
end
822

823
function elements.underOver:calculateItalicsCorrection ()
×
824
   local lastGid = getRightMostGlyphId(self.base)
×
825
   if lastGid > 0 then
×
826
      local mathMetrics = self:getMathMetrics()
×
827
      if mathMetrics.italicsCorrection[lastGid] then
×
828
         local c = mathMetrics.italicsCorrection[lastGid]
×
829
         -- If this is a big operator, and we are in display style, then the
830
         -- base glyph may be bigger than the font size. We need to adjust the
831
         -- italic correction accordingly.
832
         if self.base.atom == atomType.bigOperator and isDisplayMode(self.mode) then
×
833
            c = c * (self.base and self.base.font.size / self.font.size or 1.0)
×
834
         end
835
         return c
×
836
      end
837
   end
838
   return 0
×
839
end
840

841
function elements.underOver.output (_, _, _, _) end
×
842

843
-- terminal is the base class for leaf node
844
elements.terminal = pl.class(elements.mbox)
×
845
elements.terminal._type = "Terminal"
×
846

847
function elements.terminal:_init ()
×
848
   elements.mbox._init(self)
×
849
end
850

851
function elements.terminal.styleChildren (_) end
×
852

853
function elements.terminal.shape (_) end
×
854

855
elements.space = pl.class(elements.terminal)
×
856
elements.space._type = "Space"
×
857

858
function elements.space:_init ()
×
859
   elements.terminal._init(self)
×
860
end
861

862
function elements.space:__tostring ()
×
863
   return self._type
×
864
      .. "(width="
×
865
      .. tostring(self.width)
×
866
      .. ", height="
×
867
      .. tostring(self.height)
×
868
      .. ", depth="
×
869
      .. tostring(self.depth)
×
870
      .. ")"
×
871
end
872

873
local function getStandardLength (value)
874
   if type(value) == "string" then
×
875
      local direction = 1
×
876
      if value:sub(1, 1) == "-" then
×
877
         value = value:sub(2, -1)
×
878
         direction = -1
×
879
      end
880
      if value == "thin" then
×
881
         return SILE.types.length("3mu") * direction
×
882
      elseif value == "med" then
×
883
         return SILE.types.length("4mu plus 2mu minus 4mu") * direction
×
884
      elseif value == "thick" then
×
885
         return SILE.types.length("5mu plus 5mu") * direction
×
886
      end
887
   end
888
   return SILE.types.length(value)
×
889
end
890

891
function elements.space:_init (width, height, depth)
×
892
   elements.terminal._init(self)
×
893
   self.width = getStandardLength(width)
×
894
   self.height = getStandardLength(height)
×
895
   self.depth = getStandardLength(depth)
×
896
end
897

898
function elements.space:shape ()
×
899
   self.width = self.width:absolute() * self:getScaleDown()
×
900
   self.height = self.height:absolute() * self:getScaleDown()
×
901
   self.depth = self.depth:absolute() * self:getScaleDown()
×
902
end
903

904
function elements.space.output (_) end
×
905

906
-- text node. For any actual text output
907
elements.text = pl.class(elements.terminal)
×
908
elements.text._type = "Text"
×
909

910
function elements.text:__tostring ()
×
911
   return self._type
×
912
      .. "(atom="
×
913
      .. tostring(self.atom)
×
914
      .. ", kind="
×
915
      .. tostring(self.kind)
×
916
      .. ", script="
×
917
      .. tostring(self.script)
×
918
      .. (SU.boolean(self.stretchy, false) and ", stretchy" or "")
×
919
      .. (SU.boolean(self.largeop, false) and ", largeop" or "")
×
920
      .. ', text="'
×
921
      .. (self.originalText or self.text)
×
922
      .. '")'
×
923
end
924

925
function elements.text:_init (kind, attributes, script, text)
×
926
   elements.terminal._init(self)
×
927
   if not (kind == "number" or kind == "identifier" or kind == "operator" or kind == "string") then
×
928
      SU.error("Unknown text node kind '" .. kind .. "'; should be one of: number, identifier, operator, string")
×
929
   end
930
   self.kind = kind
×
931
   self.script = script
×
932
   self.text = text
×
933
   if self.script ~= "upright" then
×
934
      local converted = convertMathVariantScript(self.text, self.script)
×
935
      self.originalText = self.text
×
936
      self.text = converted
×
937
   end
938
   if self.kind == "operator" then
×
939
      if self.text == "-" then
×
940
         self.text = "−"
×
941
      end
942
   end
943
   for attribute, value in pairs(attributes) do
×
944
      self[attribute] = value
×
945
   end
946
end
947

948
function elements.text:shape ()
×
949
   self.font.size = self.font.size * self:getScaleDown()
×
950
   local face = SILE.font.cache(self.font, SILE.shaper.getFace)
×
951
   local mathMetrics = self:getMathMetrics()
×
952
   local glyphs = SILE.shaper:shapeToken(self.text, self.font)
×
953
   -- Use bigger variants for big operators in display style
954
   if isDisplayMode(self.mode) and SU.boolean(self.largeop, false) then
×
955
      -- We copy the glyph list to avoid modifying the shaper's cache. Yes.
956
      glyphs = pl.tablex.deepcopy(glyphs)
×
957
      local constructions = mathMetrics.mathVariants.vertGlyphConstructions[glyphs[1].gid]
×
958
      if constructions then
×
959
         local displayVariants = constructions.mathGlyphVariantRecord
×
960
         -- We select the biggest variant. TODO: we should probably select the
961
         -- first variant that is higher than displayOperatorMinHeight.
962
         local biggest
963
         local m = 0
×
964
         for _, v in ipairs(displayVariants) do
×
965
            if v.advanceMeasurement > m then
×
966
               biggest = v
×
967
               m = v.advanceMeasurement
×
968
            end
969
         end
970
         if biggest then
×
971
            glyphs[1].gid = biggest.variantGlyph
×
972
            local dimen = hb.get_glyph_dimensions(face, self.font.size, biggest.variantGlyph)
×
973
            glyphs[1].width = dimen.width
×
974
            glyphs[1].glyphAdvance = dimen.glyphAdvance
×
975
            --[[ I am told (https://github.com/alif-type/xits/issues/90) that,
976
        in fact, the relative height and depth of display-style big operators
977
        in the font is not relevant, as these should be centered around the
978
        axis. So the following code does that, while conserving their
979
        vertical size (distance from top to bottom). ]]
980
            local axisHeight = mathMetrics.constants.axisHeight * self:getScaleDown()
×
981
            local y_size = dimen.height + dimen.depth
×
982
            glyphs[1].height = y_size / 2 + axisHeight
×
983
            glyphs[1].depth = y_size / 2 - axisHeight
×
984
            -- We still need to store the font's height and depth somewhere,
985
            -- because that's what will be used to draw the glyph, and we will need
986
            -- to artificially compensate for that.
987
            glyphs[1].fontHeight = dimen.height
×
988
            glyphs[1].fontDepth = dimen.depth
×
989
         end
990
      end
991
   end
992
   SILE.shaper:preAddNodes(glyphs, self.value)
×
993
   self.value.items = glyphs
×
994
   self.value.glyphString = {}
×
995
   if glyphs and #glyphs > 0 then
×
996
      for i = 1, #glyphs do
×
997
         table.insert(self.value.glyphString, glyphs[i].gid)
×
998
      end
999
      self.width = SILE.types.length(0)
×
1000
      self.widthForSubscript = SILE.types.length(0)
×
1001
      for i = #glyphs, 1, -1 do
×
1002
         self.width = self.width + glyphs[i].glyphAdvance
×
1003
      end
1004
      -- Store width without italic correction somewhere
1005
      self.widthForSubscript = self.width
×
1006
      local itCorr = mathMetrics.italicsCorrection[glyphs[#glyphs].gid]
×
1007
      if itCorr then
×
1008
         self.width = self.width + itCorr * self:getScaleDown()
×
1009
      end
1010
      for i = 1, #glyphs do
×
1011
         self.height = i == 1 and SILE.types.length(glyphs[i].height)
×
1012
            or SILE.types.length(math.max(self.height:tonumber(), glyphs[i].height))
×
1013
         self.depth = i == 1 and SILE.types.length(glyphs[i].depth)
×
1014
            or SILE.types.length(math.max(self.depth:tonumber(), glyphs[i].depth))
×
1015
      end
1016
   else
1017
      self.width = SILE.types.length(0)
×
1018
      self.height = SILE.types.length(0)
×
1019
      self.depth = SILE.types.length(0)
×
1020
   end
1021
end
1022

1023
function elements.text.findClosestVariant (_, variants, requiredAdvance, currentAdvance)
×
1024
   local closest
1025
   local closestI
1026
   local m = requiredAdvance - currentAdvance
×
1027
   for i, variant in ipairs(variants) do
×
1028
      local diff = math.abs(variant.advanceMeasurement - requiredAdvance)
×
1029
      SU.debug("math", "stretch: diff =", diff)
×
1030
      if diff < m then
×
1031
         closest = variant
×
1032
         closestI = i
×
1033
         m = diff
×
1034
      end
1035
   end
1036
   return closest, closestI
×
1037
end
1038

1039
function elements.text:_reshapeGlyph (glyph, closestVariant, sz)
×
1040
   local face = SILE.font.cache(self.font, SILE.shaper.getFace)
×
1041
   local dimen = hb.get_glyph_dimensions(face, sz, closestVariant.variantGlyph)
×
1042
   glyph.gid = closestVariant.variantGlyph
×
1043
   glyph.width, glyph.height, glyph.depth, glyph.glyphAdvance =
×
1044
      dimen.width, dimen.height, dimen.depth, dimen.glyphAdvance
×
1045
   return dimen
×
1046
end
1047

1048
function elements.text:_stretchyReshape (target, direction)
×
1049
   -- direction is the required direction of stretching: true for vertical, false for horizontal
1050
   -- target is the required dimension of the stretched glyph, in font units
1051
   local mathMetrics = self:getMathMetrics()
×
1052
   local upem = mathMetrics.unitsPerEm
×
1053
   local sz = self.font.size
×
1054
   local requiredAdvance = target:tonumber() * upem / sz
×
1055
   SU.debug("math", "stretch: rA =", requiredAdvance)
×
1056
   -- Choose variant of the closest size. The criterion we use is to have
1057
   -- an advance measurement as close as possible as the required one.
1058
   -- The advance measurement is simply the dimension of the glyph.
1059
   -- Therefore, the selected glyph may be smaller or bigger than
1060
   -- required.
1061
   -- TODO: implement assembly of stretchable glyphs from their parts for cases
1062
   -- when the biggest variant is not big enough.
1063
   -- We copy the glyph list to avoid modifying the shaper's cache. Yes.
1064
   local glyphs = pl.tablex.deepcopy(self.value.items)
×
1065
   local glyphConstructions = direction and mathMetrics.mathVariants.vertGlyphConstructions
×
1066
      or mathMetrics.mathVariants.horizGlyphConstructions
×
1067
   local constructions = glyphConstructions[glyphs[1].gid]
×
1068
   if constructions then
×
1069
      local variants = constructions.mathGlyphVariantRecord
×
1070
      SU.debug("math", "stretch: variants =", variants)
×
1071
      local currentAdvance = (direction and (self.depth + self.height):tonumber() or self.width:tonumber()) * upem / sz
×
1072
      local closest, closestI = self:findClosestVariant(variants, requiredAdvance, currentAdvance)
×
1073
      SU.debug("math", "stretch: closestI =", closestI)
×
1074
      if closest then
×
1075
         -- Now we have to re-shape the glyph chain. We will assume there
1076
         -- is only one glyph.
1077
         -- TODO: this code is probably wrong when the vertical
1078
         -- variants have a different width than the original, because
1079
         -- the shaping phase is already done. Need to do better.
1080
         local dimen = self:_reshapeGlyph(glyphs[1], closest, sz)
×
1081
         self.width, self.depth, self.height =
×
1082
            SILE.types.length(dimen.glyphAdvance), SILE.types.length(dimen.depth), SILE.types.length(dimen.height)
×
1083
         SILE.shaper:preAddNodes(glyphs, self.value)
×
1084
         self.value.items = glyphs
×
1085
         self.value.glyphString = { glyphs[1].gid }
×
1086
         return true
×
1087
      end
1088
   end
1089
   return false
×
1090
end
1091

1092
function elements.text:_vertStretchyReshape (depth, height)
×
1093
   local hasStretched = self:_stretchyReshape(depth + height, true)
×
1094
   if hasStretched then
×
1095
      -- HACK: see output routine
1096
      self.vertExpectedSz = height + depth
×
1097
      self.vertScalingRatio = (depth + height):tonumber() / (self.height:tonumber() + self.depth:tonumber())
×
1098
      self.height = height
×
1099
      self.depth = depth
×
1100
   end
1101
   return hasStretched
×
1102
end
1103

1104
function elements.text:_horizStretchyReshape (width)
×
1105
   local hasStretched = self:_stretchyReshape(width, false)
×
1106
   if hasStretched then
×
1107
      -- HACK: see output routine
1108
      self.horizScalingRatio = width:tonumber() / self.width:tonumber()
×
1109
      self.width = width
×
1110
   end
1111
   return hasStretched
×
1112
end
1113

1114
function elements.text:output (x, y, line)
×
1115
   if not self.value.glyphString then
×
1116
      return
×
1117
   end
1118
   local compensatedY
1119
   if isDisplayMode(self.mode) and SU.boolean(self.largeop, false) and self.value.items[1].fontDepth then
×
1120
      compensatedY = SILE.types.length(y.length + self.value.items[1].depth - self.value.items[1].fontDepth)
×
1121
   else
1122
      compensatedY = y
×
1123
   end
1124
   SILE.outputter:setCursor(scaleWidth(x, line), compensatedY.length)
×
1125
   SILE.outputter:setFont(self.font)
×
1126
   -- There should be no stretch or shrink on the width of a text
1127
   -- element.
1128
   local width = self.width.length
×
1129
   -- HACK: For stretchy operators, MathML Core and OpenType define how to build large glyphs
1130
   -- from an assembly of smaller ones. It's fairly complex and idealistic...
1131
   -- Anyhow, we do not have that yet, so we just stretch the glyph artificially.
1132
   -- There are cases where this will not look very good.
1133
   -- Call that a compromise, so that long vectors or large matrices look "decent" without assembly.
1134
   if SILE.outputter.scaleFn and (self.horizScalingRatio or self.vertScalingRatio) then
×
1135
      local xratio = self.horizScalingRatio or 1
×
1136
      local yratio = self.vertScalingRatio or 1
×
1137
      SU.debug("math", "fake glyph stretch: xratio =", xratio, "yratio =", yratio)
×
1138
      SILE.outputter:scaleFn(x, y, xratio, yratio, function ()
×
1139
         SILE.outputter:drawHbox(self.value, width)
×
1140
      end)
1141
   else
1142
      SILE.outputter:drawHbox(self.value, width)
×
1143
   end
1144
end
1145

1146
elements.fraction = pl.class(elements.mbox)
×
1147
elements.fraction._type = "Fraction"
×
1148

1149
function elements.fraction:__tostring ()
×
1150
   return self._type .. "(" .. tostring(self.numerator) .. ", " .. tostring(self.denominator) .. ")"
×
1151
end
1152

1153
function elements.fraction:_init (attributes, numerator, denominator)
×
1154
   elements.mbox._init(self)
×
1155
   self.numerator = numerator
×
1156
   self.denominator = denominator
×
1157
   self.attributes = attributes
×
1158
   table.insert(self.children, numerator)
×
1159
   table.insert(self.children, denominator)
×
1160
end
1161

1162
function elements.fraction:styleChildren ()
×
1163
   self.numerator.mode = getNumeratorMode(self.mode)
×
1164
   self.denominator.mode = getDenominatorMode(self.mode)
×
1165
end
1166

1167
function elements.fraction:shape ()
×
1168
   -- MathML Core 3.3.2: "To avoid visual confusion between the fraction bar
1169
   -- and another adjacent items (e.g. minus sign or another fraction's bar),
1170
   -- a default 1-pixel space is added around the element."
1171
   -- Note that PlainTeX would likely use \nulldelimiterspace (default 1.2pt)
1172
   -- but it would depend on the surrounding context, and might be far too
1173
   -- much in some cases, so we stick to MathML's suggested padding.
1174
   self.padding = SILE.types.length("1px"):absolute()
×
1175

1176
   -- Determine relative abscissas and width
1177
   local widest, other
1178
   if self.denominator.width > self.numerator.width then
×
1179
      widest, other = self.denominator, self.numerator
×
1180
   else
1181
      widest, other = self.numerator, self.denominator
×
1182
   end
1183
   widest.relX = self.padding
×
1184
   other.relX = self.padding + (widest.width - other.width) / 2
×
1185
   self.width = widest.width + 2 * self.padding
×
1186
   -- Determine relative ordinates and height
1187
   local constants = self:getMathMetrics().constants
×
1188
   local scaleDown = self:getScaleDown()
×
1189
   self.axisHeight = constants.axisHeight * scaleDown
×
1190
   self.ruleThickness = self.attributes.linethickness
×
1191
         and SU.cast("measurement", self.attributes.linethickness):tonumber()
×
1192
      or constants.fractionRuleThickness * scaleDown
×
1193

1194
   -- MathML Core 3.3.2.2 ("Fraction with zero line thickness") uses
1195
   -- stack(DisplayStyle)GapMin, stackTop(DisplayStyle)ShiftUp and stackBottom(DisplayStyle)ShiftDown.
1196
   -- TODO not implemented
1197
   -- The most common use cases for zero line thickness are:
1198
   --  - Binomial coefficients
1199
   --  - Stacked subscript/superscript on big operators such as sums.
1200

1201
   local numeratorGapMin, denominatorGapMin, numeratorShiftUp, denominatorShiftDown
1202
   if isDisplayMode(self.mode) then
×
1203
      numeratorGapMin = constants.fractionNumDisplayStyleGapMin * scaleDown
×
1204
      denominatorGapMin = constants.fractionDenomDisplayStyleGapMin * scaleDown
×
1205
      numeratorShiftUp = constants.fractionNumeratorDisplayStyleShiftUp * scaleDown
×
1206
      denominatorShiftDown = constants.fractionDenominatorDisplayStyleShiftDown * scaleDown
×
1207
   else
1208
      numeratorGapMin = constants.fractionNumeratorGapMin * scaleDown
×
1209
      denominatorGapMin = constants.fractionDenominatorGapMin * scaleDown
×
1210
      numeratorShiftUp = constants.fractionNumeratorShiftUp * scaleDown
×
1211
      denominatorShiftDown = constants.fractionDenominatorShiftDown * scaleDown
×
1212
   end
1213

1214
   self.numerator.relY = -self.axisHeight
×
1215
      - self.ruleThickness / 2
×
1216
      - SILE.types.length(
×
1217
         math.max(
×
1218
            (numeratorGapMin + self.numerator.depth):tonumber(),
×
1219
            numeratorShiftUp - self.axisHeight - self.ruleThickness / 2
×
1220
         )
1221
      )
1222
   self.denominator.relY = -self.axisHeight
×
1223
      + self.ruleThickness / 2
×
1224
      + SILE.types.length(
×
1225
         math.max(
×
1226
            (denominatorGapMin + self.denominator.height):tonumber(),
×
1227
            denominatorShiftDown + self.axisHeight - self.ruleThickness / 2
×
1228
         )
1229
      )
1230
   self.height = self.numerator.height - self.numerator.relY
×
1231
   self.depth = self.denominator.relY + self.denominator.depth
×
1232
end
1233

1234
function elements.fraction:output (x, y, line)
×
1235
   if self.ruleThickness > 0 then
×
1236
      SILE.outputter:drawRule(
×
1237
         scaleWidth(x + self.padding, line),
×
1238
         y.length - self.axisHeight - self.ruleThickness / 2,
×
1239
         scaleWidth(self.width - 2 * self.padding, line),
×
1240
         self.ruleThickness
1241
      )
1242
   end
1243
end
1244

1245
local function newSubscript (spec)
1246
   return elements.subscript(spec.base, spec.sub, spec.sup)
×
1247
end
1248

1249
local function newUnderOver (spec)
1250
   return elements.underOver(spec.base, spec.sub, spec.sup)
×
1251
end
1252

1253
-- TODO replace with penlight equivalent
1254
local function mapList (f, l)
1255
   local ret = {}
×
1256
   for i, x in ipairs(l) do
×
1257
      ret[i] = f(i, x)
×
1258
   end
1259
   return ret
×
1260
end
1261

1262
elements.mtr = pl.class(elements.mbox)
×
1263
-- elements.mtr._type = "" -- TODO why not set?
1264

1265
function elements.mtr:_init (children)
×
1266
   self.children = children
×
1267
end
1268

1269
function elements.mtr:styleChildren ()
×
1270
   for _, c in ipairs(self.children) do
×
1271
      c.mode = self.mode
×
1272
   end
1273
end
1274

1275
function elements.mtr.shape (_) end -- done by parent table
×
1276

1277
function elements.mtr.output (_) end
×
1278

1279
elements.table = pl.class(elements.mbox)
×
1280
elements.table._type = "table" -- TODO why case difference?
×
1281

1282
function elements.table:_init (children, options)
×
1283
   elements.mbox._init(self)
×
1284
   self.children = children
×
1285
   self.options = options
×
1286
   self.nrows = #self.children
×
1287
   self.ncols = math.max(pl.utils.unpack(mapList(function (_, row)
×
1288
      return #row.children
×
1289
   end, self.children)))
×
1290
   SU.debug("math", "self.ncols =", self.ncols)
×
1291
   local spacing = SILE.settings:get("math.font.size") * 0.6 -- arbitrary ratio of the current math font size
×
1292
   self.rowspacing = self.options.rowspacing and SILE.types.length(self.options.rowspacing) or spacing
×
1293
   self.columnspacing = self.options.columnspacing and SILE.types.length(self.options.columnspacing) or spacing
×
1294
   -- Pad rows that do not have enough cells by adding cells to the
1295
   -- right.
1296
   for i, row in ipairs(self.children) do
×
1297
      for j = 1, (self.ncols - #row.children) do
×
1298
         SU.debug("math", "padding i =", i, "j =", j)
×
1299
         table.insert(row.children, elements.stackbox("H", {}))
×
1300
         SU.debug("math", "size", #row.children)
×
1301
      end
1302
   end
1303
   if options.columnalign then
×
1304
      local l = {}
×
1305
      for w in string.gmatch(options.columnalign, "[^%s]+") do
×
1306
         if not (w == "left" or w == "center" or w == "right") then
×
1307
            SU.error("Invalid specifier in `columnalign` attribute: " .. w)
×
1308
         end
1309
         table.insert(l, w)
×
1310
      end
1311
      -- Pad with last value of l if necessary
1312
      for _ = 1, (self.ncols - #l), 1 do
×
1313
         table.insert(l, l[#l])
×
1314
      end
1315
      -- On the contrary, remove excess values in l if necessary
1316
      for _ = 1, (#l - self.ncols), 1 do
×
1317
         table.remove(l)
×
1318
      end
1319
      self.options.columnalign = l
×
1320
   else
1321
      self.options.columnalign = pl.List.range(1, self.ncols):map(function (_)
×
1322
         return "center"
×
1323
      end)
1324
   end
1325
end
1326

1327
function elements.table:styleChildren ()
×
1328
   if self.mode == mathMode.display and self.options.displaystyle ~= "false" then
×
1329
      for _, c in ipairs(self.children) do
×
1330
         c.mode = mathMode.display
×
1331
      end
1332
   else
1333
      for _, c in ipairs(self.children) do
×
1334
         c.mode = mathMode.text
×
1335
      end
1336
   end
1337
end
1338

1339
function elements.table:shape ()
×
1340
   -- Determine the height (resp. depth) of each row, which is the max
1341
   -- height (resp. depth) among its elements. Then we only need to add it to
1342
   -- the table's height and center every cell vertically.
1343
   for _, row in ipairs(self.children) do
×
1344
      row.height = SILE.types.length(0)
×
1345
      row.depth = SILE.types.length(0)
×
1346
      for _, cell in ipairs(row.children) do
×
1347
         row.height = maxLength(row.height, cell.height)
×
1348
         row.depth = maxLength(row.depth, cell.depth)
×
1349
      end
1350
   end
1351
   self.vertSize = SILE.types.length(0)
×
1352
   for i, row in ipairs(self.children) do
×
1353
      self.vertSize = self.vertSize
×
1354
         + row.height
×
1355
         + row.depth
×
1356
         + (i == self.nrows and SILE.types.length(0) or self.rowspacing) -- Spacing
×
1357
   end
1358
   local rowHeightSoFar = SILE.types.length(0)
×
1359
   for i, row in ipairs(self.children) do
×
1360
      row.relY = rowHeightSoFar + row.height - self.vertSize
×
1361
      rowHeightSoFar = rowHeightSoFar
×
1362
         + row.height
×
1363
         + row.depth
×
1364
         + (i == self.nrows and SILE.types.length(0) or self.rowspacing) -- Spacing
×
1365
   end
1366
   self.width = SILE.types.length(0)
×
1367
   local thisColRelX = SILE.types.length(0)
×
1368
   -- For every column...
1369
   for i = 1, self.ncols do
×
1370
      -- Determine its width
1371
      local columnWidth = SILE.types.length(0)
×
1372
      for j = 1, self.nrows do
×
1373
         if self.children[j].children[i].width > columnWidth then
×
1374
            columnWidth = self.children[j].children[i].width
×
1375
         end
1376
      end
1377
      -- Use it to align the contents of every cell as required.
1378
      for j = 1, self.nrows do
×
1379
         local cell = self.children[j].children[i]
×
1380
         if self.options.columnalign[i] == "left" then
×
1381
            cell.relX = thisColRelX
×
1382
         elseif self.options.columnalign[i] == "center" then
×
1383
            cell.relX = thisColRelX + (columnWidth - cell.width) / 2
×
1384
         elseif self.options.columnalign[i] == "right" then
×
1385
            cell.relX = thisColRelX + (columnWidth - cell.width)
×
1386
         else
1387
            SU.error("invalid columnalign parameter")
×
1388
         end
1389
      end
1390
      thisColRelX = thisColRelX + columnWidth + (i == self.ncols and SILE.types.length(0) or self.columnspacing) -- Spacing
×
1391
   end
1392
   self.width = thisColRelX
×
1393
   -- Center myself vertically around the axis, and update relative Ys of rows accordingly
1394
   local axisHeight = self:getMathMetrics().constants.axisHeight * self:getScaleDown()
×
1395
   self.height = self.vertSize / 2 + axisHeight
×
1396
   self.depth = self.vertSize / 2 - axisHeight
×
1397
   for _, row in ipairs(self.children) do
×
1398
      row.relY = row.relY + self.vertSize / 2 - axisHeight
×
1399
      -- Also adjust width
1400
      row.width = self.width
×
1401
   end
1402
end
1403

1404
function elements.table.output (_) end
×
1405

1406
local function getRadicandMode (mode)
1407
   -- Not too sure if we should do something special/
1408
   return mode
×
1409
end
1410

1411
local function getDegreeMode (mode)
1412
   -- 2 levels smaller, up to scriptScript evntually.
1413
   -- Not too sure if we should do something else.
1414
   if mode == mathMode.display then
×
1415
      return mathMode.scriptScript
×
1416
   elseif mode == mathMode.displayCramped then
×
1417
      return mathMode.scriptScriptCramped
×
1418
   elseif mode == mathMode.text or mode == mathMode.script or mode == mathMode.scriptScript then
×
1419
      return mathMode.scriptScript
×
1420
   end
1421
   return mathMode.scriptScriptCramped
×
1422
end
1423

1424
elements.sqrt = pl.class(elements.mbox)
×
1425
elements.sqrt._type = "Sqrt"
×
1426

1427
function elements.sqrt:__tostring ()
×
1428
   return self._type .. "(" .. tostring(self.radicand) .. (self.degree and ", " .. tostring(self.degree) or "") .. ")"
×
1429
end
1430

1431
function elements.sqrt:_init (radicand, degree)
×
1432
   elements.mbox._init(self)
×
1433
   self.radicand = radicand
×
1434
   if degree then
×
1435
      self.degree = degree
×
1436
      table.insert(self.children, degree)
×
1437
   end
1438
   table.insert(self.children, radicand)
×
1439
   self.relX = SILE.types.length()
×
1440
   self.relY = SILE.types.length()
×
1441
end
1442

1443
function elements.sqrt:styleChildren ()
×
1444
   self.radicand.mode = getRadicandMode(self.mode)
×
1445
   if self.degree then
×
1446
      self.degree.mode = getDegreeMode(self.mode)
×
1447
   end
1448
end
1449

1450
function elements.sqrt:shape ()
×
1451
   local mathMetrics = self:getMathMetrics()
×
1452
   local scaleDown = self:getScaleDown()
×
1453
   local constants = mathMetrics.constants
×
1454

1455
   self.radicalRuleThickness = constants.radicalRuleThickness * scaleDown
×
1456
   if self.mode == mathMode.display or self.mode == mathMode.displayCramped then
×
1457
      self.radicalVerticalGap = constants.radicalDisplayStyleVerticalGap * scaleDown
×
1458
   else
1459
      self.radicalVerticalGap = constants.radicalVerticalGap * scaleDown
×
1460
   end
1461
   self.extraAscender = constants.radicalExtraAscender * scaleDown
×
1462

1463
   -- HACK: We draw own own radical sign in the output() method.
1464
   -- Derive dimensions for the radical sign (more or less ad hoc).
1465
   -- Note: In TeX, the radical sign extends a lot below the baseline,
1466
   -- and MathML Core also has a lot of layout text about it.
1467
   -- Not only it doesn't look good, but it's not very clear vs. OpenType.
1468
   local radicalGlyph = SILE.shaper:measureChar("√")
×
1469
   local ratio = (self.radicand.height:tonumber() + self.radicand.depth:tonumber())
×
1470
      / (radicalGlyph.height + radicalGlyph.depth)
×
1471
   local vertAdHocOffset = (ratio > 1 and math.log(ratio) or 0) * self.radicalVerticalGap
×
1472
   self.symbolHeight = SILE.types.length(radicalGlyph.height) * scaleDown
×
1473
   self.symbolDepth = (SILE.types.length(radicalGlyph.depth) + vertAdHocOffset) * scaleDown
×
1474
   self.symbolWidth = (SILE.types.length(radicalGlyph.width) + vertAdHocOffset) * scaleDown
×
1475

1476
   -- Adjust the height of the radical sign if the radicand is higher
1477
   self.symbolHeight = self.radicand.height > self.symbolHeight and self.radicand.height or self.symbolHeight
×
1478
   -- Compute the (max-)height of the short leg of the radical sign
1479
   self.symbolShortHeight = self.symbolHeight * constants.radicalDegreeBottomRaisePercent
×
1480

1481
   self.offsetX = SILE.types.length()
×
1482
   if self.degree then
×
1483
      -- Position the degree
1484
      self.degree.relY = -constants.radicalDegreeBottomRaisePercent * self.symbolHeight
×
1485
      -- Adjust the height of the short leg of the radical sign to ensure the degree is not too close
1486
      -- (empirically use radicalExtraAscender)
1487
      self.symbolShortHeight = self.symbolShortHeight - constants.radicalExtraAscender * scaleDown
×
1488
      -- Compute the width adjustment for the degree
1489
      self.offsetX = self.degree.width
×
1490
         + constants.radicalKernBeforeDegree * scaleDown
×
1491
         + constants.radicalKernAfterDegree * scaleDown
×
1492
   end
1493
   -- Position the radicand
1494
   self.radicand.relX = self.symbolWidth + self.offsetX
×
1495
   -- Compute the dimensions of the whole radical
1496
   self.width = self.radicand.width + self.symbolWidth + self.offsetX
×
1497
   self.height = self.symbolHeight + self.radicalVerticalGap + self.extraAscender
×
1498
   self.depth = self.radicand.depth
×
1499
end
1500

1501
local function _r (number)
1502
   -- Lua 5.3+ formats floats as 1.0 and integers as 1
1503
   -- Also some PDF readers do not like double precision.
1504
   return math.floor(number) == number and math.floor(number) or tonumber(string.format("%.5f", number))
×
1505
end
1506

1507
function elements.sqrt:output (x, y, line)
×
1508
   -- HACK:
1509
   -- OpenType might say we need to assemble the radical sign from parts.
1510
   -- Frankly, it's much easier to just draw it as a graphic :-)
1511
   -- Hence, here we use a PDF graphic operators to draw a nice radical sign.
1512
   -- Some values here are ad hoc, but they look good.
1513
   local h = self.height:tonumber()
×
1514
   local d = self.depth:tonumber()
×
1515
   local s0 = scaleWidth(self.offsetX, line):tonumber()
×
1516
   local sw = scaleWidth(self.symbolWidth, line):tonumber()
×
1517
   local dsh = h - self.symbolShortHeight:tonumber()
×
1518
   local dsd = self.symbolDepth:tonumber()
×
1519
   local symbol = {
×
1520
      _r(self.radicalRuleThickness),
×
1521
      "w", -- line width
1522
      1,
1523
      "j", -- round line joins
1524
      _r(sw + s0),
×
1525
      _r(self.extraAscender),
×
1526
      "m",
1527
      _r(s0 + sw * 0.90),
×
1528
      _r(self.extraAscender),
×
1529
      "l",
1530
      _r(s0 + sw * 0.4),
×
1531
      _r(h + d + dsd),
×
1532
      "l",
1533
      _r(s0 + sw * 0.2),
×
1534
      _r(dsh),
×
1535
      "l",
1536
      s0 + sw * 0.1,
×
1537
      _r(dsh + 0.5),
×
1538
      "l",
1539
      "S",
1540
   }
1541
   local svg = table.concat(symbol, " ")
×
1542
   local xscaled = scaleWidth(x, line)
×
1543
   SILE.outputter:drawSVG(svg, xscaled, y, sw, h, 1)
×
1544
   -- And now we just need to draw the bar over the radicand
1545
   SILE.outputter:drawRule(
×
1546
      s0 + self.symbolWidth + xscaled,
×
1547
      y.length - self.height + self.extraAscender - self.radicalRuleThickness / 2,
×
1548
      scaleWidth(self.radicand.width, line),
×
1549
      self.radicalRuleThickness
1550
   )
1551
end
1552

1553
elements.padded = pl.class(elements.mbox)
×
1554
elements.padded._type = "Padded"
×
1555

1556
function elements.padded:__tostring ()
×
1557
   return self._type .. "(" .. tostring(self.impadded) .. ")"
×
1558
end
1559

1560
function elements.padded:_init (attributes, impadded)
×
1561
   elements.mbox._init(self)
×
1562
   self.impadded = impadded
×
1563
   self.attributes = attributes or {}
×
1564
   table.insert(self.children, impadded)
×
1565
end
1566

1567
function elements.padded:styleChildren ()
×
1568
   self.impadded.mode = self.mode
×
1569
end
1570

1571
function elements.padded:shape ()
×
1572
   -- TODO MathML allows percentages font-relative units (em, ex) for padding
1573
   -- But our units work with font.size, not math.font.size (possibly adjusted by scaleDown)
1574
   -- so the expectations might not be met.
1575
   local width = self.attributes.width and SU.cast("measurement", self.attributes.width)
×
1576
   local height = self.attributes.height and SU.cast("measurement", self.attributes.height)
×
1577
   local depth = self.attributes.depth and SU.cast("measurement", self.attributes.depth)
×
1578
   local lspace = self.attributes.lspace and SU.cast("measurement", self.attributes.lspace)
×
1579
   local voffset = self.attributes.voffset and SU.cast("measurement", self.attributes.voffset)
×
1580
   -- Clamping for width, height, depth, lspace
1581
   width = width and (width:tonumber() > 0 and width or SILE.types.measurement())
×
1582
   height = height and (height:tonumber() > 0 and height or SILE.types.measurement())
×
1583
   depth = depth and (depth:tonumber() > 0 and depth or SILE.types.measurement())
×
1584
   lspace = lspace and (lspace:tonumber() > 0 and lspace or SILE.types.measurement())
×
1585
   -- No clamping for voffset
1586
   voffset = voffset or SILE.types.measurement(0)
×
1587
   -- Compute the dimensions
1588
   self.width = width and SILE.types.length(width) or self.impadded.width
×
1589
   self.height = height and SILE.types.length(height) or self.impadded.height
×
1590
   self.depth = depth and SILE.types.length(depth) or self.impadded.depth
×
1591
   self.impadded.relX = lspace and SILE.types.length(lspace) or SILE.types.length()
×
1592
   self.impadded.relY = voffset and SILE.types.length(voffset):negate() or SILE.types.length()
×
1593
end
1594

1595
function elements.padded.output (_, _, _, _) end
×
1596

1597
-- Bevelled fractions are not part of MathML Core, and MathML4 does not
1598
-- exactly specify how to compute the layout.
1599
elements.bevelledFraction = pl.class(elements.fraction) -- Inherit from fraction
×
1600
elements.fraction._type = "BevelledFraction"
×
1601

1602
function elements.bevelledFraction:shape ()
×
1603
   local constants = self:getMathMetrics().constants
×
1604
   local scaleDown = self:getScaleDown()
×
1605
   local hSkew = constants.skewedFractionHorizontalGap * scaleDown
×
1606
   -- OpenType has properties which are not totally explicit.
1607
   -- The definition of skewedFractionVerticalGap (and its value in fonts
1608
   -- such as Libertinus Math) seems to imply that it is measured from the
1609
   -- bottom of the numerator to the top of the denominator.
1610
   -- This does not seem to be a nice general layout.
1611
   -- So we will use superscriptShiftUp(Cramped) for the numerator:
1612
   local vSkewUp = isCrampedMode(self.mode) and constants.superscriptShiftUpCramped * scaleDown
×
1613
      or constants.superscriptShiftUp * scaleDown
×
1614
   -- And all good books say that the denominator should not be shifted down:
1615
   local vSkewDown = 0
×
1616

1617
   self.ruleThickness = self.attributes.linethickness
×
1618
         and SU.cast("measurement", self.attributes.linethickness):tonumber()
×
1619
      or constants.fractionRuleThickness * scaleDown
×
1620
   self.numerator.relX = SILE.types.length(0)
×
1621
   self.numerator.relY = SILE.types.length(-vSkewUp)
×
1622
   self.denominator.relX = self.numerator.width + hSkew
×
1623
   self.denominator.relY = SILE.types.length(vSkewDown)
×
1624
   self.width = self.numerator.width + self.denominator.width + hSkew
×
1625
   self.height = maxLength(self.numerator.height + vSkewUp, self.denominator.height - vSkewDown)
×
1626
   self.depth = maxLength(self.numerator.depth - vSkewUp, self.denominator.depth + vSkewDown)
×
1627
   self.barWidth = SILE.types.length(hSkew)
×
1628
   self.barX = self.numerator.relX + self.numerator.width
×
1629
end
1630

1631
function elements.bevelledFraction:output (x, y, line)
×
1632
   local h = self.height:tonumber()
×
1633
   local d = self.depth:tonumber()
×
1634
   local barwidth = scaleWidth(self.barWidth, line):tonumber()
×
1635
   local xscaled = scaleWidth(x + self.barX, line)
×
1636
   local rd = self.ruleThickness / 2
×
1637
   local symbol = {
×
1638
      _r(self.ruleThickness),
×
1639
      "w", -- line width
1640
      1,
1641
      "J", -- round line caps
1642
      _r(0),
×
1643
      _r(d + h - rd),
×
1644
      "m",
1645
      _r(barwidth),
×
1646
      _r(rd),
×
1647
      "l",
1648
      "S",
1649
   }
1650
   local svg = table.concat(symbol, " ")
×
1651
   SILE.outputter:drawSVG(svg, xscaled, y, barwidth, h, 1)
×
1652
end
1653

1654
elements.mathMode = mathMode
×
1655
elements.atomType = atomType
×
1656
elements.symbolDefaults = symbolDefaults
×
1657
elements.newSubscript = newSubscript
×
1658
elements.newUnderOver = newUnderOver
×
1659

1660
return elements
×
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