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

sile-typesetter / sile / 9409557472

07 Jun 2024 12:09AM UTC coverage: 69.448% (-4.5%) from 73.988%
9409557472

push

github

alerque
fix(build): Distribute vendored compat-5.3.c source file

12025 of 17315 relevant lines covered (69.45%)

6023.46 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

243
function typesetter:pushUnshaped (spec)
80✔
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)
1,622✔
246
   return self:pushHorizontal(node)
811✔
247
end
248

249
function typesetter:pushGlue (spec)
80✔
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,990✔
252
   return self:pushHorizontal(node)
995✔
253
end
254

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

263
function typesetter:pushPenalty (spec)
80✔
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)
1,044✔
266
   return self:pushHorizontal(node)
522✔
267
end
268

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

274
function typesetter:pushVbox (spec)
80✔
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)
2✔
277
   return self:pushVertical(node)
1✔
278
end
279

280
function typesetter:pushVglue (spec)
80✔
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)
1,044✔
283
   return self:pushVertical(node)
522✔
284
end
285

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

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

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

341
function typesetter:initline ()
80✔
342
   if self.state.hmodeOnly then
3,307✔
343
      return
183✔
344
   end -- https://github.com/sile-typesetter/sile/issues/1718
345
   if #self.state.nodes == 0 then
3,124✔
346
      table.insert(self.state.nodes, SILE.types.node.zerohbox())
982✔
347
      SILE.documentState.documentClass.newPar(self)
491✔
348
   end
349
end
350

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

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

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

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

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

419
local function getLastShape (nodelist)
420
   local hasGlue
421
   local last
422
   if nodelist then
52✔
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
62✔
426
         local n = nodelist[i]
62✔
427
         if n.is_nnode then
