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

sile-typesetter / sile / 9400954821

06 Jun 2024 11:12AM UTC coverage: 61.625% (-11.6%) from 73.209%
9400954821

push

github

web-flow
Merge pull request #2041 from alerque/keep-space-after-envs

2 of 30 new or added lines in 2 files covered. (6.67%)

1933 existing lines in 67 files now uncovered.

10613 of 17222 relevant lines covered (61.62%)

2870.98 hits per line

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

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

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

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

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

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

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

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

31
local warned = false
48✔
32

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

236
function typesetter:pushHbox (spec)
48✔
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)
44✔
239
   local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.types.node.hbox(spec)
44✔
240
   return self:pushHorizontal(node)
44✔
241
end
242

243
function typesetter:pushUnshaped (spec)
48✔
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)
600✔
246
   return self:pushHorizontal(node)
300✔
247
end
248

249
function typesetter:pushGlue (spec)
48✔
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)
1,092✔
252
   return self:pushHorizontal(node)
546✔
253
end
254

255
function typesetter:pushExplicitGlue (spec)
48✔
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)
86✔
258
   node.explicit = true
43✔
259
   node.discardable = false
43✔
260
   return self:pushHorizontal(node)
43✔
261
end
262

263
function typesetter:pushPenalty (spec)
48✔
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)
558✔
266
   return self:pushHorizontal(node)
279✔
267
end
268

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

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

280
function typesetter:pushVglue (spec)
48✔
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)
588✔
283
   return self:pushVertical(node)
294✔
284
end
285

286
function typesetter:pushExplicitVglue (spec)
48✔
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)
402✔
289
   node.explicit = true
201✔
290
   node.discardable = false
201✔
291
   return self:pushVertical(node)
201✔
292
end
293

294
function typesetter:pushVpenalty (spec)
48✔
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)
142✔
297
   return self:pushVertical(node)
71✔
298
end
299

300
-- Actual typesetting functions
301
function typesetter:typeset (text)
48✔
302
   text = tostring(text)
584✔
303
   if text:match("^%\r?\n$") then
584✔
304
      return
239✔
305
   end
306
   local pId = SILE.traceStack:pushText(text)
345✔
307
   for token in SU.gtoke(text, SILE.settings:get("typesetter.parseppattern")) do
1,498✔
308
      if token.separator then
463✔
309
         self:endline()
318✔
310
      else
311
         if SILE.settings:get("typesetter.softHyphen") then
608✔
312
            local warnedshy = false
304✔
313
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
912✔
314
               if token2.separator then -- soft hyphen support
304✔
UNCOV
315
                  local discretionary = SILE.types.node.discretionary({})
×
UNCOV
316
                  local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
×
UNCOV
317
                  discretionary.prebreak = { hbox }
×
UNCOV
318
                  table.insert(SILE.typesetter.state.nodes, discretionary)
×
UNCOV
319
                  if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
×
320
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
321
                  end
UNCOV
322
                  warnedshy = true
×
323
               else
324
                  self:setpar(token2.string)
304✔
325
               end
326
            end
327
         else
328
            if
UNCOV
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
UNCOV
333
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
×
UNCOV
334
            self:setpar(text)
×
335
         end
336
      end
337
   end
338
   SILE.traceStack:pop(pId)
345✔
339
end
340

341
function typesetter:initline ()
48✔
342
   if self.state.hmodeOnly then
1,691✔
343
      return
7✔
344
   end -- https://github.com/sile-typesetter/sile/issues/1718
345
   if #self.state.nodes == 0 then
1,684✔
346
      table.insert(self.state.nodes, SILE.types.node.zerohbox())
542✔
347
      SILE.documentState.documentClass.newPar(self)
271✔
348
   end
349
end
350

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

356
-- Just compute once, to avoid unicode characters in source code.
357
local speakerChangePattern = "^"
×
358
   .. luautf8.char(0x2014) -- emdash
48✔
359
   .. "[ "
×
360
   .. luautf8.char(0x00A0)
48✔
361
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
48✔
362
   .. "]+"
48✔
363
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
48✔
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)
48✔
368
function speakerChangeNode:shape ()
48✔
UNCOV
369
   local node = self._base.shape(self)
×
UNCOV
370
   local spc = node[2]
