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

sile-typesetter / sile / 11573668360

29 Oct 2024 12:17PM UTC coverage: 58.059% (-9.6%) from 67.697%
11573668360

push

github

web-flow
Merge pull request #2139 from Omikhleia/fix-math-tex-limits

TeX-math limits and other commands behaving as big ops

6 of 11 new or added lines in 1 file covered. (54.55%)

1797 existing lines in 50 files now uncovered.

10360 of 17844 relevant lines covered (58.06%)

3713.48 hits per line

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

71.8
/packages/math/texlike.lua
1
local syms = require("packages.math.unicode-symbols")
2✔
2
local bits = require("core.parserbits")
2✔
3

4
local epnf = require("epnf")
2✔
5
local lpeg = require("lpeg")
2✔
6

7
local atomType = syms.atomType
2✔
8
local symbolDefaults = syms.symbolDefaults
2✔
9
local symbols = syms.symbols
2✔
10

11
-- Grammar to parse TeX-like math
12
-- luacheck: push ignore
13
-- stylua: ignore start
14
---@diagnostic disable: undefined-global, unused-local, lowercase-global
15
local mathGrammar = function (_ENV)
16
   local _ = WS^0
2✔
17
   local eol = S"\r\n"
2✔
18
   local digit = R("09")
2✔
19
   local natural = digit^1 / tostring
2✔
20
   local pos_natural = R("19") * digit^0 / tonumber
2✔
21
   local ctrl_word = R("AZ", "az")^1
2✔
22
   local ctrl_symbol = P(1) - S"{}\\"
2✔
23
   local ctrl_sequence_name = C(ctrl_word + ctrl_symbol) / 1
2✔
24
   local comment = (
25
         P"%" *
2✔
26
         P(1-eol)^0 *
2✔
27
         eol^-1
2✔
28
      )
29
   local utf8cont = R("\128\191")
2✔
30
   local utf8code = lpeg.R("\0\127")
2✔
31
      + lpeg.R("\194\223") * utf8cont
2✔
32
      + lpeg.R("\224\239") * utf8cont * utf8cont
2✔
33
      + lpeg.R("\240\244") * utf8cont * utf8cont * utf8cont
2✔
34
   -- Identifiers inside \mo and \mi tags
35
   local sileID = C(bits.identifier + P(1)) / 1
2✔
36
   local mathMLID = (utf8code - S"\\{}%")^1 / function (...)
2✔
37
         local ret = ""
×
38
         local t = {...}
×
39
         for _,b in ipairs(t) do
×
40
         ret = ret .. b
×
41
         end
42
         return ret
×
43
      end
44
   local group = P"{" * V"mathlist" * (P"}" + E("`}` expected"))
4✔
45
   local element_no_infix =
46
      V"def" +
2✔
47
      V"command" +
2✔
48
      group +
2✔
49
      V"argument" +
2✔
50
      V"atom"
2✔
51
   local element =
52
      V"supsub" +
2✔
53
      V"subsup" +
2✔
54
      V"sup" +
2✔
55
      V"sub" +
2✔
56
      element_no_infix
2✔
57
   local sep = S",;" * _
2✔
58
   local quotedString = (P'"' * C((1-P'"')^1) * P'"')
2✔
59
   local value = ( quotedString + (1-S",;]")^1 )
2✔
60
   local pair = Cg(sileID * _ * "=" * _ * C(value)) * sep^-1 / function (...)
