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

sile-typesetter / sile / 14502192980

16 Apr 2025 08:26PM UTC coverage: 57.267% (-5.4%) from 62.627%
14502192980

push

github

alerque
chore(packages): Remove unused package interdependency, url doesn't need verbatim

Reported-by: Omikhleia <didier.willis@gmail.com>

12352 of 21569 relevant lines covered (57.27%)

871.56 hits per line

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

75.12
/packages/math/typesetter.lua
1
-- Interpret a MathML or TeX-like AST, typeset it and add it to the output.
2
local lpeg = require("lpeg")
5✔
3
local atoms = require("packages.math.atoms")
5✔
4
local b = require("packages.math.base-elements")
5✔
5
local syms = require("packages.math.unicode-symbols")
5✔
6
local accents = require("packages.math.unicode-accents")
5✔
7
local mathvariants = require("packages.math.unicode-mathvariants")
5✔
8
local mathVariantToScriptType, scriptType = mathvariants.mathVariantToScriptType, mathvariants.scriptType
5✔
9

10
local ConvertMathML
11

12
-- See MathML Core "Algorithm for determining the form of an embellished operator"
13
local scriptedElements = {
5✔
14
   mmultiscripts = true,
15
   mover = true,
16
   msub = true,
17
   msubsup = true,
18
   msup = true,
19
   munder = true,
20
   munderover = true,
21
}
22
local groupingElements = {
5✔
23
   maction = true,
24
   math = true,
25
   merror = true,
26
   mphantom = true,
27
   mprescripts = true,
28
   mrow = true,
29
   mstyle = true,
30
   semantics = true,
31
}
32
local spaceLikeElements = {
5✔
33
   mtext = true,
34
   mspace = true,
35
}
36

37
-- Space like elements are:
38
-- an mtext or mspace;
39
-- or a grouping element or mpadded all of whose in-flow children are space-like.
40
local function isSpaceLike (tree)
41
   if spaceLikeElements[tree.command] then
906✔
42
      return true
62✔
43
   end
44
   if groupingElements[tree.command] or tree.command == "mpadded" then
844✔
45
      for _, n in ipairs(tree) do
234✔
46
         if not isSpaceLike(n) then
456✔
47
            return false
222✔
48
         end
49
      end
50
      return true
6✔
51
   end
52
end
53
-- Grouping-like elements in this operator embellishing context are:
54
-- a grouping element
55
-- or an mpadded or msqrt element.
56
local isGroupingLike = function (tree)
57
   return groupingElements[tree.command] or tree.command == "mpadded" or tree.command == "msqrt"
270✔
58
end
59

60
local function embellishOperatorInPlace (tree)
61
   local lastChild
62
   local lastMo
63
   local groupLike = isGroupingLike(tree)
270✔
64
   local scripLike = scriptedElements[tree.command]
270✔
65
   for _, n in ipairs(tree) do
1,036✔
66
      -- FIXME Maybe ncomplete vs. "core form" (of other elements) in MathML Core
67
      -- This specification would make anyone's eyes bleed :D
68
      if n.command == "mo" then
766✔
69
         lastMo = n
136✔
70
         n.options.form = n.options.form
136✔
71
            or groupLike and not lastChild and "prefix" -- first-in-flow child
136✔
72
            or scripLike and lastChild and "postfix" -- last-in-flow child of a scripted element other than the first
136✔
73
            or nil
136✔
74
      end
75
      if n.command and not isSpaceLike(n) then
1,444✔
76
         lastChild = n
616✔
77
      end
78
   end
79
   if lastMo then
270✔
80
      lastMo.options.form = lastMo.options.form
64✔
81
         or groupLike and lastMo == lastChild and "postfix" -- last-in-flow child
64✔
82
         or nil
64✔
83
   end
84
end
85

86
local function convertChildren (tree)
87
   local mboxes = {}
270✔
88
   embellishOperatorInPlace(tree)
270✔
89
   for _, n in ipairs(tree) do
1,036✔
90
      local box = ConvertMathML(nil, n)
766✔
91
      if box then
766✔
92
         table.insert(mboxes, box)
678✔
93
      end
94
   end
95
   return mboxes
270✔
96
end
97

98
local function convertFirstChild (tree)
99
   -- We need to loop until the first non-nil box is found, because
100
   -- we may have blank lines in the tree.
101
   for _, n in ipairs(tree) do
×
102
      local box = ConvertMathML(nil, n)
×
103
      if box then
×
104
         return box
×
105
      end
106
   end
107
end
108

109
-- convert MathML into mbox
110
function ConvertMathML (_, content)
5✔
111
   if content == nil or content.command == nil then
798✔
112
      return nil
88✔
113
   end
114
   if content.command == "math" or content.command == "mathml" then -- toplevel
710✔
115
      return b.stackbox("H", convertChildren(content))
