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

sile-typesetter / sile / 11770252752

11 Nov 2024 01:09AM UTC coverage: 32.853% (-27.5%) from 60.402%
11770252752

Pull #2164

github

web-flow
chore(deps): Bump DeterminateSystems/nix-installer-action from 14 to 15

Bumps [DeterminateSystems/nix-installer-action](https://github.com/determinatesystems/nix-installer-action) from 14 to 15.
- [Release notes](https://github.com/determinatesystems/nix-installer-action/releases)
- [Commits](https://github.com/determinatesystems/nix-installer-action/compare/v14...v15)

---
updated-dependencies:
- dependency-name: DeterminateSystems/nix-installer-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Pull Request #2164: chore(deps): Bump DeterminateSystems/nix-installer-action from 14 to 15

5855 of 17822 relevant lines covered (32.85%)

2493.73 hits per line

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

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

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

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

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

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

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

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

31
local warned = false
4✔
32

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

291
-- Actual typesetting functions
292
function typesetter:typeset (text)
4✔
293
   text = tostring(text)
57✔
294
   if text:match("^%\r?\n$") then
57✔
295
      return
18✔
296
   end
297
   local pId = SILE.traceStack:pushText(text)
39✔
298
   local parsepattern = SILE.settings:get("typesetter.parseppattern")
39✔
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"
39✔
302

303
   local seenParaContent = true
39✔
304
   for token in SU.gtoke(text, parsepattern) do
147✔
305
      if token.separator then
69✔
306
         if obeylines and not seenParaContent then
25✔
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
19✔
313
         end
314
         self:endline()
50✔
315
      else
316
         seenParaContent = true
44✔
317
         if SILE.settings:get("typesetter.softHyphen") then
88✔
318
            local warnedshy = false
44✔
319
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
132✔
320
               if token2.separator then -- soft hyphen support
44✔
321
                  local discretionary = SILE.types.node.discretionary({})
×
322
                  local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
×
323
                  discretionary.prebreak = { hbox }
×
324
                  table.insert(SILE.typesetter.state.nodes, discretionary)
×
325
                  if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
×
326
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
327
                  end
328
                  warnedshy = true
×
329
               else
330
                  self:setpar(token2.string)
44✔
331
               end
332
            end
333
         else
334
            if
335
               SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD))
×
336
            then
337
               SU.warn("Soft hyphen encountered and ignored")
×
338
            end
339
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
×
340
            self:setpar(text)
×
341
         end
342
      end
343
   end
344
   SILE.traceStack:pop(pId)
39✔
345
end
346

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

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

370
-- Just compute once, to avoid unicode characters in source code.
371
local speakerChangePattern = "^"
×
372
   .. luautf8.char(0x2014) -- emdash
4✔
373
   .. "[ "
×
374
   .. luautf8.char(0x00A0)
4✔
375
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
4✔
376
   .. "]+"
4✔
377
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
4✔
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)
4✔
382
function speakerChangeNode:shape ()
4✔
383
   local node = self._base.shape(self)
×
384
   local spc = node[2]
×
385
   if spc and spc.is_glue then
×
386
      -- Switch the variable space glue to a fixed kern
387
      node[2] = SILE.types.node.kern({ width = spc.width.length })
×
388
      node[2].parent = self.parent
×
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
×
395
end
396

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

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

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

433
local function getLastShape (nodelist)
434
   local hasGlue
435
   local last
436
   if nodelist then
×
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
×
440
         local n = nodelist[i]
×
441
         if n.is_nnode then
×
442
            local items = n.nodes[#n.nodes].value.items
×
443
            last = items[#items]
×
444
            break
445
         end
446
         if n.is_kern and n.subtype == "punctspace" then
×
447
            -- Some languages such as French insert a special space around
448
            -- punctuations. In those case, we should not need italic correction.
449
            break
450
         end
451
         if n.is_glue then
×
452
            hasGlue = true
×
453
         end
454
      end
455
   end
456
   return last, hasGlue
×
457
end
458
local function getFirstShape (nodelist)
459
   local first
460
   local hasGlue
461
   if nodelist then
×
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
×
465
         local n = nodelist[i]
×
466
         if n.is_nnode then
×
467
            local items = n.nodes[1].value.items
×
468
            first = items[1]
×
469
            break
470
         end
471
         if n.is_kern and n.subtype == "punctspace" then
×
472
            -- Some languages such as French insert a special space around
473
            -- punctuations. In those case, we should not need italic correction.
474
            break
475
         end
476
         if n.is_glue then
×
477
            hasGlue = true
×
478
         end
479
      end
480
   end
481
   return first, hasGlue
×
482
end
483

484
local function fromItalicCorrection (precShape, curShape)
485
   local xOffset
486
   if not curShape or not precShape then
×
487
      xOffset = 0
×
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
×
501
      local delta = d > precShape.width and d - precShape.width or 0
×
502
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
×
503
   end
504
   return xOffset
×
505
end
506

507
local function toItalicCorrection (precShape, curShape)
508
   if not SILE.settings:get("typesetter.italicCorrection") then
×
509
      return
×
510
   end
511
   local xOffset
512
   if not curShape or not precShape then
×
513
      xOffset = 0
×
514
   else
515
      -- Same assumptions as fromItalicCorrection(), but on the starting side of
516
      -- the glyph.
517
      local d = curShape.x_bearing
×
518
      local delta = d < 0 and -d or 0
×
519
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
×
520
   end
521
   return xOffset
×
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")
×
530
   local face = SILE.font.cache(nnode.options, SILE.shaper.getFace)
×
531
   local font = ot.parseFont(face)
×
532
   return font.post.italicAngle ~= 0
×
533
end
534

535
function typesetter.shapeAllNodes (_, nodelist, inplace)
4✔
536
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
390✔
537
   local newNodelist = {}
195✔
538
   local prec
539
   local precShapedNodes
540
   for _, current in ipairs(nodelist) do
2,266✔
541
      if current.is_unshaped then
2,071✔
542
         local shapedNodes = current:shape()
44✔
543

544
         if SILE.settings:get("typesetter.italicCorrection") and prec then
88✔
545
            local itCorrOffset
546
            local isGlue
547
            if isItalicLike(prec) and not isItalicLike(current) then
×
548
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
549
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
550
               isGlue = precHasGlue or curHasGlue
×
551
               itCorrOffset = fromItalicCorrection(precShape, curShape)
×
552
            elseif not isItalicLike(prec) and isItalicLike(current) then
×
553
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
554
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
555
               isGlue = precHasGlue or curHasGlue
×
556
               itCorrOffset = toItalicCorrection(precShape, curShape)
×
557
            end
558
            if itCorrOffset and itCorrOffset ~= 0 then
×
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
×
565
               newNodelist[#newNodelist + 1] = makeItCorrNode({
×
566
                  width = SILE.types.length(itCorrOffset),
567
                  subtype = "itcorr",
568
               })
569
            end
570
         end
571

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

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

582
   if not inplace then
195✔
583
      return newNodelist
12✔
584
   end
585

586
   for i = 1, #newNodelist do
2,952✔
587
      nodelist[i] = newNodelist[i]
2,769✔
588
   end
589
   if #nodelist > #newNodelist then
183✔
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 ()
4✔
599
   local nodelist = self.state.nodes
181✔
600
   if #nodelist == 0 then
181✔
601
      return {}
120✔
602
   end
603
   for j = #nodelist, 1, -1 do
74✔
604
      if not nodelist[j].is_migrating then
74✔
605
         if nodelist[j].discardable then
74✔
606
            table.remove(nodelist, j)
26✔
607
         else
608
            break
609
         end
610
      end
611
   end
612
   while #nodelist > 0 and nodelist[1].is_penalty do
61✔
613
      table.remove(nodelist, 1)
×
614
   end
615
   if #nodelist == 0 then
61✔
616
      return {}
×
617
   end
618
   self:shapeAllNodes(nodelist)
61✔
619
   local parfillskip = SILE.settings:get("typesetter.parfillskip")
61✔
620
   parfillskip.discardable = false
61✔
621
   self:pushGlue(parfillskip)
61✔
622
   self:pushPenalty(-inf_bad)
61✔
623
   SU.debug("typesetter", function ()
122✔
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()
122✔
627
   local lines = self:breakIntoLines(nodelist, breakWidth)
61✔
628
   local vboxes = {}
61✔
629
   for index = 1, #lines do
151✔
630
      local line = lines[index]
90✔
631
      local migrating = {}
90✔
632
      -- Move any migrating material
633
      local nodes = {}
90✔
634
      for i = 1, #line.nodes do
1,893✔
635
         local node = line.nodes[i]
1,803✔
636
         if node.is_migrating then
1,803✔
637
            for j = 1, #node.material do
4✔
638
               migrating[#migrating + 1] = node.material[j]
2✔
639
            end
640
         else
641
            nodes[#nodes + 1] = node
1,801✔
642
         end
643
      end
644
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
90✔
645
      local pageBreakPenalty = 0
90✔
646
      if #lines > 1 and index == 1 then
90✔
647
         pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
8✔
648
      elseif #lines > 1 and index == (#lines - 1) then
86✔
649
         pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
8✔
650
      end
651
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
180✔
652
      vboxes[#vboxes + 1] = vbox
90✔
653
      for i = 1, #migrating do
92✔
654
         vboxes[#vboxes + 1] = migrating[i]
2✔
655
      end
656
      self.state.previousVbox = vbox
90✔
657
      if pageBreakPenalty > 0 then
90✔
658
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
8✔
659
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
16✔
660
      end
661
   end
662
   return vboxes
61✔
663
end
664

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

669
function typesetter:getTargetLength ()
4✔
670
   return self.frame:getTargetLength()
169✔
671
end
672

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

680
function typesetter:runHooks (category, data)
4✔
681
   if not self.hooks[category] then
188✔
682
      return data
152✔
683
   end
684
   for _, func in ipairs(self.hooks[category]) do
89✔
685
      data = func(self, data)
106✔
686
   end
687
   return data
36✔
688
end
689

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

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

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

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

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

735
   local pastTop = false
31✔
736
   for _, node in ipairs(pageNodeList) do
218✔
737
      if not pastTop and not node.discardable and not node.explicit then
187✔
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
31✔
744
      end
745
      if pastTop then
187✔
746
         if not node.is_insertion then
149✔
747
            totalHeight:___add(node.height)
149✔
748
            totalHeight:___add(node.depth)
149✔
749
         end
750
         if node.is_vglue then
149✔
751
            table.insert(glues, node)
77✔
752
            gTotal:___add(node.height)
77✔
753
         end
754
      end
755
   end
756

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

761
   local adjustment = target - totalHeight
28✔
762
   if adjustment:tonumber() > 0 then
56✔
763
      if adjustment > gTotal.stretch then
14✔
764
         if
765
            (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber()
5✔
766
         then
767
            SU.warn(
2✔
768
               "Underfull frame "
769
                  .. self.frame.id
1✔
770
                  .. ": "
1✔
771
                  .. adjustment
1✔
772
                  .. " stretchiness required to fill but only "
1✔
773
                  .. gTotal.stretch
1✔
774
                  .. " available"
1✔
775
            )
776
         end
777
         adjustment = gTotal.stretch
1✔
778
      end
779
      if gTotal.stretch:tonumber() > 0 then
28✔
780
         for i = 1, #glues do
84✔
781
            local g = glues[i]
70✔
782
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
350✔
783
         end
784
      end
785
   elseif adjustment:tonumber() < 0 then
28✔
786
      adjustment = 0 - adjustment
14✔
787
      if adjustment > gTotal.shrink then
14✔
788
         if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
70✔
789
            SU.warn(
×
790
               "Overfull frame "
791
                  .. self.frame.id
×
792
                  .. ": "
×
793
                  .. adjustment
×
794
                  .. " shrinkability required to fit but only "
×
795
                  .. gTotal.shrink
×
796
                  .. " available"
×
797
            )
798
         end
799
         adjustment = gTotal.shrink
14✔
800
      end
801
      if gTotal.shrink:tonumber() > 0 then
28✔
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)
28✔
809
end
810

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

830
   if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
108✔
831
      self:pushBack()
×
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()
×
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
27✔
842
         local lead = self:leadingFor(self.state.outputQueue[1], nil)
6✔
843
         if lead then
6✔
844
            table.insert(self.state.outputQueue, 1, lead)
6✔
845
         end
846
      end
847
   end
848
   self:runHooks("newframe")
27✔
849
end
850

851
function typesetter:pushBack ()
4✔
852
   SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
×
853
   local oldqueue = self.state.outputQueue
×
854
   self.state.outputQueue = {}
×
855
   self.state.previousVbox = nil
×
856
   local lastMargins = self:getMargins()
×
857
   for _, vbox in ipairs(oldqueue) do
×
858
      SU.debug("pushback", "process box", vbox)
×
859
      if vbox.margins and vbox.margins ~= lastMargins then
×
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
×
867
         SU.debug("pushback", "explicit", vbox)
×
868
         self:endline()
×
869
         self:pushExplicitVglue(vbox)
×
870
      elseif vbox.is_insertion then
×
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
×
874
         SU.debug("pushback", "not vglue or penalty", vbox.type)
×
875
         local discardedFistInitLine = false
×
876
         if #self.state.nodes == 0 then
×
877
            -- Setup queue but avoid calling newPar
878
            self.state.nodes[#self.state.nodes + 1] = SILE.types.node.zerohbox()
×
879
         end
880
         for i, node in ipairs(vbox.nodes) do
×
881
            if node.is_glue and not node.discardable then
×
882
               self:pushHorizontal(node)
×
883
            elseif node.is_glue and node.value == "margin" then
×
884
               SU.debug("pushback", "discard", node.value, node)
×
885
            elseif node.is_discretionary then
×
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
×
895
               if discardedFistInitLine then
×
896
                  self:pushHorizontal(node)
×
897
               end
898
               discardedFistInitLine = true
×
899
            elseif node.is_penalty then
×
900
               if not discardedFistInitLine then
×
901
                  self:pushHorizontal(node)
×
902
               end
903
            else
904
               node.bidiDone = true
×
905
               self:pushHorizontal(node)
×
906
            end
907
         end
908
      else
909
         SU.debug("pushback", "discard", vbox.type)
×
910
      end
911
      lastMargins = vbox.margins
×
912
      -- self:debugState()
913
   end
914
   while
×
915
      self.state.nodes[#self.state.nodes]
×
916
      and (self.state.nodes[#self.state.nodes].is_penalty or self.state.nodes[#self.state.nodes].is_zero)
×
917
   do
918
      self.state.nodes[#self.state.nodes] = nil
×
919
   end
920
end
921

922
function typesetter:outputLinesToPage (lines)
4✔
923
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
32✔
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
32✔
930
   for _, line in ipairs(lines) do
222✔
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
190✔
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
32✔
939
      end
940
      if pastTop then
190✔
941
         line:outputYourself(self, line)
151✔
942
      end
943
   end
944
   self.frame.state.totals.pastTop = pastTop
32✔
945
end
946

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

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

972
function typesetter.leadingFor (_, vbox, previous)
4✔
973
   -- Insert leading
974
   SU.debug("typesetter", "   Considering leading between two lines:")
96✔
975
   SU.debug("typesetter", "   1)", previous)
96✔
976
   SU.debug("typesetter", "   2)", vbox)
96✔
977
   if not previous then
96✔
978
      return SILE.types.node.vglue()
33✔
979
   end
980
   local prevDepth = previous.depth
63✔
981
   SU.debug("typesetter", "   Depth of previous line was", prevDepth)
63✔
982
   local bls = SILE.settings:get("document.baselineskip")
63✔
983
   local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
315✔
984
   SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
63✔
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()
126✔
988
   if depth > lead then
63✔
989
      return SILE.types.node.vglue(SILE.types.length(depth.length, bls.height.stretch, bls.height.shrink))
126✔
990
   else
991
      return SILE.types.node.vglue(lead)
×
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)
4✔
1001
function linerEnterNode:_init (name, outputMethod)
4✔
1002
   SILE.types.node.hbox._init(self)
×
1003
   self.outputMethod = outputMethod
×
1004
   self.name = name
×
1005
   self.is_enter = true
×
1006
end
1007
function linerEnterNode:clone ()
4✔
1008
   return linerEnterNode(self.name, self.outputMethod)
×
1009
end
1010
function linerEnterNode:outputYourself ()
4✔
1011
   SU.error("A liner enter node " .. tostring(self) .. "'' made it to output", true)
×
1012
end
1013
function linerEnterNode:__tostring ()
4✔
1014
   return "+L[" .. self.name .. "]"
×
1015
end
1016
local linerLeaveNode = pl.class(SILE.types.node.hbox)
4✔
1017
function linerLeaveNode:_init (name)
4✔
1018
   SILE.types.node.hbox._init(self)
×
1019
   self.name = name
×
1020
   self.is_leave = true
×
1021
end
1022
function linerLeaveNode:clone ()
4✔
1023
   return linerLeaveNode(self.name)
×
1024
end
1025
function linerLeaveNode:outputYourself ()
4✔
1026
   SU.error("A liner leave node " .. tostring(self) .. "'' made it to output", true)
×
1027
end
1028
function linerLeaveNode:__tostring ()
4✔
1029
   return "-L[" .. self.name .. "]"
×
1030
end
1031

1032
local linerBox = pl.class(SILE.types.node.hbox)
4✔
1033
function linerBox:_init (name, outputMethod)
4✔
1034
   SILE.types.node.hbox._init(self)
×
1035
   self.width = SILE.types.length()
×
1036
   self.height = SILE.types.length()
×
1037
   self.depth = SILE.types.length()
×
1038
   self.name = name
×
1039
   self.inner = {}
×
1040
   self.outputYourself = outputMethod
×
1041
end
1042
function linerBox:append (node)
4✔
1043
   self.inner[#self.inner + 1] = node
×
1044
   if node.is_discretionary then
×
1045
      -- Discretionary nodes don't have a width of their own.
1046
      if node.used then
×
1047
         if node.is_prebreak then
×
1048
            self.width:___add(node:prebreakWidth())
×
1049
         else
1050
            self.width:___add(node:postbreakWidth())
×
1051
         end
1052
      else
1053
         self.width:___add(node:replacementWidth())
×
1054
      end
1055
   else
1056
      self.width:___add(node.width:absolute())
×
1057
   end
1058
   self.height = SU.max(self.height, node.height)
×
1059
   self.depth = SU.max(self.depth, node.depth)
×
1060
end
1061
function linerBox:count ()
4✔
1062
   return #self.inner
×
1063
end
1064
function linerBox:outputContent (tsetter, line)
4✔
1065
   for _, node in ipairs(self.inner) do
×
1066
      node.outputYourself(node, tsetter, line)
×
1067
   end
1068
end
1069
function linerBox:__tostring ()
4✔
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)
4✔
1087
   local m = self.state.liners
1,293✔
1088
   if #m > 0 then
1,293✔
1089
      for i = 1, #m do
×
1090
         local n = m[i]:clone()
×
1091
         slice[#slice + 1] = n
×
1092
         SU.debug("typesetter.liner", "Reopening liner", n)
×
1093
      end
1094
      return true
×
1095
   end
1096
   return false
1,293✔
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)
4✔
1104
   local outSlice = {}
×
1105
   local migratingList = {}
×
1106
   local lboxStack = {}
×
1107
   for i = 1, #slice do
×
1108
      local node = slice[i]
×
1109
      if node.is_enter then
×
1110
         SU.debug("typesetter.liner", "Start reboxing", node)
×
1111
         local n = linerBox(node.name, node.outputMethod)
×
1112
         lboxStack[#lboxStack + 1] = n
×
1113
      elseif node.is_leave then
×
1114
         if #lboxStack == 0 then
×
1115
            SU.error("Multiliner box stacking mismatch" .. node)
×
1116
         elseif #lboxStack == 1 then
×
1117
            SU.debug("typesetter.liner", "End reboxing", node, "(toplevel) =", lboxStack[1]:count(), "nodes")
×
1118
            if lboxStack[1]:count() > 0 then
×
1119
               outSlice[#outSlice + 1] = lboxStack[1]
×
1120
            end
1121
         else
1122
            SU.debug("typesetter.liner", "End reboxing", node, "(sublevel) =", lboxStack[#lboxStack]:count(), "nodes")
×
1123
            if lboxStack[#lboxStack]:count() > 0 then
×
1124
               local hbox = lboxStack[#lboxStack - 1]
×
1125
               hbox:append(lboxStack[#lboxStack])
×
1126
            end
1127
         end
1128
         lboxStack[#lboxStack] = nil
×
1129
         pl.tablex.insertvalues(outSlice, migratingList)
×
1130
         migratingList = {}
×
1131
      else
1132
         if #lboxStack > 0 then
×
1133
            if not node.is_migrating then
×
1134
               local lbox = lboxStack[#lboxStack]
×
1135
               lbox:append(node)
×
1136
            else
1137
               migratingList[#migratingList + 1] = node
×
1138
            end
1139
         else
1140
            outSlice[#outSlice + 1] = node
×
1141
         end
1142
      end
1143
   end
1144
   return outSlice -- new reboxed slice
×
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)
4✔
1151
   local entered = false
1,445✔
1152
   if node.is_enter then
1,445✔
1153
      SU.debug("typesetter.liner", "Enter liner", node)
×
1154
      self.state.liners[#self.state.liners + 1] = node
×
1155
      entered = true
×
1156
   elseif node.is_leave then
1,445✔
1157
      SU.debug("typesetter.liner", "Leave liner", node)
×
1158
      if #self.state.liners == 0 then
×
1159
         SU.error("Multiliner stack mismatch" .. node)
×
1160
      elseif self.state.liners[#self.state.liners].name == node.name then
×
1161
         self.state.liners[#self.state.liners].link = node -- for consistency check
×
1162
         self.state.liners[#self.state.liners] = nil
×
1163
      else
1164
         SU.error("Multiliner stack inconsistency" .. self.state.liners[#self.state.liners] .. "vs. " .. node)
×
1165
      end
1166
   end
1167
   return entered
1,445✔
1168
end
1169

1170
function typesetter:_repeatLeaveLiners (slice, insertIndex)
4✔
1171
   for _, v in ipairs(self.state.liners) do
84✔
1172
      if not v.link then
×
1173
         local n = linerLeaveNode(v.name)
×
1174
         SU.debug("typesetter.liner", "Closing liner", n)
×
1175
         table.insert(slice, insertIndex, n)
×
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)
4✔
1184
   local LTR = self.frame:writingDirection() == "LTR"
180✔
1185
   local rskip = margins[LTR and "rskip" or "lskip"]
90✔
1186
   if not rskip then
90✔
1187
      rskip = SILE.types.node.glue(0)
×
1188
   end
1189
   if hangRight and hangRight > 0 then
90✔
1190
      rskip = SILE.types.node.glue({ width = rskip.width:tonumber() + hangRight })
×
1191
   end
1192
   rskip.value = "margin"
90✔
1193
   -- while slice[#slice].discardable do table.remove(slice, #slice) end
1194
   table.insert(slice, rskip)
90✔
1195
   table.insert(slice, SILE.types.node.zerohbox())
180✔
1196
   local lskip = margins[LTR and "lskip" or "rskip"]
90✔
1197
   if not lskip then
90✔
1198
      lskip = SILE.types.node.glue(0)
×
1199
   end
1200
   if hangLeft and hangLeft > 0 then
90✔
1201
      lskip = SILE.types.node.glue({ width = lskip.width:tonumber() + hangLeft })
×
1202
   end
1203
   lskip.value = "margin"
90✔
1204
   while slice[1].discardable do
90✔
1205
      table.remove(slice, 1)
×
1206
   end
1207
   table.insert(slice, 1, lskip)
90✔
1208
   table.insert(slice, 1, SILE.types.node.zerohbox())
180✔
1209
end
1210

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

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

1224
         for j = linestart, point.position do
1,421✔
1225
            local currentNode = nodes[j]
1,347✔
1226
            if
1227
               not currentNode.discardable
1,347✔
1228
               and not (currentNode.is_glue and not currentNode.explicit)
937✔
1229
               and not currentNode.is_zero
894✔
1230
            then
1231
               -- actual visible content starts here
1232
               lastContentNodeIndex = #slice + 1
851✔
1233
            end
1234
            if not seenLiner and lastContentNodeIndex then
1,347✔
1235
               -- Any stacked liner (unclosed from a previous line) is reopened on
1236
               -- the current line.
1237
               seenLiner = self:_repeatEnterLiners(slice)
2,514✔
1238
               lastContentNodeIndex = #slice + 1
1,257✔
1239
            end
1240
            if currentNode.is_discretionary and currentNode.used then
1,347✔
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()
14✔
1244
            end
1245
            slice[#slice + 1] = currentNode
1,347✔
1246
            if currentNode then
1,347✔
1247
               if not currentNode.discardable then
1,347✔
1248
                  seenNonDiscardable = true
937✔
1249
               end
1250
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
2,694✔
1251
            end
1252
         end
1253
         if not seenNonDiscardable then
74✔
1254
            -- Slip lines containing only discardable nodes (e.g. glues).
1255
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
2✔
1256
            linestart = point.position + 1
2✔
1257
         else
1258
            if slice[#slice].is_discretionary then
72✔
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
7✔
1262
               -- And mark it as used as prebreak for now.
1263
               slice[#slice]:markAsPrebreak()
14✔
1264
            else
1265
               linestart = point.position + 1
65✔
1266
            end
1267

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

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

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

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

1285
            local thisLine = { ratio = ratio, nodes = slice }
72✔
1286
            lines[#lines + 1] = thisLine
72✔
1287
         end
1288
      end
1289
   end
1290
   if linestart < #nodes then
43✔
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
43✔
1296
end
1297

1298
function typesetter.computeLineRatio (_, breakwidth, slice)
3✔
1299
   local naturalTotals = SILE.types.length()
72✔
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
72✔
1304
   while npos > 1 do
240✔
1305
      if slice[npos].is_glue or slice[npos].is_zero then
240✔
1306
         if slice[npos].value == "margin" then
168✔
1307
            naturalTotals:___add(slice[npos].width)
72✔
1308
         end
1309
      else
1310
         break
1311
      end
1312
      npos = npos - 1
168✔
1313
   end
1314

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

1321
   -- Until end of actual content
1322
   for i = 1, npos do
1,537✔
1323
      local node = slice[i]
1,465✔
1324
      if node.is_box then
1,465✔
1325
         skipping = false
740✔
1326
         if node.parent and not node.parent.hyphenated then
740✔
1327
            if not seenNodes[node.parent] then
356✔
1328
               naturalTotals:___add(node.parent:lineContribution())
298✔
1329
            end
1330
            seenNodes[node.parent] = true
356✔
1331
         else
1332
            naturalTotals:___add(node:lineContribution())
768✔
1333
         end
1334
      elseif node.is_penalty and node.penalty == -inf_bad then
725✔
1335
         skipping = false
41✔
1336
      elseif node.is_discretionary then
684✔
1337
         skipping = false
226✔
1338
         local seen = node.parent and seenNodes[node.parent]
226✔
1339
         if not seen then
226✔
1340
            if node.used then
19✔
1341
               if node.is_prebreak then
14✔
1342
                  naturalTotals:___add(node:prebreakWidth())
14✔
1343
                  node.height = node:prebreakHeight()
14✔
1344
               else
1345
                  naturalTotals:___add(node:postbreakWidth())
14✔
1346
                  node.height = node:postbreakHeight()
14✔
1347
               end
1348
            else
1349
               naturalTotals:___add(node:replacementWidth():absolute())
15✔
1350
               node.height = node:replacementHeight():absolute()
15✔
1351
            end
1352
         end
1353
      elseif not skipping then
458✔
1354
         naturalTotals:___add(node.width)
458✔
1355
      end
1356
   end
1357

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

1364
function typesetter:chuck () -- emergency shipout everything
3✔
1365
   self:leaveHmode(true)
13✔
1366
   if #self.state.outputQueue > 0 then
13✔
1367
      SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
1✔
1368
      self:outputLinesToPage(self.state.outputQueue)
1✔
1369
      self.state.outputQueue = {}
1✔
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))
22✔
1383
   end
1384
   if atypesetter.frame:writingDirection() == "RTL" then
22✔
1385
      advance()
×
1386
      return function () end
×
1387
   else
1388
      return advance
11✔
1389
   end
1390
end
1391
function typesetter:makeHbox (content)
3✔
1392
   local recentContribution = {}
12✔
1393
   local migratingNodes = {}
12✔
1394

1395
   self:pushState()
12✔
1396
   self.state.hmodeOnly = true
12✔
1397
   SILE.process(content)
12✔
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)
12✔
1402

1403
   -- Then we can process and measure the nodes.
1404
   local l = SILE.types.length()
12✔
1405
   local h, d = SILE.types.length(), SILE.types.length()
24✔
1406
   for i = 1, #nodes do
12✔
1407
      local node = nodes[i]
×
1408
      if node.is_migrating then
×
1409
         migratingNodes[#migratingNodes + 1] = node
×
1410
      elseif node.is_discretionary then
×
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
×
1429
         l = l + node:lineContribution():absolute()
×
1430
         h = node.height > h and node.height or h
×
1431
         d = node.depth > d and node.depth or d
×
1432
      end
1433
   end
1434
   self:popState()
12✔
1435

1436
   local hbox = SILE.types.node.hbox({
24✔
1437
      height = h,
12✔
1438
      width = l,
12✔
1439
      depth = d,
12✔
1440
      value = recentContribution,
12✔
1441
      outputYourself = function (box, atypesetter, line)
1442
         local _post = _rtl_pre_post(box, atypesetter, line)
11✔
1443
         local ox = atypesetter.frame.state.cursorX
11✔
1444
         local oy = atypesetter.frame.state.cursorY
11✔
1445
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
11✔
1446
         SU.debug("hboxes", function ()
22✔
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
11✔
1453
            node:outputYourself(atypesetter, line)
×
1454
         end
1455
         atypesetter.frame.state.cursorX = ox
11✔
1456
         atypesetter.frame.state.cursorY = oy
11✔
1457
         _post()
11✔
1458
      end,
1459
   })
1460
   return hbox, migratingNodes
12✔
1461
end
1462

1463
function typesetter:pushHlist (hlist)
3✔
1464
   for _, h in ipairs(hlist) do
×
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)
3✔
1484
   if self.state.hmodeOnly then
×
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
×
1493
      local uname = name .. "_" .. self.state.linerCount
×
1494
      SU.debug("typesetter.liner", "Applying liner in standard mode")
×
1495
      local enter = linerEnterNode(uname, outputYourself)
×
1496
      local leave = linerLeaveNode(uname)
×
1497
      self:pushHorizontal(enter)
×
1498
      SILE.process(content)
×
1499
      self:pushHorizontal(leave)
×
1500
   end
1501
end
1502

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