2✔
61
      local t = {...}; return t[1], t[#t]
54✔
62
   end
63
   local list = Cf(Ct"" * pair^0, rawset)
2✔
64
   local parameters = (
65
         P"[" *
2✔
66
         list *
2✔
67
         P"]"
2✔
68
      )^-1 / function (a)
2✔
69
            return type(a)=="table" and a or {}
206✔
70
         end
71
   local dim2_arg_inner = Ct(V"mathlist" * (P"&" * V"mathlist")^0) /
2✔
72
      function (t)
73
         t.id = "mathlist"
×
74
         return t
×
75
      end
76
   local dim2_arg =
77
      Cg(P"{" *
4✔
78
         dim2_arg_inner *
2✔
79
         (P"\\\\" * dim2_arg_inner)^1 *
2✔
80
         (P"}" + E("`}` expected"))
4✔
81
         ) / function (...)
×
82
            local t = {...}
×
83
            -- Remove the last mathlist if empty. This way,
84
            -- `inner1 \\ inner2 \\` is the same as `inner1 \\ inner2`.
85
            if not t[#t][1] or not t[#t][1][1] then table.remove(t) end
×
86
            return pl.utils.unpack(t)
×
87
         end
88

89
   local dim2_arg_inner = Ct(V"mathlist" * (P"&" * V"mathlist")^0) /
2✔
90
      function (t)
UNCOV
91
         t.id = "mathlist"
×
UNCOV
92
         return t
×
93
      end
94
   local dim2_arg =
95
      Cg(P"{" *
4✔
96
         dim2_arg_inner *
2✔
97
         (P"\\\\" * dim2_arg_inner)^1 *
2✔
98
         (P"}" + E("`}` expected"))
4✔
99
         ) / function (...)
×
UNCOV
100
         local t = {...}
×
101
         -- Remove the last mathlist if empty. This way,
102
         -- `inner1 \\ inner2 \\` is the same as `inner1 \\ inner2`.
UNCOV
103
         if not t[#t][1] or not t[#t][1][1] then table.remove(t) end
×
UNCOV
104
         return pl.utils.unpack(t)
×
105
         end
106

107
   START "math"
2✔
108
   math = V"mathlist" * EOF"Unexpected character at end of math code"
4✔
109
   mathlist = (comment + (WS * _) + element)^0
2✔
110
   supsub = element_no_infix * _ * P"^" * _ * element_no_infix * _ *
2✔
111
      P"_" * _ * element_no_infix
2✔
112
   subsup = element_no_infix * _ * P"_" * _ * element_no_infix * _ *
2✔
113
      P"^" * _ * element_no_infix
2✔
114
   sup = element_no_infix * _ * P"^" * _ * element_no_infix
2✔
115
   sub = element_no_infix * _ * P"_" * _ * element_no_infix
2✔
116
   atom = natural + C(utf8code - S"\\{}%^_&") +
2✔
117
      (P"\\{" + P"\\}") / function (s) return string.sub(s, -1) end
2✔
118
   command = (
×
119
         P"\\" *
2✔
120
         Cg(ctrl_sequence_name, "command") *
2✔
121
         Cg(parameters, "options") *
2✔
122
         (dim2_arg + group^0)
2✔
123
      )
2✔
124
   def = P"\\def" * _ * P"{" *
2✔
125
      Cg(ctrl_sequence_name, "command-name") * P"}" * _ *
2✔
126
      --P"[" * Cg(digit^1, "arity") * P"]" * _ *
127
      P"{" * V"mathlist" * P"}"
2✔
128
   argument = P"#" * Cg(pos_natural, "index")
2✔
129
end
130
-- luacheck: pop
131
-- stylua: ignore end
132
---@diagnostic enable: undefined-global, unused-local, lowercase-global
133

134
local mathParser = epnf.define(mathGrammar)
2✔
135

136
local commands = {}
2✔
137

138
-- A command type is a type for each argument it takes: either string or MathML
139
-- tree. If a command has no type, it is assumed to take only trees.
140
-- Tags like <mi>, <mo>, <mn> take a string, and this needs to be propagated in
141
-- commands that use them.
142

143
local objType = {
2✔
144
   tree = 1,
145
   str = 2,
146
}
147

148
local function inferArgTypes_aux (accumulator, typeRequired, body)
149
   if type(body) == "table" then
286✔
150
      if body.id == "argument" then
286✔
151
         local ret = accumulator
12✔
152
         table.insert(ret, body.index, typeRequired)
12✔
153
         return ret
12✔
154
      elseif body.id == "command" then
274✔
155
         if commands[body.command] then
64✔
156
            local cmdArgTypes = commands[body.command][1]
42✔
157
            if #cmdArgTypes ~= #body then
42✔
158
               SU.error(
×
159
                  "Wrong number of arguments ("
160
                     .. #body
×
161
                     .. ") for command "
×
162
                     .. body.command
×
163
                     .. " (should be "
×
164
                     .. #cmdArgTypes
×
165
                     .. ")"
×
166
               )
167
            else
168
               for i = 1, #cmdArgTypes do
72✔
169
                  accumulator = inferArgTypes_aux(accumulator, cmdArgTypes[i], body[i])
60✔
170
               end
171
            end
172
            return accumulator
42✔
173
         elseif body.command == "mi" or body.command == "mo" or body.command == "mn" then
22✔
174
            if #body ~= 1 then
×
175
               SU.error("Wrong number of arguments (" .. #body .. ") for command " .. body.command .. " (should be 1)")
×
176
            end
177
            accumulator = inferArgTypes_aux(accumulator, objType.str, body[1])
×
178
            return accumulator
×
179
         else
180
            -- Not a macro, recurse on children assuming tree type for all
181
            -- arguments
182
            for _, child in ipairs(body) do
28✔
183
               accumulator = inferArgTypes_aux(accumulator, objType.tree, child)
12✔
184
            end
185
            return accumulator
22✔
186
         end
187
      elseif body.id == "atom" then
210✔
188
         return accumulator
112✔
189
      else
190
         -- Simply recurse on children
191
         for _, child in ipairs(body) do
286✔
192
            accumulator = inferArgTypes_aux(accumulator, typeRequired, child)
376✔
193
         end
194
         return accumulator
98✔
195
      end
196
   else
197
      SU.error("invalid argument to inferArgTypes_aux")
×
198
   end
199
end
200

201
local inferArgTypes = function (body)
202
   return inferArgTypes_aux({}, objType.tree, body)
62✔
203
end
204

205
local function registerCommand (name, argTypes, func)
206
   commands[name] = { argTypes, func }
70✔
207
end
208

209
-- Computes func(func(... func(init, k1, v1), k2, v2)..., k_n, v_n), i.e. applies
210
-- func on every key-value pair in the table. Keys with numeric indices are
211
-- processed in order. This is an important property for MathML compilation below.
212
local function fold_pairs (func, table)
213
   local accumulator = {}
408✔
214
   for k, v in pl.utils.kpairs(table) do
3,776✔
215
      accumulator = func(v, k, accumulator)
2,552✔
216
   end
217
   for i, v in ipairs(table) do
911✔
218
      accumulator = func(v, i, accumulator)
1,006✔
219
   end
220
   return accumulator
408✔
221
end
222

223
local function forall (pred, list)
224
   for _, x in ipairs(list) do
12✔
225
      if not pred(x) then
20✔
226
         return false
10✔
227
      end
228
   end
229
   return true
2✔
230
end
231

232
local compileToStr = function (argEnv, mathlist)
233
   if #mathlist == 1 and mathlist.id == "atom" then
78✔
234
      -- List is a single atom
235
      return mathlist[1]
×
236
   elseif #mathlist == 1 and mathlist[1].id == "argument" then
78✔
UNCOV
237
      return argEnv[mathlist[1].index]
×
238
   elseif mathlist.id == "argument" then
78✔
239
      return argEnv[mathlist.index]
×
240
   else
241
      local ret = ""
78✔
242
      for _, elt in ipairs(mathlist) do
353✔
243
         if elt.id == "atom" then
275✔
244
            ret = ret .. elt[1]
275✔
245
         elseif elt.id == "command" and symbols[elt.command] then
×
246
            ret = ret .. symbols[elt.command]
×
247
         else
248
            SU.error("Encountered non-character token in command that takes a string")
×
249
         end
250
      end
251
      return ret
78✔
252
   end
253
end
254

255
local function isBigOperator (tree)
256
   if tree.command ~= "mo" then
12✔
257
      return false
12✔
258
   end
259
   -- Case \mo[atom=big]{ops}
260
   -- E.g. \mo[atom=big]{lim}
NEW
261
   if tree.options and tree.options.atom == "big" then
×
NEW
262
      return true
×
263
   end
264
   -- Case \mo{ops} where ops is registered as big operator (unicode-symbols)
265
   -- E.g. \mo{∑) or \sum
NEW
266
   if tree[1] and symbolDefaults[tree[1]] and symbolDefaults[tree[1]].atom == atomType.bigOperator then
×
NEW
267
      return true
×
268
   end
NEW
269
   return false
×
270
end
271

272
local function compileToMathML_aux (_, arg_env, tree)
273
   if type(tree) == "string" then
444✔
274
      return tree
36✔
275
   end
276
   local function compile_and_insert (child, key, accumulator)
277
      if type(key) ~= "number" then
1,779✔
278
         accumulator[key] = child
1,276✔
279
         return accumulator
1,276✔
280
      -- Compile all children, except if this node is a macro definition (no
281
      -- evaluation "under lambda") or the application of a registered macro
282
      -- (since evaluating the nodes depends on the macro's signature, it is more
283
      -- complex and done below)..
284
      elseif tree.id == "def" or (tree.id == "command" and commands[tree.command]) then
503✔
285
         -- Conserve unevaluated child
286
         table.insert(accumulator, child)
140✔
287
      else
288
         -- Compile next child
289
         local comp = compileToMathML_aux(nil, arg_env, child)
363✔
290
         if comp then
363✔
291
            if comp.id == "wrapper" then
301✔
292
               -- Insert all children of the wrapper node
293
               for _, inner_child in ipairs(comp) do
122✔
294
                  table.insert(accumulator, inner_child)
61✔
295
               end
296
            else
297
               table.insert(accumulator, comp)
240✔
298
            end
299
         end
300
      end
301
      return accumulator
503✔
302
   end
303
   tree = fold_pairs(compile_and_insert, tree)
816✔
304
   if tree.id == "math" then
408✔
305
      tree.command = "math"
12✔
306
      -- If the outermost `mrow` contains only other `mrow`s, remove it
307
      -- (allowing vertical stacking).
308
      if forall(function (c)
24✔
309
         return c.command == "mrow"
10✔
310
      end, tree[1]) then
24✔
311
         tree[1].command = "math"
2✔
312
         return tree[1]
2✔
313
      end
314
   elseif tree.id == "mathlist" then
396✔
315
      -- Turn mathlist into `mrow` except if it has exactly one `mtr` or `mtd`
316
      -- child.
317
      -- Note that `def`s have already been compiled away at this point.
318
      if #tree == 1 and (tree[1].command == "mtr" or tree[1].command == "mtd") then
83✔
319
         return tree[1]
×
320
      else
321
         tree.command = "mrow"
83✔
322
      end
323
      tree.command = "mrow"
83✔
324
   elseif tree.id == "atom" then
313✔
325
      local codepoints = {}
36✔
326
      for _, cp in luautf8.codes(tree[1]) do
72✔
327
         table.insert(codepoints, cp)
36✔
328
      end
329
      local cp = codepoints[1]
36✔
330
      if
331
         #codepoints == 1
36✔
332
         and ( -- If length of UTF-8 string is 1
×
333
            cp >= SU.codepoint("A") and cp <= SU.codepoint("Z")
90✔
334
            or cp >= SU.codepoint("a") and cp <= SU.codepoint("z")
90✔
335
            or cp >= SU.codepoint("Α") and cp <= SU.codepoint("Ω")
60✔
336
            or cp >= SU.codepoint("α") and cp <= SU.codepoint("ω")
60✔
337
         )
338
      then
339
         tree.command = "mi"
10✔
340
      elseif lpeg.match(lpeg.R("09") ^ 1, tree[1]) then
26✔
341
         tree.command = "mn"
2✔
342
      else
343
         tree.command = "mo"
24✔
344
      end
345
      tree.options = {}
36✔
346
   -- Translate TeX-like sub/superscripts to `munderover` or `msubsup`,
347
   -- depending on whether the base is a big operator
348
   elseif tree.id == "sup" and isBigOperator(tree[1]) then
281✔
349
      tree.command = "mover"
×
350
   elseif tree.id == "sub" and isBigOperator(tree[1]) then
285✔
UNCOV
351
      tree.command = "munder"
×
352
   elseif tree.id == "subsup" and isBigOperator(tree[1]) then
277✔
UNCOV
353
      tree.command = "munderover"
×
354
   elseif tree.id == "supsub" and isBigOperator(tree[1]) then
277✔
UNCOV
355
      tree.command = "munderover"
×
356
      local tmp = tree[2]
×
357
      tree[2] = tree[3]
×
358
      tree[3] = tmp
×
359
   elseif tree.id == "sup" then
277✔
360
      tree.command = "msup"
4✔
361
   elseif tree.id == "sub" then
273✔
362
      tree.command = "msub"
8✔
363
   elseif tree.id == "subsup" then
265✔
UNCOV
364
      tree.command = "msubsup"
×
365
   elseif tree.id == "supsub" then
265✔
366
      tree.command = "msubsup"
×
367
      local tmp = tree[2]
×
368
      tree[2] = tree[3]
×
369
      tree[3] = tmp
×
370
   elseif tree.id == "def" then
265✔
371
      local commandName = tree["command-name"]
62✔
372
      local argTypes = inferArgTypes(tree[1])
62✔
373
      registerCommand(commandName, argTypes, function (compiledArgs)
124✔
374
         return compileToMathML_aux(nil, compiledArgs, tree[1])
61✔
375
      end)
376
      return nil
62✔
377
   elseif tree.id == "command" and commands[tree.command] then
203✔
378
      local argTypes = commands[tree.command][1]
139✔
379
      local cmdFun = commands[tree.command][2]
139✔
380
      local applicationTree = tree
139✔
381
      local cmdName = tree.command
139✔
382
      if #applicationTree ~= #argTypes then
139✔
383
         SU.error(
×
384
            "Wrong number of arguments ("
385
               .. #applicationTree
×
386
               .. ") for command "
×
387
               .. cmdName
×
388
               .. " (should be "
×
389
               .. #argTypes
×
390
               .. ")"
×
391
         )
392
      end
393
      -- Compile every argument
394
      local compiledArgs = {}
139✔
395
      for i, arg in pairs(applicationTree) do
773✔
396
         if type(i) == "number" then
634✔
397
            if argTypes[i] == objType.tree then
78✔
UNCOV
398
               table.insert(compiledArgs, compileToMathML_aux(nil, arg_env, arg))
×
399
            else
400
               local x = compileToStr(arg_env, arg)
78✔
401
               table.insert(compiledArgs, x)
78✔
402
            end
403
         else
404
            -- Not an argument but an attribute. Add it to the compiled
405
            -- argument tree as-is
406
            compiledArgs[i] = applicationTree[i]
556✔
407
         end
408
      end
409
      local res = cmdFun(compiledArgs)
139✔
410
      if res.command == "mrow" then
139✔
411
         -- Mark the outer mrow to be unwrapped in the parent
412
         res.id = "wrapper"
61✔
413
      end
414
      return res
139✔
415
   elseif tree.id == "command" and symbols[tree.command] then
64✔
416
      local atom = { id = "atom", [1] = symbols[tree.command] }
8✔
417
      tree = compileToMathML_aux(nil, arg_env, atom)
16✔
418
   elseif tree.id == "argument" then
56✔
UNCOV
419
      if arg_env[tree.index] then
×
UNCOV
420
         return arg_env[tree.index]
×
421
      else
422
         SU.error("Argument #" .. tree.index .. " has escaped its scope (probably not fully applied command).")
×
423
      end
424
   end
425
   tree.id = nil
205✔
426
   return tree
205✔
427
end
428

429
local function printMathML (tree)
430
   if type(tree) == "string" then
×
431
      return tree
×
432
   end
433
   local result = "\\" .. tree.command
×
434
   if tree.options then
×
435
      local options = {}
×
436
      for k, v in pairs(tree.options) do
×
437
         table.insert(options, k .. "=" .. v)
×
438
      end
439
      if #options > 0 then
×
440
         result = result .. "[" .. table.concat(options, ", ") .. "]"
×
441
      end
442
   end
443
   if #tree > 0 then
×
444
      result = result .. "{"
×
445
      for _, child in ipairs(tree) do
×
446
         result = result .. printMathML(child)
×
447
      end
448
      result = result .. "}"
×
449
   end
450
   return result
×
451
end
452

453
local function compileToMathML (_, arg_env, tree)
454
   local result = compileToMathML_aux(_, arg_env, tree)
12✔
455
   SU.debug("texmath", function ()
24✔
456
      return "Resulting MathML: " .. printMathML(result)
×
457
   end)
458
   return result
12✔
459
end
460

461
local function convertTexlike (_, content)
462
   local ret = epnf.parsestring(mathParser, content[1])
12✔
463
   SU.debug("texmath", function ()
24✔
464
      return "Parsed TeX math: " .. pl.pretty.write(ret)
×
465
   end)
466
   return ret
12✔
467
end
468

469
registerCommand("%", {}, function ()
4✔
UNCOV
470
   return { "%", command = "mo", options = {} }
×
471
end)
472
registerCommand("mi", { [1] = objType.str }, function (x)
4✔
473
   return x
76✔
474
end)
475
registerCommand("mo", { [1] = objType.str }, function (x)
4✔
476
   return x
2✔
477
end)
478
registerCommand("mn", { [1] = objType.str }, function (x)
4✔
UNCOV
479
   return x
×
480
end)
481

482
compileToMathML(
4✔
483
   nil,
2✔
484
   {},
485
   convertTexlike(nil, {
2✔
486
      [==[
×
487
  \def{frac}{\mfrac{#1}{#2}}
488
  \def{sqrt}{\msqrt{#1}}
489
  \def{bi}{\mi[mathvariant=bold-italic]{#1}}
490
  \def{dsi}{\mi[mathvariant=double-struck]{#1}}
491

492
  \def{lim}{\mo[atom=big]{lim}}
493

494
  % From amsmath:
495
  \def{to}{\mo[atom=bin]{→}}
496
  \def{gcd}{\mo[atom=big]{gcd}}
497
  \def{sup}{\mo[atom=big]{sup}}
498
  \def{inf}{\mo[atom=big]{inf}}
499
  \def{max}{\mo[atom=big]{max}}
500
  \def{min}{\mo[atom=big]{min}}
501
  % Those use U+202F NARROW NO-BREAK SPACE in their names
502
  \def{limsup}{\mo[atom=big]{lim sup}}
503
  \def{liminf}{\mo[atom=big]{lim inf}}
504
  \def{projlim}{\mo[atom=big]{proj lim}}
505
  \def{injlim}{\mo[atom=big]{inj lim}}
506

507
  % Standard spaces gleaned from plain TeX
508
  \def{thinspace}{\mspace[width=thin]}
509
  \def{negthinspace}{\mspace[width=-thin]}
510
  \def{,}{\thinspace}
511
  \def{!}{\negthinspace}
512
  \def{medspace}{\mspace[width=med]}
513
  \def{negmedspace}{\mspace[width=-med]}
514
  \def{>}{\medspace}
515
  \def{thickspace}{\mspace[width=thick]}
516
  \def{negthickspace}{\mspace[width=-thick]}
517
  \def{;}{\thickspace}
518
  \def{enspace}{\mspace[width=1en]}
519
  \def{enskip}{\enspace}
520
  \def{quad}{\mspace[width=1em]}
521
  \def{qquad}{\mspace[width=2em]}
522

523
  % Modulus operator forms
524
  \def{bmod}{\mo{mod}}
525
  \def{pmod}{\quad(\mo{mod} #1)}
526
]==],
527
   })
528
)
529

530
return { convertTexlike, compileToMathML }
2✔
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