64✔
116
   elseif content.command == "mrow" then
678✔
117
      local ret = b.stackbox("H", convertChildren(content))
364✔
118
      -- Internal property to keep tracks or paired open/close in TeX-like syntax
119
      ret.is_paired = content.is_paired
182✔
120
      return ret
182✔
121
   elseif content.command == "mphantom" then
496✔
122
      local special = content.options.special
×
123
      return b.phantom(convertChildren(content), special)
×
124
   elseif content.command == "mi" then
496✔
125
      local script = content.options.mathvariant and mathVariantToScriptType(content.options.mathvariant)
176✔
126
      local text = content[1]
176✔
127
      if type(text) ~= "string" then
176✔
128
         SU.error("mi command contains content which is not text")
×
129
      end
130
      script = script or (luautf8.len(text) == 1 and scriptType.italic or scriptType.upright)
176✔
131
      return b.text("identifier", {}, script, text)
176✔
132
   elseif content.command == "mo" then
320✔
133
      content.options.form = content.options.form or "infix"
136✔
134
      local script = content.options.mathvariant and mathVariantToScriptType(content.options.mathvariant)
136✔
135
         or scriptType.upright
136✔
136
      local text = content[1]
136✔
137
      local attributes = {}
136✔
138
      if luautf8.len(text) == 1 then
136✔
139
         -- Re-encode single combining character as non-combining when feasible.
140
         -- HACK: This is for "accents", but it's not what MathML Core expects.
141
         -- See comment on that function.
142
         text = accents.makeNonCombining(text)
260✔
143
      end
144
      -- Attributes from the (default) operator table
145
      if syms.operatorDict[text] then
136✔
146
         attributes.atom = syms.operatorDict[text].atom
130✔
147
         local forms = syms.operatorDict[text].forms
130✔
148
         local defaultOps = forms and (forms[content.options.form] or forms.infix or forms.prefix or forms.postfix)
130✔
149
         if defaultOps then
130✔
150
            for attribute, value in pairs(defaultOps) do
800✔
151
               attributes[attribute] = value
672✔
152
            end
153
         end
154
      end
155
      -- Overwrite with attributes from the element
156
      for attribute, value in pairs(content.options) do
278✔
157
         attributes[attribute] = value
142✔
158
      end
159
      if content.options.atom then
136✔
160
         if not atoms.types[content.options.atom] then
×
161
            SU.error("Unknown atom type " .. content.options.atom)
×
162
         else
163
            attributes.atom = atoms.types[content.options.atom]
×
164
         end
165
      end
166
      if type(text) ~= "string" then
136✔
167
         SU.error("mo command contains content which is not text")
×
168
      end
169
      local cp = text and luautf8.len(text) == 1 and luautf8.codepoint(text, 1)
136✔
170
      if cp and cp >= 0x2061 and cp <= 0x2064 then
136✔
171
         -- "Invisible operators"
172
         --  - Several test cases in Joe Javawaski's Browser Test and the
173
         --  - The MathML Test Suite use these too, with ad hoc spacing attributes.
174
         -- MathML Core doesn't mention anything special about these.
175
         -- MathML4 §8.3: "They are especially important new additions to the UCS
176
         -- because they provide textual clues which can increase the quality of
177
         -- print rendering (...)" (Note the absence of indication on how "print"
178
         -- rendering is supposed to be improved.)
179
         -- MathML4 §3.1.1: "they usually render invisibly (...) but may influence
180
         -- visual spacing." (Note the ill-defined "usually" and "may" in this
181
         -- specification.)
182
         -- The best we can do is to suppress these operators, but handle any
183
         -- explicitspacing (despite not handling rsup/rspace attributes on other
184
         -- operators in our TeX-based spacing logic).
185
         -- stylua: ignore start
186
         local number = lpeg.R("09")^0  * (lpeg.P(".")^-1 * lpeg.R("09")^1)^0 / tonumber
×
187
         -- stylua: ignore end
188
         -- 0 something is 0 in whatever unit (ex. "0", "0mu", "0em" etc.)
189
         local rspace, lspace = number:match(attributes.rspace), number:match(attributes.lspace)
×
190
         if rspace == 0 and lspace == 0 then
×
191
            return nil -- Just skip the invisible operator.
×
192
         end
193
         -- Skip it but honor the non-zero spacing.
194
         if rspace == 0 then
×
195
            return b.space(attributes.lspace, 0, 0)
×
196
         end
197
         if lspace == 0 then
×
198
            return b.space(attributes.rspace, 0, 0)
×
199
         end
200
         -- I haven't found examples of invisible operators with both rspace and lspace set,
201
         -- but it may happen, whatever spaces around something invisible mean.
202
         -- We'll just stack the spaces in this case (as we can only return one box).
