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

sile-typesetter / sile / 9304191631

30 May 2024 02:21PM UTC coverage: 49.894% (-10.8%) from 60.669%
9304191631

push

github

web-flow
Merge fcc56c666 into 1a26b4f22

8447 of 16930 relevant lines covered (49.89%)

1187.3 hits per line

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

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

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

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

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

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

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

33
function typesetter:init (frame)
5✔
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
36
  refactored using a different object model. Your instance was created
37
  and initialized using the object copy syntax from the stdlib model.
38
  It has been shimmed for you using the new Penlight model, but this may
39
  lead to unexpected behaviour. Please update your code to use the new
40
  Penlight based inheritance model.]])
×
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)
5✔
48
   self:declareSettings()
9✔
49
   self.hooks = {}
9✔
50
   self.breadcrumbs = SU.breadcrumbs()
18✔
51

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

236
function typesetter:pushHbox (spec)
5✔
237
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
238
   local ntype = SU.type(spec)
×
239
   local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.types.node.hbox(spec)
×
240
   return self:pushHorizontal(node)
×
241
end
242

243
function typesetter:pushUnshaped (spec)
5✔
244
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
245
   local node = SU.type(spec) == "unshaped" and spec or SILE.types.node.unshaped(spec)
66✔
246
   return self:pushHorizontal(node)
33✔
247
end
248

249
function typesetter:pushGlue (spec)
5✔
250
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
251
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
104✔
252
   return self:pushHorizontal(node)
52✔
253
end
254

255
function typesetter:pushExplicitGlue (spec)
5✔
256
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
257
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
10✔
258
   node.explicit = true
5✔
259
   node.discardable = false
5✔
260
   return self:pushHorizontal(node)
5✔
261
end
262

263
function typesetter:pushPenalty (spec)
5✔
264
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
265
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
52✔
266
   return self:pushHorizontal(node)
26✔
267
end
268

269
function typesetter:pushMigratingMaterial (material)
5✔
270
   local node = SILE.types.node.migrating({ material = material })
×
271
   return self:pushHorizontal(node)
×
272
end
273

274
function typesetter:pushVbox (spec)
5✔
275
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
276
   local node = SU.type(spec) == "vbox" and spec or SILE.types.node.vbox(spec)
×
277
   return self:pushVertical(node)
×
278
end
279

280
function typesetter:pushVglue (spec)
5✔
281
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
282
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
74✔
283
   return self:pushVertical(node)
37✔
284
end
285

286
function typesetter:pushExplicitVglue (spec)
5✔
287
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
288
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
40✔
289
   node.explicit = true
20✔
290
   node.discardable = false
20✔
291
   return self:pushVertical(node)
20✔
292
end
293

294
function typesetter:pushVpenalty (spec)
5✔
295
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
296
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
10✔
297
   return self:pushVertical(node)
5✔
298
end
299

300
-- Actual typesetting functions
301
function typesetter:typeset (text)
5✔
302
   text = tostring(text)
51✔
303
   if text:match("^%\r?\n$") then
51✔
304
      return
10✔
305
   end
306
   local pId = SILE.traceStack:pushText(text)
41✔
307
   for token in SU.gtoke(text, SILE.settings:get("typesetter.parseppattern")) do
174✔
308
      if token.separator then
51✔
309
         self:endline()
36✔
310
      else
311
         if SILE.settings:get("typesetter.softHyphen") then
66✔
312
            local warnedshy = false
33✔
313
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
99✔
314
               if token2.separator then -- soft hyphen support
33✔
315
                  local discretionary = SILE.types.node.discretionary({})
×
316
                  local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
×
317
                  discretionary.prebreak = { hbox }
×
318
                  table.insert(SILE.typesetter.state.nodes, discretionary)
×
319
                  if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
×
320
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
321
                  end
322
                  warnedshy = true
×
323
               else
324
                  self:setpar(token2.string)
33✔
325
               end
326
            end
327
         else
328
            if
329
               SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD))
×
330
            then
331
               SU.warn("Soft hyphen encountered and ignored")
×
332
            end
333
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
×
334
            self:setpar(text)
×
335
         end
336
      end
337
   end
338
   SILE.traceStack:pop(pId)
41✔
339
end
340

341
function typesetter:initline ()
5✔
342
   if self.state.hmodeOnly then
147✔
343
      return
×
344
   end -- https://github.com/sile-typesetter/sile/issues/1718
345
   if #self.state.nodes == 0 then
