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

sile-typesetter / sile / 13909532704

17 Mar 2025 08:47PM UTC coverage: 58.465% (-8.1%) from 66.515%
13909532704

push

github

web-flow
Merge pull request #2237 from Omikhleia/feat-font-adjust

feat(core): Support ex-height and cap-height font adjustment

24 of 29 new or added lines in 3 files covered. (82.76%)

2396 existing lines in 68 files now uncovered.

12515 of 21406 relevant lines covered (58.46%)

3110.86 hits per line

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

72.45
/typesetters/base.lua
1
--- SILE typesetter class.
2
-- @interfaces typesetters
3

4
--- @type typesetter
5
local typesetter = pl.class()
28✔
6

7
typesetter.type = "typesetter"
28✔
8
typesetter._name = "base"
28✔
9

10
-- This is the default typesetter. You are, of course, welcome to create your own.
11
local awful_bad = 1073741823
28✔
12
local inf_bad = 10000
28✔
13
-- local eject_penalty = -inf_bad
14
local supereject_penalty = 2 * -inf_bad
28✔
15
-- local deplorable = 100000
16

17
-- Local helper class to compare pairs of margins
18
local _margins = pl.class({
56✔
19
   lskip = SILE.types.node.glue(),
56✔
20
   rskip = SILE.types.node.glue(),
56✔
21

22
   _init = function (self, lskip, rskip)
23
      self.lskip, self.rskip = lskip, rskip
791✔
24
   end,
25

26
   __eq = function (self, other)
UNCOV
27
      return self.lskip.width == other.lskip.width and self.rskip.width == other.rskip.width
×
28
   end,
29
})
30

31
local warned = false
28✔
32

33
function typesetter:init (frame)
28✔
34
   SU.deprecated("std.object", "pl.class", "0.13.0", "0.14.0", warned and "" or [[
×
35
      The typesetter instance inheritance system for instances has been refactored
36
      using a different object model. Your instance was created and initialized
37
      using the object copy syntax from the stdlib model. It has been shimmed for
38
      you using the new Penlight model, but this may lead to unexpected behavior.
39
      Please update your code to use the new Penlight based inheritance model.
40
   ]])
×
41
   warned = true
×
42
   self:_init(frame)
×
43
end
44

45
--- Constructor
46
-- @param frame A initial frame to attach the typesetter to.
47
function typesetter:_init (frame)
28✔
48
   self:declareSettings()
57✔
49
   self.hooks = {}
57✔
50
   self.breadcrumbs = SU.breadcrumbs()
114✔
51

52
   self.frame = nil
57✔
53
   self.stateQueue = {}
57✔
54
   self:initFrame(frame)
57✔
55
   self:initState()
57✔
56
   -- In case people use stdlib prototype syntax off of the instantiated typesetter...
57
   getmetatable(self).__call = self.init
57✔
58
   return self
57✔
59
end
60

61
--- Declare new setting types
62
function typesetter.declareSettings (_)
28✔
63
   -- Settings common to any typesetter instance.
64
   -- These shouldn't be re-declared and overwritten/reset in the typesetter