203
         return b.stackbox("H", { b.space(attributes.lspace, 0, 0), b.space(attributes.rspace, 0, 0) })
×
204
      end
205
      return b.text("operator", attributes, script, text)
136✔
206
   elseif content.command == "mn" then
184✔
207
      local script = content.options.mathvariant and mathVariantToScriptType(content.options.mathvariant)
70✔
208
         or scriptType.upright
70✔
209
      local text = content[1]
70✔
210
      if type(text) ~= "string" then
70✔
211
         SU.error("mn command contains content which is not text")
×
212
      end
213
      if string.sub(text, 1, 1) == "-" then
140✔
214
         text = "−" .. string.sub(text, 2)
×
215
      end
216
      return b.text("number", {}, script, text)
70✔
217
   elseif content.command == "mspace" then
114✔
218
      return b.space(content.options.width, content.options.height, content.options.depth)
56✔
219
   elseif content.command == "msub" then
58✔
220
      local children = convertChildren(content)
18✔
221
      if #children ~= 2 then
18✔
222
         SU.error("Wrong number of children in msub")
×
223
      end
224
      return b.newSubscript({ base = children[1], sub = children[2] })
18✔
225
   elseif content.command == "msup" then
40✔
226
      local children = convertChildren(content)
16✔
227
      if #children ~= 2 then
16✔
228
         SU.error("Wrong number of children in msup")
×
229
      end
230
      return b.newSubscript({ base = children[1], sup = children[2] })
16✔
231
   elseif content.command == "msubsup" then
24✔
232
      local children = convertChildren(content)
×
233
      if #children ~= 3 then
×
234
         SU.error("Wrong number of children in msubsup")
×
235
      end
236
      return b.newSubscript({ base = children[1], sub = children[2], sup = children[3] })
×
237
   elseif content.command == "munder" then
24✔
238
      local children = convertChildren(content)
3✔
239
      if #children ~= 2 then
