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

sile-typesetter / sile / 11651903725

03 Nov 2024 01:49PM UTC coverage: 69.199% (+0.09%) from 69.109%
11651903725

push

github

alerque
style(nix): Reformat Nix-lang sources with nixfmt-rfc-style

12662 of 18298 relevant lines covered (69.2%)

5806.29 hits per line

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

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

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

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

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

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

22
   _init = function (self, lskip, rskip)
23
      self.lskip, self.rskip = lskip, rskip
3,004✔
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
108✔
32

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

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

61
--- Declare new setting types
62
function typesetter.declareSettings (_)
108✔
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({
185✔
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({
185✔
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({
185✔
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({
185✔
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({
370✔
99
      parameter = "typesetter.parfillskip",
100
      type = "glue",
101
      default = SILE.types.node.glue("0pt plus 10000pt"),
370✔
102
      help = "Glue added at the end of a paragraph",
103
   })
104

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

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

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

126
   SILE.settings:declare({
185✔
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({
185✔
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({
185✔
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({
185✔
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({
185✔
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 ()
108✔
163
   self.state = {
345✔
164
      nodes = {},
345✔
165
      outputQueue = {},
345✔
166
      lastBadness = awful_bad,
345✔
167
      liners = {},
345✔
168
   }
345✔
169
end
170

171
function typesetter:initFrame (frame)
108✔
172
   if frame then
278✔
173
      self.frame = frame
271✔
174
      self.frame:init(self)
271✔
175
   end
176
end
177

178
function typesetter.getMargins ()
108✔
179
   return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
9,012✔
180
end
181

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

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

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

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

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

211
function typesetter:debugState ()
108✔
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)
108✔
226
   self:initline()
3,492✔
227
   self.state.nodes[#self.state.nodes + 1] = node
3,492✔
228
   return node
3,492✔
229
end
230

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

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

242
function typesetter:pushUnshaped (spec)
108✔
243
   local node = SU.type(spec) == "unshaped" and spec or SILE.types.node.unshaped(spec)
1,918✔
244
   return self:pushHorizontal(node)
959✔
245
end
246

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

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

259
function typesetter:pushPenalty (spec)
108✔
260
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
1,272✔
261
   return self:pushHorizontal(node)
636✔
262
end
263

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

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

274
function typesetter:pushVglue (spec)
108✔
275
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
1,170✔
276
   return self:pushVertical(node)
585✔
277
end
278

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

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

291
-- Actual typesetting functions
292
function typesetter:typeset (text)
108✔
293
   text = tostring(text)
1,673✔
294
   if text:match("^%\r?\n$") then
1,673✔
295
      return
626✔
296
   end
297
   local pId = SILE.traceStack:pushText(text)
1,047✔
298
   local parsepattern = SILE.settings:get("typesetter.parseppattern")
1,047✔
299
   -- NOTE: Big assumption on how to guess were are in "obeylines" mode.
300
   -- See https://github.com/sile-typesetter/sile/issues/2128
301
   local obeylines = parsepattern == "\n"
1,047✔
302

303
   local seenParaContent = true
1,047✔
304
   for token in SU.gtoke(text, parsepattern) do
3,425✔
305
      if token.separator then
1,331✔
306
         if obeylines and not seenParaContent then
379✔
307
            -- In obeylines mode, each standalone line must be kept.
308
            -- The zerohbox is not discardable, so it will be kept in the output,
309
            -- and the baseline skip will do the rest.
310
            self:pushHorizontal(SILE.types.node.zerohbox())
18✔
311
         else
312
            seenParaContent = false
373✔
313
         end
314
         self:endline()
758✔
315
      else
316
         seenParaContent = true
952✔
317
         if SILE.settings:get("typesetter.softHyphen") then
1,904✔
318
            local warnedshy = false
951✔
319
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
2,883✔
320
               if token2.separator then -- soft hyphen support
981✔
321
                  local discretionary = SILE.types.node.discretionary({})
15✔
322
                  local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
30✔
323
                  discretionary.prebreak = { hbox }
15✔
324
                  table.insert(SILE.typesetter.state.nodes, discretionary)
15✔
325
                  if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
16✔
326
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
327
                  end
328
                  warnedshy = true
15✔
329
               else
330
                  self:setpar(token2.string)
966✔
331
               end
332
            end
333
         else
334
            if
335
               SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD))
2✔
336
            then
337
               SU.warn("Soft hyphen encountered and ignored")
×
338
            end
339
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
1✔
340
            self:setpar(text)
1✔
341
         end
342
      end
343
   end
344
   SILE.traceStack:pop(pId)
1,047✔
345
end
346

347
function typesetter:initline ()
108✔
348
   if self.state.hmodeOnly then
4,022✔
349
      return
290✔
350
   end -- https://github.com/sile-typesetter/sile/issues/1718
351
   if #self.state.nodes == 0 then
3,732✔
352
      table.insert(self.state.nodes, SILE.types.node.zerohbox())
1,196✔
353
      SILE.documentState.documentClass.newPar(self)
598✔
354
   end
355
end
356

357
function typesetter:endline ()
108✔
358
   SILE.documentState.documentClass.endPar(self)
731✔
359
   self:leaveHmode()
731✔
360
   if SILE.settings:get("current.hangIndent") then
1,462✔
361
      SILE.settings:set("current.hangIndent", nil)
11✔
362
      SILE.settings:set("linebreak.hangIndent", nil)
11✔
363
   end
364
   if SILE.settings:get("current.hangAfter") then
1,462✔
365
      SILE.settings:set("current.hangAfter", nil)
11✔
366
      SILE.settings:set("linebreak.hangAfter", nil)
11✔
367
   end
368
end
369

370
-- Just compute once, to avoid unicode characters in source code.
371
local speakerChangePattern = "^"
×
372
   .. luautf8.char(0x2014) -- emdash
108✔
373
   .. "[ "
×
374
   .. luautf8.char(0x00A0)
108✔
375
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
108✔
376
   .. "]+"
108✔
377
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
108✔
378

379
-- Special unshaped node subclass to handle space after a speaker change in dialogues
380
-- introduced by an em-dash.
381
local speakerChangeNode = pl.class(SILE.types.node.unshaped)
108✔
382
function speakerChangeNode:shape ()
108✔
383
   local node = self._base.shape(self)
3✔
384
   local spc = node[2]
3✔
385
   if spc and spc.is_glue then
3✔
386
      -- Switch the variable space glue to a fixed kern
387
      node[2] = SILE.types.node.kern({ width = spc.width.length })
6✔
388
      node[2].parent = self.parent
3✔
389
   else
390
      -- Should not occur:
391
      -- How could it possibly be shaped differently?
392
      SU.warn("Speaker change logic met an unexpected case, this might be a bug")
×
393
   end
394
   return node
3✔
395
end
396

397
-- Takes string, writes onto self.state.nodes
398
function typesetter:setpar (text)
108✔
399
   text = text:gsub("\r?\n", " "):gsub("\t", " ")
973✔
400
   if #self.state.nodes == 0 then
973✔
401
      if not SILE.settings:get("typesetter.obeyspaces") then
1,060✔
402
         text = text:gsub("^%s+", "")
517✔
403
      end
404
      self:initline()
530✔
405

406
      if
407
         SILE.settings:get("typesetter.fixedSpacingAfterInitialEmdash")
530✔
408
         and not SILE.settings:get("typesetter.obeyspaces")
1,059✔
409
      then
410
         local speakerChange = false
516✔
411
         local dialogue = luautf8.gsub(text, speakerChangePattern, function ()
1,032✔
412
            speakerChange = true
3✔
413
            return speakerChangeReplacement
3✔
414
         end)
415
         if speakerChange then
516✔
416
            local node = speakerChangeNode({ text = dialogue, options = SILE.font.loadDefaults({}) })
6✔
417
            self:pushHorizontal(node)
3✔
418
            return -- done here: speaker change space handling is done after nnode shaping
3✔
419
         end
420
      end
421
   end
422
   if #text > 0 then
970✔
423
      self:pushUnshaped({ text = text, options = SILE.font.loadDefaults({}) })
1,918✔
424
   end
425
end
426

427
function typesetter:breakIntoLines (nodelist, breakWidth)
108✔
428
   self:shapeAllNodes(nodelist)
603✔
429
   local breakpoints = SILE.linebreak:doBreak(nodelist, breakWidth)
603✔
430
   return self:breakpointsToLines(breakpoints)
603✔
431
end
432

433
local function getLastShape (nodelist)
434
   local hasGlue
435
   local last
436
   if nodelist then
52✔
437
      -- The node list may contain nnodes, penalties, kern and glue
438
      -- We skip the latter, and retrieve the last shaped item.
439
      for i = #nodelist, 1, -1 do
62✔
440
         local n = nodelist[i]
62✔
441
         if n.is_nnode then
62✔
442
            local items = n.nodes[#n.nodes].value.items
50✔
443
            last = items[#items]
50✔
444
            break
50✔
445
         end
446
         if n.is_kern and n.subtype == "punctspace" then
12✔
447
            -- Some languages such as French insert a special space around
448
            -- punctuations. In those case, we should not need italic correction.
449
            break
2✔
450
         end
451
         if n.is_glue then
10✔
452
            hasGlue = true
10✔
453
         end
454
      end
455
   end
456
   return last, hasGlue
52✔
457
end
458
local function getFirstShape (nodelist)
459
   local first
460
   local hasGlue
461
   if nodelist then
52✔
462
      -- The node list may contain nnodes, penalties, kern and glue
463
      -- We skip the latter, and retrieve the first shaped item.
464
      for i = 1, #nodelist do
56✔
465
         local n = nodelist[i]
56✔
466
         if n.is_nnode then
56✔
467
            local items = n.nodes[1].value.items
48✔
468
            first = items[1]
48✔
469
            break
48✔
470
         end
471
         if n.is_kern and n.subtype == "punctspace" then
8✔
472
            -- Some languages such as French insert a special space around
473
            -- punctuations. In those case, we should not need italic correction.
474
            break
4✔
475
         end
476
         if n.is_glue then
4✔
477
            hasGlue = true
4✔
478
         end
479
      end
480
   end
481
   return first, hasGlue
52✔
482
end
483

484
local function fromItalicCorrection (precShape, curShape)
485
   local xOffset
486
   if not curShape or not precShape then
26✔
487
      xOffset = 0
4✔
488
   else
489
      -- Computing italic correction is at best heuristics.
490
      -- The strong assumption is that italic is slanted to the right.
491
      -- Thus, the part of the character that goes beyond its width is usually
492
      -- maximal at the top of the glyph.
493
      -- E.g. consider a "f", that would be the top hook extent.
494
      -- Pathological cases exist, such as fonts with a Q with a long tail,
495
      -- but these will rarely occur in usual languages. For instance, Klingon's
496
      -- "QaQ" might be an issue, but there's not much we can do...
497
      -- Another assumption is that we can distribute that extent in proportion
498
      -- with the next character's height.
499
      -- This might not work that well with non-Latin scripts.
500
      local d = precShape.glyphWidth + precShape.x_bearing
22✔
501
      local delta = d > precShape.width and d - precShape.width or 0
22✔
502
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
22✔
503
   end
504
   return xOffset
26✔
505
end
506

507
local function toItalicCorrection (precShape, curShape)
508
   if not SILE.settings:get("typesetter.italicCorrection") then
52✔
509
      return
×
510
   end
511
   local xOffset
512
   if not curShape or not precShape then
26✔
513
      xOffset = 0
2✔
514
   else
515
      -- Same assumptions as fromItalicCorrection(), but on the starting side of
516
      -- the glyph.
517
      local d = curShape.x_bearing
24✔
518
      local delta = d < 0 and -d or 0
24✔
519
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
24✔
520
   end
521
   return xOffset
26✔
522
end
523

524
local function isItalicLike (nnode)
525
   -- We could do...
526
   --  return nnode and string.lower(nnode.options.style) == "italic"
527
   -- But it's probably more robust to use the italic angle, so that
528
   -- thin italic, oblique or slanted fonts etc. may work too.
529
   local ot = require("core.opentype-parser")
130✔
530
   local face = SILE.font.cache(nnode.options, SILE.shaper.getFace)
130✔
531
   local font = ot.parseFont(face)
130✔
532
   return font.post.italicAngle ~= 0
130✔
533
end
534

535
function typesetter.shapeAllNodes (_, nodelist, inplace)
108✔
536
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
3,886✔
537
   local newNodelist = {}
1,943✔
538
   local prec
539
   local precShapedNodes
540
   for _, current in ipairs(nodelist) do
28,835✔
541
      if current.is_unshaped then
26,892✔
542
         local shapedNodes = current:shape()
985✔
543

544
         if SILE.settings:get("typesetter.italicCorrection") and prec then
1,970✔
545
            local itCorrOffset
546
            local isGlue
547
            if isItalicLike(prec) and not isItalicLike(current) then
130✔
548
               local precShape, precHasGlue = getLastShape(precShapedNodes)
26✔
549
               local curShape, curHasGlue = getFirstShape(shapedNodes)
26✔
550
               isGlue = precHasGlue or curHasGlue
26✔
551
               itCorrOffset = fromItalicCorrection(precShape, curShape)
52✔
552
            elseif not isItalicLike(prec) and isItalicLike(current) then
78✔
553
               local precShape, precHasGlue = getLastShape(precShapedNodes)
26✔
554
               local curShape, curHasGlue = getFirstShape(shapedNodes)
26✔
555
               isGlue = precHasGlue or curHasGlue
26✔
556
               itCorrOffset = toItalicCorrection(precShape, curShape)
52✔
557
            end
558
            if itCorrOffset and itCorrOffset ~= 0 then
52✔
559
               -- If one of the node contains a glue (e.g. "a \em{proof} is..."),
560
               -- line breaking may occur between them, so our correction shall be
561
               -- a glue too.
562
               -- Otherwise, the font change is considered to occur at a non-breaking
563
               -- point (e.g. "\em{proof}!") and the correction shall be a kern.
564
               local makeItCorrNode = isGlue and SILE.types.node.glue or SILE.types.node.kern
46✔
565
               newNodelist[#newNodelist + 1] = makeItCorrNode({
92✔
566
                  width = SILE.types.length(itCorrOffset),
92✔
567
                  subtype = "itcorr",
568
               })
46✔
569
            end
570
         end
571

572
         pl.tablex.insertvalues(newNodelist, shapedNodes)
985✔
573

574
         prec = current
985✔
575
         precShapedNodes = shapedNodes
985✔
576
      else
577
         prec = nil
25,907✔
578
         newNodelist[#newNodelist + 1] = current
25,907✔
579
      end
580
   end
581

582
   if not inplace then
1,943✔
583
      return newNodelist
140✔
584
   end
585

586
   for i = 1, #newNodelist do
37,276✔
587
      nodelist[i] = newNodelist[i]
35,473✔
588
   end
589
   if #nodelist > #newNodelist then
1,803✔
590
      for i = #newNodelist + 1, #nodelist do
×
591
         nodelist[i] = nil
×
592
      end
593
   end
594
end
595

596
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
597
-- Turns a node list into a list of vboxes
598
function typesetter:boxUpNodes ()
108✔
599
   local nodelist = self.state.nodes
2,045✔
600
   if #nodelist == 0 then
2,045✔
601
      return {}
1,442✔
602
   end
603
   for j = #nodelist, 1, -1 do
731✔
604
      if not nodelist[j].is_migrating then
734✔
605
         if nodelist[j].discardable then
711✔
606
            table.remove(nodelist, j)
216✔
607
         else
608
            break
609
         end
610
      end
611
   end
612
   while #nodelist > 0 and nodelist[1].is_penalty do
603✔
613
      table.remove(nodelist, 1)
×
614
   end
615
   if #nodelist == 0 then
603✔
616
      return {}
×
617
   end
618
   self:shapeAllNodes(nodelist)
603✔
619
   local parfillskip = SILE.settings:get("typesetter.parfillskip")
603✔
620
   parfillskip.discardable = false
603✔
621
   self:pushGlue(parfillskip)
603✔
622
   self:pushPenalty(-inf_bad)
603✔
623
   SU.debug("typesetter", function ()
1,206✔
624
      return "Boxed up " .. (#nodelist > 500 and #nodelist .. " nodes" or SU.ast.contentToString(nodelist))
×
625
   end)
626
   local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
1,206✔
627
   local lines = self:breakIntoLines(nodelist, breakWidth)
603✔
628
   local vboxes = {}
603✔
629
   for index = 1, #lines do
1,555✔
630
      local line = lines[index]
952✔
631
      local migrating = {}
952✔
632
      -- Move any migrating material
633
      local nodes = {}
952✔
634
      for i = 1, #line.nodes do
20,620✔
635
         local node = line.nodes[i]
19,668✔
636
         if node.is_migrating then
19,668✔
637
            for j = 1, #node.material do
48✔
638
               migrating[#migrating + 1] = node.material[j]
24✔
639
            end
640
         else
641
            nodes[#nodes + 1] = node
19,644✔
642
         end
643
      end
644
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
952✔
645
      local pageBreakPenalty = 0
952✔
646
      if #lines > 1 and index == 1 then
952✔
647
         pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
230✔
648
      elseif #lines > 1 and index == (#lines - 1) then
837✔
649
         pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
140✔
650
      end
651
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
1,904✔
652
      vboxes[#vboxes + 1] = vbox
952✔
653
      for i = 1, #migrating do
976✔
654
         vboxes[#vboxes + 1] = migrating[i]
24✔
655
      end
656
      self.state.previousVbox = vbox
952✔
657
      if pageBreakPenalty > 0 then
952✔
658
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
185✔
659
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
370✔
660
      end
661
   end
662
   return vboxes
603✔
663
end
664

665
function typesetter.pageTarget (_)
108✔
666
   SU.deprecated("SILE.typesetter:pageTarget", "SILE.typesetter:getTargetLength", "0.13.0", "0.14.0")
×
667
end
668

669
function typesetter:getTargetLength ()
108✔
670
   return self.frame:getTargetLength()
2,008✔
671
end
672

673
function typesetter:registerHook (category, func)
108✔
674
   if not self.hooks[category] then
152✔
675
      self.hooks[category] = {}
129✔
676
   end
677
   table.insert(self.hooks[category], func)
152✔
678
end
679

680
function typesetter:runHooks (category, data)
108✔
681
   if not self.hooks[category] then
2,053✔
682
      return data
1,880✔
683
   end
684
   for _, func in ipairs(self.hooks[category]) do
388✔
685
      data = func(self, data)
430✔
686
   end
687
   return data
173✔
688
end
689

690
function typesetter:registerFrameBreakHook (func)
108✔
691
   self:registerHook("framebreak", func)
21✔
692
end
693

694
function typesetter:registerNewFrameHook (func)
108✔
695
   self:registerHook("newframe", func)
2✔
696
end
697

698
function typesetter:registerPageEndHook (func)
108✔
699
   self:registerHook("pageend", func)
129✔
700
end
701

702
function typesetter:buildPage ()
108✔
703
   local pageNodeList
704
   local res
705
   if self:isQueueEmpty() then
3,880✔
706
      return false
79✔
707
   end
708
   if SILE.scratch.insertions then
1,861✔
709
      SILE.scratch.insertions.thisPage = {}
435✔
710
   end
711
   pageNodeList, res = SILE.pagebuilder:findBestBreak({
3,722✔
712
      vboxlist = self.state.outputQueue,
1,861✔
713
      target = self:getTargetLength(),
3,722✔
714
      restart = self.frame.state.pageRestart,
1,861✔
715
   })
1,861✔
716
   if not pageNodeList then -- No break yet
1,861✔
717
      -- self.frame.state.pageRestart = res
718
      self:runHooks("noframebreak")
1,704✔
719
      return false
1,704✔
720
   end
721
   SU.debug("pagebuilder", "Buildding page for", self.frame.id)
157✔
722
   self.state.lastPenalty = res
157✔
723
   self.frame.state.pageRestart = nil
157✔
724
   pageNodeList = self:runHooks("framebreak", pageNodeList)
314✔
725
   self:setVerticalGlue(pageNodeList, self:getTargetLength())
314✔
726
   self:outputLinesToPage(pageNodeList)
157✔
727
   return true
157✔
728
end
729

730
function typesetter:setVerticalGlue (pageNodeList, target)
108✔
731
   local glues = {}
157✔
732
   local gTotal = SILE.types.length()
157✔
733
   local totalHeight = SILE.types.length()
157✔
734

735
   local pastTop = false
157✔
736
   for _, node in ipairs(pageNodeList) do
2,913✔
737
      if not pastTop and not node.discardable and not node.explicit then
2,756✔
738
         -- "Ignore discardable and explicit glues at the top of a frame."
739
         -- See typesetter:outputLinesToPage()
740
         -- Note the test here doesn't check is_vglue, so will skip other
741
         -- discardable nodes (e.g. penalties), but it shouldn't matter
742
         -- for the type of computing performed here.
743
         pastTop = true
154✔
744
      end
745
      if pastTop then
2,756✔
746
         if not node.is_insertion then
2,551✔
747
            totalHeight:___add(node.height)
2,541✔
748
            totalHeight:___add(node.depth)
2,541✔
749
         end
750
         if node.is_vglue then
2,551✔
751
            table.insert(glues, node)
1,501✔
752
            gTotal:___add(node.height)
1,501✔
753
         end
754
      end
755
   end
756

757
   if totalHeight:tonumber() == 0 then
314✔
758
      return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
6✔
759
   end
760

761
   local adjustment = target - totalHeight
151✔
762
   if adjustment:tonumber() > 0 then
302✔
763
      if adjustment > gTotal.stretch then
135✔
764
         if
765
            (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber()
105✔
766
         then
767
            SU.warn(
34✔
768
               "Underfull frame "
769
                  .. self.frame.id
17✔
770
                  .. ": "
17✔
771
                  .. adjustment
17✔
772
                  .. " stretchiness required to fill but only "
17✔
773
                  .. gTotal.stretch
17✔
774
                  .. " available"
17✔
775
            )
776
         end
777
         adjustment = gTotal.stretch
21✔
778
      end
779
      if gTotal.stretch:tonumber() > 0 then
270✔
780
         for i = 1, #glues do
1,539✔
781
            local g = glues[i]
1,416✔
782
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
7,080✔
783
         end
784
      end
785
   elseif adjustment:tonumber() < 0 then
32✔
786
      adjustment = 0 - adjustment
16✔
787
      if adjustment > gTotal.shrink then
16✔
788
         if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
80✔
789
            SU.warn(
4✔
790
               "Overfull frame "
791
                  .. self.frame.id
2✔
792
                  .. ": "
2✔
793
                  .. adjustment
2✔
794
                  .. " shrinkability required to fit but only "
2✔
795
                  .. gTotal.shrink
2✔
796
                  .. " available"
2✔
797
            )
798
         end
799
         adjustment = gTotal.shrink
16✔
800
      end
801
      if gTotal.shrink:tonumber() > 0 then
32✔
802
         for i = 1, #glues do
×
803
            local g = glues[i]
×
804
            g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
805
         end
806
      end
807
   end
808
   SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
151✔
809
end
810

811
function typesetter:initNextFrame ()
108✔
812
   local oldframe = self.frame
54✔
813
   self.frame:leave(self)
54✔
814
   if #self.state.outputQueue == 0 then
54✔
815
      self.state.previousVbox = nil
35✔
816
   end
817
   if self.frame.next and self.state.lastPenalty > supereject_penalty then
54✔
818
      self:initFrame(SILE.getFrame(self.frame.next))
36✔
819
   elseif not self.frame:isMainContentFrame() then
84✔
820
      if #self.state.outputQueue > 0 then
15✔
821
         SU.warn("Overfull content for frame " .. self.frame.id)
2✔
822
         self:chuck()
2✔
823
      end
824
   else
825
      self:runHooks("pageend")
27✔
826
      SILE.documentState.documentClass:endPage()
27✔
827
      self:initFrame(SILE.documentState.documentClass:newPage())
54✔
828
   end
829

830
   if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
216✔
831
      self:pushBack()
7✔
832
      -- Some what of a hack below.
833
      -- Before calling this method, we were in vertical mode...
834
      -- pushback occurred, and it seems it messes up a bit...
835
      -- Regardless what it does, at the end, we ought to be in vertical mode
836
      -- again:
837
      self:leaveHmode()
14✔
838
   else
839
      -- If I have some things on the vertical list already, they need
840
      -- proper top-of-frame leading applied.
841
      if #self.state.outputQueue > 0 then
47✔
842
         local lead = self:leadingFor(self.state.outputQueue[1], nil)
15✔
843
         if lead then
15✔
844
            table.insert(self.state.outputQueue, 1, lead)
14✔
845
         end
846
      end
847
   end
848
   self:runHooks("newframe")
54✔
849
end
850

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

922
function typesetter:outputLinesToPage (lines)
108✔
923
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
215✔
924
   -- It would have been nice to avoid storing this "pastTop" into a frame
925
   -- state, to keep things less entangled. There are situations, though,
926
   -- we will have left horizontal mode (triggering output), but will later
927
   -- call typesetter:chuck() do deal with any remaining content, and we need
928
   -- to know whether some content has been output already.
929
   local pastTop = self.frame.state.totals.pastTop
215✔
930
   for _, line in ipairs(lines) do
3,137✔
931
      -- Ignore discardable and explicit glues at the top of a frame:
932
      -- Annoyingly, explicit glue *should* disappear at the top of a page.
933
      -- if you don't want that, add an empty vbox or something.
934
      if not pastTop and not line.discardable and not line.explicit then
2,922✔
935
         -- Note the test here doesn't check is_vglue, so will skip other
936
         -- discardable nodes (e.g. penalties), but it shouldn't matter
937
         -- for outputting.
938
         pastTop = true
207✔
939
      end
940
      if pastTop then
2,922✔
941
         line:outputYourself(self, line)
2,655✔
942
      end
943
   end
944
   self.frame.state.totals.pastTop = pastTop
215✔
945
end
946

947
function typesetter:leaveHmode (independent)
108✔
948
   if self.state.hmodeOnly then
2,045✔
949
      SU.error("Paragraphs are forbidden in restricted horizontal mode")
×
950
   end
951
   SU.debug("typesetter", "Leaving hmode")
2,045✔
952
   local margins = self:getMargins()
2,045✔
953
   local vboxlist = self:boxUpNodes()
2,045✔
954
   self.state.nodes = {}
2,045✔
955
   -- Push output lines into boxes and ship them to the page builder
956
   for _, vbox in ipairs(vboxlist) do
4,157✔
957
      vbox.margins = margins
2,112✔
958
      self:pushVertical(vbox)
2,112✔
959
   end
960
   if independent then
2,045✔
961
      return
202✔
962
   end
963
   if self:buildPage() then
3,686✔
964
      self:initNextFrame()
51✔
965
   end
966
end
967

968
function typesetter:inhibitLeading ()
108✔
969
   self.state.previousVbox = nil
×
970
end
971

972
function typesetter.leadingFor (_, vbox, previous)
108✔
973
   -- Insert leading
974
   SU.debug("typesetter", "   Considering leading between two lines:")
912✔
975
   SU.debug("typesetter", "   1)", previous)
912✔
976
   SU.debug("typesetter", "   2)", vbox)
912✔
977
   if not previous then
912✔
978
      return SILE.types.node.vglue()
214✔
979
   end
980
   local prevDepth = previous.depth
698✔
981
   SU.debug("typesetter", "   Depth of previous line was", prevDepth)
698✔
982
   local bls = SILE.settings:get("document.baselineskip")
698✔
983
   local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
3,490✔
984
   SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
698✔
985

986
   -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
987
   local lead = SILE.settings:get("document.lineskip").height:absolute()
1,396✔
988
   if depth > lead then
698✔
989
      return SILE.types.node.vglue(SILE.types.length(depth.length, bls.height.stretch, bls.height.shrink))
1,228✔
990
   else
991
      return SILE.types.node.vglue(lead)
84✔
992
   end
993
end
994

995
-- Beginning of liner logic (constructs spanning over several lines)
996

997
-- These two special nodes are used to track the current liner entry and exit.
998
-- As Sith Lords, they are always two: they are local here, so no one can
999
-- use one alone and break the balance of the Force.
1000
local linerEnterNode = pl.class(SILE.types.node.hbox)
108✔
1001
function linerEnterNode:_init (name, outputMethod)
108✔
1002
   SILE.types.node.hbox._init(self)
10✔
1003
   self.outputMethod = outputMethod
10✔
1004
   self.name = name
10✔
1005
   self.is_enter = true
10✔
1006
end
1007
function linerEnterNode:clone ()
108✔
1008
   return linerEnterNode(self.name, self.outputMethod)
4✔
1009
end
1010
function linerEnterNode:outputYourself ()
108✔
1011
   SU.error("A liner enter node " .. tostring(self) .. "'' made it to output", true)
×
1012
end
1013
function linerEnterNode:__tostring ()
108✔
1014
   return "+L[" .. self.name .. "]"
×
1015
end
1016
local linerLeaveNode = pl.class(SILE.types.node.hbox)
108✔
1017
function linerLeaveNode:_init (name)
108✔
1018
   SILE.types.node.hbox._init(self)
10✔
1019
   self.name = name
10✔
1020
   self.is_leave = true
10✔
1021
end
1022
function linerLeaveNode:clone ()
108✔
1023
   return linerLeaveNode(self.name)
×
1024
end
1025
function linerLeaveNode:outputYourself ()
108✔
1026
   SU.error("A liner leave node " .. tostring(self) .. "'' made it to output", true)
×
1027
end
1028
function linerLeaveNode:__tostring ()
108✔
1029
   return "-L[" .. self.name .. "]"
×
1030
end
1031

1032
local linerBox = pl.class(SILE.types.node.hbox)
108✔
1033
function linerBox:_init (name, outputMethod)
108✔
1034
   SILE.types.node.hbox._init(self)
10✔
1035
   self.width = SILE.types.length()
20✔
1036
   self.height = SILE.types.length()
20✔
1037
   self.depth = SILE.types.length()
20✔
1038
   self.name = name
10✔
1039
   self.inner = {}
10✔
1040
   self.outputYourself = outputMethod
10✔
1041
end
1042
function linerBox:append (node)
108✔
1043
   self.inner[#self.inner + 1] = node
63✔
1044
   if node.is_discretionary then
63✔
1045
      -- Discretionary nodes don't have a width of their own.
1046
      if node.used then
15✔
1047
         if node.is_prebreak then
2✔
1048
            self.width:___add(node:prebreakWidth())
3✔
1049
         else
1050
            self.width:___add(node:postbreakWidth())
2✔
1051
         end
1052
      else
1053
         self.width:___add(node:replacementWidth())
26✔
1054
      end
1055
   else
1056
      self.width:___add(node.width:absolute())
96✔
1057
   end
1058
   self.height = SU.max(self.height, node.height)
126✔
1059
   self.depth = SU.max(self.depth, node.depth)
126✔
1060
end
1061
function linerBox:count ()
108✔
1062
   return #self.inner
20✔
1063
end
1064
function linerBox:outputContent (tsetter, line)
108✔
1065
   for _, node in ipairs(self.inner) do
43✔
1066
      node.outputYourself(node, tsetter, line)
36✔
1067
   end
1068
end
1069
function linerBox:__tostring ()
108✔
1070
   return "*L["
×
1071
      .. self.name
×
1072
      .. "]H<"
×
1073
      .. tostring(self.width)
×
1074
      .. ">^"
×
1075
      .. tostring(self.height)
×
1076
      .. "-"
×
1077
      .. tostring(self.depth)
×
1078
      .. "v"
×
1079
end
1080

1081
--- Any unclosed liner is reopened on the current line, so we clone and repeat it.
1082
-- An assumption is that the inserts are done after the current slice content,
1083
-- supposed to be just before meaningful (visible) content.
1084
-- @tparam slice slice
1085
-- @treturn boolean Whether a liner was reopened
1086
function typesetter:_repeatEnterLiners (slice)
108✔
1087
   local m = self.state.liners
14,594✔
1088
   if #m > 0 then
14,594✔
1089
      for i = 1, #m do
7✔
1090
         local n = m[i]:clone()
4✔
1091
         slice[#slice + 1] = n
4✔
1092
         SU.debug("typesetter.liner", "Reopening liner", n)
4✔
1093
      end
1094
      return true
3✔
1095
   end
1096
   return false
14,591✔
1097
end
1098

1099
--- All pairs of liners are rebuilt as hboxes wrapping their content.
1100
-- Migrating content, however, must be kept outside the hboxes at top slice level.
1101
-- @tparam table slice Flat nodes from current line
1102
-- @treturn table New reboxed slice
1103
function typesetter._reboxLiners (_, slice)
108✔
1104
   local outSlice = {}
8✔
1105
   local migratingList = {}
8✔
1106
   local lboxStack = {}
8✔
1107
   for i = 1, #slice do
189✔
1108
      local node = slice[i]
181✔
1109
      if node.is_enter then
181✔
1110
         SU.debug("typesetter.liner", "Start reboxing", node)
10✔
1111
         local n = linerBox(node.name, node.outputMethod)
10✔
1112
         lboxStack[#lboxStack + 1] = n
10✔
1113
      elseif node.is_leave then
171✔
1114
         if #lboxStack == 0 then
10✔
1115
            SU.error("Multiliner box stacking mismatch" .. node)
×
1116
         elseif #lboxStack == 1 then
10✔
1117
            SU.debug("typesetter.liner", "End reboxing", node, "(toplevel) =", lboxStack[1]:count(), "nodes")
16✔
1118
            if lboxStack[1]:count() > 0 then
16✔
1119
               outSlice[#outSlice + 1] = lboxStack[1]
8✔
1120
            end
1121
         else
1122
            SU.debug("typesetter.liner", "End reboxing", node, "(sublevel) =", lboxStack[#lboxStack]:count(), "nodes")
4✔
1123
            if lboxStack[#lboxStack]:count() > 0 then
4✔
1124
               local hbox = lboxStack[#lboxStack - 1]
2✔
1125
               hbox:append(lboxStack[#lboxStack])
2✔
1126
            end
1127
         end
1128
         lboxStack[#lboxStack] = nil
10✔
1129
         pl.tablex.insertvalues(outSlice, migratingList)
10✔
1130
         migratingList = {}
10✔
1131
      else
1132
         if #lboxStack > 0 then
161✔
1133
            if not node.is_migrating then
61✔
1134
               local lbox = lboxStack[#lboxStack]
61✔
1135
               lbox:append(node)
122✔
1136
            else
1137
               migratingList[#migratingList + 1] = node
×
1138
            end
1139
         else
1140
            outSlice[#outSlice + 1] = node
100✔
1141
         end
1142
      end
1143
   end
1144
   return outSlice -- new reboxed slice
8✔
1145
end
1146

1147
--- Check if a node is a liner, and process it if so, in a stack.
1148
-- @tparam table node Current node (any type)
1149
-- @treturn boolean Whether a liner was opened
1150
function typesetter:_processIfLiner (node)
108✔
1151
   local entered = false
15,937✔
1152
   if node.is_enter then
15,937✔
1153
      SU.debug("typesetter.liner", "Enter liner", node)
6✔
1154
      self.state.liners[#self.state.liners + 1] = node
6✔
1155
      entered = true
6✔
1156
   elseif node.is_leave then
15,931✔
1157
      SU.debug("typesetter.liner", "Leave liner", node)
6✔
1158
      if #self.state.liners == 0 then
6✔
1159
         SU.error("Multiliner stack mismatch" .. node)
×
1160
      elseif self.state.liners[#self.state.liners].name == node.name then
6✔
1161
         self.state.liners[#self.state.liners].link = node -- for consistency check
6✔
1162
         self.state.liners[#self.state.liners] = nil
6✔
1163
      else
1164
         SU.error("Multiliner stack inconsistency" .. self.state.liners[#self.state.liners] .. "vs. " .. node)
×
1165
      end
1166
   end
1167
   return entered
15,937✔
1168
end
1169

1170
function typesetter:_repeatLeaveLiners (slice, insertIndex)
108✔
1171
   for _, v in ipairs(self.state.liners) do
950✔
1172
      if not v.link then
4✔
1173
         local n = linerLeaveNode(v.name)
4✔
1174
         SU.debug("typesetter.liner", "Closing liner", n)
4✔
1175
         table.insert(slice, insertIndex, n)
4✔
1176
      else
1177
         SU.error("Multiliner stack inconsistency" .. v)
×
1178
      end
1179
   end
1180
end
1181
-- End of liner logic
1182

1183
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
108✔
1184
   local LTR = self.frame:writingDirection() == "LTR"
1,904✔
1185
   local rskip = margins[LTR and "rskip" or "lskip"]
952✔
1186
   if not rskip then
952✔
1187
      rskip = SILE.types.node.glue(0)
×
1188
   end
1189
   if hangRight and hangRight > 0 then
952✔
1190
      rskip = SILE.types.node.glue({ width = rskip.width:tonumber() + hangRight })
63✔
1191
   end
1192
   rskip.value = "margin"
952✔
1193
   -- while slice[#slice].discardable do table.remove(slice, #slice) end
1194
   table.insert(slice, rskip)
952✔
1195
   table.insert(slice, SILE.types.node.zerohbox())
1,904✔
1196
   local lskip = margins[LTR and "lskip" or "rskip"]
952✔
1197
   if not lskip then
952✔
1198
      lskip = SILE.types.node.glue(0)
×
1199
   end
1200
   if hangLeft and hangLeft > 0 then
952✔
1201
      lskip = SILE.types.node.glue({ width = lskip.width:tonumber() + hangLeft })
102✔
1202
   end
1203
   lskip.value = "margin"
952✔
1204
   while slice[1].discardable do
958✔
1205
      table.remove(slice, 1)
12✔
1206
   end
1207
   table.insert(slice, 1, lskip)
952✔
1208
   table.insert(slice, 1, SILE.types.node.zerohbox())
1,904✔
1209
end
1210

1211
function typesetter:breakpointsToLines (breakpoints)
108✔
1212
   local linestart = 1
603✔
1213
   local lines = {}
603✔
1214
   local nodes = self.state.nodes
603✔
1215

1216
   for i = 1, #breakpoints do
1,561✔
1217
      local point = breakpoints[i]
958✔
1218
      if point.position ~= 0 then
958✔
1219
         local slice = {}
958✔
1220
         local seenNonDiscardable = false
958✔
1221
         local seenLiner = false
958✔
1222
         local lastContentNodeIndex
1223

1224
         for j = linestart, point.position do
16,895✔
1225
            local currentNode = nodes[j]
15,937✔
1226
            if
1227
               not currentNode.discardable
15,937✔
1228
               and not (currentNode.is_glue and not currentNode.explicit)
10,592✔
1229
               and not currentNode.is_zero
9,987✔
1230
            then
1231
               -- actual visible content starts here
1232
               lastContentNodeIndex = #slice + 1
9,378✔
1233
            end
1234
            if not seenLiner and lastContentNodeIndex then
15,937✔
1235
               -- Any stacked liner (unclosed from a previous line) is reopened on
1236
               -- the current line.
1237
               seenLiner = self:_repeatEnterLiners(slice)
29,188✔
1238
               lastContentNodeIndex = #slice + 1
14,594✔
1239
            end
1240
            if currentNode.is_discretionary and currentNode.used then
15,937✔
1241
               -- This is the used (prebreak) discretionary from a previous line,
1242
               -- repeated. Replace it with a clone, changed to a postbreak.
1243
               currentNode = currentNode:cloneAsPostbreak()
198✔
1244
            end
1245
            slice[#slice + 1] = currentNode
15,937✔
1246
            if currentNode then
15,937✔
1247
               if not currentNode.discardable then
15,937✔
1248
                  seenNonDiscardable = true
10,592✔
1249
               end
1250
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
31,874✔
1251
            end
1252
         end
1253
         if not seenNonDiscardable then
958✔
1254
            -- Slip lines containing only discardable nodes (e.g. glues).
1255
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
6✔
1256
            linestart = point.position + 1
6✔
1257
         else
1258
            if slice[#slice].is_discretionary then
952✔
1259
               -- The line ends, with a discretionary:
1260
               -- repeat it on the next line, so as to account for a potential postbreak.
1261
               linestart = point.position
99✔
1262
               -- And mark it as used as prebreak for now.
1263
               slice[#slice]:markAsPrebreak()
198✔
1264
            else
1265
               linestart = point.position + 1
853✔
1266
            end
1267

1268
            -- Any unclosed liner is closed on the next line in reverse order.
1269
            if lastContentNodeIndex then
952✔
1270
               self:_repeatLeaveLiners(slice, lastContentNodeIndex + 1)
946✔
1271
            end
1272

1273
            -- Then only we can add some extra margin glue...
1274
            local mrg = self:getMargins()
952✔
1275
            self:addrlskip(slice, mrg, point.left, point.right)
952✔
1276

1277
            -- And compute the line...
1278
            local ratio = self:computeLineRatio(point.width, slice)
952✔
1279

1280
            -- Re-shuffle liners, if any, into their own boxes.
1281
            if seenLiner then
952✔
1282
               slice = self:_reboxLiners(slice)
16✔
1283
            end
1284

1285
            local thisLine = { ratio = ratio, nodes = slice }
952✔
1286
            lines[#lines + 1] = thisLine
952✔
1287
         end
1288
      end
1289
   end
1290
   if linestart < #nodes then
603✔
1291
      -- Abnormal, but warn so that one has a chance to check which bits
1292
      -- are missing at output.
1293
      SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
1294
   end
1295
   return lines
603✔
1296
end
1297

1298
function typesetter.computeLineRatio (_, breakwidth, slice)
108✔
1299
   local naturalTotals = SILE.types.length()
952✔
1300

1301
   -- From the line end, account for the margin but skip any trailing
1302
   -- glues (spaces to ignore) and zero boxes until we reach actual content.
1303
   local npos = #slice
952✔
1304
   while npos > 1 do
3,099✔
1305
      if slice[npos].is_glue or slice[npos].is_zero then
3,099✔
1306
         if slice[npos].value == "margin" then
2,147✔
1307
            naturalTotals:___add(slice[npos].width)
952✔
1308
         end
1309
      else
1310
         break
1311
      end
1312
      npos = npos - 1
2,147✔
1313
   end
1314

1315
   -- Due to discretionaries, keep track of seen parent nodes
1316
   local seenNodes = {}
952✔
1317
   -- CODE SMELL: Not sure which node types were supposed to be skipped
1318
   -- at initial positions in the line!
1319
   local skipping = true
952✔
1320

1321
   -- Until end of actual content
1322
   for i = 1, npos do
18,546✔
1323
      local node = slice[i]
17,594✔
1324
      if node.is_box then
17,594✔
1325
         skipping = false
8,866✔
1326
         if node.parent and not node.parent.hyphenated then
8,866✔
1327
            if not seenNodes[node.parent] then
2,406✔
1328
               naturalTotals:___add(node.parent:lineContribution())
2,004✔
1329
            end
1330
            seenNodes[node.parent] = true
2,406✔
1331
         else
1332
            naturalTotals:___add(node:lineContribution())
12,920✔
1333
         end
1334
      elseif node.is_penalty and node.penalty == -inf_bad then
8,728✔
1335
         skipping = false
608✔
1336
      elseif node.is_discretionary then
8,120✔
1337
         skipping = false
1,741✔
1338
         local seen = node.parent and seenNodes[node.parent]
1,741✔
1339
         if not seen then
1,741✔
1340
            if node.used then
337✔
1341
               if node.is_prebreak then
198✔
1342
                  naturalTotals:___add(node:prebreakWidth())
198✔
1343
                  node.height = node:prebreakHeight()
198✔
1344
               else
1345
                  naturalTotals:___add(node:postbreakWidth())
198✔
1346
                  node.height = node:postbreakHeight()
198✔
1347
               end
1348
            else
1349
               naturalTotals:___add(node:replacementWidth():absolute())
417✔
1350
               node.height = node:replacementHeight():absolute()
417✔
1351
            end
1352
         end
1353
      elseif not skipping then
6,379✔
1354
         naturalTotals:___add(node.width)
6,379✔
1355
      end
1356
   end
1357

1358
   local _left = breakwidth:tonumber() - naturalTotals:tonumber()
2,856✔
1359
   local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
1,904✔
1360
   ratio = math.max(ratio, -1)
952✔
1361
   return ratio, naturalTotals
952✔
1362
end
1363

1364
function typesetter:chuck () -- emergency shipout everything
108✔
1365
   self:leaveHmode(true)
66✔
1366
   if #self.state.outputQueue > 0 then
66✔
1367
      SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
53✔
1368
      self:outputLinesToPage(self.state.outputQueue)
53✔
1369
      self.state.outputQueue = {}
53✔
1370
   end
1371
end
1372

1373
-- Logic for building an hbox from content.
1374
-- It returns the hbox and an horizontal list of (migrating) elements
1375
-- extracted outside of it.
1376
-- None of these are pushed to the typesetter node queue. The caller
1377
-- is responsible of doing it, if the hbox is built for anything
1378
-- else than e.g. measuring it. Likewise, the call has to decide
1379
-- what to do with the migrating content.
1380
local _rtl_pre_post = function (box, atypesetter, line)
1381
   local advance = function ()
1382
      atypesetter.frame:advanceWritingDirection(box:scaledWidth(line))
728✔
1383
   end
1384
   if atypesetter.frame:writingDirection() == "RTL" then
728✔
1385
      advance()
×
1386
      return function () end
×
1387
   else
1388
      return advance
364✔
1389
   end
1390
end
1391
function typesetter:makeHbox (content)
108✔
1392
   local recentContribution = {}
140✔
1393
   local migratingNodes = {}
140✔
1394

1395
   self:pushState()
140✔
1396
   self.state.hmodeOnly = true
140✔
1397
   SILE.process(content)
140✔
1398

1399
   -- We must do a first pass for shaping the nnodes:
1400
   -- This is also where italic correction may occur.
1401
   local nodes = self:shapeAllNodes(self.state.nodes, false)
140✔
1402

1403
   -- Then we can process and measure the nodes.
1404
   local l = SILE.types.length()
140✔
1405
   local h, d = SILE.types.length(), SILE.types.length()
280✔
1406
   for i = 1, #nodes do
397✔
1407
      local node = nodes[i]
257✔
1408
      if node.is_migrating then
257✔
1409
         migratingNodes[#migratingNodes + 1] = node
×
1410
      elseif node.is_discretionary then
257✔
1411
         -- HACK https://github.com/sile-typesetter/sile/issues/583
1412
         -- Discretionary nodes have a null line contribution...
1413
         -- But if discretionary nodes occur inside an hbox, since the latter
1414
         -- is not line-broken, they will never be marked as 'used' and will
1415
         -- evaluate to the replacement content (if any)...
1416
         recentContribution[#recentContribution + 1] = node
×
1417
         l = l + node:replacementWidth():absolute()
×
1418
         -- The replacement content may have ascenders and descenders...
1419
         local hdisc = node:replacementHeight():absolute()
×
1420
         local ddisc = node:replacementDepth():absolute()
×
1421
         h = hdisc > h and hdisc or h
×
1422
         d = ddisc > d and ddisc or d
×
1423
      -- By the way it's unclear how this is expected to work in TTB
1424
      -- writing direction. For other type of nodes, the line contribution
1425
      -- evaluates to the height rather than the width in TTB, but the
1426
      -- whole logic might then be dubious there too...
1427
      else
1428
         recentContribution[#recentContribution + 1] = node
257✔
1429
         l = l + node:lineContribution():absolute()
771✔
1430
         h = node.height > h and node.height or h
374✔
1431
         d = node.depth > d and node.depth or d
352✔
1432
      end
1433
   end
1434
   self:popState()
140✔
1435

1436
   local hbox = SILE.types.node.hbox({
280✔
1437
      height = h,
140✔
1438
      width = l,
140✔
1439
      depth = d,
140✔
1440
      value = recentContribution,
140✔
1441
      outputYourself = function (box, atypesetter, line)
1442
         local _post = _rtl_pre_post(box, atypesetter, line)
364✔
1443
         local ox = atypesetter.frame.state.cursorX
364✔
1444
         local oy = atypesetter.frame.state.cursorY
364✔
1445
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
364✔
1446
         SU.debug("hboxes", function ()
728✔
1447
            -- setCursor is also invoked by the internal (wrapped) hboxes etc.
1448
            -- so we must show our debug box before outputting its content.
1449
            SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
1450
            return "Drew debug outline around hbox"
×
1451
         end)
1452
         for _, node in ipairs(box.value) do
1,104✔
1453
            node:outputYourself(atypesetter, line)
740✔
1454
         end
1455
         atypesetter.frame.state.cursorX = ox
364✔
1456
         atypesetter.frame.state.cursorY = oy
364✔
1457
         _post()
364✔
1458
      end,
1459
   })
1460
   return hbox, migratingNodes
140✔
1461
end
1462

1463
function typesetter:pushHlist (hlist)
108✔
1464
   for _, h in ipairs(hlist) do
12✔
1465
      self:pushHorizontal(h)
×
1466
   end
1467
end
1468

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

1503
return typesetter
108✔
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