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

sile-typesetter / sile / 9269282710

28 May 2024 12:27PM UTC coverage: 73.451% (+7.2%) from 66.229%
9269282710

push

github

web-flow
Merge pull request #2025 from Mic92/patch-1

11733 of 15974 relevant lines covered (73.45%)

7213.35 hits per line

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

66.44
/typesetters/base.lua
1
--- SILE typesetter (default/base) class.
2
--
3
-- @copyright License: MIT
4
-- @module typesetters.base
5
--
6

7
-- Typesetter base class
8

9
local typesetter = pl.class()
180✔
10
typesetter.type = "typesetter"
180✔
11
typesetter._name = "base"
180✔
12

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

20
-- Local helper class to compare pairs of margins
21
local _margins = pl.class({
360✔
22
    lskip = SILE.nodefactory.glue(),
360✔
23
    rskip = SILE.nodefactory.glue(),
360✔
24

25
    _init = function (self, lskip, rskip)
26
      self.lskip, self.rskip = lskip, rskip
3,451✔
27
    end,
28

29
    __eq = function (self, other)
30
      return self.lskip.width == other.lskip.width and self.rskip.width == other.rskip.width
5✔
31
    end
32

33
  })
34

35
local warned = false
180✔
36

37
function typesetter:init (frame)
180✔
38
  SU.deprecated("std.object", "pl.class", "0.13.0", "0.14.0", warned and "" or [[
×
39
  The typesetter instance inheritance system for instances has been
40
  refactored using a different object model. Your instance was created
41
  and initialized using the object copy syntax from the stdlib model.
42
  It has been shimmed for you using the new Penlight model, but this may
43
  lead to unexpected behaviour. Please update your code to use the new
44
  Penlight based inheritance model.]])
×
45
  warned = true
×
46
  self:_init(frame)
×
47
end
48

49
function typesetter:_init (frame)
180✔
50
  self:declareSettings()
314✔
51
  self.hooks = {}
314✔
52
  self.breadcrumbs = SU.breadcrumbs()
628✔
53

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

63
function typesetter.declareSettings(_)
180✔
64

65
  -- Settings common to any typesetter instance.
66
  -- These shouldn't be re-declared and overwritten/reset in the typesetter