147✔
346
      self.state.nodes[#self.state.nodes + 1] = SILE.types.node.zerohbox()
52✔
347
      SILE.documentState.documentClass.newPar(self)
26✔
348
   end
349
end
350

351
function typesetter:endline ()
5✔
352
   self:leaveHmode()
37✔
353
   SILE.documentState.documentClass.endPar(self)
37✔
354
end
355

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

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

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

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

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

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

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

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

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

521
function typesetter.shapeAllNodes (_, nodelist, inplace)
5✔
522
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
156✔
523
   local newNodelist = {}
78✔
524
   local prec
525
   local precShapedNodes
526
   for _, current in ipairs(nodelist) do
1,290✔
527
      if current.is_unshaped then
1,212✔
528
         local shapedNodes = current:shape()
33✔
529

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

558
         pl.tablex.insertvalues(newNodelist, shapedNodes)
33✔
559

560
         prec = current
33✔
561
         precShapedNodes = shapedNodes
33✔
562
      else
563
         prec = nil
1,179✔
564
         newNodelist[#newNodelist + 1] = current
1,179✔
565
      end
566
   end
567

568
   if not inplace then
78✔
569
      return newNodelist
×
570
   end
571

572
   for i = 1, #newNodelist do
1,728✔
573
      nodelist[i] = newNodelist[i]
1,650✔
574
   end
575
   if #nodelist > #newNodelist then
78✔
576
      for i = #newNodelist + 1, #nodelist do
×
577
         nodelist[i] = nil
×
578
      end
579
   end
580
end
581

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

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

655
function typesetter:getTargetLength ()
5✔
656
   return self.frame:getTargetLength()
62✔
657
end
658

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

666
function typesetter:runHooks (category, data)
5✔
667
   if not self.hooks[category] then
62✔
668
      return data
57✔
669
   end
670
   for _, func in ipairs(self.hooks[category]) do
10✔
671
      data = func(self, data)
10✔
672
   end
673
   return data
5✔
674
end
675

676
function typesetter:registerFrameBreakHook (func)
5✔
677
   self:registerHook("framebreak", func)
×
678
end
679

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

684
function typesetter:registerPageEndHook (func)
5✔
685
   self:registerHook("pageend", func)
5✔
686
end
687

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

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

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

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

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

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

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

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

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

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

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

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

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

981
-- Beggining of liner logic (contructs spanning over several lines)
982

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

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

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

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

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

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

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

1197
function typesetter:breakpointsToLines (breakpoints)
5✔
1198
   local linestart = 1
26✔
1199
   local lines = {}
26✔
1200
   local nodes = self.state.nodes
26✔
1201

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

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

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

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

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

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

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

1284
function typesetter.computeLineRatio (_, breakwidth, slice)
5✔
1285
   local naturalTotals = SILE.types.length()
38✔
1286

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

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

1307
   -- Until end of actual content
1308
   for i = 1, npos do
744✔
1309
      local node = slice[i]
706✔
1310
      if node.is_box then
706✔
1311
         skipping = false
359✔
1312
         if node.parent and not node.parent.hyphenated then
359✔
1313
            if not seenNodes[node.parent] then
47✔
1314
               naturalTotals:___add(node.parent:lineContribution())
38✔
1315
            end
1316
            seenNodes[node.parent] = true
47✔
1317
         else
1318
            naturalTotals:___add(node:lineContribution())
624✔
1319
         end
1320
      elseif node.is_penalty and node.penalty == -inf_bad then
347✔
1321
         skipping = false
26✔
1322
      elseif node.is_discretionary then
321✔
1323
         skipping = false
30✔
1324
         local seen = node.parent and seenNodes[node.parent]
30✔
1325
         if not seen then
30✔
1326
            if node.used then
2✔
1327
               if node.is_prebreak then
2✔
1328
                  naturalTotals:___add(node:prebreakWidth())
2✔
1329
                  node.height = node:prebreakHeight()
2✔
1330
               else
1331
                  naturalTotals:___add(node:postbreakWidth())
2✔
1332
                  node.height = node:postbreakHeight()
2✔
1333
               end
1334
            else
1335
               naturalTotals:___add(node:replacementWidth():absolute())
×
1336
               node.height = node:replacementHeight():absolute()
×
1337
            end
1338
         end
1339
      elseif not skipping then
291✔
1340
         naturalTotals:___add(node.width)
291✔
1341
      end
1342
   end
1343

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

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

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

1381
   self:pushState()
×
1382
   self.state.hmodeOnly = true
×
1383
   SILE.process(content)
×
1384

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

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

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

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

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

1489
return typesetter
5✔
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