65
   -- constructor (see issue https://github.com/sile-typesetter/sile/issues/1708).
66
   -- On the other hand, it's fairly acceptable to have them made global:
67
   -- Any derived typesetter, whatever its implementation, should likely provide
68
   -- some logic for them (= widows, orphans, spacing, etc.)
69

70
   SILE.settings:declare({
57✔
71
      parameter = "typesetter.widowpenalty",
72
      type = "integer",
73
      default = 3000,
74
      help = "Penalty to be applied to widow lines (at the start of a paragraph)",
75
   })
76

77
   SILE.settings:declare({
57✔
78
      parameter = "typesetter.parseppattern",
79
      type = "string or integer",
80
      default = "\r?\n[\r\n]+",
81
      help = "Lua pattern used to separate paragraphs",
82
   })
83

84
   SILE.settings:declare({
57✔
85
      parameter = "typesetter.obeyspaces",
86
      type = "boolean or nil",
87
      default = nil,
88
      help = "Whether to ignore paragraph initial spaces",
89
   })
90

91
   SILE.settings:declare({
57✔
92
      parameter = "typesetter.brokenpenalty",
93
      type = "integer",
94
      default = 100,
95
      help = "Penalty to be applied to broken (hyphenated) lines",
96
   })
97

98
   SILE.settings:declare({
57✔
99
      parameter = "typesetter.orphanpenalty",
100
      type = "integer",
101
      default = 3000,
102
      help = "Penalty to be applied to orphan lines (at the end of a paragraph)",
103
   })
104

105
   SILE.settings:declare({
114✔
106
      parameter = "typesetter.parfillskip",
107
      type = "glue",
108
      default = SILE.types.node.glue("0pt plus 10000pt"),
114✔
109
      help = "Glue added at the end of a paragraph",
110
   })
111

112
   SILE.settings:declare({
57✔
113
      parameter = "document.letterspaceglue",
114
      type = "glue or nil",
115
      default = nil,
116
      help = "Glue added between tokens",
117
   })
118

119
   SILE.settings:declare({
114✔
120
      parameter = "typesetter.underfulltolerance",
121
      type = "length or nil",
122
      default = SILE.types.length("1em"),
114✔
123
      help = "Amount a page can be underfull without warning",
124
   })
125

126
   SILE.settings:declare({
114✔
127
      parameter = "typesetter.overfulltolerance",
128
      type = "length or nil",
129
      default = SILE.types.length("5pt"),
114✔
130
      help = "Amount a page can be overfull without warning",
131
   })
132

133
   SILE.settings:declare({
57✔
134
      parameter = "typesetter.breakwidth",
135
      type = "measurement or nil",
136
      default = nil,
137
      help = "Width to break lines at",
138
   })
139

140
   SILE.settings:declare({
57✔
141
      parameter = "typesetter.italicCorrection",
142
      type = "boolean",
143
      default = false,
144
      help = "Whether italic correction is activated or not",
145
   })
146

147
   SILE.settings:declare({
57✔
148
      parameter = "typesetter.italicCorrection.punctuation",
149
      type = "boolean",
150
      default = true,
151
      help = "Whether italic correction is compensated on special punctuation spaces (e.g. in French)",
152
   })
153

154
   SILE.settings:declare({
57✔
155
      parameter = "typesetter.softHyphen",
156
      type = "boolean",
157
      default = true,
158
      help = "When true, soft hyphens are rendered as discretionary breaks, otherwise they are ignored",
159
   })
160

161
   SILE.settings:declare({
57✔
162
      parameter = "typesetter.softHyphenWarning",
163
      type = "boolean",
164
      default = false,
165
      help = "When true, a warning is issued when a soft hyphen is encountered",
166
   })
167

168
   SILE.settings:declare({
57✔
169
      parameter = "typesetter.fixedSpacingAfterInitialEmdash",
170
      type = "boolean",
171
      default = true,
172
      help = "When true, em-dash starting a paragraph is considered as a speaker change in a dialogue",
173
   })
174
end
175

176
function typesetter:initState ()
28✔
177
   self.state = {
75✔
178
      nodes = {},
75✔
179
      outputQueue = {},
75✔
180
      lastBadness = awful_bad,
75✔
181
      liners = {},
75✔
182
   }
75✔
183
end
184

185
function typesetter:initFrame (frame)
28✔
186
   if frame then
93✔
187
      self.frame = frame
91✔
188
      self.frame:init(self)
91✔
189
   end
190
end
191

192
function typesetter.getMargins ()
28✔
193
   return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
2,373✔
194
end
195

196
function typesetter.setMargins (_, margins)
28✔
UNCOV
197
   SILE.settings:set("document.lskip", margins.lskip)
×
UNCOV
198
   SILE.settings:set("document.rskip", margins.rskip)
×
199
end
200

201
function typesetter:pushState ()
28✔
202
   self.stateQueue[#self.stateQueue + 1] = self.state
18✔
203
   self:initState()
18✔
204
end
205

206
function typesetter:popState (ncount)
28✔
207
   local offset = ncount and #self.stateQueue - ncount or nil
18✔
208
   self.state = table.remove(self.stateQueue, offset)
36✔
209
   if not self.state then
18✔
UNCOV
210
      SU.error("Typesetter state queue empty")
×
211
   end
212
end
213

214
function typesetter:isQueueEmpty ()
28✔
215
   if not self.state then
626✔
216
      return nil
×
217
   end
218
   return #self.state.nodes == 0 and #self.state.outputQueue == 0
626✔
219
end
220

221
function typesetter:vmode ()
28✔
222
   return #self.state.nodes == 0
390✔
223
end
224

225
function typesetter:debugState ()
28✔
UNCOV
226
   print("\n---\nI am in " .. (self:vmode() and "vertical" or "horizontal") .. " mode")
×
UNCOV
227
   print("Writing into " .. tostring(self.frame))
×
UNCOV
228
   print("Recent contributions: ")
×
UNCOV
229
   for i = 1, #self.state.nodes do
×
UNCOV
230
      io.stderr:write(self.state.nodes[i] .. " ")
×
231
   end
UNCOV
232
   print("\nVertical list: ")
×
UNCOV
233
   for i = 1, #self.state.outputQueue do
×
UNCOV
234
      print("  " .. self.state.outputQueue[i])
×
235
   end
236
end
237

238
-- Boxy stuff
239
function typesetter:pushHorizontal (node)
28✔
240
   self:initline()
810✔
241
   self.state.nodes[#self.state.nodes + 1] = node
810✔
242
   return node
810✔
243
end
244

245
function typesetter:pushVertical (vbox)
28✔
246
   self.state.outputQueue[#self.state.outputQueue + 1] = vbox
899✔
247
   return vbox
899✔
248
end
249

250
function typesetter:pushHbox (spec)
28✔
251
   local ntype = SU.type(spec)
44✔
252
   local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.types.node.hbox(spec)
44✔
253
   return self:pushHorizontal(node)
44✔
254
end
255

256
function typesetter:pushUnshaped (spec)
28✔
257
   local node = SU.type(spec) == "unshaped" and spec or SILE.types.node.unshaped(spec)
318✔
258
   return self:pushHorizontal(node)
159✔
259
end
260

261
function typesetter:pushGlue (spec)
28✔
262
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
718✔
263
   return self:pushHorizontal(node)
359✔
264
end
265

266
function typesetter:pushExplicitGlue (spec)
28✔
267
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
42✔
268
   node.explicit = true
21✔
269
   node.discardable = false
21✔
270
   return self:pushHorizontal(node)
21✔
271
end
272

273
function typesetter:pushPenalty (spec)
28✔
274
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
356✔
275
   return self:pushHorizontal(node)
178✔
276
end
277

278
function typesetter:pushMigratingMaterial (material)
28✔
279
   local node = SILE.types.node.migrating({ material = material })
2✔
280
   return self:pushHorizontal(node)
2✔
281
end
282

283
function typesetter:pushVbox (spec)
28✔
UNCOV
284
   local node = SU.type(spec) == "vbox" and spec or SILE.types.node.vbox(spec)
×
UNCOV
285
   return self:pushVertical(node)
×
286
end
287

288
function typesetter:pushVglue (spec)
28✔
289
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
268✔
290
   return self:pushVertical(node)
134✔
291
end
292

293
function typesetter:pushExplicitVglue (spec)
28✔
294
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
244✔
295
   node.explicit = true
122✔
296
   node.discardable = false
122✔
297
   return self:pushVertical(node)
122✔
298
end
299

300
function typesetter:pushVpenalty (spec)
28✔
301
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
264✔
302
   return self:pushVertical(node)
132✔
303
end
304

305
-- Actual typesetting functions
306
function typesetter:typeset (text)
28✔
307
   text = tostring(text)
316✔
308
   if text:match("^%\r?\n$") then
316✔
309
      return
138✔
310
   end
311
   local pId = SILE.traceStack:pushText(text)
178✔
312
   local parsepattern = SILE.settings:get("typesetter.parseppattern")
178✔
313
   -- NOTE: Big assumption on how to guess were are in "obeylines" mode.
314
   -- See https://github.com/sile-typesetter/sile/issues/2128
315
   local obeylines = parsepattern == "\n"
178✔
316

317
   local seenParaContent = true
178✔
318
   for token in SU.gtoke(text, parsepattern) do
621✔
319
      if token.separator then
265✔
320
         if obeylines and not seenParaContent then
104✔
321
            -- In obeylines mode, each standalone line must be kept.
322
            -- The zerohbox is not discardable, so it will be kept in the output,
323
            -- and the baseline skip will do the rest.
324
            self:pushHorizontal(SILE.types.node.zerohbox())
18✔
325
         else
326
            seenParaContent = false
98✔
327
         end
328
         self:endline()
208✔
329
      else
330
         seenParaContent = true
161✔
331
         if SILE.settings:get("typesetter.softHyphen") then
322✔
332
            local warnedshy = false
161✔
333
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
483✔
334
               if token2.separator then -- soft hyphen support
161✔
UNCOV
335
                  local discretionary = SILE.types.node.discretionary({})
×
UNCOV
336
                  local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
×
337
                  discretionary.prebreak = { hbox }
×
UNCOV
338
                  table.insert(SILE.typesetter.state.nodes, discretionary)
×
UNCOV
339
                  if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
×
UNCOV
340
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
341
                  end
UNCOV
342
                  warnedshy = true
×
343
               else
344
                  self:setpar(token2.string)
161✔
345
               end
346
            end
347
         else
348
            if
UNCOV
349
               SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD))
×
350
            then
UNCOV
351
               SU.warn("Soft hyphen encountered and ignored")
×
352
            end
UNCOV
353
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
×
UNCOV
354
            self:setpar(text)
×
355
         end
356
      end
357
   end
358
   SILE.traceStack:pop(pId)
178✔
359
end
360

361
function typesetter:initline ()
28✔
362
   if self.state.hmodeOnly then
923✔
363
      return
11✔
364
   end -- https://github.com/sile-typesetter/sile/issues/1718
365
   if #self.state.nodes == 0 then
912✔
366
      table.insert(self.state.nodes, SILE.types.node.zerohbox())
356✔
367
      SILE.documentState.documentClass.newPar(self)
178✔
368
   end
369
end
370

371
function typesetter:endline ()
28✔
372
   SILE.documentState.documentClass.endPar(self)
168✔
373
   self:leaveHmode()
168✔
374
   if SILE.settings:get("current.hangIndent") then
336✔
UNCOV
375
      SILE.settings:set("current.hangIndent", nil)
×
UNCOV
376
      SILE.settings:set("linebreak.hangIndent", nil)
×
377
   end
378
   if SILE.settings:get("current.hangAfter") then
336✔
UNCOV
379
      SILE.settings:set("current.hangAfter", nil)
×
UNCOV
380
      SILE.settings:set("linebreak.hangAfter", nil)
×
381
   end
382
end
383

384
-- Just compute once, to avoid unicode characters in source code.
UNCOV
385
local speakerChangePattern = "^"
×
386
   .. luautf8.char(0x2014) -- emdash
28✔
UNCOV
387
   .. "[ "
×
388
   .. luautf8.char(0x00A0)
28✔
389
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
28✔
390
   .. "]+"
28✔
391
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
28✔
392

393
-- Special unshaped node subclass to handle space after a speaker change in dialogues
394
-- introduced by an em-dash.
395
local speakerChangeNode = pl.class(SILE.types.node.unshaped)
28✔
396
function speakerChangeNode:shape ()
28✔
UNCOV
397
   local node = self._base.shape(self)
×
UNCOV
398
   local spc = node[2]
×
UNCOV
399
   if spc and spc.is_glue then
×
400
      -- Switch the variable space glue to a fixed kern
UNCOV
401
      node[2] = SILE.types.node.kern({ width = spc.width.length })
×
UNCOV
402
      node[2].parent = self.parent
×
403
   else
404
      -- Should not occur:
405
      -- How could it possibly be shaped differently?
UNCOV
406
      SU.warn("Speaker change logic met an unexpected case, this might be a bug")
×
407
   end
UNCOV
408
   return node
×
409
end
410

411
-- Takes string, writes onto self.state.nodes
412
function typesetter:setpar (text)
28✔
413
   text = text:gsub("\r?\n", " "):gsub("\t", " ")
161✔
414
   if #self.state.nodes == 0 then
161✔
415
      if not SILE.settings:get("typesetter.obeyspaces") then
226✔
416
         text = text:gsub("^%s+", "")
103✔
417
      end
418
      self:initline()
113✔
419

420
      if
421
         SILE.settings:get("typesetter.fixedSpacingAfterInitialEmdash")
113✔
422
         and not SILE.settings:get("typesetter.obeyspaces")
226✔
423
      then
424
         local speakerChange = false
103✔
425
         local dialogue = luautf8.gsub(text, speakerChangePattern, function ()
206✔
UNCOV
426
            speakerChange = true
×
UNCOV
427
            return speakerChangeReplacement
×
428
         end)
429
         if speakerChange then
103✔
UNCOV
430
            local node = speakerChangeNode({ text = dialogue, options = SILE.font.loadDefaults({}) })
×
UNCOV
431
            self:pushHorizontal(node)
×
UNCOV
432
            return -- done here: speaker change space handling is done after nnode shaping
×
433
         end
434
      end
435
   end
436
   if #text > 0 then
161✔
437
      self:pushUnshaped({ text = text, options = SILE.font.loadDefaults({}) })
318✔
438
   end
439
end
440

441
function typesetter:breakIntoLines (nodelist, breakWidth)
28✔
442
   self:shapeAllNodes(nodelist)
178✔
443
   local breakpoints = SILE.linebreak:doBreak(nodelist, breakWidth)
178✔
444
   return self:breakpointsToLines(breakpoints)
178✔
445
end
446

447
--- Extract the last shaped item from a node list.
448
-- @tparam table nodelist A list of nodes.
449
-- @treturn table The last shaped item.
450
-- @treturn boolean Whether the list contains a glue after the last shaped item.
451
-- @treturn number|nil The width of a punctuation kern after the last shaped item, if any.
452
local function getLastShape (nodelist)
453
   local lastShape
454
   local hasGlue
455
   local punctSpaceWidth
UNCOV
456
   if nodelist then
×
457
      -- The node list may contain nnodes, penalties, kern and glue
458
      -- We skip the latter, and retrieve the last shaped item.
UNCOV
459
      for i = #nodelist, 1, -1 do
×
UNCOV
460
         local n = nodelist[i]
×
UNCOV
461
         if n.is_nnode then
×
UNCOV
462
            local items = n.nodes[#n.nodes].value.items
×
UNCOV
463
            lastShape = items[#items]
×
464
            break
465
         end
UNCOV
466
         if n.is_kern and n.subtype == "punctspace" then
×
467
            -- Some languages such as French insert a special space around
468
            -- punctuations.
469
            -- In those case, we have different strategies for handling
470
            -- italic correction.
UNCOV
471
            punctSpaceWidth = n.width:tonumber()
×
472
         end
UNCOV
473
         if n.is_glue then
×
UNCOV
474
            hasGlue = true
×
475
         end
476
      end
477
   end
UNCOV
478
   return lastShape, hasGlue, punctSpaceWidth
×
479
end
480

481
--- Extract the first shaped item from a node list.
482
-- @tparam table nodelist A list of nodes.
483
-- @treturn table The first shaped item.
484
-- @treturn boolean Whether the list contains a glue before the first shaped item.
485
-- @treturn number|nil The width of a punctuation kern before the first shaped item, if any.
486
local function getFirstShape (nodelist)
487
   local firstShape
488
   local hasGlue
489
   local punctSpaceWidth
UNCOV
490
   if nodelist then
×
491
      -- The node list may contain nnodes, penalties, kern and glue
492
      -- We skip the latter, and retrieve the first shaped item.
UNCOV
493
      for i = 1, #nodelist do
×
UNCOV
494
         local n = nodelist[i]
×
UNCOV
495
         if n.is_nnode then
×
UNCOV
496
            local items = n.nodes[1].value.items
×
UNCOV
497
            firstShape = items[1]
×
498
            break
499
         end
UNCOV
500
         if n.is_kern and n.subtype == "punctspace" then
×
501
            -- Some languages such as French insert a special space around
502
            -- punctuations.
503
            -- In those case, we have different strategies for handling
504
            -- italic correction.
UNCOV
505
            punctSpaceWidth = n.width:tonumber()
×
506
         end
UNCOV
507
         if n.is_glue then
×
UNCOV
508
            hasGlue = true
×
509
         end
510
      end
511
   end
UNCOV
512
   return firstShape, hasGlue, punctSpaceWidth
×
513
end
514

515
--- Compute the italic correction when switching from italic to non-italic.
516
-- Computing italic correction is at best heuristics.
517
-- The strong assumption is that italic is slanted to the right.
518
-- Thus, the part of the character that goes beyond its width is usually maximal at the top of the glyph.
519
-- E.g. consider a "f", that would be the top hook extent.
520
-- Pathological cases exist, such as fonts with a Q with a long tail, but these will rarely occur in usual languages.
521
-- For instance, Klingon's "QaQ" might be an issue, but there's not much we can do...
522
-- Another assumption is that we can distribute that extent in proportion with the next character's height.
523
-- This might not work that well with non-Latin scripts.
524
--
525
-- @tparam table precShape The last shaped item (italic).
526
-- @tparam table curShape The first shaped item (non-italic).
527
-- @tparam number|nil punctSpaceWidth The width of a punctuation kern between the two items, if any.
528
local function fromItalicCorrection (precShape, curShape, punctSpaceWidth)
529
   local xOffset
UNCOV
530
   if not curShape or not precShape then
×
UNCOV
531
      xOffset = 0
×
UNCOV
532
   elseif precShape.height <= 0 then
×
UNCOV
533
      xOffset = 0
×
534
   else
UNCOV
535
      local d = precShape.glyphWidth + precShape.x_bearing
×
UNCOV
536
      local delta = d > precShape.width and d - precShape.width or 0
×
UNCOV
537
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
×
UNCOV
538
      if punctSpaceWidth and SILE.settings:get("typesetter.italicCorrection.punctuation") then
×
UNCOV
539
         xOffset = xOffset - punctSpaceWidth > 0 and (xOffset - punctSpaceWidth) or 0
×
540
      end
541
   end
UNCOV
542
   return xOffset
×
543
end
544

545
--- Compute the italic correction when switching from non-italic to italic.
546
-- Same assumptions as fromItalicCorrection(), but on the starting side of the glyph.
547
--
548
-- @tparam table precShape The last shaped item (non-italic).
549
-- @tparam table curShape The first shaped item (italic).
550
-- @tparam number|nil punctSpaceWidth The width of a punctuation kern between the two items, if any.
551
local function toItalicCorrection (precShape, curShape, punctSpaceWidth)
552
   local xOffset
UNCOV
553
   if not curShape or not precShape then
×
UNCOV
554
      xOffset = 0
×
UNCOV
555
   elseif precShape.depth <= 0 then
×
UNCOV
556
      xOffset = 0
×
557
   else
UNCOV
558
      local d = curShape.x_bearing
×
UNCOV
559
      local delta = d < 0 and -d or 0
×
UNCOV
560
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
×
UNCOV
561
      if punctSpaceWidth and SILE.settings:get("typesetter.italicCorrection.punctuation") then
×
UNCOV
562
         xOffset = punctSpaceWidth - xOffset > 0 and xOffset or 0
×
563
      end
564
   end
UNCOV
565
   return xOffset
×
566
end
567

568
local function isItalicLike (nnode)
569
   -- We could do...
570
   --  return nnode and string.lower(nnode.options.style) == "italic"
571
   -- But it's probably more robust to use the italic angle, so that
572
   -- thin italic, oblique or slanted fonts etc. may work too.
UNCOV
573
   local ot = require("core.opentype-parser")
×
UNCOV
574
   local face = SILE.font.cache(nnode.options, SILE.shaper.getFace)
×
UNCOV
575
   local font = ot.parseFont(face)
×
UNCOV
576
   return font.post.italicAngle ~= 0
×
577
end
578

579
function typesetter.shapeAllNodes (_, nodelist, inplace)
28✔
580
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
1,096✔
581
   local newNodelist = {}
548✔
582
   local prec
583
   local precShapedNodes
584
   local isItalicCorrectionEnabled = SILE.settings:get("typesetter.italicCorrection")
548✔
585
   for _, current in ipairs(nodelist) do
5,892✔
586
      if current.is_unshaped then
5,344✔
587
         local shapedNodes = current:shape()
180✔
588

589
         if isItalicCorrectionEnabled and prec then
180✔
590
            local itCorrOffset
591
            local isGlue
UNCOV
592
            if isItalicLike(prec) and not isItalicLike(current) then
×
UNCOV
593
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
UNCOV
594
               local curShape, curHasGlue, curPunctSpaceWidth = getFirstShape(shapedNodes)
×
UNCOV
595
               isGlue = precHasGlue or curHasGlue
×
UNCOV
596
               itCorrOffset = fromItalicCorrection(precShape, curShape, curPunctSpaceWidth)
×
UNCOV
597
            elseif not isItalicLike(prec) and isItalicLike(current) then
×
UNCOV
598
               local precShape, precHasGlue, precPunctSpaceWidth = getLastShape(precShapedNodes)
×
UNCOV
599
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
UNCOV
600
               isGlue = precHasGlue or curHasGlue
×
UNCOV
601
               itCorrOffset = toItalicCorrection(precShape, curShape, precPunctSpaceWidth)
×
602
            end
UNCOV
603
            if itCorrOffset and itCorrOffset ~= 0 then
×
604
               -- If one of the node contains a glue (e.g. "a \em{proof} is..."),
605
               -- line breaking may occur between them, so our correction shall be
606
               -- a glue too.
607
               -- Otherwise, the font change is considered to occur at a non-breaking
608
               -- point (e.g. "\em{proof}!") and the correction shall be a kern.
UNCOV
609
               local makeItCorrNode = isGlue and SILE.types.node.glue or SILE.types.node.kern
×
UNCOV
610
               newNodelist[#newNodelist + 1] = makeItCorrNode({
×
611
                  width = SILE.types.length(itCorrOffset),
612
                  subtype = "itcorr",
613
               })
614
            end
615
         end
616

617
         pl.tablex.insertvalues(newNodelist, shapedNodes)
180✔
618

619
         prec = current
180✔
620
         precShapedNodes = shapedNodes
180✔
621
      else
622
         prec = nil
5,164✔
623
         newNodelist[#newNodelist + 1] = current
5,164✔
624
      end
625
   end
626

627
   if not inplace then
548✔
628
      return newNodelist
15✔
629
   end
630

631
   for i = 1, #newNodelist do
7,426✔
632
      nodelist[i] = newNodelist[i]
6,893✔
633
   end
634
   if #nodelist > #newNodelist then
533✔
UNCOV
635
      for i = #newNodelist + 1, #nodelist do
×
UNCOV
636
         nodelist[i] = nil
×
637
      end
638
   end
639
end
640

641
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
642
-- Turns a node list into a list of vboxes
643
function typesetter:boxUpNodes ()
28✔
644
   local nodelist = self.state.nodes
549✔
645
   if #nodelist == 0 then
549✔
646
      return {}
371✔
647
   end
648
   for j = #nodelist, 1, -1 do
212✔
649
      if not nodelist[j].is_migrating then
213✔
650
         if nodelist[j].discardable then
212✔
651
            table.remove(nodelist, j)
68✔
652
         else
653
            break
654
         end
655
      end
656
   end
657
   while #nodelist > 0 and nodelist[1].is_penalty do
178✔
UNCOV
658
      table.remove(nodelist, 1)
×
659
   end
660
   if #nodelist == 0 then
178✔
UNCOV
661
      return {}
×
662
   end
663
   self:shapeAllNodes(nodelist)
178✔
664
   local parfillskip = SILE.settings:get("typesetter.parfillskip")
178✔
665
   parfillskip.discardable = false
178✔
666
   self:pushGlue(parfillskip)
178✔
667
   self:pushPenalty(-inf_bad)
178✔
668
   SU.debug("typesetter", function ()
356✔
UNCOV
669
      return "Boxed up " .. (#nodelist > 500 and #nodelist .. " nodes" or SU.ast.contentToString(nodelist))
×
670
   end)
671
   local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
356✔
672
   local lines = self:breakIntoLines(nodelist, breakWidth)
178✔
673
   local vboxes = {}
178✔
674
   for index = 1, #lines do
420✔
675
      local line = lines[index]
242✔
676
      local migrating = {}
242✔
677
      -- Move any migrating material
678
      local nodes = {}
242✔
679
      for i = 1, #line.nodes do
4,300✔
680
         local node = line.nodes[i]
4,058✔
681
         if node.is_migrating then
4,058✔
682
            for j = 1, #node.material do
4✔
683
               migrating[#migrating + 1] = node.material[j]
2✔
684
            end
685
         else
686
            nodes[#nodes + 1] = node
4,056✔
687
         end
688
      end
689
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
242✔
690
      local pageBreakPenalty = 0
242✔
691
      if #lines > 1 and index == 1 then
242✔
692
         pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
32✔
693
      elseif #lines > 1 and index == (#lines - 1) then
226✔
694
         pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
20✔
695
      elseif line.is_broken then
216✔
696
         pageBreakPenalty = SILE.settings:get("typesetter.brokenpenalty")
12✔
697
      end
698
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
484✔
699
      vboxes[#vboxes + 1] = vbox
242✔
700
      for i = 1, #migrating do
244✔
701
         vboxes[#vboxes + 1] = migrating[i]
2✔
702
      end
703
      self.state.previousVbox = vbox
242✔
704
      if pageBreakPenalty > 0 then
242✔
705
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
26✔
706
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
52✔
707
      end
708
   end
709
   return vboxes
178✔
710
end
711

712
function typesetter.pageTarget (_)
28✔
UNCOV
713
   SU.deprecated("SILE.typesetter:pageTarget", "SILE.typesetter:getTargetLength", "0.13.0", "0.14.0")
×
714
end
715

716
function typesetter:getTargetLength ()
28✔
717
   return self.frame:getTargetLength()
534✔
718
end
719

720
function typesetter:registerHook (category, func)
28✔
721
   if not self.hooks[category] then
36✔
722
      self.hooks[category] = {}
32✔
723
   end
724
   table.insert(self.hooks[category], func)
36✔
725
end
726

727
function typesetter:runHooks (category, data)
28✔
728
   if not self.hooks[category] then
552✔
729
      return data
490✔
730
   end
731
   for _, func in ipairs(self.hooks[category]) do
143✔
732
      data = func(self, data)
162✔
733
   end
734
   return data
62✔
735
end
736

737
function typesetter:registerFrameBreakHook (func)
28✔
738
   self:registerHook("framebreak", func)
4✔
739
end
740

741
function typesetter:registerNewFrameHook (func)
28✔
UNCOV
742
   self:registerHook("newframe", func)
×
743
end
744

745
function typesetter:registerPageEndHook (func)
28✔
746
   self:registerHook("pageend", func)
32✔
747
end
748

749
function typesetter:buildPage ()
28✔
750
   local pageNodeList
751
   local res
752
   if self:isQueueEmpty() then
1,028✔
753
      return false
36✔
754
   end
755
   if SILE.scratch.insertions then
478✔
756
      SILE.scratch.insertions.thisPage = {}
124✔
757
   end
758
   pageNodeList, res = SILE.pagebuilder:findBestBreak({
956✔
759
      vboxlist = self.state.outputQueue,
478✔
760
      target = self:getTargetLength(),
956✔
761
      restart = self.frame.state.pageRestart,
478✔
762
   })
478✔
763
   if not pageNodeList then -- No break yet
478✔
764
      -- self.frame.state.pageRestart = res
765
      self:runHooks("noframebreak")
422✔
766
      return false
422✔
767
   end
768
   SU.debug("pagebuilder", "Buildding page for", self.frame.id)
56✔
769
   self.state.lastPenalty = res
56✔
770
   self.frame.state.pageRestart = nil
56✔
771
   pageNodeList = self:runHooks("framebreak", pageNodeList)
112✔
772
   self:setVerticalGlue(pageNodeList, self:getTargetLength())
112✔
773
   self:outputLinesToPage(pageNodeList)
56✔
774
   return true
56✔
775
end
776

777
function typesetter:setVerticalGlue (pageNodeList, target)
28✔
778
   local glues = {}
56✔
779
   local gTotal = SILE.types.length()
56✔
780
   local totalHeight = SILE.types.length()
56✔
781

782
   local pastTop = false
56✔
783
   for _, node in ipairs(pageNodeList) do
801✔
784
      if not pastTop and not node.discardable and not node.explicit then
745✔
785
         -- "Ignore discardable and explicit glues at the top of a frame."
786
         -- See typesetter:outputLinesToPage()
787
         -- Note the test here doesn't check is_vglue, so will skip other
788
         -- discardable nodes (e.g. penalties), but it shouldn't matter
789
         -- for the type of computing performed here.
790
         pastTop = true
56✔
791
      end
792
      if pastTop then
745✔
793
         if not node.is_insertion then
670✔
794
            totalHeight:___add(node.height)
670✔
795
            totalHeight:___add(node.depth)
670✔
796
         end
797
         if node.is_vglue then
670✔
798
            table.insert(glues, node)
360✔
799
            gTotal:___add(node.height)
360✔
800
         end
801
      end
802
   end
803

804
   if totalHeight:tonumber() == 0 then
112✔
805
      return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
3✔
806
   end
807

808
   local adjustment = target - totalHeight
53✔
809
   if adjustment:tonumber() > 0 then
106✔
810
      if adjustment > gTotal.stretch then
38✔
811
         if
812
            (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber()
10✔
813
         then
814
            SU.warn(
2✔
815
               "Underfull frame "
816
                  .. self.frame.id
1✔
817
                  .. ": "
1✔
818
                  .. adjustment
1✔
819
                  .. " stretchiness required to fill but only "
1✔
820
                  .. gTotal.stretch
1✔
821
                  .. " available"
1✔
822
            )
823
         end
824
         adjustment = gTotal.stretch
2✔
825
      end
826
      if gTotal.stretch:tonumber() > 0 then
76✔
827
         for i = 1, #glues do
385✔
828
            local g = glues[i]
348✔
829
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
1,740✔
830
         end
831
      end
832
   elseif adjustment:tonumber() < 0 then
30✔
833
      adjustment = 0 - adjustment
15✔
834
      if adjustment > gTotal.shrink then
15✔
835
         if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
75✔
836
            SU.warn(
2✔
837
               "Overfull frame "
838
                  .. self.frame.id
1✔
839
                  .. ": "
1✔
840
                  .. adjustment
1✔
841
                  .. " shrinkability required to fit but only "
1✔
842
                  .. gTotal.shrink
1✔
843
                  .. " available"
1✔
844
            )
845
         end
846
         adjustment = gTotal.shrink
15✔
847
      end
848
      if gTotal.shrink:tonumber() > 0 then
30✔
UNCOV
849
         for i = 1, #glues do
×
UNCOV
850
            local g = glues[i]
×
UNCOV
851
            g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
852
         end
853
      end
854
   end
855
   SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
53✔
856
end
857

858
function typesetter:initNextFrame ()
28✔
859
   local oldframe = self.frame
30✔
860
   self.frame:leave(self)
30✔
861
   if #self.state.outputQueue == 0 then
30✔
862
      self.state.previousVbox = nil
22✔
863
   end
864
   if self.frame.next and self.state.lastPenalty > supereject_penalty then
30✔
UNCOV
865
      self:initFrame(SILE.getFrame(self.frame.next))
×
866
   elseif not self.frame:isMainContentFrame() then
60✔
867
      if #self.state.outputQueue > 0 then
14✔
868
         SU.warn("Overfull content for frame " .. self.frame.id)
1✔
869
         self:chuck()
1✔
870
      end
871
   else
872
      self:runHooks("pageend")
16✔
873
      SILE.documentState.documentClass:endPage()
16✔
874
      self:initFrame(SILE.documentState.documentClass:newPage())
32✔
875
   end
876

877
   if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
120✔
UNCOV
878
      self:pushBack()
×
879
      -- Some what of a hack below.
880
      -- Before calling this method, we were in vertical mode...
881
      -- pushback occurred, and it seems it messes up a bit...
882
      -- Regardless what it does, at the end, we ought to be in vertical mode
883
      -- again:
UNCOV
884
      self:leaveHmode()
×
885
   else
886
      -- If I have some things on the vertical list already, they need
887
      -- proper top-of-frame leading applied.
888
      if #self.state.outputQueue > 0 then
30✔
889
         local lead = self:leadingFor(self.state.outputQueue[1], nil)
7✔
890
         if lead then
7✔
891
            table.insert(self.state.outputQueue, 1, lead)
6✔
892
         end
893
      end
894
   end
895
   self:runHooks("newframe")
30✔
896
end
897

898
function typesetter:pushBack ()
28✔
UNCOV
899
   SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
×
UNCOV
900
   local oldqueue = self.state.outputQueue
×
901
   self.state.outputQueue = {}
×
UNCOV
902
   self.state.previousVbox = nil
×
UNCOV
903
   local lastMargins = self:getMargins()
×
UNCOV
904
   for _, vbox in ipairs(oldqueue) do
×
UNCOV
905
      SU.debug("pushback", "process box", vbox)
×
UNCOV
906
      if vbox.margins and vbox.margins ~= lastMargins then
×
UNCOV
907
         SU.debug("pushback", "new margins", lastMargins, vbox.margins)
×
UNCOV
908
         if not self.state.grid then
×
UNCOV
909
            self:endline()
×
910
         end
UNCOV
911
         self:setMargins(vbox.margins)
×
912
      end
UNCOV
913
      if vbox.explicit then
×
914
         SU.debug("pushback", "explicit", vbox)
×
UNCOV
915
         self:endline()
×
UNCOV
916
         self:pushExplicitVglue(vbox)
×
UNCOV
917
      elseif vbox.is_insertion then
×
UNCOV
918
         SU.debug("pushback", "pushBack", "insertion", vbox)
×
UNCOV
919
         SILE.typesetter:pushMigratingMaterial({ material = { vbox } })
×
UNCOV
920
      elseif not vbox.is_vglue and not vbox.is_penalty then
×
UNCOV
921
         SU.debug("pushback", "not vglue or penalty", vbox.type)
×
UNCOV
922
         local discardedFistInitLine = false
×
UNCOV
923
         if #self.state.nodes == 0 then
×
924
            -- Setup queue but avoid calling newPar
UNCOV
925
            self.state.nodes[#self.state.nodes + 1] = SILE.types.node.zerohbox()
×
926
         end
UNCOV
927
         for i, node in ipairs(vbox.nodes) do
×
UNCOV
928
            if node.is_glue and not node.discardable then
×
UNCOV
929
               self:pushHorizontal(node)
×
UNCOV
930
            elseif node.is_glue and node.value == "margin" then
×
UNCOV
931
               SU.debug("pushback", "discard", node.value, node)
×
UNCOV
932
            elseif node.is_discretionary then
×
UNCOV
933
               SU.debug("pushback", "re-mark discretionary as unused", node)
×
UNCOV
934
               node.used = false
×
UNCOV
935
               if i == 1 then
×
UNCOV
936
                  SU.debug("pushback", "keep first discretionary", node)
×
UNCOV
937
                  self:pushHorizontal(node)
×
938
               else
UNCOV
939
                  SU.debug("pushback", "discard all other discretionaries", node)
×
940
               end
UNCOV
941
            elseif node.is_zero then
×
UNCOV
942
               if discardedFistInitLine then
×
UNCOV
943
                  self:pushHorizontal(node)
×
944
               end
UNCOV
945
               discardedFistInitLine = true
×
UNCOV
946
            elseif node.is_penalty then
×
UNCOV
947
               if not discardedFistInitLine then
×
UNCOV
948
                  self:pushHorizontal(node)
×
949
               end
950
            else
UNCOV
951
               node.bidiDone = true
×
UNCOV
952
               self:pushHorizontal(node)
×
953
            end
954
         end
955
      else
UNCOV
956
         SU.debug("pushback", "discard", vbox.type)
×
957
      end
UNCOV
958
      lastMargins = vbox.margins
×
959
      -- self:debugState()
960
   end
UNCOV
961
   while
×
UNCOV
962
      self.state.nodes[#self.state.nodes]
×
UNCOV
963
      and (self.state.nodes[#self.state.nodes].is_penalty or self.state.nodes[#self.state.nodes].is_zero)
×
964
   do
UNCOV
965
      self.state.nodes[#self.state.nodes] = nil
×
966
   end
967
end
968

969
function typesetter:outputLinesToPage (lines)
28✔
970
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
70✔
971
   -- It would have been nice to avoid storing this "pastTop" into a frame
972
   -- state, to keep things less entangled. There are situations, though,
973
   -- we will have left horizontal mode (triggering output), but will later
974
   -- call typesetter:chuck() do deal with any remaining content, and we need
975
   -- to know whether some content has been output already.
976
   local pastTop = self.frame.state.totals.pastTop
70✔
977
   for _, line in ipairs(lines) do
857✔
978
      -- Ignore discardable and explicit glues at the top of a frame:
979
      -- Annoyingly, explicit glue *should* disappear at the top of a page.
980
      -- if you don't want that, add an empty vbox or something.
981
      if not pastTop and not line.discardable and not line.explicit then
787✔
982
         -- Note the test here doesn't check is_vglue, so will skip other
983
         -- discardable nodes (e.g. penalties), but it shouldn't matter
984
         -- for outputting.
985
         pastTop = true
69✔
986
      end
987
      if pastTop then
787✔
988
         line:outputYourself(self, line)
695✔
989
      end
990
   end
991
   self.frame.state.totals.pastTop = pastTop
70✔
992
end
993

994
function typesetter:leaveHmode (independent)
28✔
995
   if self.state.hmodeOnly then
549✔
UNCOV
996
      SU.error("Paragraphs are forbidden in restricted horizontal mode")
×
997
   end
998
   SU.debug("typesetter", "Leaving hmode")
549✔
999
   local margins = self:getMargins()
549✔
1000
   local vboxlist = self:boxUpNodes()
549✔
1001
   self.state.nodes = {}
549✔
1002
   -- Push output lines into boxes and ship them to the page builder
1003
   for _, vbox in ipairs(vboxlist) do
1,060✔
1004
      vbox.margins = margins
511✔
1005
      self:pushVertical(vbox)
511✔
1006
   end
1007
   if independent then
549✔
1008
      return
58✔
1009
   end
1010
   if self:buildPage() then
982✔
1011
      self:initNextFrame()
29✔
1012
   end
1013
end
1014

1015
function typesetter:inhibitLeading ()
28✔
UNCOV
1016
   self.state.previousVbox = nil
×
1017
end
1018

1019
function typesetter.leadingFor (_, vbox, previous)
28✔
1020
   -- Insert leading
1021
   SU.debug("typesetter", "   Considering leading between two lines:")
240✔
1022
   SU.debug("typesetter", "   1)", previous)
240✔
1023
   SU.debug("typesetter", "   2)", vbox)
240✔
1024
   if not previous then
240✔
1025
      return SILE.types.node.vglue()
68✔
1026
   end
1027
   local prevDepth = previous.depth
172✔
1028
   SU.debug("typesetter", "   Depth of previous line was", prevDepth)
172✔
1029
   local bls = SILE.settings:get("document.baselineskip")
172✔
1030
   local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
860✔
1031
   SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
172✔
1032

1033
   -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
1034
   local lead = SILE.settings:get("document.lineskip").height:absolute()
344✔
1035
   if depth > lead then
172✔
1036
      return SILE.types.node.vglue(SILE.types.length(depth.length, bls.height.stretch, bls.height.shrink))
274✔
1037
   else
1038
      return SILE.types.node.vglue(lead)
35✔
1039
   end
1040
end
1041

1042
-- Beginning of liner logic (constructs spanning over several lines)
1043

1044
-- These two special nodes are used to track the current liner entry and exit.
1045
-- As Sith Lords, they are always two: they are local here, so no one can
1046
-- use one alone and break the balance of the Force.
1047
local linerEnterNode = pl.class(SILE.types.node.hbox)
28✔
1048
function linerEnterNode:_init (name, outputMethod)
28✔
1049
   SILE.types.node.hbox._init(self)
1✔
1050
   self.outputMethod = outputMethod
1✔
1051
   self.name = name
1✔
1052
   self.is_enter = true
1✔
1053
end
1054
function linerEnterNode:clone ()
28✔
UNCOV
1055
   return linerEnterNode(self.name, self.outputMethod)
×
1056
end
1057
function linerEnterNode:outputYourself ()
28✔
UNCOV
1058
   SU.error("A liner enter node " .. tostring(self) .. "'' made it to output", true)
×
1059
end
1060
function linerEnterNode:__tostring ()
28✔
UNCOV
1061
   return "+L[" .. self.name .. "]"
×
1062
end
1063
local linerLeaveNode = pl.class(SILE.types.node.hbox)
28✔
1064
function linerLeaveNode:_init (name)
28✔
1065
   SILE.types.node.hbox._init(self)
1✔
1066
   self.name = name
1✔
1067
   self.is_leave = true
1✔
1068
end
1069
function linerLeaveNode:clone ()
28✔
1070
   return linerLeaveNode(self.name)
×
1071
end
1072
function linerLeaveNode:outputYourself ()
28✔
1073
   SU.error("A liner leave node " .. tostring(self) .. "'' made it to output", true)
×
1074
end
1075
function linerLeaveNode:__tostring ()
28✔
1076
   return "-L[" .. self.name .. "]"
×
1077
end
1078

1079
local linerBox = pl.class(SILE.types.node.hbox)
28✔
1080
function linerBox:_init (name, outputMethod)
28✔
1081
   SILE.types.node.hbox._init(self)
1✔
1082
   self.width = SILE.types.length()
2✔
1083
   self.height = SILE.types.length()
2✔
1084
   self.depth = SILE.types.length()
2✔
1085
   self.name = name
1✔
1086
   self.inner = {}
1✔
1087
   self.outputYourself = outputMethod
1✔
1088
end
1089
function linerBox:append (node)
28✔
1090
   self.inner[#self.inner + 1] = node
1✔
1091
   if node.is_discretionary then
1✔
1092
      -- Discretionary nodes don't have a width of their own.
UNCOV
1093
      if node.used then
×
UNCOV
1094
         if node.is_prebreak then
×
UNCOV
1095
            self.width:___add(node:prebreakWidth())
×
1096
         else
UNCOV
1097
            self.width:___add(node:postbreakWidth())
×
1098
         end
1099
      else
UNCOV
1100
         self.width:___add(node:replacementWidth())
×
1101
      end
1102
   else
1103
      self.width:___add(node.width:absolute())
2✔
1104
   end
1105
   self.height = SU.max(self.height, node.height)
2✔
1106
   self.depth = SU.max(self.depth, node.depth)
2✔
1107
end
1108
function linerBox:count ()
28✔
1109
   return #self.inner
2✔
1110
end
1111
function linerBox:outputContent (tsetter, line)
28✔
1112
   for _, node in ipairs(self.inner) do
2✔
1113
      node.outputYourself(node, tsetter, line)
1✔
1114
   end
1115
end
1116
function linerBox:__tostring ()
28✔
UNCOV
1117
   return "*L["
×
UNCOV
1118
      .. self.name
×
UNCOV
1119
      .. "]H<"
×
UNCOV
1120
      .. tostring(self.width)
×
UNCOV
1121
      .. ">^"
×
UNCOV
1122
      .. tostring(self.height)
×
UNCOV
1123
      .. "-"
×
UNCOV
1124
      .. tostring(self.depth)
×
UNCOV
1125
      .. "v"
×
1126
end
1127

1128
--- Any unclosed liner is reopened on the current line, so we clone and repeat it.
1129
-- An assumption is that the inserts are done after the current slice content,
1130
-- supposed to be just before meaningful (visible) content.
1131
-- @tparam table slice Flat nodes from current line
1132
-- @treturn boolean Whether a liner was reopened
1133
function typesetter:_repeatEnterLiners (slice)
28✔
1134
   local m = self.state.liners
2,698✔
1135
   if #m > 0 then
2,698✔
UNCOV
1136
      for i = 1, #m do
×
1137
         local n = m[i]:clone()
×
UNCOV
1138
         slice[#slice + 1] = n
×
UNCOV
1139
         SU.debug("typesetter.liner", "Reopening liner", n)
×
1140
      end
UNCOV
1141
      return true
×
1142
   end
1143
   return false
2,698✔
1144
end
1145

1146
--- All pairs of liners are rebuilt as hboxes wrapping their content.
1147
-- Migrating content, however, must be kept outside the hboxes at top slice level.
1148
-- @tparam table slice Flat nodes from current line
1149
-- @treturn table New reboxed slice
1150
function typesetter._reboxLiners (_, slice)
28✔
1151
   local outSlice = {}
1✔
1152
   local migratingList = {}
1✔
1153
   local lboxStack = {}
1✔
1154
   for i = 1, #slice do
33✔
1155
      local node = slice[i]
32✔
1156
      if node.is_enter then
32✔
1157
         SU.debug("typesetter.liner", "Start reboxing", node)
1✔
1158
         local n = linerBox(node.name, node.outputMethod)
1✔
1159
         lboxStack[#lboxStack + 1] = n
1✔
1160
      elseif node.is_leave then
31✔
1161
         if #lboxStack == 0 then
1✔
UNCOV
1162
            SU.error("Multiliner box stacking mismatch" .. node)
×
1163
         elseif #lboxStack == 1 then
1✔
1164
            SU.debug("typesetter.liner", "End reboxing", node, "(toplevel) =", lboxStack[1]:count(), "nodes")
2✔
1165
            if lboxStack[1]:count() > 0 then
2✔
1166
               outSlice[#outSlice + 1] = lboxStack[1]
1✔
1167
            end
1168
         else
UNCOV
1169
            SU.debug("typesetter.liner", "End reboxing", node, "(sublevel) =", lboxStack[#lboxStack]:count(), "nodes")
×
UNCOV
1170
            if lboxStack[#lboxStack]:count() > 0 then
×
UNCOV
1171
               local hbox = lboxStack[#lboxStack - 1]
×
UNCOV
1172
               hbox:append(lboxStack[#lboxStack])
×
1173
            end
1174
         end
1175
         lboxStack[#lboxStack] = nil
1✔
1176
         pl.tablex.insertvalues(outSlice, migratingList)
1✔
1177
         migratingList = {}
1✔
1178
      else
1179
         if #lboxStack > 0 then
30✔
1180
            if not node.is_migrating then
1✔
1181
               local lbox = lboxStack[#lboxStack]
1✔
1182
               lbox:append(node)
2✔
1183
            else
UNCOV
1184
               migratingList[#migratingList + 1] = node
×
1185
            end
1186
         else
1187
            outSlice[#outSlice + 1] = node
29✔
1188
         end
1189
      end
1190
   end
1191
   return outSlice -- new reboxed slice
1✔
1192
end
1193

1194
--- Check if a node is a liner, and process it if so, in a stack.
1195
-- @tparam table node Current node (any type)
1196
-- @treturn boolean Whether a liner was opened
1197
function typesetter:_processIfLiner (node)
28✔
1198
   local entered = false
3,096✔
1199
   if node.is_enter then
3,096✔
1200
      SU.debug("typesetter.liner", "Enter liner", node)
1✔
1201
      self.state.liners[#self.state.liners + 1] = node
1✔
1202
      entered = true
1✔
1203
   elseif node.is_leave then
3,095✔
1204
      SU.debug("typesetter.liner", "Leave liner", node)
1✔
1205
      if #self.state.liners == 0 then
1✔
UNCOV
1206
         SU.error("Multiliner stack mismatch" .. node)
×
1207
      elseif self.state.liners[#self.state.liners].name == node.name then
1✔
1208
         self.state.liners[#self.state.liners].link = node -- for consistency check
1✔
1209
         self.state.liners[#self.state.liners] = nil
1✔
1210
      else
UNCOV
1211
         SU.error("Multiliner stack inconsistency" .. self.state.liners[#self.state.liners] .. "vs. " .. node)
×
1212
      end
1213
   end
1214
   return entered
3,096✔
1215
end
1216

1217
function typesetter:_repeatLeaveLiners (slice, insertIndex)
28✔
1218
   for _, v in ipairs(self.state.liners) do
235✔
UNCOV
1219
      if not v.link then
×
UNCOV
1220
         local n = linerLeaveNode(v.name)
×
UNCOV
1221
         SU.debug("typesetter.liner", "Closing liner", n)
×
UNCOV
1222
         table.insert(slice, insertIndex, n)
×
1223
      else
UNCOV
1224
         SU.error("Multiliner stack inconsistency" .. v)
×
1225
      end
1226
   end
1227
end
1228
-- End of liner logic
1229

1230
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
28✔
1231
   local LTR = self.frame:writingDirection() == "LTR"
484✔
1232
   local rskip = margins[LTR and "rskip" or "lskip"]
242✔
1233
   if not rskip then
242✔
UNCOV
1234
      rskip = SILE.types.node.glue(0)
×
1235
   end
1236
   if hangRight and hangRight > 0 then
242✔
1237
      rskip = SILE.types.node.glue({ width = rskip.width:tonumber() + hangRight })
30✔
1238
   end
1239
   rskip.value = "margin"
242✔
1240
   -- while slice[#slice].discardable do table.remove(slice, #slice) end
1241
   table.insert(slice, rskip)
242✔
1242
   table.insert(slice, SILE.types.node.zerohbox())
484✔
1243
   local lskip = margins[LTR and "lskip" or "rskip"]
242✔
1244
   if not lskip then
242✔
UNCOV
1245
      lskip = SILE.types.node.glue(0)
×
1246
   end
1247
   if hangLeft and hangLeft > 0 then
242✔
1248
      lskip = SILE.types.node.glue({ width = lskip.width:tonumber() + hangLeft })
24✔
1249
   end
1250
   lskip.value = "margin"
242✔
1251
   while slice[1].discardable do
242✔
UNCOV
1252
      table.remove(slice, 1)
×
1253
   end
1254
   table.insert(slice, 1, lskip)
242✔
1255
   table.insert(slice, 1, SILE.types.node.zerohbox())
484✔
1256
end
1257

1258
function typesetter:breakpointsToLines (breakpoints)
28✔
1259
   local linestart = 1
178✔
1260
   local lines = {}
178✔
1261
   local nodes = self.state.nodes
178✔
1262

1263
   for i = 1, #breakpoints do
424✔
1264
      local point = breakpoints[i]
246✔
1265
      if point.position ~= 0 then
246✔
1266
         local slice = {}
246✔
1267
         local seenNonDiscardable = false
246✔
1268
         local seenLiner = false
246✔
1269
         local lastContentNodeIndex
1270

1271
         for j = linestart, point.position do
3,342✔
1272
            local currentNode = nodes[j]
3,096✔
1273
            if
1274
               not currentNode.discardable
3,096✔
1275
               and not (currentNode.is_glue and not currentNode.explicit)
2,023✔
1276
               and not currentNode.is_zero
1,845✔
1277
            then
1278
               -- actual visible content starts here
1279
               lastContentNodeIndex = #slice + 1
1,661✔
1280
            end
1281
            if not seenLiner and lastContentNodeIndex then
3,096✔
1282
               -- Any stacked liner (unclosed from a previous line) is reopened on
1283
               -- the current line.
1284
               seenLiner = self:_repeatEnterLiners(slice)
5,396✔
1285
               lastContentNodeIndex = #slice + 1
2,698✔
1286
            end
1287
            if currentNode.is_discretionary and currentNode.used then
3,096✔
1288
               -- This is the used (prebreak) discretionary from a previous line,
1289
               -- repeated. Replace it with a clone, changed to a postbreak.
1290
               currentNode = currentNode:cloneAsPostbreak()
30✔
1291
            end
1292
            slice[#slice + 1] = currentNode
3,096✔
1293
            if currentNode then
3,096✔
1294
               if not currentNode.discardable then
3,096✔
1295
                  seenNonDiscardable = true
2,023✔
1296
               end
1297
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
6,192✔
1298
            end
1299
         end
1300
         if not seenNonDiscardable then
246✔
1301
            -- Slip lines containing only discardable nodes (e.g. glues).
1302
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
4✔
1303
            linestart = point.position + 1
4✔
1304
         else
1305
            local is_broken = false
242✔
1306
            if slice[#slice].is_discretionary then
242✔
1307
               -- The line ends, with a discretionary:
1308
               -- repeat it on the next line, so as to account for a potential postbreak.
1309
               linestart = point.position
15✔
1310
               -- And mark it as used as prebreak for now.
1311
               slice[#slice]:markAsPrebreak()
15✔
1312
               -- We'll want a "brokenpenalty" eventually (if not an orphan or widow)
1313
               -- to discourage page breaking after this line.
1314
               is_broken = true
15✔
1315
            else
1316
               linestart = point.position + 1
227✔
1317
            end
1318

1319
            -- Any unclosed liner is closed on the next line in reverse order.
1320
            if lastContentNodeIndex then
242✔
1321
               self:_repeatLeaveLiners(slice, lastContentNodeIndex + 1)
235✔
1322
            end
1323

1324
            -- Then only we can add some extra margin glue...
1325
            local mrg = self:getMargins()
242✔
1326
            self:addrlskip(slice, mrg, point.left, point.right)
242✔
1327

1328
            -- And compute the line...
1329
            local ratio = self:computeLineRatio(point.width, slice)
242✔
1330

1331
            -- Re-shuffle liners, if any, into their own boxes.
1332
            if seenLiner then
242✔
1333
               slice = self:_reboxLiners(slice)
2✔
1334
            end
1335

1336
            local thisLine = { ratio = ratio, nodes = slice, is_broken = is_broken }
242✔
1337
            lines[#lines + 1] = thisLine
242✔
1338
         end
1339
      end
1340
   end
1341
   if linestart < #nodes then
178✔
1342
      -- Abnormal, but warn so that one has a chance to check which bits
1343
      -- are missing at output.
UNCOV
1344
      SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
1345
   end
1346
   return lines
178✔
1347
end
1348

1349
function typesetter.computeLineRatio (_, breakwidth, slice)
28✔
1350
   local naturalTotals = SILE.types.length()
242✔
1351

1352
   -- From the line end, account for the margin but skip any trailing
1353
   -- glues (spaces to ignore) and zero boxes until we reach actual content.
1354
   local npos = #slice
242✔
1355
   while npos > 1 do
780✔
1356
      if slice[npos].is_glue or slice[npos].is_zero then
779✔
1357
         if slice[npos].value == "margin" then
538✔
1358
            naturalTotals:___add(slice[npos].width)
243✔
1359
         end
1360
      else
1361
         break
1362
      end
1363
      npos = npos - 1
538✔
1364
   end
1365

1366
   -- Due to discretionaries, keep track of seen parent nodes
1367
   local seenNodes = {}
242✔
1368
   -- CODE SMELL: Not sure which node types were supposed to be skipped
1369
   -- at initial positions in the line!
1370
   local skipping = true
242✔
1371

1372
   -- Until end of actual content
1373
   for i = 1, npos do
3,764✔
1374
      local node = slice[i]
3,522✔
1375
      if node.is_box then
3,522✔
1376
         skipping = false
1,746✔
1377
         if node.parent and not node.parent.hyphenated then
1,746✔
1378
            if not seenNodes[node.parent] then
414✔
1379
               naturalTotals:___add(node.parent:lineContribution())
346✔
1380
            end
1381
            seenNodes[node.parent] = true
414✔
1382
         else
1383
            naturalTotals:___add(node:lineContribution())
2,664✔
1384
         end
1385
      elseif node.is_penalty and node.penalty == -inf_bad then
1,776✔
1386
         skipping = false
175✔
1387
      elseif node.is_discretionary then
1,601✔
1388
         skipping = false
292✔
1389
         local seen = node.parent and seenNodes[node.parent]
292✔
1390
         if not seen then
292✔
1391
            if node.used then
51✔
1392
               if node.is_prebreak then
30✔
1393
                  naturalTotals:___add(node:prebreakWidth())
30✔
1394
                  node.height = node:prebreakHeight()
30✔
1395
               else
1396
                  naturalTotals:___add(node:postbreakWidth())
30✔
1397
                  node.height = node:postbreakHeight()
30✔
1398
               end
1399
            else
1400
               naturalTotals:___add(node:replacementWidth():absolute())
63✔
1401
               node.height = node:replacementHeight():absolute()
63✔
1402
            end
1403
         end
1404
      elseif not skipping then
1,309✔
1405
         naturalTotals:___add(node.width)
1,309✔
1406
      end
1407
   end
1408

1409
   local _left = breakwidth:tonumber() - naturalTotals:tonumber()
726✔
1410
   local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
484✔
1411
   ratio = math.max(ratio, -1)
242✔
1412
   return ratio, naturalTotals
242✔
1413
end
1414

1415
function typesetter:chuck () -- emergency shipout everything
28✔
1416
   self:leaveHmode(true)
25✔
1417
   if #self.state.outputQueue > 0 then
25✔
1418
      SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
12✔
1419
      self:outputLinesToPage(self.state.outputQueue)
12✔
1420
      self.state.outputQueue = {}
12✔
1421
   end
1422
end
1423

1424
-- Logic for building an hbox from content.
1425
-- It returns the hbox and an horizontal list of (migrating) elements
1426
-- extracted outside of it.
1427
-- None of these are pushed to the typesetter node queue. The caller
1428
-- is responsible of doing it, if the hbox is built for anything
1429
-- else than e.g. measuring it. Likewise, the call has to decide
1430
-- what to do with the migrating content.
1431
local _rtl_pre_post = function (box, atypesetter, line)
1432
   local advance = function ()
1433
      atypesetter.frame:advanceWritingDirection(box:scaledWidth(line))
28✔
1434
   end
1435
   if atypesetter.frame:writingDirection() == "RTL" then
28✔
UNCOV
1436
      advance()
×
UNCOV
1437
      return function () end
×
1438
   else
1439
      return advance
14✔
1440
   end
1441
end
1442
function typesetter:makeHbox (content)
28✔
1443
   local recentContribution = {}
15✔
1444
   local migratingNodes = {}
15✔
1445

1446
   self:pushState()
15✔
1447
   self.state.hmodeOnly = true
15✔
1448
   SILE.process(content)
15✔
1449

1450
   -- We must do a first pass for shaping the nnodes:
1451
   -- This is also where italic correction may occur.
1452
   local nodes = self:shapeAllNodes(self.state.nodes, false)
15✔
1453

1454
   -- Then we can process and measure the nodes.
1455
   local l = SILE.types.length()
15✔
1456
   local h, d = SILE.types.length(), SILE.types.length()
30✔
1457
   for i = 1, #nodes do
22✔
1458
      local node = nodes[i]
7✔
1459
      if node.is_migrating then
7✔
UNCOV
1460
         migratingNodes[#migratingNodes + 1] = node
×
1461
      elseif node.is_discretionary then
7✔
1462
         -- HACK https://github.com/sile-typesetter/sile/issues/583
1463
         -- Discretionary nodes have a null line contribution...
1464
         -- But if discretionary nodes occur inside an hbox, since the latter
1465
         -- is not line-broken, they will never be marked as 'used' and will
1466
         -- evaluate to the replacement content (if any)...
UNCOV
1467
         recentContribution[#recentContribution + 1] = node
×
UNCOV
1468
         l = l + node:replacementWidth():absolute()
×
1469
         -- The replacement content may have ascenders and descenders...
UNCOV
1470
         local hdisc = node:replacementHeight():absolute()
×
UNCOV
1471
         local ddisc = node:replacementDepth():absolute()
×
UNCOV
1472
         h = hdisc > h and hdisc or h
×
UNCOV
1473
         d = ddisc > d and ddisc or d
×
1474
      -- By the way it's unclear how this is expected to work in TTB
1475
      -- writing direction. For other type of nodes, the line contribution
1476
      -- evaluates to the height rather than the width in TTB, but the
1477
      -- whole logic might then be dubious there too...
1478
      else
1479
         recentContribution[#recentContribution + 1] = node
7✔
1480
         l = l + node:lineContribution():absolute()
21✔
1481
         h = node.height > h and node.height or h
11✔
1482
         d = node.depth > d and node.depth or d
12✔
1483
      end
1484
   end
1485
   self:popState()
15✔
1486

1487
   local hbox = SILE.types.node.hbox({
30✔
1488
      height = h,
15✔
1489
      width = l,
15✔
1490
      depth = d,
15✔
1491
      value = recentContribution,
15✔
1492
      outputYourself = function (box, atypesetter, line)
1493
         local _post = _rtl_pre_post(box, atypesetter, line)
14✔
1494
         local ox = atypesetter.frame.state.cursorX
14✔
1495
         local oy = atypesetter.frame.state.cursorY
14✔
1496
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
14✔
1497
         SU.debug("hboxes", function ()
28✔
1498
            -- setCursor is also invoked by the internal (wrapped) hboxes etc.
1499
            -- so we must show our debug box before outputting its content.
UNCOV
1500
            SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
UNCOV
1501
            return "Drew debug outline around hbox"
×
1502
         end)
1503
         for _, node in ipairs(box.value) do
21✔
1504
            node:outputYourself(atypesetter, line)
7✔
1505
         end
1506
         atypesetter.frame.state.cursorX = ox
14✔
1507
         atypesetter.frame.state.cursorY = oy
14✔
1508
         _post()
14✔
1509
      end,
1510
   })
1511
   return hbox, migratingNodes
15✔
1512
end
1513

1514
function typesetter:pushHlist (hlist)
28✔
1515
   for _, h in ipairs(hlist) do
3✔
UNCOV
1516
      self:pushHorizontal(h)
×
1517
   end
1518
end
1519

1520
--- A liner is a construct that may span multiple lines.
1521
-- This is the user-facing method for creating such liners in packages.
1522
-- The content may be line-broken, and each bit on each line will be wrapped
1523
-- into a box.
1524
-- These boxes will be formatted according to some output logic.
1525
-- The output method has the same signature as the outputYourself method
1526
-- of a box, and is responsible for outputting the liner inner content with the
1527
-- outputContent(typesetter, line) method, possibly surrounded by some additional
1528
-- effects.
1529
-- If we are already in horizontal-restricted mode, the liner is processed
1530
-- immediately, since line breaking won't occur then.
1531
-- @tparam string name Name of the liner (useful for debugging)
1532
-- @tparam table content SILE AST to process
1533
-- @tparam function outputYourself Output method for wrapped boxes
1534
function typesetter:liner (name, content, outputYourself)
28✔
1535
   if self.state.hmodeOnly then
1✔
UNCOV
1536
      SU.debug("typesetter.liner", "Applying liner in horizontal-restricted mode")
×
UNCOV
1537
      local hbox, hlist = self:makeHbox(content)
×
UNCOV
1538
      local lbox = linerBox(name, outputYourself)
×
UNCOV
1539
      lbox:append(hbox)
×
UNCOV
1540
      self:pushHorizontal(lbox)
×
UNCOV
1541
      self:pushHlist(hlist)
×
1542
   else
1543
      self.state.linerCount = (self.state.linerCount or 0) + 1
1✔
1544
      local uname = name .. "_" .. self.state.linerCount
1✔
1545
      SU.debug("typesetter.liner", "Applying liner in standard mode")
1✔
1546
      local enter = linerEnterNode(uname, outputYourself)
1✔
1547
      local leave = linerLeaveNode(uname)
1✔
1548
      self:pushHorizontal(enter)
1✔
1549
      SILE.process(content)
1✔
1550
      self:pushHorizontal(leave)
1✔
1551
   end
1552
end
1553

1554
--- Flatten a node list into just its string representation.
1555
-- @tparam table nodes Typeset nodes
1556
-- @treturn string Text reconstruction of the nodes
1557
local function _nodesToText (nodes)
1558
   -- A real interword space width depends on several settings (depending on variable
1559
   -- spaces being enabled or not, etc.), and the computation below takes that into
1560
   -- account.
1561
   local iwspc = SILE.shaper:measureSpace(SILE.font.loadDefaults({}))
4✔
1562
   local iwspcmin = (iwspc.length - iwspc.shrink):tonumber()
4✔
1563

1564
   local string = ""
2✔
1565
   for i = 1, #nodes do
4✔
1566
      local node = nodes[i]
2✔
1567
      if node.is_nnode or node.is_unshaped then
4✔
1568
         string = string .. node:toText()
4✔
UNCOV
1569
      elseif node.is_glue or node.is_kern then
×
1570
         -- What we want to avoid is "small" glues or kerns to be expanded as full
1571
         -- spaces.
1572
         -- Comparing them to half of the smallest width of a possibly shrinkable
1573
         -- interword space is fairly fragile and empirical: the content could contain
1574
         -- font changes, so the comparison is wrong in the general case.
1575
         -- It's a simplistic approach. We cannot really be sure what a "space" meant
1576
         -- at the point where the kern or glue got absolutized.
UNCOV
1577
         if node.width:tonumber() > iwspcmin * 0.5 then
×
UNCOV
1578
            string = string .. " "
×
1579
         end
UNCOV
1580
      elseif not (node.is_zerohbox or node.is_migrating) then
×
1581
         -- Here, typically, the main case is an hbox.
1582
         -- Even if extracting its content could be possible in some regular cases
1583
         -- we cannot take a general decision, as it is a versatile object  and its
1584
         -- outputYourself() method could moreover have been redefined to do fancy
1585
         -- things. Better warn and skip.
UNCOV
1586
         SU.warn("Some content could not be converted to text: " .. node)
×
1587
      end
1588
   end
1589
   -- Trim leading and trailing spaces, and simplify internal spaces.
1590
   return pl.stringx.strip(string):gsub("%s%s+", " ")
4✔
1591
end
1592

1593
--- Convert a SILE AST to a textual representation.
1594
-- This is similar to SU.ast.contentToString(), but it performs a full
1595
-- typesetting of the content, and then reconstructs the text from the
1596
-- typeset nodes.
1597
-- @tparam table content SILE AST to process
1598
-- @treturn string Textual representation of the content
1599
function typesetter:contentToText (content)
28✔
1600
   self:pushState()
2✔
1601
   self.state.hmodeOnly = true
2✔
1602
   SILE.process(content)
2✔
1603
   local text = _nodesToText(self.state.nodes)
2✔
1604
   self:popState()
2✔
1605
   return text
2✔
1606
end
1607

1608
return typesetter
28✔
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