62✔
428
            local items = n.nodes[#n.nodes].value.items
50✔
429
            last = items[#items]
50✔
430
            break
50✔
431
         end
432
         if n.is_kern and n.subtype == "punctspace" then
12✔
433
            -- Some languages such as French insert a special space around
434
            -- punctuations. In those case, we should not need italic correction.
435
            break
2✔
436
         end
437
         if n.is_glue then
10✔
438
            hasGlue = true
10✔
439
         end
440
      end
441
   end
442
   return last, hasGlue
52✔
443
end
444
local function getFirstShape (nodelist)
445
   local first
446
   local hasGlue
447
   if nodelist then
52✔
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
56✔
451
         local n = nodelist[i]
56✔
452
         if n.is_nnode then
56✔
453
            local items = n.nodes[1].value.items
48✔
454
            first = items[1]
48✔
455
            break
48✔
456
         end
457
         if n.is_kern and n.subtype == "punctspace" then
8✔
458
            -- Some languages such as French insert a special space around
459
            -- punctuations. In those case, we should not need italic correction.
460
            break
4✔
461
         end
462
         if n.is_glue then
4✔
463
            hasGlue = true
4✔
464
         end
465
      end
466
   end
467
   return first, hasGlue
52✔
468
end
469

470
local function fromItalicCorrection (precShape, curShape)
471
   local xOffset
472
   if not curShape or not precShape then
26✔
473
      xOffset = 0
4✔
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
22✔
487
      local delta = d > precShape.width and d - precShape.width or 0
22✔
488
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
22✔
489
   end
490
   return xOffset
26✔
491
end
492

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

521
function typesetter.shapeAllNodes (_, nodelist, inplace)
80✔
522
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
3,104✔
523
   local newNodelist = {}
1,552✔
524
   local prec
525
   local precShapedNodes
526
   for _, current in ipairs(nodelist) do
24,333✔
527
      if current.is_unshaped then
22,781✔
528
         local shapedNodes = current:shape()
832✔
529

530
         if SILE.settings:get("typesetter.italicCorrection") and prec then
1,664✔
531
            local itCorrOffset
532
            local isGlue
533
            if isItalicLike(prec) and not isItalicLike(current) then
130✔
534
               local precShape, precHasGlue = getLastShape(precShapedNodes)
26✔
535
               local curShape, curHasGlue = getFirstShape(shapedNodes)
26✔
536
               isGlue = precHasGlue or curHasGlue
26✔
537
               itCorrOffset = fromItalicCorrection(precShape, curShape)
52✔
538
            elseif not isItalicLike(prec) and isItalicLike(current) then
78✔
539
               local precShape, precHasGlue = getLastShape(precShapedNodes)
26✔
540
               local curShape, curHasGlue = getFirstShape(shapedNodes)
26✔
541
               isGlue = precHasGlue or curHasGlue
26✔
542
               itCorrOffset = toItalicCorrection(precShape, curShape)
52✔
543
            end
544
            if itCorrOffset and itCorrOffset ~= 0 then
52✔
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
46✔
551
               newNodelist[#newNodelist + 1] = makeItCorrNode({
92✔
552
                  width = SILE.types.length(itCorrOffset),
92✔
553
                  subtype = "itcorr",
554
               })
46✔
555
            end
556
         end
557

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

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

568
   if not inplace then
1,552✔
569
      return newNodelist
84✔
570
   end
571

572
   for i = 1, #newNodelist do
31,605✔
573
      nodelist[i] = newNodelist[i]
30,137✔
574
   end
575
   if #nodelist > #newNodelist then
1,468✔
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 ()
80✔
585
   local nodelist = self.state.nodes
1,101✔
586
   if #nodelist == 0 then
1,101✔
587
      return {}
611✔
588
   end
589
   for j = #nodelist, 1, -1 do
605✔
590
      if not nodelist[j].is_migrating then
607✔
591
         if nodelist[j].discardable then
585✔
592
            table.remove(nodelist, j)
190✔
593
         else
594
            break
595
         end
596
      end
597
   end
598
   while #nodelist > 0 and nodelist[1].is_penalty do
490✔
599
      table.remove(nodelist, 1)
×
600
   end
601
   if #nodelist == 0 then
490✔
602
      return {}
×
603
   end
604
   self:shapeAllNodes(nodelist)
490✔
605
   local parfillskip = SILE.settings:get("typesetter.parfillskip")
490✔
606
   parfillskip.discardable = false
490✔
607
   self:pushGlue(parfillskip)
490✔
608
   self:pushPenalty(-inf_bad)
490✔
609
   SU.debug("typesetter", function ()
980✔
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()
980✔
613
   local lines = self:breakIntoLines(nodelist, breakWidth)
490✔
614
   local vboxes = {}
490✔
615
   for index = 1, #lines do
1,283✔
616
      local line = lines[index]
793✔
617
      local migrating = {}
793✔
618
      -- Move any migrating material
619
      local nodes = {}
793✔
620
      for i = 1, #line.nodes do
17,635✔
621
         local node = line.nodes[i]
16,842✔
622
         if node.is_migrating then
16,842✔
623
            for j = 1, #node.material do
48✔
624
               migrating[#migrating + 1] = node.material[j]
24✔
625
            end
626
         else
627
            nodes[#nodes + 1] = node
16,818✔
628
         end
629
      end
630
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
793✔
631
      local pageBreakPenalty = 0
793✔
632
      if #lines > 1 and index == 1 then
793✔
633
         pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
192✔
634
      elseif #lines > 1 and index == (#lines - 1) then
697✔
635
         pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
116✔
636
      end
637
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
1,586✔
638
      vboxes[#vboxes + 1] = vbox
793✔
639
      for i = 1, #migrating do
817✔
640
         vboxes[#vboxes + 1] = migrating[i]
24✔
641
      end
642
      self.state.previousVbox = vbox
793✔
643
      if pageBreakPenalty > 0 then
793✔
644
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
154✔
645
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
308✔
646
      end
647
   end
648
   return vboxes
490✔
649
end
650

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

655
function typesetter:getTargetLength ()
80✔
656
   return self.frame:getTargetLength()
1,078✔
657
end
658

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

666
function typesetter:runHooks (category, data)
80✔
667
   if not self.hooks[category] then
1,123✔
668
      return data
985✔
669
   end
670
   for _, func in ipairs(self.hooks[category]) do
313✔
671
      data = func(self, data)
350✔
672
   end
673
   return data
138✔
674
end
675

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

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

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

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

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

721
   local pastTop = false
120✔
722
   for _, node in ipairs(pageNodeList) do
2,428✔
723
      if not pastTop and not node.discardable and not node.explicit then
2,308✔
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
117✔
730
      end
731
      if pastTop then
2,308✔
732
         if not node.is_insertion then
2,146✔
733
            totalHeight:___add(node.height)
2,136✔
734
            totalHeight:___add(node.depth)
2,136✔
735
         end
736
         if node.is_vglue then
2,146✔
737
            table.insert(glues, node)
1,295✔
738
            gTotal:___add(node.height)
1,295✔
739
         end
740
      end
741
   end
742

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

747
   local adjustment = target - totalHeight
114✔
748
   if adjustment:tonumber() > 0 then
228✔
749
      if adjustment > gTotal.stretch then
98✔
750
         if
751
            (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber()
55✔
752
         then
753
            SU.warn(
18✔
754
               "Underfull frame "
755
                  .. self.frame.id
9✔
756
                  .. ": "
9✔
757
                  .. adjustment
9✔
758
                  .. " stretchiness required to fill but only "
9✔
759
                  .. gTotal.stretch
9✔
760
                  .. " available"
9✔
761
            )
762
         end
763
         adjustment = gTotal.stretch
11✔
764
      end
765
      if gTotal.stretch:tonumber() > 0 then
196✔
766
         for i = 1, #glues do
1,290✔
767
            local g = glues[i]
1,195✔
768
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
5,975✔
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)
114✔
795
end
796

797
function typesetter:initNextFrame ()
80✔
798
   local oldframe = self.frame
43✔
799
   self.frame:leave(self)
43✔
800
   if #self.state.outputQueue == 0 then
43✔
801
      self.state.previousVbox = nil
27✔
802
   end
803
   if self.frame.next and self.state.lastPenalty > supereject_penalty then
43✔
804
      self:initFrame(SILE.getFrame(self.frame.next))
12✔
805
   elseif not self.frame:isMainContentFrame() then
78✔
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")
25✔
812
      SILE.documentState.documentClass:endPage()
25✔
813
      self:initFrame(SILE.documentState.documentClass:newPage())
50✔
814
   end
815

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

837
function typesetter:pushBack ()
80✔
838
   SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
6✔
839
   local oldqueue = self.state.outputQueue
6✔
840
   self.state.outputQueue = {}
6✔
841
   self.state.previousVbox = nil
6✔
842
   local lastMargins = self:getMargins()
6✔
843
   for _, vbox in ipairs(oldqueue) do
22✔
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]
8✔
902
      and (self.state.nodes[#self.state.nodes].is_penalty or self.state.nodes[#self.state.nodes].is_zero)
8✔
903
   do
904
      self.state.nodes[#self.state.nodes] = nil
2✔
905
   end
906
end
907

908
function typesetter:outputLinesToPage (lines)
80✔
909
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
161✔
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
161✔
916
   for _, line in ipairs(lines) do
2,591✔
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
2,430✔
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
156✔
925
      end
926
      if pastTop then
2,430✔
927
         line:outputYourself(self, line)
2,224✔
928
      end
929
   end
930
   self.frame.state.totals.pastTop = pastTop
161✔
931
end
932

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

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

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

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

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

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

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

1210
         for j = linestart, point.position do
14,511✔
1211
            local currentNode = nodes[j]
13,712✔
1212
            if
1213
               not currentNode.discardable
13,712✔
1214
               and not (currentNode.is_glue and not currentNode.explicit)
9,099✔
1215
               and not currentNode.is_zero
8,607✔
1216
            then
1217
               -- actual visible content starts here
1218
               lastContentNodeIndex = #slice + 1
8,111✔
1219
            end
1220
            if not seenLiner and lastContentNodeIndex then
13,712✔
1221
               -- Any stacked liner (unclosed from a previous line) is reopened on
1222
               -- the current line.
1223
               seenLiner = self:_repeatEnterLiners(slice)
25,342✔
1224
               lastContentNodeIndex = #slice + 1
12,671✔
1225
            end
1226
            if currentNode.is_discretionary and currentNode.used then
13,712✔
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()
182✔
1230
            end
1231
            slice[#slice + 1] = currentNode
13,712✔
1232
            if currentNode then
13,712✔
1233
               if not currentNode.discardable then
13,712✔
1234
                  seenNonDiscardable = true
9,099✔
1235
               end
1236
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
27,424✔
1237
            end
1238
         end
1239
         if not seenNonDiscardable then
799✔
1240
            -- Slip lines containing only discardable nodes (e.g. glues).
1241
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
6✔
1242
            linestart = point.position + 1
6✔
1243
         else
1244
            if slice[#slice].is_discretionary then
793✔
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
91✔
1248
               -- And mark it as used as prebreak for now.
1249
               slice[#slice]:markAsPrebreak()
182✔
1250
            else
1251
               linestart = point.position + 1
702✔
1252
            end
1253

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

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

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

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

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

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

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

1307
   -- Until end of actual content
1308
   for i = 1, npos do
15,876✔
1309
      local node = slice[i]
15,083✔
1310
      if node.is_box then
15,083✔
1311
         skipping = false
7,568✔
1312
         if node.parent and not node.parent.hyphenated then
7,568✔
1313
            if not seenNodes[node.parent] then
2,269✔
1314
               naturalTotals:___add(node.parent:lineContribution())
1,888✔
1315
            end
1316
            seenNodes[node.parent] = true
2,269✔
1317
         else
1318
            naturalTotals:___add(node:lineContribution())
10,598✔
1319
         end
1320
      elseif node.is_penalty and node.penalty == -inf_bad then
7,515✔
1321
         skipping = false
494✔
1322
      elseif node.is_discretionary then
7,021✔
1323
         skipping = false
1,583✔
1324
         local seen = node.parent and seenNodes[node.parent]
1,583✔
1325
         if not seen then
1,583✔
1326
            if node.used then
258✔
1327
               if node.is_prebreak then
182✔
1328
                  naturalTotals:___add(node:prebreakWidth())
182✔
1329
                  node.height = node:prebreakHeight()
182✔
1330
               else
1331
                  naturalTotals:___add(node:postbreakWidth())
182✔
1332
                  node.height = node:postbreakHeight()
182✔
1333
               end
1334
            else
1335
               naturalTotals:___add(node:replacementWidth():absolute())
228✔
1336
               node.height = node:replacementHeight():absolute()
228✔
1337
            end
1338
         end
1339
      elseif not skipping then
5,438✔
1340
         naturalTotals:___add(node.width)
5,438✔
1341
      end
1342
   end
1343

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

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

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

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

1422
   local hbox = SILE.types.node.hbox({
191✔
1423
      height = h,
106✔
1424
      width = l,
84✔
1425
      depth = d,
264✔
1426
      value = recentContribution,
624✔
1427
      outputYourself = function (box, atypesetter, line)
22✔
1428
         local _post = _rtl_pre_post(box, atypesetter, line)
247✔
1429
         local ox = atypesetter.frame.state.cursorX
247✔
1430
         local oy = atypesetter.frame.state.cursorY
293✔
1431
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
288✔
1432
         SU.debug("hboxes", function ()
535✔
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))
46✔
1436
            return "Drew debug outline around hbox"
39✔
1437
         end)
1438
         for _, node in ipairs(box.value) do
700✔
1439
            node:outputYourself(atypesetter, line)
453✔
1440
         end
1441
         atypesetter.frame.state.cursorX = ox
325✔
1442
         atypesetter.frame.state.cursorY = oy
247✔
1443
         _post()
247✔
1444
      end,
1445
   })
1446
   return hbox, migratingNodes
146✔
1447
end
1448

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

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

© 2025 Coveralls, Inc