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

sile-typesetter / sile / 11573237387

29 Oct 2024 11:50AM UTC coverage: 57.701% (-2.2%) from 59.882%
11573237387

push

github

web-flow
Merge f9757d6cf into 8390534e6

10284 of 17823 relevant lines covered (57.7%)

4153.06 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

171
function typesetter:initFrame (frame)
22✔
172
   if frame then
84✔
173
      self.frame = frame
82✔
174
      self.frame:init(self)
82✔
175
   end
176
end
177

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

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

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

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

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

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

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

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

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

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

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

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

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

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

269
function typesetter:pushVbox (spec)
22✔
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)
22✔
275
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
212✔
276
   return self:pushVertical(node)
106✔
277
end
278

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

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

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

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

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

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

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

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

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

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

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

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

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

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

520
function typesetter.shapeAllNodes (_, nodelist, inplace)
22✔
521
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
784✔
522
   local newNodelist = {}
392✔
523
   local prec
524
   local precShapedNodes
525
   for _, current in ipairs(nodelist) do
4,801✔
526
      if current.is_unshaped then
4,409✔
527
         local shapedNodes = current:shape()
143✔
528

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

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

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

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

571
   for i = 1, #newNodelist do
6,204✔
572
      nodelist[i] = newNodelist[i]
5,827✔
573
   end
574
   if #nodelist > #newNodelist then
377✔
575
      for i = #newNodelist + 1, #nodelist do
×
576
         nodelist[i] = nil
×
577
      end
578
   end
579
end
580

581
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
582
-- Turns a node list into a list of vboxes
583
function typesetter:boxUpNodes ()
22✔
584
   local nodelist = self.state.nodes
411✔
585
   if #nodelist == 0 then
411✔
586
      return {}
285✔
587
   end
588
   for j = #nodelist, 1, -1 do
155✔
589
      if not nodelist[j].is_migrating then
156✔
590
         if nodelist[j].discardable then
155✔
591
            table.remove(nodelist, j)
58✔
592
         else
593
            break
594
         end
595
      end
596
   end
597
   while #nodelist > 0 and nodelist[1].is_penalty do
126✔
598
      table.remove(nodelist, 1)
×
599
   end
600
   if #nodelist == 0 then
126✔
601
      return {}
×
602
   end
603
   self:shapeAllNodes(nodelist)
126✔
604
   local parfillskip = SILE.settings:get("typesetter.parfillskip")
126✔
605
   parfillskip.discardable = false
126✔
606
   self:pushGlue(parfillskip)
126✔
607
   self:pushPenalty(-inf_bad)
126✔
608
   SU.debug("typesetter", function ()
252✔
609
      return "Boxed up " .. (#nodelist > 500 and #nodelist .. " nodes" or SU.ast.contentToString(nodelist))
×
610
   end)
611
   local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
252✔
612
   local lines = self:breakIntoLines(nodelist, breakWidth)
126✔
613
   local vboxes = {}
126✔
614
   for index = 1, #lines do
316✔
615
      local line = lines[index]
190✔
616
      local migrating = {}
190✔
617
      -- Move any migrating material
618
      local nodes = {}
190✔
619
      for i = 1, #line.nodes do
3,617✔
620
         local node = line.nodes[i]
3,427✔
621
         if node.is_migrating then
3,427✔
622
            for j = 1, #node.material do
4✔
623
               migrating[#migrating + 1] = node.material[j]
2✔
624
            end
625
         else
626
            nodes[#nodes + 1] = node
3,425✔
627
         end
628
      end
629
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
190✔
630
      local pageBreakPenalty = 0
190✔
631
      if #lines > 1 and index == 1 then
190✔
632
         pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
32✔
633
      elseif #lines > 1 and index == (#lines - 1) then
174✔
634
         pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
20✔
635
      end
636
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
380✔
637
      vboxes[#vboxes + 1] = vbox
190✔
638
      for i = 1, #migrating do
192✔
639
         vboxes[#vboxes + 1] = migrating[i]
2✔
640
      end
641
      self.state.previousVbox = vbox
190✔
642
      if pageBreakPenalty > 0 then
190✔
643
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
26✔
644
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
52✔
645
      end
646
   end
647
   return vboxes
126✔
648
end
649

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

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

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

665
function typesetter:runHooks (category, data)
22✔
666
   if not self.hooks[category] then
417✔
667
      return data
361✔
668
   end
669
   for _, func in ipairs(self.hooks[category]) do
131✔
670
      data = func(self, data)
150✔
671
   end
672
   return data
56✔
673
end
674

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1306
   -- Until end of actual content
1307
   for i = 1, npos do
3,185✔
1308
      local node = slice[i]
2,995✔
1309
      if node.is_box then
2,995✔
1310
         skipping = false
1,504✔
1311
         if node.parent and not node.parent.hyphenated then
1,504✔
1312
            if not seenNodes[node.parent] then
414✔
1313
               naturalTotals:___add(node.parent:lineContribution())
346✔
1314
            end
1315
            seenNodes[node.parent] = true
414✔
1316
         else
1317
            naturalTotals:___add(node:lineContribution())
2,180✔
1318
         end
1319
      elseif node.is_penalty and node.penalty == -inf_bad then
1,491✔
1320
         skipping = false
123✔
1321
      elseif node.is_discretionary then
1,368✔
1322
         skipping = false
292✔
1323
         local seen = node.parent and seenNodes[node.parent]
292✔
1324
         if not seen then
292✔
1325
            if node.used then
51✔
1326
               if node.is_prebreak then
30✔
1327
                  naturalTotals:___add(node:prebreakWidth())
30✔
1328
                  node.height = node:prebreakHeight()
30✔
1329
               else
1330
                  naturalTotals:___add(node:postbreakWidth())
30✔
1331
                  node.height = node:postbreakHeight()
30✔
1332
               end
1333
            else
1334
               naturalTotals:___add(node:replacementWidth():absolute())
63✔
1335
               node.height = node:replacementHeight():absolute()
63✔
1336
            end
1337
         end
1338
      elseif not skipping then
1,076✔
1339
         naturalTotals:___add(node.width)
1,076✔
1340
      end
1341
   end
1342

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

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

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

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

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

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

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

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

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

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