×
UNCOV
371
   if spc and spc.is_glue then
×
372
      -- Switch the variable space glue to a fixed kern
UNCOV
373
      node[2] = SILE.types.node.kern({ width = spc.width.length })
×
UNCOV
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
UNCOV
380
   return node
×
381
end
382

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

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

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

419
local function getLastShape (nodelist)
420
   local hasGlue
421
   local last
UNCOV
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.
UNCOV
425
      for i = #nodelist, 1, -1 do
×
UNCOV
426
         local n = nodelist[i]
×
UNCOV
427
         if n.is_nnode then
×
UNCOV
428
            local items = n.nodes[#n.nodes].value.items
×
UNCOV
429
            last = items[#items]
×
430
            break
431
         end
UNCOV
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
UNCOV
437
         if n.is_glue then
×
UNCOV
438
            hasGlue = true
×
439
         end
440
      end
441
   end
UNCOV
442
   return last, hasGlue
×
443
end
444
local function getFirstShape (nodelist)
445
   local first
446
   local hasGlue
UNCOV
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.
UNCOV
450
      for i = 1, #nodelist do
×
UNCOV
451
         local n = nodelist[i]
×
UNCOV
452
         if n.is_nnode then
×
UNCOV
453
            local items = n.nodes[1].value.items
×
UNCOV
454
            first = items[1]
×
455
            break
456
         end
UNCOV
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
UNCOV
462
         if n.is_glue then
×
UNCOV
463
            hasGlue = true
×
464
         end
465
      end
466
   end
UNCOV
467
   return first, hasGlue
×
468
end
469

470
local function fromItalicCorrection (precShape, curShape)
471
   local xOffset
UNCOV
472
   if not curShape or not precShape then
×
UNCOV
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.
UNCOV
486
      local d = precShape.glyphWidth + precShape.x_bearing
×
UNCOV
487
      local delta = d > precShape.width and d - precShape.width or 0
×
UNCOV
488
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
×
489
   end
UNCOV
490
   return xOffset
×
491
end
492

493
local function toItalicCorrection (precShape, curShape)
UNCOV
494
   if not SILE.settings:get("typesetter.italicCorrection") then
×
495
      return
×
496
   end
497
   local xOffset
UNCOV
498
   if not curShape or not precShape then
×
UNCOV
499
      xOffset = 0
×
500
   else
501
      -- Same assumptions as fromItalicCorrection(), but on the starting side of
502
      -- the glyph.
UNCOV
503
      local d = curShape.x_bearing
×
UNCOV
504
      local delta = d < 0 and -d or 0
×
UNCOV
505
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
×
506
   end
UNCOV
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.
UNCOV
515
   local ot = require("core.opentype-parser")
×
UNCOV
516
   local face = SILE.font.cache(nnode.options, SILE.shaper.getFace)
×
UNCOV
517
   local font = ot.parseFont(face)
×
UNCOV
518
   return font.post.italicAngle ~= 0
×
519
end
520

521
function typesetter.shapeAllNodes (_, nodelist, inplace)
48✔
522
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
1,646✔
523
   local newNodelist = {}
823✔
524
   local prec
525
   local precShapedNodes
526
   for _, current in ipairs(nodelist) do
11,863✔
527
      if current.is_unshaped then
11,040✔
528
         local shapedNodes = current:shape()
319✔
529

530
         if SILE.settings:get("typesetter.italicCorrection") and prec then
638✔
531
            local itCorrOffset
532
            local isGlue
UNCOV
533
            if isItalicLike(prec) and not isItalicLike(current) then
×
UNCOV
534
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
UNCOV
535
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
UNCOV
536
               isGlue = precHasGlue or curHasGlue
×
UNCOV
537
               itCorrOffset = fromItalicCorrection(precShape, curShape)
×
UNCOV
538
            elseif not isItalicLike(prec) and isItalicLike(current) then
×
UNCOV
539
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
UNCOV
540
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
UNCOV
541
               isGlue = precHasGlue or curHasGlue
×
UNCOV
542
               itCorrOffset = toItalicCorrection(precShape, curShape)
×
543
            end
UNCOV
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.
UNCOV
550
               local makeItCorrNode = isGlue and SILE.types.node.glue or SILE.types.node.kern
×
UNCOV
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)
319✔
559

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

568
   if not inplace then
823✔
569
      return newNodelist
15✔
570
   end
571

572
   for i = 1, #newNodelist do
15,296✔
573
      nodelist[i] = newNodelist[i]
14,488✔
574
   end
575
   if #nodelist > #newNodelist then
808✔
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 ()
48✔
585
   local nodelist = self.state.nodes
640✔
586
   if #nodelist == 0 then
640✔
587
      return {}
370✔
588
   end
589
   for j = #nodelist, 1, -1 do
328✔
590
      if not nodelist[j].is_migrating then
330✔
591
         if nodelist[j].discardable then
324✔
592
            table.remove(nodelist, j)
108✔
593
         else
594
            break
595
         end
596
      end
597
   end
598
   while #nodelist > 0 and nodelist[1].is_penalty do
270✔
599
      table.remove(nodelist, 1)
×
600
   end
601
   if #nodelist == 0 then
270✔
602
      return {}
×
603
   end
604
   self:shapeAllNodes(nodelist)
270✔
605
   local parfillskip = SILE.settings:get("typesetter.parfillskip")
270✔
606
   parfillskip.discardable = false
270✔
607
   self:pushGlue(parfillskip)
270✔
608
   self:pushPenalty(-inf_bad)
270✔
609
   SU.debug("typesetter", function ()
540✔
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()
540✔
613
   local lines = self:breakIntoLines(nodelist, breakWidth)
270✔
614
   local vboxes = {}
270✔
615
   for index = 1, #lines do
667✔
616
      local line = lines[index]
397✔
617
      local migrating = {}
397✔
618
      -- Move any migrating material
619
      local nodes = {}
397✔
620
      for i = 1, #line.nodes do
8,150✔
621
         local node = line.nodes[i]
7,753✔
622
         if node.is_migrating then
7,753✔
623
            for j = 1, #node.material do
12✔
624
               migrating[#migrating + 1] = node.material[j]
6✔
625
            end
626
         else
627
            nodes[#nodes + 1] = node
7,747✔
628
         end
629
      end
630
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
397✔
631
      local pageBreakPenalty = 0
397✔
632
      if #lines > 1 and index == 1 then
397✔
633
         pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
82✔
634
      elseif #lines > 1 and index == (#lines - 1) then
356✔
635
         pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
54✔
636
      end
637
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
794✔
638
      vboxes[#vboxes + 1] = vbox
397✔
639
      for i = 1, #migrating do
403✔
640
         vboxes[#vboxes + 1] = migrating[i]
6✔
641
      end
642
      self.state.previousVbox = vbox
397✔
643
      if pageBreakPenalty > 0 then
397✔
644
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
68✔
645
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
136✔
646
      end
647
   end
648
   return vboxes
270✔
649
end
650

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

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

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

666
function typesetter:runHooks (category, data)
48✔
667
   if not self.hooks[category] then
656✔
668
      return data
566✔
669
   end
670
   for _, func in ipairs(self.hooks[category]) do
206✔
671
      data = func(self, data)
232✔
672
   end
673
   return data
90✔
674
end
675

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

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

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

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

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

721
   local pastTop = false
82✔
722
   for _, node in ipairs(pageNodeList) do
1,261✔
723
      if not pastTop and not node.discardable and not node.explicit then
1,179✔
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
79✔
730
      end
731
      if pastTop then
1,179✔
732
         if not node.is_insertion then
1,068✔
733
            totalHeight:___add(node.height)
1,066✔
734
            totalHeight:___add(node.depth)
1,066✔
735
         end
736
         if node.is_vglue then
1,068✔
737
            table.insert(glues, node)
667✔
738
            gTotal:___add(node.height)
667✔
739
         end
740
      end
741
   end
742

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

747
   local adjustment = target - totalHeight
76✔
748
   if adjustment:tonumber() > 0 then
152✔
749
      if adjustment > gTotal.stretch then
60✔
750
         if
751
            (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber()
30✔
752
         then
753
            SU.warn(
10✔
754
               "Underfull frame "
755
                  .. self.frame.id
5✔
756
                  .. ": "
5✔
757
                  .. adjustment
5✔
758
                  .. " stretchiness required to fill but only "
5✔
759
                  .. gTotal.stretch
5✔
760
                  .. " available"
5✔
761
            )
762
         end
763
         adjustment = gTotal.stretch
6✔
764
      end
765
      if gTotal.stretch:tonumber() > 0 then
120✔
766
         for i = 1, #glues do
671✔
767
            local g = glues[i]
612✔
768
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
3,060✔
769
         end
770
      end
771
   elseif adjustment:tonumber() < 0 then
32✔
772
      adjustment = 0 - adjustment
16✔
773
      if adjustment > gTotal.shrink then
16✔
774
         if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
80✔
775
            SU.warn(
4✔
776
               "Overfull frame "
777
                  .. self.frame.id
2✔
778
                  .. ": "
2✔
779
                  .. adjustment
2✔
780
                  .. " shrinkability required to fit but only "
2✔
781
                  .. gTotal.shrink
2✔
782
                  .. " available"
2✔
783
            )
784
         end
785
         adjustment = gTotal.shrink
16✔
786
      end
787
      if gTotal.shrink:tonumber() > 0 then
32✔
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)
76✔
795
end
796

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

816
   if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
144✔
817
      self:pushBack()
4✔
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()
8✔
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
32✔
828
         local lead = self:leadingFor(self.state.outputQueue[1], nil)
9✔
829
         if lead then
9✔
830
            table.insert(self.state.outputQueue, 1, lead)
8✔
831
         end
832
      end
833
   end
834
   self:runHooks("newframe")
36✔
835
end
836

837
function typesetter:pushBack ()
48✔
838
   SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
4✔
839
   local oldqueue = self.state.outputQueue
4✔
840
   self.state.outputQueue = {}
4✔
841
   self.state.previousVbox = nil
4✔
842
   local lastMargins = self:getMargins()
4✔
843
   for _, vbox in ipairs(oldqueue) do
20✔
844
      SU.debug("pushback", "process box", vbox)
16✔
845
      if vbox.margins and vbox.margins ~= lastMargins then
16✔
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
16✔
853
         SU.debug("pushback", "explicit", vbox)
×
854
         self:endline()
×
855
         self:pushExplicitVglue(vbox)
×
856
      elseif vbox.is_insertion then
16✔
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
16✔
860
         SU.debug("pushback", "not vglue or penalty", vbox.type)
6✔
861
         local discardedFistInitLine = false
6✔
862
         if #self.state.nodes == 0 then
6✔
863
            -- Setup queue but avoid calling newPar
864
            self.state.nodes[#self.state.nodes + 1] = SILE.types.node.zerohbox()
4✔
865
         end
866
         for i, node in ipairs(vbox.nodes) do
236✔
867
            if node.is_glue and not node.discardable then
230✔
868
               self:pushHorizontal(node)
4✔
869
            elseif node.is_glue and node.value == "margin" then
228✔
870
               SU.debug("pushback", "discard", node.value, node)
24✔
871
            elseif node.is_discretionary then
216✔
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
216✔
881
               if discardedFistInitLine then
14✔
882
                  self:pushHorizontal(node)
8✔
883
               end
884
               discardedFistInitLine = true
14✔
885
            elseif node.is_penalty then
202✔
886
               if not discardedFistInitLine then
2✔
887
                  self:pushHorizontal(node)
×
888
               end
889
            else
890
               node.bidiDone = true
200✔
891
               self:pushHorizontal(node)
200✔
892
            end
893
         end
894
      else
895
         SU.debug("pushback", "discard", vbox.type)
10✔
896
      end
897
      lastMargins = vbox.margins
16✔
898
      -- self:debugState()
899
   end
900
   while
×
901
      self.state.nodes[#self.state.nodes]
6✔
902
      and (self.state.nodes[#self.state.nodes].is_penalty or self.state.nodes[#self.state.nodes].is_zero)
6✔
903
   do
904
      self.state.nodes[#self.state.nodes] = nil
2✔
905
   end
906
end
907

908
function typesetter:outputLinesToPage (lines)
48✔
909
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
113✔
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
113✔
916
   for _, line in ipairs(lines) do
1,384✔
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
1,271✔
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
108✔
925
      end
926
      if pastTop then
1,271✔
927
         line:outputYourself(self, line)
1,126✔
928
      end
929
   end
930
   self.frame.state.totals.pastTop = pastTop
113✔
931
end
932

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

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

958
function typesetter.leadingFor (_, vbox, previous)
48✔
959
   -- Insert leading
960
   SU.debug("typesetter", "   Considering leading between two lines:")
375✔
961
   SU.debug("typesetter", "   1)", previous)
375✔
962
   SU.debug("typesetter", "   2)", vbox)
375✔
963
   if not previous then
375✔
964
      return SILE.types.node.vglue()
109✔
965
   end
966
   local prevDepth = previous.depth
266✔
967
   SU.debug("typesetter", "   Depth of previous line was", prevDepth)
266✔
968
   local bls = SILE.settings:get("document.baselineskip")
266✔
969
   local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
1,330✔
970
   SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
266✔
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()
532✔
974
   if depth > lead then
266✔
975
      return SILE.types.node.vglue(SILE.types.length(depth.length, bls.height.stretch, bls.height.shrink))
396✔
976
   else
977
      return SILE.types.node.vglue(lead)
68✔
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)
96✔
987
function linerEnterNode:_init (name, outputMethod)
48✔
988
   SILE.nodefactory.hbox._init(self)
2✔
989
   self.outputMethod = outputMethod
1✔
990
   self.name = name
1✔
991
   self.is_enter = true
1✔
992
end
993
function linerEnterNode:clone ()
48✔
UNCOV
994
   return linerEnterNode(self.name, self.outputMethod)
×
995
end
996
function linerEnterNode:outputYourself ()
48✔
997
   SU.error("A liner enter node " .. tostring(self) .. "'' made it to output", true)
×
998
end
999
function linerEnterNode:__tostring ()
48✔
1000
   return "+L[" .. self.name .. "]"
×
1001
end
1002
local linerLeaveNode = pl.class(SILE.nodefactory.hbox)
96✔
1003
function linerLeaveNode:_init (name)
48✔
1004
   SILE.nodefactory.hbox._init(self)
2✔
1005
   self.name = name
1✔
1006
   self.is_leave = true
1✔
1007
end
1008
function linerLeaveNode:clone ()
48✔
1009
   return linerLeaveNode(self.name)
×
1010
end
1011
function linerLeaveNode:outputYourself ()
48✔
1012
   SU.error("A liner leave node " .. tostring(self) .. "'' made it to output", true)
×
1013
end
1014
function linerLeaveNode:__tostring ()
48✔
1015
   return "-L[" .. self.name .. "]"
×
1016
end
1017

1018
local linerBox = pl.class(SILE.nodefactory.hbox)
96✔
1019
function linerBox:_init (name, outputMethod)
48✔
1020
   SILE.nodefactory.hbox._init(self)
2✔
1021
   self.width = SILE.length()
2✔
1022
   self.height = SILE.length()
2✔
1023
   self.depth = SILE.length()
2✔
1024
   self.name = name
1✔
1025
   self.inner = {}
1✔
1026
   self.outputYourself = outputMethod
1✔
1027
end
1028
function linerBox:append (node)
48✔
1029
   self.inner[#self.inner + 1] = node
1✔
1030
   if node.is_discretionary then
1✔
1031
      -- Discretionary nodes don't have a width of their own.
UNCOV
1032
      if node.used then
×
UNCOV
1033
         if node.is_prebreak then
×
UNCOV
1034
            self.width:___add(node:prebreakWidth())
×
1035
         else
UNCOV
1036
            self.width:___add(node:postbreakWidth())
×
1037
         end
1038
      else
UNCOV
1039
         self.width:___add(node:replacementWidth())
×
1040
      end
1041
   else
1042
      self.width:___add(node.width:absolute())
2✔
1043
   end
1044
   self.height = SU.max(self.height, node.height)
2✔
1045
   self.depth = SU.max(self.depth, node.depth)
2✔
1046
end
1047
function linerBox:count ()
48✔
1048
   return #self.inner
2✔
1049
end
1050
function linerBox:outputContent (tsetter, line)
48✔
1051
   for _, node in ipairs(self.inner) do
2✔
1052
      node.outputYourself(node, tsetter, line)
1✔
1053
   end
1054
end
1055
function linerBox:__tostring ()
48✔
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)
48✔
1073
   local m = self.state.liners
5,611✔
1074
   if #m > 0 then
5,611✔
UNCOV
1075
      for i = 1, #m do
×
UNCOV
1076
         local n = m[i]:clone()
×
UNCOV
1077
         slice[#slice + 1] = n
×
UNCOV
1078
         SU.debug("typesetter.liner", "Reopening liner", n)
×
1079
      end
UNCOV
1080
      return true
×
1081
   end
1082
   return false
5,611✔
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)
48✔
1090
   local outSlice = {}
1✔
1091
   local migratingList = {}
1✔
1092
   local lboxStack = {}
1✔
1093
   for i = 1, #slice do
33✔
1094
      local node = slice[i]
32✔
1095
      if node.is_enter then
32✔
1096
         SU.debug("typesetter.liner", "Start reboxing", node)
1✔
1097
         local n = linerBox(node.name, node.outputMethod)
1✔
1098
         lboxStack[#lboxStack + 1] = n
1✔
1099
      elseif node.is_leave then
31✔
1100
         if #lboxStack == 0 then
1✔
1101
            SU.error("Multiliner box stacking mismatch" .. node)
×
1102
         elseif #lboxStack == 1 then
1✔
1103
            SU.debug("typesetter.liner", "End reboxing", node, "(toplevel) =", lboxStack[1]:count(), "nodes")
2✔
1104
            if lboxStack[1]:count() > 0 then
2✔
1105
               outSlice[#outSlice + 1] = lboxStack[1]
1✔
1106
            end
1107
         else
UNCOV
1108
            SU.debug("typesetter.liner", "End reboxing", node, "(sublevel) =", lboxStack[#lboxStack]:count(), "nodes")
×
UNCOV
1109
            if lboxStack[#lboxStack]:count() > 0 then
×
UNCOV
1110
               local hbox = lboxStack[#lboxStack - 1]
×
UNCOV
1111
               hbox:append(lboxStack[#lboxStack])
×
1112
            end
1113
         end
1114
         lboxStack[#lboxStack] = nil
1✔
1115
         pl.tablex.insertvalues(outSlice, migratingList)
1✔
1116
         migratingList = {}
1✔
1117
      else
1118
         if #lboxStack > 0 then
30✔
1119
            if not node.is_migrating then
1✔
1120
               local lbox = lboxStack[#lboxStack]
1✔
1121
               lbox:append(node)
2✔
1122
            else
1123
               migratingList[#migratingList + 1] = node
×
1124
            end
1125
         else
1126
            outSlice[#outSlice + 1] = node
29✔
1127
         end
1128
      end
1129
   end
1130
   return outSlice -- new reboxed slice
1✔
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)
48✔
1137
   local entered = false
6,174✔
1138
   if node.is_enter then
6,174✔
1139
      SU.debug("typesetter.liner", "Enter liner", node)
1✔
1140
      self.state.liners[#self.state.liners + 1] = node
1✔
1141
      entered = true
1✔
1142
   elseif node.is_leave then
6,173✔
1143
      SU.debug("typesetter.liner", "Leave liner", node)
1✔
1144
      if #self.state.liners == 0 then
1✔
1145
         SU.error("Multiliner stack mismatch" .. node)
×
1146
      elseif self.state.liners[#self.state.liners].name == node.name then
1✔
1147
         self.state.liners[#self.state.liners].link = node -- for consistency check
1✔
1148
         self.state.liners[#self.state.liners] = nil
1✔
1149
      else
1150
         SU.error("Multiliner stack inconsistency" .. self.state.liners[#self.state.liners] .. "vs. " .. node)
×
1151
      end
1152
   end
1153
   return entered
6,174✔
1154
end
1155

1156
function typesetter:_repeatLeaveLiners (slice, insertIndex)
48✔
1157
   for _, v in ipairs(self.state.liners) do
396✔
UNCOV
1158
      if not v.link then
×
UNCOV
1159
         local n = linerLeaveNode(v.name)
×
UNCOV
1160
         SU.debug("typesetter.liner", "Closing liner", n)
×
UNCOV
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)
48✔
1170
   local LTR = self.frame:writingDirection() == "LTR"
794✔
1171
   local rskip = margins[LTR and "rskip" or "lskip"]
397✔
1172
   if not rskip then
397✔
1173
      rskip = SILE.types.node.glue(0)
×
1174
   end
1175
   if hangRight and hangRight > 0 then
397✔
1176
      rskip = SILE.types.node.glue({ width = rskip.width:tonumber() + hangRight })
30✔
1177
   end
1178
   rskip.value = "margin"
397✔
1179
   -- while slice[#slice].discardable do table.remove(slice, #slice) end
1180
   table.insert(slice, rskip)
397✔
1181
   table.insert(slice, SILE.types.node.zerohbox())
794✔
1182
   local lskip = margins[LTR and "lskip" or "rskip"]
397✔
1183
   if not lskip then
397✔
1184
      lskip = SILE.types.node.glue(0)
×
1185
   end
1186
   if hangLeft and hangLeft > 0 then
397✔
1187
      lskip = SILE.types.node.glue({ width = lskip.width:tonumber() + hangLeft })
24✔
1188
   end
1189
   lskip.value = "margin"
397✔
1190
   while slice[1].discardable do
399✔
1191
      table.remove(slice, 1)
4✔
1192
   end
1193
   table.insert(slice, 1, lskip)
397✔
1194
   table.insert(slice, 1, SILE.types.node.zerohbox())
794✔
1195
end
1196

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

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

1210
         for j = linestart, point.position do
6,576✔
1211
            local currentNode = nodes[j]
6,174✔
1212
            if
1213
               not currentNode.discardable
6,174✔
1214
               and not (currentNode.is_glue and not currentNode.explicit)
3,956✔
1215
               and not currentNode.is_zero
3,684✔
1216
            then
1217
               -- actual visible content starts here
1218
               lastContentNodeIndex = #slice + 1
3,408✔
1219
            end
1220
            if not seenLiner and lastContentNodeIndex then
6,174✔
1221
               -- Any stacked liner (unclosed from a previous line) is reopened on
1222
               -- the current line.
1223
               seenLiner = self:_repeatEnterLiners(slice)
11,222✔
1224
               lastContentNodeIndex = #slice + 1
5,611✔
1225
            end
1226
            if currentNode.is_discretionary and currentNode.used then
6,174✔
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()
56✔
1230
            end
1231
            slice[#slice + 1] = currentNode
6,174✔
1232
            if currentNode then
6,174✔
1233
               if not currentNode.discardable then
6,174✔
1234
                  seenNonDiscardable = true
3,956✔
1235
               end
1236
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
12,348✔
1237
            end
1238
         end
1239
         if not seenNonDiscardable then
402✔
1240
            -- Slip lines containing only discardable nodes (e.g. glues).
1241
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
5✔
1242
            linestart = point.position + 1
5✔
1243
         else
1244
            if slice[#slice].is_discretionary then
397✔
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
28✔
1248
               -- And mark it as used as prebreak for now.
1249
               slice[#slice]:markAsPrebreak()
56✔
1250
            else
1251
               linestart = point.position + 1
369✔
1252
            end
1253

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

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

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

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

1271
            local thisLine = { ratio = ratio, nodes = slice }
397✔
1272
            lines[#lines + 1] = thisLine
397✔
1273
         end
1274
      end
1275
   end
1276
   if linestart < #nodes then
270✔
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
270✔
1282
end
1283

1284
function typesetter.computeLineRatio (_, breakwidth, slice)
48✔
1285
   local naturalTotals = SILE.types.length()
397✔
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
397✔
1290
   while npos > 1 do
1,287✔
1291
      if slice[npos].is_glue or slice[npos].is_zero then
1,286✔
1292
         if slice[npos].value == "margin" then
890✔
1293
            naturalTotals:___add(slice[npos].width)
398✔
1294
         end
1295
      else
1296
         break
1297
      end
1298
      npos = npos - 1
890✔
1299
   end
1300

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

1307
   -- Until end of actual content
1308
   for i = 1, npos do
7,262✔
1309
      local node = slice[i]
6,865✔
1310
      if node.is_box then
6,865✔
1311
         skipping = false
3,461✔
1312
         if node.parent and not node.parent.hyphenated then
3,461✔
1313
            if not seenNodes[node.parent] then
745✔
1314
               naturalTotals:___add(node.parent:lineContribution())
626✔
1315
            end
1316
            seenNodes[node.parent] = true
745✔
1317
         else
1318
            naturalTotals:___add(node:lineContribution())
5,432✔
1319
         end
1320
      elseif node.is_penalty and node.penalty == -inf_bad then
3,404✔
1321
         skipping = false
275✔
1322
      elseif node.is_discretionary then
3,129✔
1323
         skipping = false
513✔
1324
         local seen = node.parent and seenNodes[node.parent]
513✔
1325
         if not seen then
513✔
1326
            if node.used then
81✔
1327
               if node.is_prebreak then
56✔
1328
                  naturalTotals:___add(node:prebreakWidth())
56✔
1329
                  node.height = node:prebreakHeight()
56✔
1330
               else
1331
                  naturalTotals:___add(node:postbreakWidth())
56✔
1332
                  node.height = node:postbreakHeight()
56✔
1333
               end
1334
            else
1335
               naturalTotals:___add(node:replacementWidth():absolute())
75✔
1336
               node.height = node:replacementHeight():absolute()
75✔
1337
            end
1338
         end
1339
      elseif not skipping then
2,616✔
1340
         naturalTotals:___add(node.width)
2,616✔
1341
      end
1342
   end
1343

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

1350
function typesetter:chuck () -- emergency shipout everything
48✔
1351
   self:leaveHmode(true)
42✔
1352
   if #self.state.outputQueue > 0 then
42✔
1353
      SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
29✔
1354
      self:outputLinesToPage(self.state.outputQueue)
29✔
1355
      self.state.outputQueue = {}
29✔
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))
28✔
1369
   end
1370
   if atypesetter.frame:writingDirection() == "RTL" then
28✔
1371
      advance()
×
1372
      return function () end
×
1373
   else
1374
      return advance
14✔
1375
   end
1376
end
1377
function typesetter:makeHbox (content)
48✔
1378
   local recentContribution = {}
15✔
1379
   local migratingNodes = {}
15✔
1380

1381
   self:pushState()
15✔
1382
   self.state.hmodeOnly = true
15✔
1383
   SILE.process(content)
15✔
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)
15✔
1388

1389
   -- Then we can process and measure the nodes.
1390
   local l = SILE.types.length()
15✔
1391
   local h, d = SILE.types.length(), SILE.types.length()
30✔
1392
   for i = 1, #nodes do
22✔
1393
      local node = nodes[i]
7✔
1394
      if node.is_migrating then
7✔
1395
         migratingNodes[#migratingNodes + 1] = node
×
1396
      elseif node.is_discretionary then
7✔
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
7✔
1415
         l = l + node:lineContribution():absolute()
21✔
1416
         h = node.height > h and node.height or h
11✔
1417
         d = node.depth > d and node.depth or d
12✔
1418
      end
1419
   end
1420
   self:popState()
15✔
1421

1422
   local hbox = SILE.types.node.hbox({
30✔
1423
      height = h,
15✔
1424
      width = l,
15✔
1425
      depth = d,
15✔
1426
      value = recentContribution,
15✔
1427
      outputYourself = function (box, atypesetter, line)
1428
         local _post = _rtl_pre_post(box, atypesetter, line)
14✔
1429
         local ox = atypesetter.frame.state.cursorX
14✔
1430
         local oy = atypesetter.frame.state.cursorY
14✔
1431
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
14✔
1432
         SU.debug("hboxes", function ()
28✔
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
21✔
1439
            node:outputYourself(atypesetter, line)
7✔
1440
         end
1441
         atypesetter.frame.state.cursorX = ox
14✔
1442
         atypesetter.frame.state.cursorY = oy
14✔
1443
         _post()
14✔
1444
      end,
1445
   })
1446
   return hbox, migratingNodes
15✔
1447
end
1448

1449
function typesetter:pushHlist (hlist)
48✔
1450
   for _, h in ipairs(hlist) do
3✔
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)
48✔
1470
   if self.state.hmodeOnly then
1✔
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
1✔
1479
      local uname = name .. "_" .. self.state.linerCount
1✔
1480
      SU.debug("typesetter.liner", "Applying liner in standard mode")
1✔
1481
      local enter = linerEnterNode(uname, outputYourself)
1✔
1482
      local leave = linerLeaveNode(uname)
1✔
1483
      self:pushHorizontal(enter)
1✔
1484
      SILE.process(content)
1✔
1485
      self:pushHorizontal(leave)
1✔
1486
   end
1487
end
1488

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