3✔
240
         SU.error("Wrong number of children in munder" .. #children)
×
241
      end
242
      local elt = b.newUnderOver({ attributes = content.options, base = children[1], sub = children[2] })
3✔
243
      elt.movablelimits = content.is_hacked_movablelimits
3✔
244
      return elt
3✔
245
   elseif content.command == "mover" then
21✔
246
      local children = convertChildren(content)
3✔
247
      if #children ~= 2 then
3✔
248
         SU.error("Wrong number of children in mover")
×
249
      end
250
      local elt = b.newUnderOver({ attributes = content.options, base = children[1], sup = children[2] })
3✔
251
      elt.movablelimits = content.is_hacked_movablelimits
3✔
252
      return elt
3✔
253
   elseif content.command == "munderover" then
18✔
254
      local children = convertChildren(content)
2✔
255
      if #children ~= 3 then
2✔
256
         SU.error("Wrong number of children in munderover")
×
257
      end
258
      return b.newUnderOver({ attributes = content.options, base = children[1], sub = children[2], sup = children[3] })
2✔
259
   elseif content.command == "mfrac" then
16✔
260
      local children = convertChildren(content)
10✔
261
      if #children ~= 2 then
10✔
262
         SU.error("Wrong number of children in mfrac: " .. #children)
×
263
      end
264
      return SU.boolean(content.options.bevelled, false)
10✔
265
            and b.bevelledFraction(content.options, children[1], children[2])
10✔
266
         or b.fraction(content.options, children[1], children[2])
20✔
267
   elseif content.command == "msqrt" then
6✔
268
      local children = convertChildren(content)
×
269
      -- "The <msqrt> element generates an anonymous <mrow> box called the msqrt base
270
      return b.sqrt(b.stackbox("H", children))
×
271
   elseif content.command == "mroot" then
6✔
272
      local children = convertChildren(content)
×
273
      return b.sqrt(children[1], children[2])
×
274
   elseif content.command == "mtable" or content.command == "table" then
6✔
275
      local children = convertChildren(content)
4✔
276
      return b.table(children, content.options)
4✔
277
   elseif content.command == "mtr" then
2✔
278
      return b.mtr(convertChildren(content))
×
279
   elseif content.command == "mtd" then
2✔
280
      return b.stackbox("H", convertChildren(content))
×
281
   elseif content.command == "mtext" or content.command == "ms" then
2✔
282
      if #content > 1 then
2✔
283
         SU.error("Wrong number of children in " .. content.command .. ": " .. #content)
×
284
      end
285
      local text = content[1] or "" -- empty mtext is allowed, and found in examples...
2✔
286
      if type(text) ~= "string" then
2✔
287
         SU.error(content.command .. " command contains content which is not text")
×
288
      end
289
      -- MathML Core 3.2.1.1 Layout of <mtext> has some wording about forced line breaks
290
      -- and soft wrap opportunities: ignored here.
291
      -- There's also some explanations about CSS, italic correction etc. which we ignore too.
292
      text = text:gsub("[\n\r]", " ")
2✔
293
      return b.text("string", {}, scriptType.upright, text:gsub("%s+", " "))
2✔
294
   elseif content.command == "maction" then
×
295
      -- MathML Core 3.6: display as mrow, ignoring all but the first child
296
      return b.stackbox("H", { convertFirstChild(content) })
×
297
   elseif content.command == "mstyle" then
×
298
      -- It's an mrow, but with some style attributes that we ignore.
299
      SU.warn("MathML mstyle is not fully supported yet")
×
300
      return b.stackbox("H", convertChildren(content))
×
301
   elseif content.command == "mpadded" then
×
302
      -- MathML Core 3.3.6.1: The <mpadded> element generates an anonymous <mrow> box
303
      -- called the "impadded inner box"
304
      return b.padded(content.options, b.stackbox("H", convertChildren(content)))
×
305
   else
306
      SU.error("Unknown math command " .. content.command)
×
307
   end
308
end
309

310
local function handleMath (_, mbox, options)
311
   local mode = options and options.mode or "text"
32✔
312
   local counter = SU.boolean(options.numbered, false) and "equation"
64✔
313
   counter = options.counter or counter -- overrides the default "equation" counter
32✔
314

315
   if mode == "display" then
32✔
316
      mbox.mode = b.mathMode.display
19✔
317
   elseif mode == "text" then
13✔
318
      mbox.mode = b.mathMode.textCramped
13✔
319
   else
320
      SU.error("Unknown math mode " .. mode)
×
321
   end
322

323
   SU.debug("math", function ()
64✔
324
      return "Resulting mbox: " .. tostring(mbox)
×
325
   end)
326
   mbox:styleDescendants()
32✔
327
   mbox:shapeTree()
32✔
328

329
   if mode == "display" then
32✔
330
      -- See https://github.com/sile-typesetter/sile/issues/2160
331
      --    We are not exactly doing the right things here with respect to
332
      --    paragraphing expectations.
333
      -- The vertical penalty will flush the previous paragraph, if any.
334
      SILE.call("penalty", { penalty = SILE.settings:get("math.predisplaypenalty"), vertical = true })
38✔
335
      SILE.typesetter:pushExplicitVglue(SILE.settings:get("math.displayskip"))
38✔
336
      -- Repeating the penalty after the skip does not hurt but should not be
337
      -- necessary if our page builder did its stuff correctly.
338
      SILE.call("penalty", { penalty = SILE.settings:get("math.predisplaypenalty"), vertical = true })
38✔
339
      SILE.settings:temporarily(function ()
38✔
340
         -- Center the equation in the space available up to the counter (if any),
341
         -- respecting the fixed part of the left and right skips.
342
         local lskip = SILE.settings:get("document.lskip") or SILE.types.node.glue()
38✔
343
         local rskip = SILE.settings:get("document.rskip") or SILE.types.node.glue()
38✔
344
         SILE.settings:set("document.parindent", SILE.types.node.glue())
38✔
345
         SILE.settings:set("current.parindent", SILE.types.node.glue())
38✔
346
         SILE.settings:set("document.lskip", SILE.types.node.hfillglue(lskip.width.length))
38✔
347
         SILE.settings:set("document.rskip", SILE.types.node.glue(rskip.width.length))
38✔
348
         SILE.settings:set("typesetter.parfillskip", SILE.types.node.glue())
38✔
349
         SILE.settings:set("document.spaceskip", SILE.types.length("1spc", 0, 0))
38✔
350
         SILE.typesetter:pushHorizontal(mbox)
19✔
351
         SILE.typesetter:pushExplicitGlue(SILE.types.node.hfillglue())
38✔
352
         if counter then
19✔
353
            options.counter = counter
×
354
            SILE.call("increment-counter", { id = counter })
×
355
            SILE.call("math:numberingstyle", options)
×
356
         elseif options.number then
19✔
357
            SILE.call("math:numberingstyle", options)
×
358
         end
359
         -- The vertical penalty will flush the equation.
360
         -- It must be done in the temporary settings block, because these have
361
         -- to apply as line boxes are being built.
362
         SILE.call("penalty", { penalty = SILE.settings:get("math.postdisplaypenalty"), vertical = true })
38✔
363
      end)
364
      SILE.typesetter:pushExplicitVglue(SILE.settings:get("math.displayskip"))
38✔
365
      -- Repeating: Same remark as for the predisplay penalty above.
366
      SILE.call("penalty", { penalty = SILE.settings:get("math.postdisplaypenalty"), vertical = true })
57✔
367
   else
368
      SILE.typesetter:pushHorizontal(mbox)
13✔
369
   end
370
end
371

372
return { ConvertMathML, handleMath }
5✔
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