67
  -- constructor (see issue https://github.com/sile-typesetter/sile/issues/1708).
68
  -- On the other hand, it's fairly acceptable to have them made global:
69
  -- Any derived typesetter, whatever its implementation, should likely provide
70
  -- some logic for them (= widows, orphans, spacing, etc.)
71

72
  SILE.settings:declare({
314✔
73
    parameter = "typesetter.widowpenalty",
74
    type = "integer",
75
    default = 3000,
76
    help = "Penalty to be applied to widow lines (at the start of a paragraph)"
×
77
  })
78

79
  SILE.settings:declare({
314✔
80
    parameter = "typesetter.parseppattern",
81
    type = "string or integer",
82
    default = "\r?\n[\r\n]+",
83
    help = "Lua pattern used to separate paragraphs"
×
84
  })
85

86
  SILE.settings:declare({
314✔
87
    parameter = "typesetter.obeyspaces",
88
    type = "boolean or nil",
89
    default = nil,
90
    help = "Whether to ignore paragraph initial spaces"
×
91
  })
92

93
  SILE.settings:declare({
314✔
94
    parameter = "typesetter.orphanpenalty",
95
    type = "integer",
96
    default = 3000,
97
    help = "Penalty to be applied to orphan lines (at the end of a paragraph)"
×
98
  })
99

100
  SILE.settings:declare({
628✔
101
    parameter = "typesetter.parfillskip",
102
    type = "glue",
103
    default = SILE.nodefactory.glue("0pt plus 10000pt"),
628✔
104
    help = "Glue added at the end of a paragraph"
×
105
  })
106

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

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

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

128
  SILE.settings:declare({
314✔
129
    parameter = "typesetter.breakwidth",
130
    type = "measurement or nil",
131
    default = nil,
132
    help = "Width to break lines at"
×
133
  })
134

135
  SILE.settings:declare({
314✔
136
    parameter = "typesetter.softHyphen",
137
    type = "boolean",
138
    default = true,
139
    help = "When true, soft hyphens are rendered as discretionary breaks, otherwise they are ignored"
×
140
  })
141

142
  SILE.settings:declare({
314✔
143
    parameter = "typesetter.softHyphenWarning",
144
    type = "boolean",
145
    default = false,
146
    help = "When true, a warning is issued when a soft hyphen is encountered"
×
147
  })
148

149
  SILE.settings:declare({
314✔
150
    parameter = "typesetter.fixedSpacingAfterInitialEmdash",
151
    type = "boolean",
152
    default = true,
153
    help = "When true, em-dash starting a paragraph is considered as a speaker change in a dialogue"
×
154
  })
155
end
156

157
function typesetter:initState ()
180✔
158
  self.state = {
369✔
159
    nodes = {},
369✔
160
    outputQueue = {},
369✔
161
    lastBadness = awful_bad,
369✔
162
  }
369✔
163
end
164

165
function typesetter:initFrame (frame)
180✔
166
  if frame then
510✔
167
    self.frame = frame
486✔
168
    self.frame:init(self)
486✔
169
  end
170
end
171

172
function typesetter.getMargins ()
180✔
173
  return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
10,353✔
174
end
175

176
function typesetter.setMargins (_, margins)
180✔
177
  SILE.settings:set("document.lskip", margins.lskip)
2✔
178
  SILE.settings:set("document.rskip", margins.rskip)
2✔
179
end
180

181
function typesetter:pushState ()
180✔
182
  self.stateQueue[#self.stateQueue+1] = self.state
55✔
183
  self:initState()
55✔
184
end
185

186
function typesetter:popState (ncount)
180✔
187
  local offset = ncount and #self.stateQueue - ncount or nil
55✔
188
  self.state = table.remove(self.stateQueue, offset)
110✔
189
  if not self.state then SU.error("Typesetter state queue empty") end
55✔
190
end
191

192
function typesetter:isQueueEmpty ()
180✔
193
  if not self.state then return nil end
2,494✔
194
  return #self.state.nodes == 0 and #self.state.outputQueue == 0
2,494✔
195
end
196

197
function typesetter:vmode ()
180✔
198
  return #self.state.nodes == 0
443✔
199
end
200

201
function typesetter:debugState ()
180✔
202
  print("\n---\nI am in "..(self:vmode() and "vertical" or "horizontal").." mode")
×
203
  print("Writing into " .. tostring(self.frame))
×
204
  print("Recent contributions: ")
×
205
  for i = 1, #(self.state.nodes) do
×
206
    io.stderr:write(self.state.nodes[i].. " ")
×
207
  end
208
  print("\nVertical list: ")
×
209
  for i = 1, #(self.state.outputQueue) do
×
210
    print("  "..self.state.outputQueue[i])
×
211
  end
212
end
213

214
-- Boxy stuff
215
function typesetter:pushHorizontal (node)
180✔
216
  self:initline()
5,547✔
217
  self.state.nodes[#self.state.nodes+1] = node
5,547✔
218
  return node
5,547✔
219
end
220

221
function typesetter:pushVertical (vbox)
180✔
222
  self.state.outputQueue[#self.state.outputQueue+1] = vbox
4,964✔
223
  return vbox
4,964✔
224
end
225

226
function typesetter:pushHbox (spec)
180✔
227
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
228
  local ntype = SU.type(spec)
284✔
229
  local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.nodefactory.hbox(spec)
284✔
230
  return self:pushHorizontal(node)
284✔
231
end
232

233
function typesetter:pushUnshaped (spec)
180✔
234
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
235
  local node = SU.type(spec) == "unshaped" and spec or SILE.nodefactory.unshaped(spec)
3,186✔
236
  return self:pushHorizontal(node)
1,593✔
237
end
238

239
function typesetter:pushGlue (spec)
180✔
240
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
241
  local node = SU.type(spec) == "glue" and spec or SILE.nodefactory.glue(spec)
3,546✔
242
  return self:pushHorizontal(node)
1,773✔
243
end
244

245
function typesetter:pushExplicitGlue (spec)
180✔
246
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
247
  local node = SU.type(spec) == "glue" and spec or SILE.nodefactory.glue(spec)
168✔
248
  node.explicit = true
84✔
249
  node.discardable = false
84✔
250
  return self:pushHorizontal(node)
84✔
251
end
252

253
function typesetter:pushPenalty (spec)
180✔
254
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
255
  local node = SU.type(spec) == "penalty" and spec or SILE.nodefactory.penalty(spec)
1,824✔
256
  return self:pushHorizontal(node)
912✔
257
end
258

259
function typesetter:pushMigratingMaterial (material)
180✔
260
  local node = SILE.nodefactory.migrating({ material = material })
86✔
261
  return self:pushHorizontal(node)
86✔
262
end
263

264
function typesetter:pushVbox (spec)
180✔
265
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
266
  local node = SU.type(spec) == "vbox" and spec or SILE.nodefactory.vbox(spec)
4✔
267
  return self:pushVertical(node)
2✔
268
end
269

270
function typesetter:pushVglue (spec)
180✔
271
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
272
  local node = SU.type(spec) == "vglue" and spec or SILE.nodefactory.vglue(spec)
1,664✔
273
  return self:pushVertical(node)
832✔
274
end
275

276
function typesetter:pushExplicitVglue (spec)
180✔
277
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
278
  local node = SU.type(spec) == "vglue" and spec or SILE.nodefactory.vglue(spec)
1,040✔
279
  node.explicit = true
520✔
280
  node.discardable = false
520✔
281
  return self:pushVertical(node)
520✔
282
end
283

284
function typesetter:pushVpenalty (spec)
180✔
285
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
286
  local node = SU.type(spec) == "penalty" and spec or SILE.nodefactory.penalty(spec)
452✔
287
  return self:pushVertical(node)
226✔
288
end
289

290
-- Actual typesetting functions
291
function typesetter:typeset (text)
180✔
292
  text = tostring(text)
2,681✔
293
  if text:match("^%\r?\n$") then return end
2,681✔
294
  local pId = SILE.traceStack:pushText(text)
1,854✔
295
  for token in SU.gtoke(text, SILE.settings:get("typesetter.parseppattern")) do
7,627✔
296
    if token.separator then
2,065✔
297
      self:endline()
948✔
298
    else
299
      if SILE.settings:get("typesetter.softHyphen") then
3,182✔
300
        local warnedshy = false
1,590✔
301
        for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
4,800✔
302
          if token2.separator then -- soft hyphen support
1,620✔
303
            local discretionary = SILE.nodefactory.discretionary({})
15✔
304
            local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
30✔
305
            discretionary.prebreak = { hbox }
15✔
306
            table.insert(SILE.typesetter.state.nodes, discretionary)
15✔
307
            if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
16✔
308
              SU.warn("Soft hyphen encountered and replaced with discretionary")
×
309
            end
310
            warnedshy = true
15✔
311
          else
312
            self:setpar(token2.string)
1,605✔
313
          end
314
        end
315
      else
316
        if SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD)) then
2✔
317
          SU.warn("Soft hyphen encountered and ignored")
×
318
        end
319
        text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
1✔
320
        self:setpar(text)
1✔
321
      end
322
    end
323
  end
324
  SILE.traceStack:pop(pId)
1,854✔
325
end
326

327
function typesetter:initline ()
180✔
328
  if self.state.hmodeOnly then return end -- https://github.com/sile-typesetter/sile/issues/1718
6,282✔
329
  if (#self.state.nodes == 0) then
5,827✔
330
    self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
1,702✔
331
    SILE.documentState.documentClass.newPar(self)
851✔
332
  end
333
end
334

335
function typesetter:endline ()
180✔
336
  self:leaveHmode()
835✔
337
  SILE.documentState.documentClass.endPar(self)
835✔
338
end
339

340
-- Just compute once, to avoid unicode characters in source code.
341
local speakerChangePattern = "^"
×
342
   .. luautf8.char(0x2014) -- emdash
180✔
343
   .. "[ " .. luautf8.char(0x00A0) .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
180✔
344
   .. "]+"
180✔
345
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
180✔
346

347
-- Special unshaped node subclass to handle space after a speaker change in dialogues
348
-- introduced by an em-dash.
349
local speakerChangeNode = pl.class(SILE.nodefactory.unshaped)
180✔
350
function speakerChangeNode:shape()
180✔
351
  local node = self._base.shape(self)
3✔
352
  local spc = node[2]
3✔
353
  if spc and spc.is_glue then
3✔
354
    -- Switch the variable space glue to a fixed kern
355
    node[2] = SILE.nodefactory.kern({ width = spc.width.length })
6✔
356
    node[2].parent = self.parent
3✔
357
  else
358
    -- Should not occur:
359
    -- How could it possibly be shaped differently?
360
    SU.warn("Speaker change logic met an unexpected case, this might be a bug.")
×
361
  end
362
  return node
3✔
363
end
364

365
-- Takes string, writes onto self.state.nodes
366
function typesetter:setpar (text)
180✔
367
  text = text:gsub("\r?\n", " "):gsub("\t", " ")
1,619✔
368
  if (#self.state.nodes == 0) then
1,619✔
369
    if not SILE.settings:get("typesetter.obeyspaces") then
1,470✔
370
      text = text:gsub("^%s+", "")
725✔
371
    end
372
    self:initline()
735✔
373

374
    if SILE.settings:get("typesetter.fixedSpacingAfterInitialEmdash") and not SILE.settings:get("typesetter.obeyspaces") then
2,204✔
375
      local speakerChange = false
724✔
376
      local dialogue = luautf8.gsub(text, speakerChangePattern, function ()
1,448✔
377
        speakerChange = true
3✔
378
        return speakerChangeReplacement
3✔
379
      end)
380
      if speakerChange then
724✔
381
        local node = speakerChangeNode({ text = dialogue, options = SILE.font.loadDefaults({})})
6✔
382
        self:pushHorizontal(node)
3✔
383
        return -- done here: speaker change space handling is done after nnode shaping
3✔
384
      end
385
    end
386
  end
387
  if #text >0 then
1,616✔
388
    self:pushUnshaped({ text = text, options= SILE.font.loadDefaults({})})
3,186✔
389
  end
390
end
391

392
function typesetter:breakIntoLines (nodelist, breakWidth)
180✔
393
  self:shapeAllNodes(nodelist)
854✔
394
  local breakpoints = SILE.linebreak:doBreak(nodelist, breakWidth)
854✔
395
  return self:breakpointsToLines(breakpoints)
854✔
396
end
397

398
function typesetter.shapeAllNodes (_, nodelist)
180✔
399
  local newNl = {}
2,547✔
400
  for i = 1, #nodelist do
45,135✔
401
    if nodelist[i].is_unshaped then
42,588✔
402
      pl.tablex.insertvalues(newNl, nodelist[i]:shape())
3,912✔
403
    else
404
      newNl[#newNl+1] = nodelist[i]
41,284✔
405
    end
406
  end
407
  for i =1, #newNl do nodelist[i]=newNl[i] end
56,664✔
408
  if #nodelist > #newNl then
2,547✔
409
    for i=#newNl+1, #nodelist do nodelist[i]=nil end
×
410
  end
411
end
412

413
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
414
-- Turns a node list into a list of vboxes
415
function typesetter:boxUpNodes ()
180✔
416
  local nodelist = self.state.nodes
1,964✔
417
  if #nodelist == 0 then return {} end
1,964✔
418
  for j = #nodelist, 1, -1 do
1,047✔
419
    if not nodelist[j].is_migrating then
1,058✔
420
      if nodelist[j].discardable then
1,013✔
421
        table.remove(nodelist, j)
318✔
422
      else
423
        break
424
      end
425
    end
426
  end
427
  while (#nodelist > 0 and nodelist[1].is_penalty) do table.remove(nodelist, 1) end
854✔
428
  if #nodelist == 0 then return {} end
854✔
429
  self:shapeAllNodes(nodelist)
854✔
430
  local parfillskip = SILE.settings:get("typesetter.parfillskip")
854✔
431
  parfillskip.discardable = false
854✔
432
  self:pushGlue(parfillskip)
854✔
433
  self:pushPenalty(-inf_bad)
854✔
434
  SU.debug("typesetter", function ()
1,708✔
435
    return "Boxed up "..(#nodelist > 500 and (#nodelist).." nodes" or SU.contentToString(nodelist))
×
436
  end)
437
  local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
1,708✔
438
  local lines = self:breakIntoLines(nodelist, breakWidth)
854✔
439
  local vboxes = {}
854✔
440
  for index=1, #lines do
2,332✔
441
    local line = lines[index]
1,478✔
442
    local migrating = {}
1,478✔
443
    -- Move any migrating material
444
    local nodes = {}
1,478✔
445
    for i =1, #line.nodes do
33,524✔
446
      local node = line.nodes[i]
32,046✔
447
      if node.is_migrating then
32,046✔
448
        for j=1, #node.material do migrating[#migrating+1] = node.material[j] end
86✔
449
      else
450
        nodes[#nodes+1] = node
31,960✔
451
      end
452
    end
453
    local vbox = SILE.nodefactory.vbox({ nodes = nodes, ratio = line.ratio })
1,478✔
454
    local pageBreakPenalty = 0
1,478✔
455
    if (#lines > 1 and index == 1) then
1,478✔
456
      pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
×
457
    elseif (#lines > 1 and index == (#lines-1)) then
19✔
458
      pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
×
459
    end
460
    vboxes[#vboxes+1] = self:leadingFor(vbox, self.state.previousVbox)
38✔
461
    vboxes[#vboxes+1] = vbox
19✔
462
    for i=1, #migrating do vboxes[#vboxes+1] = migrating[i] end
19✔
463
    self.state.previousVbox = vbox
19✔
464
    if pageBreakPenalty > 0 then
19✔
465
      SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
×
466
      vboxes[#vboxes+1] = SILE.nodefactory.penalty(pageBreakPenalty)
×
467
    end
468
  end
469
  return vboxes
19✔
470
end
471

472
function typesetter.pageTarget (_)
3✔
473
  SU.deprecated("SILE.typesetter:pageTarget", "SILE.typesetter:getTargetLength", "0.13.0", "0.14.0")
×
474
end
475

476
function typesetter:getTargetLength ()
3✔
477
  return self.frame:getTargetLength()
46✔
478
end
479

480
function typesetter:registerHook (category, func)
3✔
481
  if not self.hooks[category] then self.hooks[category] = {} end
3✔
482
  table.insert(self.hooks[category], func)
3✔
483
end
484

485
function typesetter:runHooks (category, data)
3✔
486
  if not self.hooks[category] then return data end
46✔
487
  for _, func in ipairs(self.hooks[category]) do
6✔
488
    data = func(self, data)
6✔
489
  end
490
  return data
3✔
491
end
492

493
function typesetter:registerFrameBreakHook (func)
3✔
494
  self:registerHook("framebreak", func)
×
495
end
496

497
function typesetter:registerNewFrameHook (func)
3✔
498
  self:registerHook("newframe", func)
×
499
end
500

501
function typesetter:registerPageEndHook (func)
3✔
502
  self:registerHook("pageend", func)
3✔
503
end
504

505
function typesetter:buildPage ()
3✔
506
  local pageNodeList
507
  local res
508
  if self:isQueueEmpty() then return false end
90✔
509
  if SILE.scratch.insertions then SILE.scratch.insertions.thisPage = {} end
43✔
510
  pageNodeList, res = SILE.pagebuilder:findBestBreak({
86✔
511
    vboxlist = self.state.outputQueue,
43✔
512
    target   = self:getTargetLength(),
86✔
513
    restart  = self.frame.state.pageRestart
43✔
514
  })
43✔
515
  if not pageNodeList then -- No break yet
43✔
516
    -- self.frame.state.pageRestart = res
517
    self:runHooks("noframebreak")
40✔
518
    return false
40✔
519
  end
520
  SU.debug("pagebuilder", "Buildding page for", self.frame.id)
3✔
521
  self.state.lastPenalty = res
3✔
522
  self.frame.state.pageRestart = nil
3✔
523
  pageNodeList = self:runHooks("framebreak", pageNodeList)
6✔
524
  self:setVerticalGlue(pageNodeList, self:getTargetLength())
6✔
525
  self:outputLinesToPage(pageNodeList)
3✔
526
  return true
3✔
527
end
528

529
function typesetter:setVerticalGlue (pageNodeList, target)
3✔
530
  local glues = {}
3✔
531
  local gTotal = SILE.length()
3✔
532
  local totalHeight = SILE.length()
3✔
533

534
  local pastTop = false
3✔
535
  for _, node in ipairs(pageNodeList) do
78✔
536
    if not pastTop and not node.discardable and not node.explicit then
75✔
537
      -- "Ignore discardable and explicit glues at the top of a frame."
538
      -- See typesetter:outputLinesToPage()
539
      -- Note the test here doesn't check is_vglue, so will skip other
540
      -- discardable nodes (e.g. penalties), but it shouldn't matter
541
      -- for the type of computing performed here.
542
      pastTop = true
3✔
543
    end
544
    if pastTop then
75✔
545
      if not node.is_insertion then
70✔
546
        totalHeight:___add(node.height)
70✔
547
        totalHeight:___add(node.depth)
70✔
548
      end
549
      if node.is_vglue then
70✔
550
        table.insert(glues, node)
54✔
551
        gTotal:___add(node.height)
54✔
552
      end
553
    end
554
  end
555

556
  if totalHeight:tonumber() == 0 then
6✔
557
   return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
×
558
  end
559

560
  local adjustment = target - totalHeight
3✔
561
  if adjustment:tonumber() > 0 then
6✔
562
    if adjustment > gTotal.stretch then
3✔
563
      if (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber() then
×
564
        SU.warn("Underfull frame " .. self.frame.id .. ": " .. adjustment .. " stretchiness required to fill but only " .. gTotal.stretch .. " available")
×
565
      end
566
      adjustment = gTotal.stretch
×
567
    end
568
    if gTotal.stretch:tonumber() > 0 then
6✔
569
      for i = 1, #glues do
57✔
570
        local g = glues[i]
54✔
571
        g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
270✔
572
      end
573
    end
574
  elseif adjustment:tonumber() < 0 then
×
575
    adjustment = 0 - adjustment
×
576
    if adjustment > gTotal.shrink then
×
577
      if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
×
578
        SU.warn("Overfull frame " .. self.frame.id .. ": " .. adjustment .. " shrinkability required to fit but only " .. gTotal.shrink .. " available")
×
579
      end
580
      adjustment = gTotal.shrink
×
581
    end
582
    if gTotal.shrink:tonumber() > 0 then
×
583
      for i = 1, #glues do
×
584
        local g  = glues[i]
×
585
        g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
586
      end
587
    end
588
  end
589
  SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
3✔
590
end
591

592
function typesetter:initNextFrame ()
3✔
593
  local oldframe = self.frame
×
594
  self.frame:leave(self)
×
595
  if #self.state.outputQueue == 0 then
×
596
    self.state.previousVbox = nil
×
597
  end
598
  if self.frame.next and self.state.lastPenalty > supereject_penalty then
×
599
    self:initFrame(SILE.getFrame(self.frame.next))
×
600
  elseif not self.frame:isMainContentFrame() then
×
601
    if #self.state.outputQueue > 0 then
×
602
      SU.warn("Overfull content for frame " .. self.frame.id)
×
603
      self:chuck()
×
604
    end
605
  else
606
    self:runHooks("pageend")
×
607
    SILE.documentState.documentClass:endPage()
×
608
    self:initFrame(SILE.documentState.documentClass:newPage())
×
609
  end
610

611
  if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
×
612
    self:pushBack()
×
613
    -- Some what of a hack below.
614
    -- Before calling this method, we were in vertical mode...
615
    -- pushback occurred, and it seems it messes up a bit...
616
    -- Regardless what it does, at the end, we ought to be in vertical mode
617
    -- again:
618
    self:leaveHmode()
×
619
  else
620
    -- If I have some things on the vertical list already, they need
621
    -- proper top-of-frame leading applied.
622
    if #self.state.outputQueue > 0 then
×
623
      local lead = self:leadingFor(self.state.outputQueue[1], nil)
×
624
      if lead then
×
625
        table.insert(self.state.outputQueue, 1, lead)
×
626
      end
627
    end
628
  end
629
  self:runHooks("newframe")
×
630

631
end
632

633
function typesetter:pushBack ()
3✔
634
  SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
×
635
  local oldqueue = self.state.outputQueue
×
636
  self.state.outputQueue = {}
×
637
  self.state.previousVbox = nil
×
638
  local lastMargins = self:getMargins()
×
639
  for _, vbox in ipairs(oldqueue) do
×
640
    SU.debug("pushback", "process box", vbox)
×
641
    if vbox.margins and vbox.margins ~= lastMargins then
×
642
      SU.debug("pushback", "new margins", lastMargins, vbox.margins)
×
643
      if not self.state.grid then self:endline() end
×
644
      self:setMargins(vbox.margins)
×
645
    end
646
    if vbox.explicit then
×
647
      SU.debug("pushback", "explicit", vbox)
×
648
      self:endline()
×
649
      self:pushExplicitVglue(vbox)
×
650
    elseif vbox.is_insertion then
×
651
      SU.debug("pushback", "pushBack", "insertion", vbox)
×
652
      SILE.typesetter:pushMigratingMaterial({ material = { vbox } })
×
653
    elseif not vbox.is_vglue and not vbox.is_penalty then
×
654
      SU.debug("pushback", "not vglue or penalty", vbox.type)
×
655
      local discardedFistInitLine = false
×
656
      if (#self.state.nodes == 0) then
×
657
        -- Setup queue but avoid calling newPar
658
        self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
×
659
      end
660
      for i, node in ipairs(vbox.nodes) do
×
661
        if node.is_glue and not node.discardable then
×
662
          self:pushHorizontal(node)
×
663
        elseif node.is_glue and node.value == "margin" then
×
664
          SU.debug("pushback", "discard", node.value, node)
×
665
        elseif node.is_discretionary then
×
666
          SU.debug("pushback", "re-mark discretionary as unused", node)
×
667
          node.used = false
×
668
          if i == 1 then
×
669
            SU.debug("pushback", "keep first discretionary", node)
×
670
            self:pushHorizontal(node)
×
671
          else
672
            SU.debug("pushback", "discard all other discretionaries", node)
×
673
          end
674
        elseif node.is_zero then
×
675
          if discardedFistInitLine then self:pushHorizontal(node) end
×
676
          discardedFistInitLine = true
×
677
        elseif node.is_penalty then
×
678
          if not discardedFistInitLine then self:pushHorizontal(node) end
×
679
        else
680
          node.bidiDone = true
×
681
          self:pushHorizontal(node)
×
682
        end
683
      end
684
    else
685
      SU.debug("pushback", "discard", vbox.type)
×
686
    end
687
    lastMargins = vbox.margins
×
688
    -- self:debugState()
689
  end
690
  while self.state.nodes[#self.state.nodes]
×
691
  and (self.state.nodes[#self.state.nodes].is_penalty
×
692
    or self.state.nodes[#self.state.nodes].is_zero) do
×
693
    self.state.nodes[#self.state.nodes] = nil
×
694
  end
695
end
696

697
function typesetter:outputLinesToPage (lines)
3✔
698
  SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
6✔
699
  -- It would have been nice to avoid storing this "pastTop" into a frame
700
  -- state, to keep things less entangled. There are situations, though,
701
  -- we will have left horizontal mode (triggering output), but will later
702
  -- call typesetter:chuck() do deal with any remaining content, and we need
703
  -- to know whether some content has been output already.
704
  local pastTop = self.frame.state.totals.pastTop
6✔
705
  for _, line in ipairs(lines) do
90✔
706
    -- Ignore discardable and explicit glues at the top of a frame:
707
    -- Annoyingly, explicit glue *should* disappear at the top of a page.
708
    -- if you don't want that, add an empty vbox or something.
709
    if not pastTop and not line.discardable and not line.explicit then
84✔
710
      -- Note the test here doesn't check is_vglue, so will skip other
711
      -- discardable nodes (e.g. penalties), but it shouldn't matter
712
      -- for outputting.
713
      pastTop = true
6✔
714
    end
715
    if pastTop then
84✔
716
      line:outputYourself(self, line)
76✔
717
    end
718
  end
719
  self.frame.state.totals.pastTop = pastTop
6✔
720
end
721

722
function typesetter:leaveHmode (independent)
3✔
723
  if self.state.hmodeOnly then
48✔
724
    -- HACK HBOX
725
    -- This should likely be an error, but may break existing uses
726
    -- (although these are probably already defective).
727
    -- See also comment HACK HBOX in typesetter:makeHbox().
728
    SU.warn([[Building paragraphs in this context may have unpredictable results.
×
729
It will likely break in future versions]])
×
730
  end
731
  SU.debug("typesetter", "Leaving hmode")
48✔
732
  local margins = self:getMargins()
48✔
733
  local vboxlist = self:boxUpNodes()
48✔
734
  self.state.nodes = {}
48✔
735
  -- Push output lines into boxes and ship them to the page builder
736
  for _, vbox in ipairs(vboxlist) do
86✔
737
    vbox.margins = margins
38✔
738
    self:pushVertical(vbox)
38✔
739
  end
740
  if independent then return end
48✔
741
  if self:buildPage() then
84✔
742
    self:initNextFrame()
×
743
  end
744
end
745

746
function typesetter:inhibitLeading ()
3✔
747
  self.state.previousVbox = nil
×
748
end
749

750
function typesetter.leadingFor (_, vbox, previous)
3✔
751
  -- Insert leading
752
  SU.debug("typesetter", "   Considering leading between two lines:")
19✔
753
  SU.debug("typesetter", "   1)", previous)
19✔
754
  SU.debug("typesetter", "   2)", vbox)
19✔
755
  if not previous then return SILE.nodefactory.vglue() end
19✔
756
  local prevDepth = previous.depth
13✔
757
  SU.debug("typesetter", "   Depth of previous line was", prevDepth)
13✔
758
  local bls = SILE.settings:get("document.baselineskip")
13✔
759
  local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
65✔
760
  SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
13✔
761

762
  -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
763
  local lead = SILE.settings:get("document.lineskip").height:absolute()
26✔
764
  if depth > lead then
13✔
765
    return SILE.nodefactory.vglue(SILE.length(depth.length, bls.height.stretch, bls.height.shrink))
4✔
766
  else
767
    return SILE.nodefactory.vglue(lead)
11✔
768
  end
769
end
770

771
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
3✔
772
  local LTR = self.frame:writingDirection() == "LTR"
38✔
773
  local rskip = margins[LTR and "rskip" or "lskip"]
19✔
774
  if not rskip then rskip = SILE.nodefactory.glue(0) end
19✔
775
  if hangRight and hangRight > 0 then
19✔
776
    rskip = SILE.nodefactory.glue({ width = rskip.width:tonumber() + hangRight })
×
777
  end
778
  rskip.value = "margin"
19✔
779
  -- while slice[#slice].discardable do table.remove(slice, #slice) end
780
  table.insert(slice, rskip)
19✔
781
  table.insert(slice, SILE.nodefactory.zerohbox())
38✔
782
  local lskip = margins[LTR and "lskip" or "rskip"]
19✔
783
  if not lskip then lskip = SILE.nodefactory.glue(0) end
19✔
784
  if hangLeft and hangLeft > 0 then
19✔
785
    lskip = SILE.nodefactory.glue({ width = lskip.width:tonumber() + hangLeft })
×
786
  end
787
  lskip.value = "margin"
19✔
788
  while slice[1].discardable do table.remove(slice, 1) end
19✔
789
  table.insert(slice, 1, lskip)
19✔
790
  table.insert(slice, 1, SILE.nodefactory.zerohbox())
38✔
791
end
792

793
function typesetter:breakpointsToLines (breakpoints)
3✔
794
  local linestart = 1
19✔
795
  local lines = {}
19✔
796
  local nodes = self.state.nodes
19✔
797

798
  for i = 1, #breakpoints do
38✔
799
    local point = breakpoints[i]
19✔
800
    if point.position ~= 0 then
19✔
801
      local slice = {}
19✔
802
      local seenNonDiscardable = false
19✔
803
      for j = linestart, point.position do
225✔
804
        slice[#slice+1] = nodes[j]
206✔
805
        if nodes[j] then
206✔
806
          if not nodes[j].discardable then
206✔
807
            seenNonDiscardable = true
129✔
808
          end
809
        end
810
      end
811
      if not seenNonDiscardable then
19✔
812
        -- Slip lines containing only discardable nodes (e.g. glues).
813
        SU.debug("typesetter", "Skipping a line containing only discardable nodes")
×
814
        linestart = point.position + 1
×
815
      else
816
        -- If the line ends with a discretionary, repeat it on the next line,
817
        -- so as to account for a potential postbreak.
818
        if slice[#slice].is_discretionary then
19✔
819
          linestart = point.position
×
820
        else
821
          linestart = point.position + 1
19✔
822
        end
823

824
        -- Then only we can add some extra margin glue...
825
        local mrg = self:getMargins()
19✔
826
        self:addrlskip(slice, mrg, point.left, point.right)
19✔
827

828
        -- And compute the line...
829
        local ratio = self:computeLineRatio(point.width, slice)
19✔
830
        local thisLine = { ratio = ratio, nodes = slice }
19✔
831
        lines[#lines+1] = thisLine
19✔
832
      end
833
    end
834
  end
835
  if linestart < #nodes then
19✔
836
    -- Abnormal, but warn so that one has a chance to check which bits
837
    -- are missing at output.
838
    SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
839
  end
840
  return lines
19✔
841
end
842

843
function typesetter.computeLineRatio (_, breakwidth, slice)
3✔
844
  -- This somewhat wrong, see #1362 and #1528
845
  -- This is a somewhat partial workaround, at least made consistent with
846
  -- the nnode and discretionary outputYourself routines
847
  -- (which are somewhat wrong too, or to put it otherwise, the whole
848
  -- logic here, marking nodes without removing/replacing them, likely makes
849
  -- things more complex than they should).
850
  -- TODO Possibly consider a full rewrite/refactor.
851
  local naturalTotals = SILE.length()
19✔
852

853
  -- From the line end, check if the line is hyphenated (to account for a prebreak)
854
  -- or contains extraneous glues (e.g. to account for spaces to ignore).
855
  local n = #slice
19✔
856
  while n > 1 do
57✔
857
    if slice[n].is_glue or slice[n].is_zero then
57✔
858
      -- Skip margin glues (they'll be accounted for in the loop below) and
859
      -- zero boxes, so as to reach actual content...
860
      if slice[n].value ~= "margin" then
38✔
861
        -- ... but any other glue than a margin, at the end of a line, is actually
862
        -- extraneous. It will however also be accounted for below, so subtract
863
        -- them to cancel their width. Typically, if a line break occurred at
864
        -- a space, the latter is then at the end of the line now, and must be
865
        -- ignored.
866
        naturalTotals:___sub(slice[n].width)
19✔
867
      end
868
    elseif slice[n].is_discretionary then
19✔
869
      -- Stop as we reached an hyphenation, and account for the prebreak.
870
      slice[n].used = true
×
871
      if slice[n].parent then
×
872
        slice[n].parent.hyphenated = true
×
873
      end
874
      naturalTotals:___add(slice[n]:prebreakWidth())
×
875
      slice[n].height = slice[n]:prebreakHeight()
×
876
      break
877
    else
878
      -- Stop as we reached actual content.
879
      break
880
    end
881
    n = n - 1
38✔
882
  end
883

884
  local seenNodes = {}
19✔
885
  local skipping = true
19✔
886
  for i, node in ipairs(slice) do
301✔
887
    if node.is_box then
282✔
888
      skipping = false
148✔
889
      if node.parent and not node.parent.hyphenated then
148✔
890
        if not seenNodes[node.parent] then
×
891
          naturalTotals:___add(node.parent:lineContribution())
×
892
        end
893
        seenNodes[node.parent] = true
×
894
      else
895
        naturalTotals:___add(node:lineContribution())
296✔
896
      end
897
    elseif node.is_penalty and node.penalty == -inf_bad then
134✔
898
      skipping = false
19✔
899
    elseif node.is_discretionary then
115✔
900
      skipping = false
×
901
      local seen = node.parent and seenNodes[node.parent]
×
902
      if not seen and not node.used then
×
903
        naturalTotals:___add(node:replacementWidth():absolute())
×
904
        slice[i].height = slice[i]:replacementHeight():absolute()
×
905
      end
906
    elseif not skipping then
115✔
907
      naturalTotals:___add(node.width)
115✔
908
    end
909
  end
910

911
  -- From the line start, skip glues and margins, and check if it then starts
912
  -- with a used discretionary. If so, account for a postbreak.
913
  n = 1
19✔
914
  while n < #slice do
95✔
915
    if slice[n].is_discretionary and slice[n].used then
95✔
916
      naturalTotals:___add(slice[n]:postbreakWidth())
×
917
      slice[n].height = slice[n]:postbreakHeight()
×
918
      break
919
    elseif not (slice[n].is_glue or slice[n].is_zero) then
95✔
920
      break
19✔
921
    end
922
    n = n + 1
76✔
923
  end
924

925
  local _left = breakwidth:tonumber() - naturalTotals:tonumber()
57✔
926
  local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
38✔
927
  ratio = math.max(ratio, -1)
19✔
928
  return ratio, naturalTotals
19✔
929
end
930

931
function typesetter:chuck () -- emergency shipout everything
3✔
932
  self:leaveHmode(true)
3✔
933
  if (#self.state.outputQueue > 0) then
3✔
934
    SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
3✔
935
    self:outputLinesToPage(self.state.outputQueue)
3✔
936
    self.state.outputQueue = {}
3✔
937
  end
938
end
939

940
-- Logic for building an hbox from content.
941
-- It returns the hbox and an horizontal list of (migrating) elements
942
-- extracted outside of it.
943
-- None of these are pushed to the typesetter node queue. The caller
944
-- is responsible of doing it, if the hbox is built for anything
945
-- else than e.g. measuring it. Likewise, the call has to decide
946
-- what to do with the migrating content.
947
local _rtl_pre_post = function (box, atypesetter, line)
948
  local advance = function () atypesetter.frame:advanceWritingDirection(box:scaledWidth(line)) end
×
949
  if atypesetter.frame:writingDirection() == "RTL" then
×
950
    advance()
×
951
    return function () end
×
952
  else
953
    return advance
×
954
  end
955
end
956
function typesetter:makeHbox (content)
3✔
957
  local recentContribution = {}
×
958
  local migratingNodes = {}
×
959

960
  -- HACK HBOX
961
  -- This is from the original implementation.
962
  -- It would be somewhat cleaner to use a temporary typesetter state
963
  -- (pushState/popState) rather than using the current one, removing
964
  -- the processed nodes from it afterwards. However, as long
965
  -- as leaving horizontal mode is not strictly forbidden here, it would
966
  -- lead to a possibly different result (the output queue being skipped).
967
  -- See also HACK HBOX comment in typesetter:leaveHmode().
968
  local index = #(self.state.nodes)+1
×
969
  self.state.hmodeOnly = true
×
970
  SILE.process(content)
×
971
  self.state.hmodeOnly = false -- Wouldn't be needed in a temporary state
×
972

973
  local l = SILE.length()
×
974
  local h, d = SILE.length(), SILE.length()
×
975
  for i = index, #(self.state.nodes) do
×
976
    local node = self.state.nodes[i]
×
977
    if node.is_migrating then
×
978
      migratingNodes[#migratingNodes+1] = node
×
979
    elseif node.is_unshaped then
×
980
      local shape = node:shape()
×
981
      for _, attr in ipairs(shape) do
×
982
        recentContribution[#recentContribution+1] = attr
×
983
        h = attr.height > h and attr.height or h
×
984
        d = attr.depth > d and attr.depth or d
×
985
        l = l + attr:lineContribution():absolute()
×
986
      end
987
    elseif node.is_discretionary then
×
988
      -- HACK https://github.com/sile-typesetter/sile/issues/583
989
      -- Discretionary nodes have a null line contribution...
990
      -- But if discretionary nodes occur inside an hbox, since the latter
991
      -- is not line-broken, they will never be marked as 'used' and will
992
      -- evaluate to the replacement content (if any)...
993
      recentContribution[#recentContribution+1] = node
×
994
      l = l + node:replacementWidth():absolute()
×
995
      -- The replacement content may have ascenders and descenders...
996
      local hdisc = node:replacementHeight():absolute()
×
997
      local ddisc = node:replacementDepth():absolute()
×
998
      h = hdisc > h and hdisc or h
×
999
      d = ddisc > d and ddisc or d
×
1000
      -- By the way it's unclear how this is expected to work in TTB
1001
      -- writing direction. For other type of nodes, the line contribution
1002
      -- evaluates to the height rather than the width in TTB, but the
1003
      -- whole logic might then be dubious there too...
1004
    else
1005
      recentContribution[#recentContribution+1] = node
×
1006
      l = l + node:lineContribution():absolute()
×
1007
      h = node.height > h and node.height or h
×
1008
      d = node.depth > d and node.depth or d
×
1009
    end
1010
    self.state.nodes[i] = nil -- wouldn't be needed in a temporary state
×
1011
  end
1012

1013
  local hbox = SILE.nodefactory.hbox({
×
1014
      height = h,
1015
      width = l,
1016
      depth = d,
1017
      value = recentContribution,
1018
      outputYourself = function (box, atypesetter, line)
1019
        local _post = _rtl_pre_post(box, atypesetter, line)
×
1020
        local ox = atypesetter.frame.state.cursorX
×
1021
        local oy = atypesetter.frame.state.cursorY
×
1022
        SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
×
1023
        for _, node in ipairs(box.value) do
×
1024
          node:outputYourself(atypesetter, line)
×
1025
        end
1026
        atypesetter.frame.state.cursorX = ox
×
1027
        atypesetter.frame.state.cursorY = oy
×
1028
        _post()
×
1029
        SU.debug("hboxes", function ()
×
1030
          SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
1031
          return "Drew debug outline around hbox"
×
1032
        end)
1033
      end
1034
    })
1035
  return hbox, migratingNodes
×
1036
end
1037

1038
function typesetter:pushHlist (hlist)
3✔
1039
  for _, h in ipairs(hlist) do
×
1040
    self:pushHorizontal(h)
×
1041
  end
1042
end
1043

1044
return typesetter
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc