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

sile-typesetter / sile / 11135083927

01 Oct 2024 11:26PM UTC coverage: 62.071% (-7.3%) from 69.333%
11135083927

push

github

alerque
style: Reformat Lua with stylua

0 of 9 new or added lines in 2 files covered. (0.0%)

1678 existing lines in 57 files now uncovered.

11071 of 17836 relevant lines covered (62.07%)

5220.48 hits per line

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

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

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

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

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

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

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

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

31
local warned = false
50✔
32

33
function typesetter:init (frame)
50✔
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)
50✔
48
   self:declareSettings()
97✔
49
   self.hooks = {}
97✔
50
   self.breadcrumbs = SU.breadcrumbs()
194✔
51

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

61
--- Declare new setting types
62
function typesetter.declareSettings (_)
50✔
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({
97✔
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({
97✔
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({
97✔
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({
97✔
92
      parameter = "typesetter.orphanpenalty",
93
      type = "integer",
94
      default = 3000,
95
      help = "Penalty to be applied to orphan lines (at the end of a paragraph)",
96
   })
97

98
   SILE.settings:declare({
194✔
99
      parameter = "typesetter.parfillskip",
100
      type = "glue",
101
      default = SILE.types.node.glue("0pt plus 10000pt"),
194✔
102
      help = "Glue added at the end of a paragraph",
103
   })
104

105
   SILE.settings:declare({
97✔
106
      parameter = "document.letterspaceglue",
107
      type = "glue or nil",
108
      default = nil,
109
      help = "Glue added between tokens",
110
   })
111

112
   SILE.settings:declare({
194✔
113
      parameter = "typesetter.underfulltolerance",
114
      type = "length or nil",
115
      default = SILE.types.length("1em"),
194✔
116
      help = "Amount a page can be underfull without warning",
117
   })
118

119
   SILE.settings:declare({
194✔
120
      parameter = "typesetter.overfulltolerance",
121
      type = "length or nil",
122
      default = SILE.types.length("5pt"),
194✔
123
      help = "Amount a page can be overfull without warning",
124
   })
125

126
   SILE.settings:declare({
97✔
127
      parameter = "typesetter.breakwidth",
128
      type = "measurement or nil",
129
      default = nil,
130
      help = "Width to break lines at",
131
   })
132

133
   SILE.settings:declare({
97✔
134
      parameter = "typesetter.italicCorrection",
135
      type = "boolean",
136
      default = false,
137
      help = "Whether italic correction is activated or not",
138
   })
139

140
   SILE.settings:declare({
97✔
141
      parameter = "typesetter.softHyphen",
142
      type = "boolean",
143
      default = true,
144
      help = "When true, soft hyphens are rendered as discretionary breaks, otherwise they are ignored",
145
   })
146

147
   SILE.settings:declare({
97✔
148
      parameter = "typesetter.softHyphenWarning",
149
      type = "boolean",
150
      default = false,
151
      help = "When true, a warning is issued when a soft hyphen is encountered",
152
   })
153

154
   SILE.settings:declare({
97✔
155
      parameter = "typesetter.fixedSpacingAfterInitialEmdash",
156
      type = "boolean",
157
      default = true,
158
      help = "When true, em-dash starting a paragraph is considered as a speaker change in a dialogue",
159
   })
160
end
161

162
function typesetter:initState ()
50✔
163
   self.state = {
118✔
164
      nodes = {},
118✔
165
      outputQueue = {},
118✔
166
      lastBadness = awful_bad,
118✔
167
      liners = {},
118✔
168
   }
118✔
169
end
170

171
function typesetter:initFrame (frame)
50✔
172
   if frame then
150✔
173
      self.frame = frame
147✔
174
      self.frame:init(self)
147✔
175
   end
176
end
177

178
function typesetter.getMargins ()
50✔
179
   return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
4,284✔
180
end
181

182
function typesetter.setMargins (_, margins)
50✔
183
   SILE.settings:set("document.lskip", margins.lskip)
×
184
   SILE.settings:set("document.rskip", margins.rskip)
×
185
end
186

187
function typesetter:pushState ()
50✔
188
   self.stateQueue[#self.stateQueue + 1] = self.state
21✔
189
   self:initState()
21✔
190
end
191

192
function typesetter:popState (ncount)
50✔
193
   local offset = ncount and #self.stateQueue - ncount or nil
21✔
194
   self.state = table.remove(self.stateQueue, offset)
42✔
195
   if not self.state then
21✔
196
      SU.error("Typesetter state queue empty")
×
197
   end
198
end
199

200
function typesetter:isQueueEmpty ()
50✔
201
   if not self.state then
1,134✔
202
      return nil
×
203
   end
204
   return #self.state.nodes == 0 and #self.state.outputQueue == 0
1,134✔
205
end
206

207
function typesetter:vmode ()
50✔
208
   return #self.state.nodes == 0
457✔
209
end
210

211
function typesetter:debugState ()
50✔
212
   print("\n---\nI am in " .. (self:vmode() and "vertical" or "horizontal") .. " mode")
×
213
   print("Writing into " .. tostring(self.frame))
×
214
   print("Recent contributions: ")
×
215
   for i = 1, #self.state.nodes do
×
216
      io.stderr:write(self.state.nodes[i] .. " ")
×
217
   end
218
   print("\nVertical list: ")
×
219
   for i = 1, #self.state.outputQueue do
×
220
      print("  " .. self.state.outputQueue[i])
×
221
   end
222
end
223

224
-- Boxy stuff
225
function typesetter:pushHorizontal (node)
50✔
226
   self:initline()
1,545✔
227
   self.state.nodes[#self.state.nodes + 1] = node
1,545✔
228
   return node
1,545✔
229
end
230

231
function typesetter:pushVertical (vbox)
50✔
232
   self.state.outputQueue[#self.state.outputQueue + 1] = vbox
1,500✔
233
   return vbox
1,500✔
234
end
235

236
function typesetter:pushHbox (spec)
50✔
237
   local ntype = SU.type(spec)
46✔
238
   local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.types.node.hbox(spec)
46✔
239
   return self:pushHorizontal(node)
46✔
240
end
241

242
function typesetter:pushUnshaped (spec)
50✔
243
   local node = SU.type(spec) == "unshaped" and spec or SILE.types.node.unshaped(spec)
618✔
244
   return self:pushHorizontal(node)
309✔
245
end
246

247
function typesetter:pushGlue (spec)
50✔
248
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
1,144✔
249
   return self:pushHorizontal(node)
572✔
250
end
251

252
function typesetter:pushExplicitGlue (spec)
50✔
253
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
86✔
254
   node.explicit = true
43✔
255
   node.discardable = false
43✔
256
   return self:pushHorizontal(node)
43✔
257
end
258

259
function typesetter:pushPenalty (spec)
50✔
260
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
584✔
261
   return self:pushHorizontal(node)
292✔
262
end
263

264
function typesetter:pushMigratingMaterial (material)
50✔
265
   local node = SILE.types.node.migrating({ material = material })
6✔
266
   return self:pushHorizontal(node)
6✔
267
end
268

269
function typesetter:pushVbox (spec)
50✔
UNCOV
270
   local node = SU.type(spec) == "vbox" and spec or SILE.types.node.vbox(spec)
×
UNCOV
271
   return self:pushVertical(node)
×
272
end
273

274
function typesetter:pushVglue (spec)
50✔
275
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
544✔
276
   return self:pushVertical(node)
272✔
277
end
278

279
function typesetter:pushExplicitVglue (spec)
50✔
280
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
412✔
281
   node.explicit = true
206✔
282
   node.discardable = false
206✔
283
   return self:pushVertical(node)
206✔
284
end
285

286
function typesetter:pushVpenalty (spec)
50✔
287
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
148✔
288
   return self:pushVertical(node)
74✔
289
end
290

291
-- Actual typesetting functions
292
function typesetter:typeset (text)
50✔
293
   text = tostring(text)
622✔
294
   if text:match("^%\r?\n$") then
622✔
295
      return
257✔
296
   end
297
   local pId = SILE.traceStack:pushText(text)
365✔
298
   for token in SU.gtoke(text, SILE.settings:get("typesetter.parseppattern")) do
1,580✔
299
      if token.separator then
485✔
300
         self:endline()
344✔
301
      else
302
         if SILE.settings:get("typesetter.softHyphen") then
626✔
303
            local warnedshy = false
313✔
304
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
939✔
305
               if token2.separator then -- soft hyphen support
313✔
UNCOV
306
                  local discretionary = SILE.types.node.discretionary({})
×
UNCOV
307
                  local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
×
UNCOV
308
                  discretionary.prebreak = { hbox }
×
UNCOV
309
                  table.insert(SILE.typesetter.state.nodes, discretionary)
×
UNCOV
310
                  if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
×
UNCOV
311
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
312
                  end
UNCOV
313
                  warnedshy = true
×
314
               else
315
                  self:setpar(token2.string)
313✔
316
               end
317
            end
318
         else
319
            if
320
               SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD))
×
321
            then
UNCOV
322
               SU.warn("Soft hyphen encountered and ignored")
×
323
            end
UNCOV
324
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
×
UNCOV
325
            self:setpar(text)
×
326
         end
327
      end
328
   end
329
   SILE.traceStack:pop(pId)
365✔
330
end
331

332
function typesetter:initline ()
50✔
333
   if self.state.hmodeOnly then
1,754✔
334
      return
7✔
335
   end -- https://github.com/sile-typesetter/sile/issues/1718
336
   if #self.state.nodes == 0 then
1,747✔
337
      table.insert(self.state.nodes, SILE.types.node.zerohbox())
568✔
338
      SILE.documentState.documentClass.newPar(self)
284✔
339
   end
340
end
341

342
function typesetter:endline ()
50✔
343
   SILE.documentState.documentClass.endPar(self)
360✔
344
   self:leaveHmode()
360✔
345
   if SILE.settings:get("current.hangIndent") then
720✔
346
      SILE.settings:set("current.hangIndent", nil)
4✔
347
      SILE.settings:set("linebreak.hangIndent", nil)
4✔
348
   end
349
   if SILE.settings:get("current.hangAfter") then
720✔
350
      SILE.settings:set("current.hangAfter", nil)
4✔
351
      SILE.settings:set("linebreak.hangAfter", nil)
4✔
352
   end
353
end
354

355
-- Just compute once, to avoid unicode characters in source code.
UNCOV
356
local speakerChangePattern = "^"
×
357
   .. luautf8.char(0x2014) -- emdash
50✔
UNCOV
358
   .. "[ "
×
359
   .. luautf8.char(0x00A0)
50✔
360
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
50✔
361
   .. "]+"
50✔
362
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
50✔
363

364
-- Special unshaped node subclass to handle space after a speaker change in dialogues
365
-- introduced by an em-dash.
366
local speakerChangeNode = pl.class(SILE.types.node.unshaped)
50✔
367
function speakerChangeNode:shape ()
50✔
UNCOV
368
   local node = self._base.shape(self)
×
UNCOV
369
   local spc = node[2]
×
UNCOV
370
   if spc and spc.is_glue then
×
371
      -- Switch the variable space glue to a fixed kern
UNCOV
372
      node[2] = SILE.types.node.kern({ width = spc.width.length })
×
UNCOV
373
      node[2].parent = self.parent
×
374
   else
375
      -- Should not occur:
376
      -- How could it possibly be shaped differently?
UNCOV
377
      SU.warn("Speaker change logic met an unexpected case, this might be a bug")
×
378
   end
UNCOV
379
   return node
×
380
end
381

382
-- Takes string, writes onto self.state.nodes
383
function typesetter:setpar (text)
50✔
384
   text = text:gsub("\r?\n", " "):gsub("\t", " ")
313✔
385
   if #self.state.nodes == 0 then
313✔
386
      if not SILE.settings:get("typesetter.obeyspaces") then
418✔
387
         text = text:gsub("^%s+", "")
209✔
388
      end
389
      self:initline()
209✔
390

391
      if
392
         SILE.settings:get("typesetter.fixedSpacingAfterInitialEmdash")
209✔
393
         and not SILE.settings:get("typesetter.obeyspaces")
418✔
394
      then
395
         local speakerChange = false
209✔
396
         local dialogue = luautf8.gsub(text, speakerChangePattern, function ()
418✔
UNCOV
397
            speakerChange = true
×
UNCOV
398
            return speakerChangeReplacement
×
399
         end)
400
         if speakerChange then
209✔
UNCOV
401
            local node = speakerChangeNode({ text = dialogue, options = SILE.font.loadDefaults({}) })
×
UNCOV
402
            self:pushHorizontal(node)
×
UNCOV
403
            return -- done here: speaker change space handling is done after nnode shaping
×
404
         end
405
      end
406
   end
407
   if #text > 0 then
313✔
408
      self:pushUnshaped({ text = text, options = SILE.font.loadDefaults({}) })
618✔
409
   end
410
end
411

412
function typesetter:breakIntoLines (nodelist, breakWidth)
50✔
413
   self:shapeAllNodes(nodelist)
283✔
414
   local breakpoints = SILE.linebreak:doBreak(nodelist, breakWidth)
283✔
415
   return self:breakpointsToLines(breakpoints)
283✔
416
end
417

418
local function getLastShape (nodelist)
419
   local hasGlue
420
   local last
UNCOV
421
   if nodelist then
×
422
      -- The node list may contain nnodes, penalties, kern and glue
423
      -- We skip the latter, and retrieve the last shaped item.
UNCOV
424
      for i = #nodelist, 1, -1 do
×
UNCOV
425
         local n = nodelist[i]
×
UNCOV
426
         if n.is_nnode then
×
UNCOV
427
            local items = n.nodes[#n.nodes].value.items
×
UNCOV
428
            last = items[#items]
×
429
            break
430
         end
UNCOV
431
         if n.is_kern and n.subtype == "punctspace" then
×
432
            -- Some languages such as French insert a special space around
433
            -- punctuations. In those case, we should not need italic correction.
434
            break
435
         end
UNCOV
436
         if n.is_glue then
×
UNCOV
437
            hasGlue = true
×
438
         end
439
      end
440
   end
UNCOV
441
   return last, hasGlue
×
442
end
443
local function getFirstShape (nodelist)
444
   local first
445
   local hasGlue
UNCOV
446
   if nodelist then
×
447
      -- The node list may contain nnodes, penalties, kern and glue
448
      -- We skip the latter, and retrieve the first shaped item.
UNCOV
449
      for i = 1, #nodelist do
×
UNCOV
450
         local n = nodelist[i]
×
UNCOV
451
         if n.is_nnode then
×
UNCOV
452
            local items = n.nodes[1].value.items
×
UNCOV
453
            first = items[1]
×
454
            break
455
         end
UNCOV
456
         if n.is_kern and n.subtype == "punctspace" then
×
457
            -- Some languages such as French insert a special space around
458
            -- punctuations. In those case, we should not need italic correction.
459
            break
460
         end
UNCOV
461
         if n.is_glue then
×
UNCOV
462
            hasGlue = true
×
463
         end
464
      end
465
   end
UNCOV
466
   return first, hasGlue
×
467
end
468

469
local function fromItalicCorrection (precShape, curShape)
470
   local xOffset
UNCOV
471
   if not curShape or not precShape then
×
UNCOV
472
      xOffset = 0
×
473
   else
474
      -- Computing italic correction is at best heuristics.
475
      -- The strong assumption is that italic is slanted to the right.
476
      -- Thus, the part of the character that goes beyond its width is usually
477
      -- maximal at the top of the glyph.
478
      -- E.g. consider a "f", that would be the top hook extent.
479
      -- Pathological cases exist, such as fonts with a Q with a long tail,
480
      -- but these will rarely occur in usual languages. For instance, Klingon's
481
      -- "QaQ" might be an issue, but there's not much we can do...
482
      -- Another assumption is that we can distribute that extent in proportion
483
      -- with the next character's height.
484
      -- This might not work that well with non-Latin scripts.
UNCOV
485
      local d = precShape.glyphWidth + precShape.x_bearing
×
UNCOV
486
      local delta = d > precShape.width and d - precShape.width or 0
×
UNCOV
487
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
×
488
   end
UNCOV
489
   return xOffset
×
490
end
491

492
local function toItalicCorrection (precShape, curShape)
UNCOV
493
   if not SILE.settings:get("typesetter.italicCorrection") then
×
UNCOV
494
      return
×
495
   end
496
   local xOffset
UNCOV
497
   if not curShape or not precShape then
×
UNCOV
498
      xOffset = 0
×
499
   else
500
      -- Same assumptions as fromItalicCorrection(), but on the starting side of
501
      -- the glyph.
UNCOV
502
      local d = curShape.x_bearing
×
503
      local delta = d < 0 and -d or 0
×
UNCOV
504
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
×
505
   end
UNCOV
506
   return xOffset
×
507
end
508

509
local function isItalicLike (nnode)
510
   -- We could do...
511
   --  return nnode and string.lower(nnode.options.style) == "italic"
512
   -- But it's probably more robust to use the italic angle, so that
513
   -- thin italic, oblique or slanted fonts etc. may work too.
UNCOV
514
   local ot = require("core.opentype-parser")
×
UNCOV
515
   local face = SILE.font.cache(nnode.options, SILE.shaper.getFace)
×
UNCOV
516
   local font = ot.parseFont(face)
×
UNCOV
517
   return font.post.italicAngle ~= 0
×
518
end
519

520
function typesetter.shapeAllNodes (_, nodelist, inplace)
50✔
521
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
1,724✔
522
   local newNodelist = {}
862✔
523
   local prec
524
   local precShapedNodes
525
   for _, current in ipairs(nodelist) do
13,085✔
526
      if current.is_unshaped then
12,223✔
527
         local shapedNodes = current:shape()
328✔
528

529
         if SILE.settings:get("typesetter.italicCorrection") and prec then
656✔
530
            local itCorrOffset
531
            local isGlue
UNCOV
532
            if isItalicLike(prec) and not isItalicLike(current) then
×
UNCOV
533
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
UNCOV
534
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
UNCOV
535
               isGlue = precHasGlue or curHasGlue
×
UNCOV
536
               itCorrOffset = fromItalicCorrection(precShape, curShape)
×
UNCOV
537
            elseif not isItalicLike(prec) and isItalicLike(current) then
×
UNCOV
538
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
UNCOV
539
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
UNCOV
540
               isGlue = precHasGlue or curHasGlue
×
UNCOV
541
               itCorrOffset = toItalicCorrection(precShape, curShape)
×
542
            end
UNCOV
543
            if itCorrOffset and itCorrOffset ~= 0 then
×
544
               -- If one of the node contains a glue (e.g. "a \em{proof} is..."),
545
               -- line breaking may occur between them, so our correction shall be
546
               -- a glue too.
547
               -- Otherwise, the font change is considered to occur at a non-breaking
548
               -- point (e.g. "\em{proof}!") and the correction shall be a kern.
UNCOV
549
               local makeItCorrNode = isGlue and SILE.types.node.glue or SILE.types.node.kern
×
UNCOV
550
               newNodelist[#newNodelist + 1] = makeItCorrNode({
×
551
                  width = SILE.types.length(itCorrOffset),
552
                  subtype = "itcorr",
553
               })
554
            end
555
         end
556

557
         pl.tablex.insertvalues(newNodelist, shapedNodes)
328✔
558

559
         prec = current
328✔
560
         precShapedNodes = shapedNodes
328✔
561
      else
562
         prec = nil
11,895✔
563
         newNodelist[#newNodelist + 1] = current
11,895✔
564
      end
565
   end
566

567
   if not inplace then
862✔
568
      return newNodelist
15✔
569
   end
570

571
   for i = 1, #newNodelist do
17,041✔
572
      nodelist[i] = newNodelist[i]
16,194✔
573
   end
574
   if #nodelist > #newNodelist then
847✔
UNCOV
575
      for i = #newNodelist + 1, #nodelist do
×
UNCOV
576
         nodelist[i] = nil
×
577
      end
578
   end
579
end
580

581
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
582
-- Turns a node list into a list of vboxes
583
function typesetter:boxUpNodes ()
50✔
584
   local nodelist = self.state.nodes
990✔
585
   if #nodelist == 0 then
990✔
586
      return {}
707✔
587
   end
588
   for j = #nodelist, 1, -1 do
341✔
589
      if not nodelist[j].is_migrating then
343✔
590
         if nodelist[j].discardable then
337✔
591
            table.remove(nodelist, j)
108✔
592
         else
593
            break
594
         end
595
      end
596
   end
597
   while #nodelist > 0 and nodelist[1].is_penalty do
283✔
UNCOV
598
      table.remove(nodelist, 1)
×
599
   end
600
   if #nodelist == 0 then
283✔
UNCOV
601
      return {}
×
602
   end
603
   self:shapeAllNodes(nodelist)
283✔
604
   local parfillskip = SILE.settings:get("typesetter.parfillskip")
283✔
605
   parfillskip.discardable = false
283✔
606
   self:pushGlue(parfillskip)
283✔
607
   self:pushPenalty(-inf_bad)
283✔
608
   SU.debug("typesetter", function ()
566✔
UNCOV
609
      return "Boxed up " .. (#nodelist > 500 and #nodelist .. " nodes" or SU.ast.contentToString(nodelist))
×
610
   end)
611
   local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
566✔
612
   local lines = self:breakIntoLines(nodelist, breakWidth)
283✔
613
   local vboxes = {}
283✔
614
   for index = 1, #lines do
717✔
615
      local line = lines[index]
434✔
616
      local migrating = {}
434✔
617
      -- Move any migrating material
618
      local nodes = {}
434✔
619
      for i = 1, #line.nodes do
9,268✔
620
         local node = line.nodes[i]
8,834✔
621
         if node.is_migrating then
8,834✔
622
            for j = 1, #node.material do
12✔
623
               migrating[#migrating + 1] = node.material[j]
6✔
624
            end
625
         else
626
            nodes[#nodes + 1] = node
8,828✔
627
         end
628
      end
629
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
434✔
630
      local pageBreakPenalty = 0
434✔
631
      if #lines > 1 and index == 1 then
434✔
632
         pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
88✔
633
      elseif #lines > 1 and index == (#lines - 1) then
390✔
634
         pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
62✔
635
      end
636
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
868✔
637
      vboxes[#vboxes + 1] = vbox
434✔
638
      for i = 1, #migrating do
440✔
639
         vboxes[#vboxes + 1] = migrating[i]
6✔
640
      end
641
      self.state.previousVbox = vbox
434✔
642
      if pageBreakPenalty > 0 then
434✔
643
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
75✔
644
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
150✔
645
      end
646
   end
647
   return vboxes
283✔
648
end
649

650
function typesetter.pageTarget (_)
50✔
UNCOV
651
   SU.deprecated("SILE.typesetter:pageTarget", "SILE.typesetter:getTargetLength", "0.13.0", "0.14.0")
×
652
end
653

654
function typesetter:getTargetLength ()
50✔
655
   return self.frame:getTargetLength()
965✔
656
end
657

658
function typesetter:registerHook (category, func)
50✔
659
   if not self.hooks[category] then
70✔
660
      self.hooks[category] = {}
59✔
661
   end
662
   table.insert(self.hooks[category], func)
70✔
663
end
664

665
function typesetter:runHooks (category, data)
50✔
666
   if not self.hooks[category] then
991✔
667
      return data
897✔
668
   end
669
   for _, func in ipairs(self.hooks[category]) do
214✔
670
      data = func(self, data)
240✔
671
   end
672
   return data
94✔
673
end
674

675
function typesetter:registerFrameBreakHook (func)
50✔
676
   self:registerHook("framebreak", func)
10✔
677
end
678

679
function typesetter:registerNewFrameHook (func)
50✔
UNCOV
680
   self:registerHook("newframe", func)
×
681
end
682

683
function typesetter:registerPageEndHook (func)
50✔
684
   self:registerHook("pageend", func)
60✔
685
end
686

687
function typesetter:buildPage ()
50✔
688
   local pageNodeList
689
   local res
690
   if self:isQueueEmpty() then
1,868✔
691
      return false
55✔
692
   end
693
   if SILE.scratch.insertions then
879✔
694
      SILE.scratch.insertions.thisPage = {}
184✔
695
   end
696
   pageNodeList, res = SILE.pagebuilder:findBestBreak({
1,758✔
697
      vboxlist = self.state.outputQueue,
879✔
698
      target = self:getTargetLength(),
1,758✔
699
      restart = self.frame.state.pageRestart,
879✔
700
   })
879✔
701
   if not pageNodeList then -- No break yet
879✔
702
      -- self.frame.state.pageRestart = res
703
      self:runHooks("noframebreak")
793✔
704
      return false
793✔
705
   end
706
   SU.debug("pagebuilder", "Buildding page for", self.frame.id)
86✔
707
   self.state.lastPenalty = res
86✔
708
   self.frame.state.pageRestart = nil
86✔
709
   pageNodeList = self:runHooks("framebreak", pageNodeList)
172✔
710
   self:setVerticalGlue(pageNodeList, self:getTargetLength())
172✔
711
   self:outputLinesToPage(pageNodeList)
86✔
712
   return true
86✔
713
end
714

715
function typesetter:setVerticalGlue (pageNodeList, target)
50✔
716
   local glues = {}
86✔
717
   local gTotal = SILE.types.length()
86✔
718
   local totalHeight = SILE.types.length()
86✔
719

720
   local pastTop = false
86✔
721
   for _, node in ipairs(pageNodeList) do
1,328✔
722
      if not pastTop and not node.discardable and not node.explicit then
1,242✔
723
         -- "Ignore discardable and explicit glues at the top of a frame."
724
         -- See typesetter:outputLinesToPage()
725
         -- Note the test here doesn't check is_vglue, so will skip other
726
         -- discardable nodes (e.g. penalties), but it shouldn't matter
727
         -- for the type of computing performed here.
728
         pastTop = true
83✔
729
      end
730
      if pastTop then
1,242✔
731
         if not node.is_insertion then
1,129✔
732
            totalHeight:___add(node.height)
1,127✔
733
            totalHeight:___add(node.depth)
1,127✔
734
         end
735
         if node.is_vglue then
1,129✔
736
            table.insert(glues, node)
685✔
737
            gTotal:___add(node.height)
685✔
738
         end
739
      end
740
   end
741

742
   if totalHeight:tonumber() == 0 then
172✔
743
      return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
6✔
744
   end
745

746
   local adjustment = target - totalHeight
80✔
747
   if adjustment:tonumber() > 0 then
160✔
748
      if adjustment > gTotal.stretch then
64✔
749
         if
750
            (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber()
35✔
751
         then
752
            SU.warn(
12✔
753
               "Underfull frame "
754
                  .. self.frame.id
6✔
755
                  .. ": "
6✔
756
                  .. adjustment
6✔
757
                  .. " stretchiness required to fill but only "
6✔
758
                  .. gTotal.stretch
6✔
759
                  .. " available"
6✔
760
            )
761
         end
762
         adjustment = gTotal.stretch
7✔
763
      end
764
      if gTotal.stretch:tonumber() > 0 then
128✔
765
         for i = 1, #glues do
701✔
766
            local g = glues[i]
638✔
767
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
3,190✔
768
         end
769
      end
770
   elseif adjustment:tonumber() < 0 then
32✔
771
      adjustment = 0 - adjustment
16✔
772
      if adjustment > gTotal.shrink then
16✔
773
         if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
80✔
774
            SU.warn(
4✔
775
               "Overfull frame "
776
                  .. self.frame.id
2✔
777
                  .. ": "
2✔
778
                  .. adjustment
2✔
779
                  .. " shrinkability required to fit but only "
2✔
780
                  .. gTotal.shrink
2✔
781
                  .. " available"
2✔
782
            )
783
         end
784
         adjustment = gTotal.shrink
16✔
785
      end
786
      if gTotal.shrink:tonumber() > 0 then
32✔
UNCOV
787
         for i = 1, #glues do
×
UNCOV
788
            local g = glues[i]
×
UNCOV
789
            g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
790
         end
791
      end
792
   end
793
   SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
80✔
794
end
795

796
function typesetter:initNextFrame ()
50✔
797
   local oldframe = self.frame
38✔
798
   self.frame:leave(self)
38✔
799
   if #self.state.outputQueue == 0 then
38✔
800
      self.state.previousVbox = nil
25✔
801
   end
802
   if self.frame.next and self.state.lastPenalty > supereject_penalty then
38✔
803
      self:initFrame(SILE.getFrame(self.frame.next))
9✔
804
   elseif not self.frame:isMainContentFrame() then
70✔
805
      if #self.state.outputQueue > 0 then
14✔
806
         SU.warn("Overfull content for frame " .. self.frame.id)
1✔
807
         self:chuck()
1✔
808
      end
809
   else
810
      self:runHooks("pageend")
21✔
811
      SILE.documentState.documentClass:endPage()
21✔
812
      self:initFrame(SILE.documentState.documentClass:newPage())
42✔
813
   end
814

815
   if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
152✔
816
      self:pushBack()
4✔
817
      -- Some what of a hack below.
818
      -- Before calling this method, we were in vertical mode...
819
      -- pushback occurred, and it seems it messes up a bit...
820
      -- Regardless what it does, at the end, we ought to be in vertical mode
821
      -- again:
822
      self:leaveHmode()
8✔
823
   else
824
      -- If I have some things on the vertical list already, they need
825
      -- proper top-of-frame leading applied.
826
      if #self.state.outputQueue > 0 then
34✔
827
         local lead = self:leadingFor(self.state.outputQueue[1], nil)
10✔
828
         if lead then
10✔
829
            table.insert(self.state.outputQueue, 1, lead)
9✔
830
         end
831
      end
832
   end
833
   self:runHooks("newframe")
38✔
834
end
835

836
function typesetter:pushBack ()
50✔
837
   SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
4✔
838
   local oldqueue = self.state.outputQueue
4✔
839
   self.state.outputQueue = {}
4✔
840
   self.state.previousVbox = nil
4✔
841
   local lastMargins = self:getMargins()
4✔
842
   for _, vbox in ipairs(oldqueue) do
20✔
843
      SU.debug("pushback", "process box", vbox)
16✔
844
      if vbox.margins and vbox.margins ~= lastMargins then
16✔
UNCOV
845
         SU.debug("pushback", "new margins", lastMargins, vbox.margins)
×
UNCOV
846
         if not self.state.grid then
×
UNCOV
847
            self:endline()
×
848
         end
UNCOV
849
         self:setMargins(vbox.margins)
×
850
      end
851
      if vbox.explicit then
16✔
UNCOV
852
         SU.debug("pushback", "explicit", vbox)
×
UNCOV
853
         self:endline()
×
854
         self:pushExplicitVglue(vbox)
×
855
      elseif vbox.is_insertion then
16✔
856
         SU.debug("pushback", "pushBack", "insertion", vbox)
×
UNCOV
857
         SILE.typesetter:pushMigratingMaterial({ material = { vbox } })
×
858
      elseif not vbox.is_vglue and not vbox.is_penalty then
16✔
859
         SU.debug("pushback", "not vglue or penalty", vbox.type)
6✔
860
         local discardedFistInitLine = false
6✔
861
         if #self.state.nodes == 0 then
6✔
862
            -- Setup queue but avoid calling newPar
863
            self.state.nodes[#self.state.nodes + 1] = SILE.types.node.zerohbox()
4✔
864
         end
865
         for i, node in ipairs(vbox.nodes) do
236✔
866
            if node.is_glue and not node.discardable then
230✔
867
               self:pushHorizontal(node)
4✔
868
            elseif node.is_glue and node.value == "margin" then
228✔
869
               SU.debug("pushback", "discard", node.value, node)
24✔
870
            elseif node.is_discretionary then
216✔
UNCOV
871
               SU.debug("pushback", "re-mark discretionary as unused", node)
×
UNCOV
872
               node.used = false
×
UNCOV
873
               if i == 1 then
×
UNCOV
874
                  SU.debug("pushback", "keep first discretionary", node)
×
UNCOV
875
                  self:pushHorizontal(node)
×
876
               else
UNCOV
877
                  SU.debug("pushback", "discard all other discretionaries", node)
×
878
               end
879
            elseif node.is_zero then
216✔
880
               if discardedFistInitLine then
14✔
881
                  self:pushHorizontal(node)
8✔
882
               end
883
               discardedFistInitLine = true
14✔
884
            elseif node.is_penalty then
202✔
885
               if not discardedFistInitLine then
2✔
UNCOV
886
                  self:pushHorizontal(node)
×
887
               end
888
            else
889
               node.bidiDone = true
200✔
890
               self:pushHorizontal(node)
200✔
891
            end
892
         end
893
      else
894
         SU.debug("pushback", "discard", vbox.type)
10✔
895
      end
896
      lastMargins = vbox.margins
16✔
897
      -- self:debugState()
898
   end
UNCOV
899
   while
×
900
      self.state.nodes[#self.state.nodes]
6✔
901
      and (self.state.nodes[#self.state.nodes].is_penalty or self.state.nodes[#self.state.nodes].is_zero)
6✔
902
   do
903
      self.state.nodes[#self.state.nodes] = nil
2✔
904
   end
905
end
906

907
function typesetter:outputLinesToPage (lines)
50✔
908
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
118✔
909
   -- It would have been nice to avoid storing this "pastTop" into a frame
910
   -- state, to keep things less entangled. There are situations, though,
911
   -- we will have left horizontal mode (triggering output), but will later
912
   -- call typesetter:chuck() do deal with any remaining content, and we need
913
   -- to know whether some content has been output already.
914
   local pastTop = self.frame.state.totals.pastTop
118✔
915
   for _, line in ipairs(lines) do
1,454✔
916
      -- Ignore discardable and explicit glues at the top of a frame:
917
      -- Annoyingly, explicit glue *should* disappear at the top of a page.
918
      -- if you don't want that, add an empty vbox or something.
919
      if not pastTop and not line.discardable and not line.explicit then
1,336✔
920
         -- Note the test here doesn't check is_vglue, so will skip other
921
         -- discardable nodes (e.g. penalties), but it shouldn't matter
922
         -- for outputting.
923
         pastTop = true
113✔
924
      end
925
      if pastTop then
1,336✔
926
         line:outputYourself(self, line)
1,188✔
927
      end
928
   end
929
   self.frame.state.totals.pastTop = pastTop
118✔
930
end
931

932
function typesetter:leaveHmode (independent)
50✔
933
   if self.state.hmodeOnly then
990✔
UNCOV
934
      SU.error("Paragraphs are forbidden in restricted horizontal mode")
×
935
   end
936
   SU.debug("typesetter", "Leaving hmode")
990✔
937
   local margins = self:getMargins()
990✔
938
   local vboxlist = self:boxUpNodes()
990✔
939
   self.state.nodes = {}
990✔
940
   -- Push output lines into boxes and ship them to the page builder
941
   for _, vbox in ipairs(vboxlist) do
1,938✔
942
      vbox.margins = margins
948✔
943
      self:pushVertical(vbox)
948✔
944
   end
945
   if independent then
990✔
946
      return
100✔
947
   end
948
   if self:buildPage() then
1,780✔
949
      self:initNextFrame()
37✔
950
   end
951
end
952

953
function typesetter:inhibitLeading ()
50✔
UNCOV
954
   self.state.previousVbox = nil
×
955
end
956

957
function typesetter.leadingFor (_, vbox, previous)
50✔
958
   -- Insert leading
959
   SU.debug("typesetter", "   Considering leading between two lines:")
413✔
960
   SU.debug("typesetter", "   1)", previous)
413✔
961
   SU.debug("typesetter", "   2)", vbox)
413✔
962
   if not previous then
413✔
963
      return SILE.types.node.vglue()
112✔
964
   end
965
   local prevDepth = previous.depth
301✔
966
   SU.debug("typesetter", "   Depth of previous line was", prevDepth)
301✔
967
   local bls = SILE.settings:get("document.baselineskip")
301✔
968
   local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
1,505✔
969
   SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
301✔
970

971
   -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
972
   local lead = SILE.settings:get("document.lineskip").height:absolute()
602✔
973
   if depth > lead then
301✔
974
      return SILE.types.node.vglue(SILE.types.length(depth.length, bls.height.stretch, bls.height.shrink))
464✔
975
   else
976
      return SILE.types.node.vglue(lead)
69✔
977
   end
978
end
979

980
-- Beginning of liner logic (constructs spanning over several lines)
981

982
-- These two special nodes are used to track the current liner entry and exit.
983
-- As Sith Lords, they are always two: they are local here, so no one can
984
-- use one alone and break the balance of the Force.
985
local linerEnterNode = pl.class(SILE.types.node.hbox)
50✔
986
function linerEnterNode:_init (name, outputMethod)
50✔
987
   SILE.types.node.hbox._init(self)
1✔
988
   self.outputMethod = outputMethod
1✔
989
   self.name = name
1✔
990
   self.is_enter = true
1✔
991
end
992
function linerEnterNode:clone ()
50✔
UNCOV
993
   return linerEnterNode(self.name, self.outputMethod)
×
994
end
995
function linerEnterNode:outputYourself ()
50✔
UNCOV
996
   SU.error("A liner enter node " .. tostring(self) .. "'' made it to output", true)
×
997
end
998
function linerEnterNode:__tostring ()
50✔
UNCOV
999
   return "+L[" .. self.name .. "]"
×
1000
end
1001
local linerLeaveNode = pl.class(SILE.types.node.hbox)
50✔
1002
function linerLeaveNode:_init (name)
50✔
1003
   SILE.types.node.hbox._init(self)
1✔
1004
   self.name = name
1✔
1005
   self.is_leave = true
1✔
1006
end
1007
function linerLeaveNode:clone ()
50✔
1008
   return linerLeaveNode(self.name)
×
1009
end
1010
function linerLeaveNode:outputYourself ()
50✔
UNCOV
1011
   SU.error("A liner leave node " .. tostring(self) .. "'' made it to output", true)
×
1012
end
1013
function linerLeaveNode:__tostring ()
50✔
UNCOV
1014
   return "-L[" .. self.name .. "]"
×
1015
end
1016

1017
local linerBox = pl.class(SILE.types.node.hbox)
50✔
1018
function linerBox:_init (name, outputMethod)
50✔
1019
   SILE.types.node.hbox._init(self)
1✔
1020
   self.width = SILE.types.length()
2✔
1021
   self.height = SILE.types.length()
2✔
1022
   self.depth = SILE.types.length()
2✔
1023
   self.name = name
1✔
1024
   self.inner = {}
1✔
1025
   self.outputYourself = outputMethod
1✔
1026
end
1027
function linerBox:append (node)
50✔
1028
   self.inner[#self.inner + 1] = node
1✔
1029
   if node.is_discretionary then
1✔
1030
      -- Discretionary nodes don't have a width of their own.
UNCOV
1031
      if node.used then
×
UNCOV
1032
         if node.is_prebreak then
×
UNCOV
1033
            self.width:___add(node:prebreakWidth())
×
1034
         else
UNCOV
1035
            self.width:___add(node:postbreakWidth())
×
1036
         end
1037
      else
UNCOV
1038
         self.width:___add(node:replacementWidth())
×
1039
      end
1040
   else
1041
      self.width:___add(node.width:absolute())
2✔
1042
   end
1043
   self.height = SU.max(self.height, node.height)
2✔
1044
   self.depth = SU.max(self.depth, node.depth)
2✔
1045
end
1046
function linerBox:count ()
50✔
1047
   return #self.inner
2✔
1048
end
1049
function linerBox:outputContent (tsetter, line)
50✔
1050
   for _, node in ipairs(self.inner) do
2✔
1051
      node.outputYourself(node, tsetter, line)
1✔
1052
   end
1053
end
1054
function linerBox:__tostring ()
50✔
UNCOV
1055
   return "*L["
×
UNCOV
1056
      .. self.name
×
UNCOV
1057
      .. "]H<"
×
UNCOV
1058
      .. tostring(self.width)
×
UNCOV
1059
      .. ">^"
×
UNCOV
1060
      .. tostring(self.height)
×
UNCOV
1061
      .. "-"
×
UNCOV
1062
      .. tostring(self.depth)
×
UNCOV
1063
      .. "v"
×
1064
end
1065

1066
--- Any unclosed liner is reopened on the current line, so we clone and repeat it.
1067
-- An assumption is that the inserts are done after the current slice content,
1068
-- supposed to be just before meaningful (visible) content.
1069
-- @tparam slice slice
1070
-- @treturn boolean Whether a liner was reopened
1071
function typesetter:_repeatEnterLiners (slice)
50✔
1072
   local m = self.state.liners
6,518✔
1073
   if #m > 0 then
6,518✔
UNCOV
1074
      for i = 1, #m do
×
UNCOV
1075
         local n = m[i]:clone()
×
UNCOV
1076
         slice[#slice + 1] = n
×
UNCOV
1077
         SU.debug("typesetter.liner", "Reopening liner", n)
×
1078
      end
UNCOV
1079
      return true
×
1080
   end
1081
   return false
6,518✔
1082
end
1083

1084
--- All pairs of liners are rebuilt as hboxes wrapping their content.
1085
-- Migrating content, however, must be kept outside the hboxes at top slice level.
1086
-- @tparam table slice Flat nodes from current line
1087
-- @treturn table New reboxed slice
1088
function typesetter._reboxLiners (_, slice)
50✔
1089
   local outSlice = {}
1✔
1090
   local migratingList = {}
1✔
1091
   local lboxStack = {}
1✔
1092
   for i = 1, #slice do
33✔
1093
      local node = slice[i]
32✔
1094
      if node.is_enter then
32✔
1095
         SU.debug("typesetter.liner", "Start reboxing", node)
1✔
1096
         local n = linerBox(node.name, node.outputMethod)
1✔
1097
         lboxStack[#lboxStack + 1] = n
1✔
1098
      elseif node.is_leave then
31✔
1099
         if #lboxStack == 0 then
1✔
UNCOV
1100
            SU.error("Multiliner box stacking mismatch" .. node)
×
1101
         elseif #lboxStack == 1 then
1✔
1102
            SU.debug("typesetter.liner", "End reboxing", node, "(toplevel) =", lboxStack[1]:count(), "nodes")
2✔
1103
            if lboxStack[1]:count() > 0 then
2✔
1104
               outSlice[#outSlice + 1] = lboxStack[1]
1✔
1105
            end
1106
         else
UNCOV
1107
            SU.debug("typesetter.liner", "End reboxing", node, "(sublevel) =", lboxStack[#lboxStack]:count(), "nodes")
×
UNCOV
1108
            if lboxStack[#lboxStack]:count() > 0 then
×
1109
               local hbox = lboxStack[#lboxStack - 1]
×
UNCOV
1110
               hbox:append(lboxStack[#lboxStack])
×
1111
            end
1112
         end
1113
         lboxStack[#lboxStack] = nil
1✔
1114
         pl.tablex.insertvalues(outSlice, migratingList)
1✔
1115
         migratingList = {}
1✔
1116
      else
1117
         if #lboxStack > 0 then
30✔
1118
            if not node.is_migrating then
1✔
1119
               local lbox = lboxStack[#lboxStack]
1✔
1120
               lbox:append(node)
2✔
1121
            else
UNCOV
1122
               migratingList[#migratingList + 1] = node
×
1123
            end
1124
         else
1125
            outSlice[#outSlice + 1] = node
29✔
1126
         end
1127
      end
1128
   end
1129
   return outSlice -- new reboxed slice
1✔
1130
end
1131

1132
--- Check if a node is a liner, and process it if so, in a stack.
1133
-- @tparam table node Current node (any type)
1134
-- @treturn boolean Whether a liner was opened
1135
function typesetter:_processIfLiner (node)
50✔
1136
   local entered = false
7,106✔
1137
   if node.is_enter then
7,106✔
1138
      SU.debug("typesetter.liner", "Enter liner", node)
1✔
1139
      self.state.liners[#self.state.liners + 1] = node
1✔
1140
      entered = true
1✔
1141
   elseif node.is_leave then
7,105✔
1142
      SU.debug("typesetter.liner", "Leave liner", node)
1✔
1143
      if #self.state.liners == 0 then
1✔
UNCOV
1144
         SU.error("Multiliner stack mismatch" .. node)
×
1145
      elseif self.state.liners[#self.state.liners].name == node.name then
1✔
1146
         self.state.liners[#self.state.liners].link = node -- for consistency check
1✔
1147
         self.state.liners[#self.state.liners] = nil
1✔
1148
      else
UNCOV
1149
         SU.error("Multiliner stack inconsistency" .. self.state.liners[#self.state.liners] .. "vs. " .. node)
×
1150
      end
1151
   end
1152
   return entered
7,106✔
1153
end
1154

1155
function typesetter:_repeatLeaveLiners (slice, insertIndex)
50✔
1156
   for _, v in ipairs(self.state.liners) do
434✔
UNCOV
1157
      if not v.link then
×
1158
         local n = linerLeaveNode(v.name)
×
UNCOV
1159
         SU.debug("typesetter.liner", "Closing liner", n)
×
UNCOV
1160
         table.insert(slice, insertIndex, n)
×
1161
      else
UNCOV
1162
         SU.error("Multiliner stack inconsistency" .. v)
×
1163
      end
1164
   end
1165
end
1166
-- End of liner logic
1167

1168
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
50✔
1169
   local LTR = self.frame:writingDirection() == "LTR"
868✔
1170
   local rskip = margins[LTR and "rskip" or "lskip"]
434✔
1171
   if not rskip then
434✔
UNCOV
1172
      rskip = SILE.types.node.glue(0)
×
1173
   end
1174
   if hangRight and hangRight > 0 then
434✔
1175
      rskip = SILE.types.node.glue({ width = rskip.width:tonumber() + hangRight })
63✔
1176
   end
1177
   rskip.value = "margin"
434✔
1178
   -- while slice[#slice].discardable do table.remove(slice, #slice) end
1179
   table.insert(slice, rskip)
434✔
1180
   table.insert(slice, SILE.types.node.zerohbox())
868✔
1181
   local lskip = margins[LTR and "lskip" or "rskip"]
434✔
1182
   if not lskip then
434✔
UNCOV
1183
      lskip = SILE.types.node.glue(0)
×
1184
   end
1185
   if hangLeft and hangLeft > 0 then
434✔
1186
      lskip = SILE.types.node.glue({ width = lskip.width:tonumber() + hangLeft })
48✔
1187
   end
1188
   lskip.value = "margin"
434✔
1189
   while slice[1].discardable do
436✔
1190
      table.remove(slice, 1)
4✔
1191
   end
1192
   table.insert(slice, 1, lskip)
434✔
1193
   table.insert(slice, 1, SILE.types.node.zerohbox())
868✔
1194
end
1195

1196
function typesetter:breakpointsToLines (breakpoints)
50✔
1197
   local linestart = 1
283✔
1198
   local lines = {}
283✔
1199
   local nodes = self.state.nodes
283✔
1200

1201
   for i = 1, #breakpoints do
721✔
1202
      local point = breakpoints[i]
438✔
1203
      if point.position ~= 0 then
438✔
1204
         local slice = {}
438✔
1205
         local seenNonDiscardable = false
438✔
1206
         local seenLiner = false
438✔
1207
         local lastContentNodeIndex
1208

1209
         for j = linestart, point.position do
7,544✔
1210
            local currentNode = nodes[j]
7,106✔
1211
            if
1212
               not currentNode.discardable
7,106✔
1213
               and not (currentNode.is_glue and not currentNode.explicit)
4,622✔
1214
               and not currentNode.is_zero
4,337✔
1215
            then
1216
               -- actual visible content starts here
1217
               lastContentNodeIndex = #slice + 1
4,048✔
1218
            end
1219
            if not seenLiner and lastContentNodeIndex then
7,106✔
1220
               -- Any stacked liner (unclosed from a previous line) is reopened on
1221
               -- the current line.
1222
               seenLiner = self:_repeatEnterLiners(slice)
13,036✔
1223
               lastContentNodeIndex = #slice + 1
6,518✔
1224
            end
1225
            if currentNode.is_discretionary and currentNode.used then
7,106✔
1226
               -- This is the used (prebreak) discretionary from a previous line,
1227
               -- repeated. Replace it with a clone, changed to a postbreak.
1228
               currentNode = currentNode:cloneAsPostbreak()
82✔
1229
            end
1230
            slice[#slice + 1] = currentNode
7,106✔
1231
            if currentNode then
7,106✔
1232
               if not currentNode.discardable then
7,106✔
1233
                  seenNonDiscardable = true
4,622✔
1234
               end
1235
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
14,212✔
1236
            end
1237
         end
1238
         if not seenNonDiscardable then
438✔
1239
            -- Slip lines containing only discardable nodes (e.g. glues).
1240
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
4✔
1241
            linestart = point.position + 1
4✔
1242
         else
1243
            if slice[#slice].is_discretionary then
434✔
1244
               -- The line ends, with a discretionary:
1245
               -- repeat it on the next line, so as to account for a potential postbreak.
1246
               linestart = point.position
41✔
1247
               -- And mark it as used as prebreak for now.
1248
               slice[#slice]:markAsPrebreak()
82✔
1249
            else
1250
               linestart = point.position + 1
393✔
1251
            end
1252

1253
            -- Any unclosed liner is closed on the next line in reverse order.
1254
            if lastContentNodeIndex then
434✔
1255
               self:_repeatLeaveLiners(slice, lastContentNodeIndex + 1)
434✔
1256
            end
1257

1258
            -- Then only we can add some extra margin glue...
1259
            local mrg = self:getMargins()
434✔
1260
            self:addrlskip(slice, mrg, point.left, point.right)
434✔
1261

1262
            -- And compute the line...
1263
            local ratio = self:computeLineRatio(point.width, slice)
434✔
1264

1265
            -- Re-shuffle liners, if any, into their own boxes.
1266
            if seenLiner then
434✔
1267
               slice = self:_reboxLiners(slice)
2✔
1268
            end
1269

1270
            local thisLine = { ratio = ratio, nodes = slice }
434✔
1271
            lines[#lines + 1] = thisLine
434✔
1272
         end
1273
      end
1274
   end
1275
   if linestart < #nodes then
283✔
1276
      -- Abnormal, but warn so that one has a chance to check which bits
1277
      -- are missing at output.
UNCOV
1278
      SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
1279
   end
1280
   return lines
283✔
1281
end
1282

1283
function typesetter.computeLineRatio (_, breakwidth, slice)
50✔
1284
   local naturalTotals = SILE.types.length()
434✔
1285

1286
   -- From the line end, account for the margin but skip any trailing
1287
   -- glues (spaces to ignore) and zero boxes until we reach actual content.
1288
   local npos = #slice
434✔
1289
   while npos > 1 do
1,407✔
1290
      if slice[npos].is_glue or slice[npos].is_zero then
1,407✔
1291
         if slice[npos].value == "margin" then
973✔
1292
            naturalTotals:___add(slice[npos].width)
434✔
1293
         end
1294
      else
1295
         break
1296
      end
1297
      npos = npos - 1
973✔
1298
   end
1299

1300
   -- Due to discretionaries, keep track of seen parent nodes
1301
   local seenNodes = {}
434✔
1302
   -- CODE SMELL: Not sure which node types were supposed to be skipped
1303
   -- at initial positions in the line!
1304
   local skipping = true
434✔
1305

1306
   -- Until end of actual content
1307
   for i = 1, npos do
8,297✔
1308
      local node = slice[i]
7,863✔
1309
      if node.is_box then
7,863✔
1310
         skipping = false
3,972✔
1311
         if node.parent and not node.parent.hyphenated then
3,972✔
1312
            if not seenNodes[node.parent] then
995✔
1313
               naturalTotals:___add(node.parent:lineContribution())
832✔
1314
            end
1315
            seenNodes[node.parent] = true
995✔
1316
         else
1317
            naturalTotals:___add(node:lineContribution())
5,954✔
1318
         end
1319
      elseif node.is_penalty and node.penalty == -inf_bad then
3,891✔
1320
         skipping = false
288✔
1321
      elseif node.is_discretionary then
3,603✔
1322
         skipping = false
693✔
1323
         local seen = node.parent and seenNodes[node.parent]
693✔
1324
         if not seen then
693✔
1325
            if node.used then
114✔
1326
               if node.is_prebreak then
82✔
1327
                  naturalTotals:___add(node:prebreakWidth())
82✔
1328
                  node.height = node:prebreakHeight()
82✔
1329
               else
1330
                  naturalTotals:___add(node:postbreakWidth())
82✔
1331
                  node.height = node:postbreakHeight()
82✔
1332
               end
1333
            else
1334
               naturalTotals:___add(node:replacementWidth():absolute())
96✔
1335
               node.height = node:replacementHeight():absolute()
96✔
1336
            end
1337
         end
1338
      elseif not skipping then
2,910✔
1339
         naturalTotals:___add(node.width)
2,910✔
1340
      end
1341
   end
1342

1343
   local _left = breakwidth:tonumber() - naturalTotals:tonumber()
1,302✔
1344
   local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
868✔
1345
   ratio = math.max(ratio, -1)
434✔
1346
   return ratio, naturalTotals
434✔
1347
end
1348

1349
function typesetter:chuck () -- emergency shipout everything
50✔
1350
   self:leaveHmode(true)
43✔
1351
   if #self.state.outputQueue > 0 then
43✔
1352
      SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
30✔
1353
      self:outputLinesToPage(self.state.outputQueue)
30✔
1354
      self.state.outputQueue = {}
30✔
1355
   end
1356
end
1357

1358
-- Logic for building an hbox from content.
1359
-- It returns the hbox and an horizontal list of (migrating) elements
1360
-- extracted outside of it.
1361
-- None of these are pushed to the typesetter node queue. The caller
1362
-- is responsible of doing it, if the hbox is built for anything
1363
-- else than e.g. measuring it. Likewise, the call has to decide
1364
-- what to do with the migrating content.
1365
local _rtl_pre_post = function (box, atypesetter, line)
1366
   local advance = function ()
1367
      atypesetter.frame:advanceWritingDirection(box:scaledWidth(line))
28✔
1368
   end
1369
   if atypesetter.frame:writingDirection() == "RTL" then
28✔
UNCOV
1370
      advance()
×
UNCOV
1371
      return function () end
×
1372
   else
1373
      return advance
14✔
1374
   end
1375
end
1376
function typesetter:makeHbox (content)
50✔
1377
   local recentContribution = {}
15✔
1378
   local migratingNodes = {}
15✔
1379

1380
   self:pushState()
15✔
1381
   self.state.hmodeOnly = true
15✔
1382
   SILE.process(content)
15✔
1383

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

1388
   -- Then we can process and measure the nodes.
1389
   local l = SILE.types.length()
15✔
1390
   local h, d = SILE.types.length(), SILE.types.length()
30✔
1391
   for i = 1, #nodes do
22✔
1392
      local node = nodes[i]
7✔
1393
      if node.is_migrating then
7✔
UNCOV
1394
         migratingNodes[#migratingNodes + 1] = node
×
1395
      elseif node.is_discretionary then
7✔
1396
         -- HACK https://github.com/sile-typesetter/sile/issues/583
1397
         -- Discretionary nodes have a null line contribution...
1398
         -- But if discretionary nodes occur inside an hbox, since the latter
1399
         -- is not line-broken, they will never be marked as 'used' and will
1400
         -- evaluate to the replacement content (if any)...
UNCOV
1401
         recentContribution[#recentContribution + 1] = node
×
UNCOV
1402
         l = l + node:replacementWidth():absolute()
×
1403
         -- The replacement content may have ascenders and descenders...
UNCOV
1404
         local hdisc = node:replacementHeight():absolute()
×
UNCOV
1405
         local ddisc = node:replacementDepth():absolute()
×
UNCOV
1406
         h = hdisc > h and hdisc or h
×
UNCOV
1407
         d = ddisc > d and ddisc or d
×
1408
      -- By the way it's unclear how this is expected to work in TTB
1409
      -- writing direction. For other type of nodes, the line contribution
1410
      -- evaluates to the height rather than the width in TTB, but the
1411
      -- whole logic might then be dubious there too...
1412
      else
1413
         recentContribution[#recentContribution + 1] = node
7✔
1414
         l = l + node:lineContribution():absolute()
21✔
1415
         h = node.height > h and node.height or h
11✔
1416
         d = node.depth > d and node.depth or d
12✔
1417
      end
1418
   end
1419
   self:popState()
15✔
1420

1421
   local hbox = SILE.types.node.hbox({
30✔
1422
      height = h,
15✔
1423
      width = l,
15✔
1424
      depth = d,
15✔
1425
      value = recentContribution,
15✔
1426
      outputYourself = function (box, atypesetter, line)
1427
         local _post = _rtl_pre_post(box, atypesetter, line)
14✔
1428
         local ox = atypesetter.frame.state.cursorX
14✔
1429
         local oy = atypesetter.frame.state.cursorY
14✔
1430
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
14✔
1431
         SU.debug("hboxes", function ()
28✔
1432
            -- setCursor is also invoked by the internal (wrapped) hboxes etc.
1433
            -- so we must show our debug box before outputting its content.
UNCOV
1434
            SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
UNCOV
1435
            return "Drew debug outline around hbox"
×
1436
         end)
1437
         for _, node in ipairs(box.value) do
21✔
1438
            node:outputYourself(atypesetter, line)
7✔
1439
         end
1440
         atypesetter.frame.state.cursorX = ox
14✔
1441
         atypesetter.frame.state.cursorY = oy
14✔
1442
         _post()
14✔
1443
      end,
1444
   })
1445
   return hbox, migratingNodes
15✔
1446
end
1447

1448
function typesetter:pushHlist (hlist)
50✔
1449
   for _, h in ipairs(hlist) do
3✔
UNCOV
1450
      self:pushHorizontal(h)
×
1451
   end
1452
end
1453

1454
--- A liner is a construct that may span multiple lines.
1455
-- This is the user-facing method for creating such liners in packages.
1456
-- The content may be line-broken, and each bit on each line will be wrapped
1457
-- into a box.
1458
-- These boxes will be formatted according to some output logic.
1459
-- The output method has the same signature as the outputYourself method
1460
-- of a box, and is responsible for outputting the liner inner content with the
1461
-- outputContent(typesetter, line) method, possibly surrounded by some additional
1462
-- effects.
1463
-- If we are already in horizontal-restricted mode, the liner is processed
1464
-- immediately, since line breaking won't occur then.
1465
-- @tparam string name Name of the liner (useful for debugging)
1466
-- @tparam table content SILE AST to process
1467
-- @tparam function outputYourself Output method for wrapped boxes
1468
function typesetter:liner (name, content, outputYourself)
50✔
1469
   if self.state.hmodeOnly then
1✔
UNCOV
1470
      SU.debug("typesetter.liner", "Applying liner in horizontal-restricted mode")
×
UNCOV
1471
      local hbox, hlist = self:makeHbox(content)
×
UNCOV
1472
      local lbox = linerBox(name, outputYourself)
×
UNCOV
1473
      lbox:append(hbox)
×
UNCOV
1474
      self:pushHorizontal(lbox)
×
UNCOV
1475
      self:pushHlist(hlist)
×
1476
   else
1477
      self.state.linerCount = (self.state.linerCount or 0) + 1
1✔
1478
      local uname = name .. "_" .. self.state.linerCount
1✔
1479
      SU.debug("typesetter.liner", "Applying liner in standard mode")
1✔
1480
      local enter = linerEnterNode(uname, outputYourself)
1✔
1481
      local leave = linerLeaveNode(uname)
1✔
1482
      self:pushHorizontal(enter)
1✔
1483
      SILE.process(content)
1✔
1484
      self:pushHorizontal(leave)
1✔
1485
   end
1486
end
1487

1488
return typesetter
50✔
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