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

sile-typesetter / sile / 7246678005

18 Dec 2023 10:19AM UTC coverage: 67.096% (-7.5%) from 74.62%
7246678005

push

github

web-flow
chore(deps): Bump actions/upload-artifact from 3 to 4 (#1940)

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

10583 of 15773 relevant lines covered (67.1%)

3150.6 hits per line

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

89.08
/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()
64✔
10
typesetter.type = "typesetter"
64✔
11
typesetter._name = "base"
64✔
12

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

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

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

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

33
  })
34

35
local warned = false
64✔
36

37
function typesetter:init (frame)
64✔
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)
64✔
50
  self:declareSettings()
121✔
51
  self.hooks = {}
121✔
52
  self.breadcrumbs = SU.breadcrumbs()
242✔
53

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

63
function typesetter.declareSettings(_)
64✔
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({
121✔
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({
121✔
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({
121✔
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({
121✔
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({
242✔
101
    parameter = "typesetter.parfillskip",
102
    type = "glue",
103
    default = SILE.nodefactory.glue("0pt plus 10000pt"),
242✔
104
    help = "Glue added at the end of a paragraph"
×
105
  })
106

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

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

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

128
  SILE.settings:declare({
121✔
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({
121✔
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({
121✔
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
end
149

150
function typesetter:initState ()
64✔
151
  self.state = {
136✔
152
    nodes = {},
136✔
153
    outputQueue = {},
136✔
154
    lastBadness = awful_bad,
136✔
155
  }
136✔
156
end
157

158
function typesetter:initFrame (frame)
64✔
159
  if frame then
191✔
160
    self.frame = frame
185✔
161
    self.frame:init(self)
185✔
162
  end
163
end
164

165
function typesetter.getMargins ()
64✔
166
  return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
4,341✔
167
end
168

169
function typesetter.setMargins (_, margins)
64✔
170
  SILE.settings:set("document.lskip", margins.lskip)
×
171
  SILE.settings:set("document.rskip", margins.rskip)
×
172
end
173

174
function typesetter:pushState ()
64✔
175
  self.stateQueue[#self.stateQueue+1] = self.state
15✔
176
  self:initState()
15✔
177
end
178

179
function typesetter:popState (ncount)
64✔
180
  local offset = ncount and #self.stateQueue - ncount or nil
15✔
181
  self.state = table.remove(self.stateQueue, offset)
30✔
182
  if not self.state then SU.error("Typesetter state queue empty") end
15✔
183
end
184

185
function typesetter:isQueueEmpty ()
64✔
186
  if not self.state then return nil end
1,037✔
187
  return #self.state.nodes == 0 and #self.state.outputQueue == 0
1,037✔
188
end
189

190
function typesetter:vmode ()
64✔
191
  return #self.state.nodes == 0
136✔
192
end
193

194
function typesetter:debugState ()
64✔
195
  print("\n---\nI am in "..(self:vmode() and "vertical" or "horizontal").." mode")
×
196
  print("Writing into " .. tostring(self.frame))
×
197
  print("Recent contributions: ")
×
198
  for i = 1, #(self.state.nodes) do
×
199
    io.stderr:write(self.state.nodes[i].. " ")
×
200
  end
201
  print("\nVertical list: ")
×
202
  for i = 1, #(self.state.outputQueue) do
×
203
    print("  "..self.state.outputQueue[i])
×
204
  end
205
end
206

207
-- Boxy stuff
208
function typesetter:pushHorizontal (node)
64✔
209
  self:initline()
2,015✔
210
  self.state.nodes[#self.state.nodes+1] = node
2,015✔
211
  return node
2,015✔
212
end
213

214
function typesetter:pushVertical (vbox)
64✔
215
  self.state.outputQueue[#self.state.outputQueue+1] = vbox
2,068✔
216
  return vbox
2,068✔
217
end
218

219
function typesetter:pushHbox (spec)
64✔
220
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
221
  local ntype = SU.type(spec)
67✔
222
  local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.nodefactory.hbox(spec)
67✔
223
  return self:pushHorizontal(node)
67✔
224
end
225

226
function typesetter:pushUnshaped (spec)
64✔
227
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
228
  local node = SU.type(spec) == "unshaped" and spec or SILE.nodefactory.unshaped(spec)
988✔
229
  return self:pushHorizontal(node)
494✔
230
end
231

232
function typesetter:pushGlue (spec)
64✔
233
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
234
  local node = SU.type(spec) == "glue" and spec or SILE.nodefactory.glue(spec)
1,464✔
235
  return self:pushHorizontal(node)
732✔
236
end
237

238
function typesetter:pushExplicitGlue (spec)
64✔
239
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
240
  local node = SU.type(spec) == "glue" and spec or SILE.nodefactory.glue(spec)
74✔
241
  node.explicit = true
37✔
242
  node.discardable = false
37✔
243
  return self:pushHorizontal(node)
37✔
244
end
245

246
function typesetter:pushPenalty (spec)
64✔
247
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
248
  local node = SU.type(spec) == "penalty" and spec or SILE.nodefactory.penalty(spec)
782✔
249
  return self:pushHorizontal(node)
391✔
250
end
251

252
function typesetter:pushMigratingMaterial (material)
64✔
253
  local node = SILE.nodefactory.migrating({ material = material })
22✔
254
  return self:pushHorizontal(node)
22✔
255
end
256

257
function typesetter:pushVbox (spec)
64✔
258
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
259
  local node = SU.type(spec) == "vbox" and spec or SILE.nodefactory.vbox(spec)
2✔
260
  return self:pushVertical(node)
1✔
261
end
262

263
function typesetter:pushVglue (spec)
64✔
264
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
265
  local node = SU.type(spec) == "vglue" and spec or SILE.nodefactory.vglue(spec)
786✔
266
  return self:pushVertical(node)
393✔
267
end
268

269
function typesetter:pushExplicitVglue (spec)
64✔
270
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
271
  local node = SU.type(spec) == "vglue" and spec or SILE.nodefactory.vglue(spec)
480✔
272
  node.explicit = true
240✔
273
  node.discardable = false
240✔
274
  return self:pushVertical(node)
240✔
275
end
276

277
function typesetter:pushVpenalty (spec)
64✔
278
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
279
  local node = SU.type(spec) == "penalty" and spec or SILE.nodefactory.penalty(spec)
176✔
280
  return self:pushVertical(node)
88✔
281
end
282

283
-- Actual typesetting functions
284
function typesetter:typeset (text)
64✔
285
  text = tostring(text)
881✔
286
  if text:match("^%\r?\n$") then return end
881✔
287
  local pId = SILE.traceStack:pushText(text)
607✔
288
  for token in SU.gtoke(text, SILE.settings:get("typesetter.parseppattern")) do
2,518✔
289
    if token.separator then
697✔
290
      self:endline()
428✔
291
    else
292
      if SILE.settings:get("typesetter.softHyphen") then
966✔
293
        local warnedshy = false
482✔
294
        for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
1,476✔
295
          if token2.separator then -- soft hyphen support
512✔
296
            local discretionary = SILE.nodefactory.discretionary({})
15✔
297
            local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
30✔
298
            discretionary.prebreak = { hbox }
15✔
299
            table.insert(SILE.typesetter.state.nodes, discretionary)
15✔
300
            if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
16✔
301
              SU.warn("Soft hyphen encountered and replaced with discretionary")
×
302
            end
303
            warnedshy = true
15✔
304
          else
305
            self:setpar(token2.string)
497✔
306
          end
307
        end
308
      else
309
        if SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD)) then
2✔
310
          SU.warn("Soft hyphen encountered and ignored")
×
311
        end
312
        text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
1✔
313
        self:setpar(text)
1✔
314
      end
315
    end
316
  end
317
  SILE.traceStack:pop(pId)
607✔
318
end
319

320
function typesetter:initline ()
64✔
321
  if self.state.hmodeOnly then return end -- https://github.com/sile-typesetter/sile/issues/1718
2,292✔
322
  if (#self.state.nodes == 0) then
2,264✔
323
    self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
720✔
324
    SILE.documentState.documentClass.newPar(self)
360✔
325
  end
326
end
327

328
function typesetter:endline ()
64✔
329
  self:leaveHmode()
401✔
330
  SILE.documentState.documentClass.endPar(self)
401✔
331
end
332

333
-- Takes string, writes onto self.state.nodes
334
function typesetter:setpar (text)
64✔
335
  text = text:gsub("\r?\n", " "):gsub("\t", " ")
498✔
336
  if (#self.state.nodes == 0) then
498✔
337
    if not SILE.settings:get("typesetter.obeyspaces") then
554✔
338
      text = text:gsub("^%s+", "")
277✔
339
    end
340
    self:initline()
277✔
341
  end
342
  if #text >0 then
498✔
343
    self:pushUnshaped({ text = text, options= SILE.font.loadDefaults({})})
988✔
344
  end
345
end
346

347
function typesetter:breakIntoLines (nodelist, breakWidth)
64✔
348
  self:shapeAllNodes(nodelist)
359✔
349
  local breakpoints = SILE.linebreak:doBreak(nodelist, breakWidth)
359✔
350
  return self:breakpointsToLines(breakpoints)
359✔
351
end
352

353
function typesetter.shapeAllNodes (_, nodelist)
64✔
354
  local newNl = {}
1,075✔
355
  for i = 1, #nodelist do
18,919✔
356
    if nodelist[i].is_unshaped then
17,844✔
357
      pl.tablex.insertvalues(newNl, nodelist[i]:shape())
1,458✔
358
    else
359
      newNl[#newNl+1] = nodelist[i]
17,358✔
360
    end
361
  end
362
  for i =1, #newNl do nodelist[i]=newNl[i] end
23,986✔
363
  if #nodelist > #newNl then
1,075✔
364
    for i=#newNl+1, #nodelist do nodelist[i]=nil end
×
365
  end
366
end
367

368
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
369
-- Turns a node list into a list of vboxes
370
function typesetter:boxUpNodes ()
64✔
371
  local nodelist = self.state.nodes
851✔
372
  if #nodelist == 0 then return {} end
851✔
373
  for j = #nodelist, 1, -1 do
439✔
374
    if not nodelist[j].is_migrating then
441✔
375
      if nodelist[j].discardable then
421✔
376
        table.remove(nodelist, j)
124✔
377
      else
378
        break
379
      end
380
    end
381
  end
382
  while (#nodelist > 0 and nodelist[1].is_penalty) do table.remove(nodelist, 1) end
359✔
383
  if #nodelist == 0 then return {} end
359✔
384
  self:shapeAllNodes(nodelist)
359✔
385
  local parfillskip = SILE.settings:get("typesetter.parfillskip")
359✔
386
  parfillskip.discardable = false
359✔
387
  self:pushGlue(parfillskip)
359✔
388
  self:pushPenalty(-inf_bad)
359✔
389
  SU.debug("typesetter", function ()
718✔
390
    return "Boxed up "..(#nodelist > 500 and (#nodelist).." nodes" or SU.contentToString(nodelist))
×
391
  end)
392
  local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
718✔
393
  local lines = self:breakIntoLines(nodelist, breakWidth)
359✔
394
  local vboxes = {}
359✔
395
  for index=1, #lines do
951✔
396
    local line = lines[index]
592✔
397
    local migrating = {}
592✔
398
    -- Move any migrating material
399
    local nodes = {}
592✔
400
    for i =1, #line.nodes do
13,866✔
401
      local node = line.nodes[i]
13,274✔
402
      if node.is_migrating then
13,274✔
403
        for j=1, #node.material do migrating[#migrating+1] = node.material[j] end
22✔
404
      else
405
        nodes[#nodes+1] = node
13,252✔
406
      end
407
    end
408
    local vbox = SILE.nodefactory.vbox({ nodes = nodes, ratio = line.ratio })
592✔
409
    local pageBreakPenalty = 0
592✔
410
    if (#lines > 1 and index == 1) then
592✔
411
      pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
130✔
412
    elseif (#lines > 1 and index == (#lines-1)) then
527✔
413
      pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
104✔
414
    end
415
    vboxes[#vboxes+1] = self:leadingFor(vbox, self.state.previousVbox)
1,184✔
416
    vboxes[#vboxes+1] = vbox
592✔
417
    for i=1, #migrating do vboxes[#vboxes+1] = migrating[i] end
603✔
418
    self.state.previousVbox = vbox
592✔
419
    if pageBreakPenalty > 0 then
592✔
420
      SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
117✔
421
      vboxes[#vboxes+1] = SILE.nodefactory.penalty(pageBreakPenalty)
234✔
422
    end
423
  end
424
  return vboxes
359✔
425
end
426

427
function typesetter.pageTarget (_)
64✔
428
  SU.deprecated("SILE.typesetter:pageTarget", "SILE.typesetter:getTargetLength", "0.13.0", "0.14.0")
×
429
end
430

431
function typesetter:getTargetLength ()
64✔
432
  return self.frame:getTargetLength()
843✔
433
end
434

435
function typesetter:registerHook (category, func)
64✔
436
  if not self.hooks[category] then self.hooks[category] = {} end
92✔
437
  table.insert(self.hooks[category], func)
92✔
438
end
439

440
function typesetter:runHooks (category, data)
64✔
441
  if not self.hooks[category] then return data end
871✔
442
  for _, func in ipairs(self.hooks[category]) do
270✔
443
    data = func(self, data)
304✔
444
  end
445
  return data
118✔
446
end
447

448
function typesetter:registerFrameBreakHook (func)
64✔
449
  self:registerHook("framebreak", func)
13✔
450
end
451

452
function typesetter:registerNewFrameHook (func)
64✔
453
  self:registerHook("newframe", func)
2✔
454
end
455

456
function typesetter:registerPageEndHook (func)
64✔
457
  self:registerHook("pageend", func)
77✔
458
end
459

460
function typesetter:buildPage ()
64✔
461
  local pageNodeList
462
  local res
463
  if self:isQueueEmpty() then return false end
1,562✔
464
  if SILE.scratch.insertions then SILE.scratch.insertions.thisPage = {} end
739✔
465
  pageNodeList, res = SILE.pagebuilder:findBestBreak({
1,478✔
466
    vboxlist = self.state.outputQueue,
739✔
467
    target   = self:getTargetLength(),
1,478✔
468
    restart  = self.frame.state.pageRestart
739✔
469
  })
739✔
470
  if not pageNodeList then -- No break yet
739✔
471
    -- self.frame.state.pageRestart = res
472
    self:runHooks("noframebreak")
637✔
473
    return false
637✔
474
  end
475
  SU.debug("pagebuilder", "Buildding page for", self.frame.id)
102✔
476
  self.state.lastPenalty = res
102✔
477
  self.frame.state.pageRestart = nil
102✔
478
  pageNodeList = self:runHooks("framebreak", pageNodeList)
204✔
479
  self:setVerticalGlue(pageNodeList, self:getTargetLength())
204✔
480
  self:outputLinesToPage(pageNodeList)
102✔
481
  return true
102✔
482
end
483

484
function typesetter:setVerticalGlue (pageNodeList, target)
64✔
485
  local glues = {}
102✔
486
  local gTotal = SILE.length()
102✔
487
  local totalHeight = SILE.length()
102✔
488

489
  local pastTop = false
102✔
490
  for _, node in ipairs(pageNodeList) do
1,815✔
491
    if not pastTop and not node.discardable and not node.explicit then
1,713✔
492
      -- "Ignore discardable and explicit glues at the top of a frame."
493
      -- See typesetter:outputLinesToPage()
494
      -- Note the test here doesn't check is_vglue, so will skip other
495
      -- discardable nodes (e.g. penalties), but it shouldn't matter
496
      -- for the type of computing performed here.
497
      pastTop = true
99✔
498
    end
499
    if pastTop then
1,713✔
500
      if not node.is_insertion then
1,579✔
501
        totalHeight:___add(node.height)
1,570✔
502
        totalHeight:___add(node.depth)
1,570✔
503
      end
504
      if node.is_vglue then
1,579✔
505
        table.insert(glues, node)
960✔
506
        gTotal:___add(node.height)
960✔
507
      end
508
    end
509
  end
510

511
  if totalHeight:tonumber() == 0 then
204✔
512
   return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
6✔
513
  end
514

515
  local adjustment = target - totalHeight
96✔
516
  if adjustment:tonumber() > 0 then
192✔
517
    if adjustment > gTotal.stretch then
80✔
518
      if (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber() then
45✔
519
        SU.warn("Underfull frame " .. self.frame.id .. ": " .. adjustment .. " stretchiness required to fill but only " .. gTotal.stretch .. " available")
7✔
520
      end
521
      adjustment = gTotal.stretch
9✔
522
    end
523
    if gTotal.stretch:tonumber() > 0 then
160✔
524
      for i = 1, #glues do
937✔
525
        local g = glues[i]
860✔
526
        g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
4,300✔
527
      end
528
    end
529
  elseif adjustment:tonumber() < 0 then
32✔
530
    adjustment = 0 - adjustment
16✔
531
    if adjustment > gTotal.shrink then
16✔
532
      if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
80✔
533
        SU.warn("Overfull frame " .. self.frame.id .. ": " .. adjustment .. " shrinkability required to fit but only " .. gTotal.shrink .. " available")
2✔
534
      end
535
      adjustment = gTotal.shrink
16✔
536
    end
537
    if gTotal.shrink:tonumber() > 0 then
32✔
538
      for i = 1, #glues do
×
539
        local g  = glues[i]
×
540
        g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
541
      end
542
    end
543
  end
544
  SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
96✔
545
end
546

547
function typesetter:initNextFrame ()
64✔
548
  local oldframe = self.frame
41✔
549
  self.frame:leave(self)
41✔
550
  if #self.state.outputQueue == 0 then
41✔
551
    self.state.previousVbox = nil
25✔
552
  end
553
  if self.frame.next and self.state.lastPenalty > supereject_penalty then
41✔
554
    self:initFrame(SILE.getFrame(self.frame.next))
9✔
555
  elseif not self.frame:isMainContentFrame() then
76✔
556
    if #self.state.outputQueue > 0 then
14✔
557
      SU.warn("Overfull content for frame " .. self.frame.id)
1✔
558
      self:chuck()
1✔
559
    end
560
  else
561
    self:runHooks("pageend")
24✔
562
    SILE.documentState.documentClass:endPage()
24✔
563
    self:initFrame(SILE.documentState.documentClass:newPage())
48✔
564
  end
565

566
  if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
164✔
567
    self:pushBack()
4✔
568
    -- Some what of a hack below.
569
    -- Before calling this method, we were in vertical mode...
570
    -- pushback occurred, and it seems it messes up a bit...
571
    -- Regardless what it does, at the end, we ought to be in vertical mode
572
    -- again:
573
    self:leaveHmode()
8✔
574
  else
575
    -- If I have some things on the vertical list already, they need
576
    -- proper top-of-frame leading applied.
577
    if #self.state.outputQueue > 0 then
37✔
578
      local lead = self:leadingFor(self.state.outputQueue[1], nil)
13✔
579
      if lead then
13✔
580
        table.insert(self.state.outputQueue, 1, lead)
12✔
581
      end
582
    end
583
  end
584
  self:runHooks("newframe")
41✔
585

586
end
587

588
function typesetter:pushBack ()
64✔
589
  SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
4✔
590
  local oldqueue = self.state.outputQueue
4✔
591
  self.state.outputQueue = {}
4✔
592
  self.state.previousVbox = nil
4✔
593
  local lastMargins = self:getMargins()
4✔
594
  for _, vbox in ipairs(oldqueue) do
20✔
595
    SU.debug("pushback", "process box", vbox)
16✔
596
    if vbox.margins and vbox.margins ~= lastMargins then
16✔
597
      SU.debug("pushback", "new margins", lastMargins, vbox.margins)
×
598
      if not self.state.grid then self:endline() end
×
599
      self:setMargins(vbox.margins)
×
600
    end
601
    if vbox.explicit then
16✔
602
      SU.debug("pushback", "explicit", vbox)
×
603
      self:endline()
×
604
      self:pushExplicitVglue(vbox)
×
605
    elseif vbox.is_insertion then
16✔
606
      SU.debug("pushback", "pushBack", "insertion", vbox)
×
607
      SILE.typesetter:pushMigratingMaterial({ material = { vbox } })
×
608
    elseif not vbox.is_vglue and not vbox.is_penalty then
16✔
609
      SU.debug("pushback", "not vglue or penalty", vbox.type)
6✔
610
      local discardedFistInitLine = false
6✔
611
      if (#self.state.nodes == 0) then
6✔
612
        -- Setup queue but avoid calling newPar
613
        self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
4✔
614
      end
615
      for i, node in ipairs(vbox.nodes) do
236✔
616
        if node.is_glue and not node.discardable then
230✔
617
          self:pushHorizontal(node)
4✔
618
        elseif node.is_glue and node.value == "margin" then
228✔
619
          SU.debug("pushback", "discard", node.value, node)
24✔
620
        elseif node.is_discretionary then
216✔
621
          SU.debug("pushback", "re-mark discretionary as unused", node)
×
622
          node.used = false
×
623
          if i == 1 then
×
624
            SU.debug("pushback", "keep first discretionary", node)
×
625
            self:pushHorizontal(node)
×
626
          else
627
            SU.debug("pushback", "discard all other discretionaries", node)
×
628
          end
629
        elseif node.is_zero then
216✔
630
          if discardedFistInitLine then self:pushHorizontal(node) end
14✔
631
          discardedFistInitLine = true
14✔
632
        elseif node.is_penalty then
202✔
633
          if not discardedFistInitLine then self:pushHorizontal(node) end
2✔
634
        else
635
          node.bidiDone = true
200✔
636
          self:pushHorizontal(node)
200✔
637
        end
638
      end
639
    else
640
      SU.debug("pushback", "discard", vbox.type)
10✔
641
    end
642
    lastMargins = vbox.margins
16✔
643
    -- self:debugState()
644
  end
645
  while self.state.nodes[#self.state.nodes]
6✔
646
  and (self.state.nodes[#self.state.nodes].is_penalty
6✔
647
    or self.state.nodes[#self.state.nodes].is_zero) do
4✔
648
    self.state.nodes[#self.state.nodes] = nil
2✔
649
  end
650
end
651

652
function typesetter:outputLinesToPage (lines)
64✔
653
  SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
141✔
654
  -- It would have been nice to avoid storing this "pastTop" into a frame
655
  -- state, to keep things less entangled. There are situations, though,
656
  -- we will have left horizontal mode (triggering output), but will later
657
  -- call typesetter:chuck() do deal with any remaining content, and we need
658
  -- to know whether some content has been output already.
659
  local pastTop = self.frame.state.totals.pastTop
141✔
660
  for _, line in ipairs(lines) do
1,971✔
661
    -- Ignore discardable and explicit glues at the top of a frame:
662
    -- Annoyingly, explicit glue *should* disappear at the top of a page.
663
    -- if you don't want that, add an empty vbox or something.
664
    if not pastTop and not line.discardable and not line.explicit then
1,830✔
665
      -- Note the test here doesn't check is_vglue, so will skip other
666
      -- discardable nodes (e.g. penalties), but it shouldn't matter
667
      -- for outputting.
668
      pastTop = true
136✔
669
    end
670
    if pastTop then
1,830✔
671
      line:outputYourself(self, line)
1,654✔
672
    end
673
  end
674
  self.frame.state.totals.pastTop = pastTop
141✔
675
end
676

677
function typesetter:leaveHmode (independent)
64✔
678
  if self.state.hmodeOnly then
851✔
679
    -- HACK HBOX
680
    -- This should likely be an error, but may break existing uses
681
    -- (although these are probably already defective).
682
    -- See also comment HACK HBOX in typesetter:makeHbox().
683
    SU.warn([[Building paragraphs in this context may have unpredictable results.
×
684
It will likely break in future versions]])
×
685
  end
686
  SU.debug("typesetter", "Leaving hmode")
851✔
687
  local margins = self:getMargins()
851✔
688
  local vboxlist = self:boxUpNodes()
851✔
689
  self.state.nodes = {}
851✔
690
  -- Push output lines into boxes and ship them to the page builder
691
  for _, vbox in ipairs(vboxlist) do
2,173✔
692
    vbox.margins = margins
1,322✔
693
    self:pushVertical(vbox)
1,322✔
694
  end
695
  if independent then return end
851✔
696
  if self:buildPage() then
1,438✔
697
    self:initNextFrame()
39✔
698
  end
699
end
700

701
function typesetter:inhibitLeading ()
64✔
702
  self.state.previousVbox = nil
2✔
703
end
704

705
function typesetter.leadingFor (_, vbox, previous)
64✔
706
  -- Insert leading
707
  SU.debug("typesetter", "   Considering leading between two lines:")
550✔
708
  SU.debug("typesetter", "   1)", previous)
550✔
709
  SU.debug("typesetter", "   2)", vbox)
550✔
710
  if not previous then return SILE.nodefactory.vglue() end
550✔
711
  local prevDepth = previous.depth
406✔
712
  SU.debug("typesetter", "   Depth of previous line was", prevDepth)
406✔
713
  local bls = SILE.settings:get("document.baselineskip")
406✔
714
  local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
2,030✔
715
  SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
406✔
716

717
  -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
718
  local lead = SILE.settings:get("document.lineskip").height:absolute()
812✔
719
  if depth > lead then
406✔
720
    return SILE.nodefactory.vglue(SILE.length(depth.length, bls.height.stretch, bls.height.shrink))
674✔
721
  else
722
    return SILE.nodefactory.vglue(lead)
69✔
723
  end
724
end
725

726
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
64✔
727
  local LTR = self.frame:writingDirection() == "LTR"
1,184✔
728
  local rskip = margins[LTR and "rskip" or "lskip"]
592✔
729
  if not rskip then rskip = SILE.nodefactory.glue(0) end
592✔
730
  if hangRight and hangRight > 0 then
592✔
731
    rskip = SILE.nodefactory.glue({ width = rskip.width:tonumber() + hangRight })
63✔
732
  end
733
  rskip.value = "margin"
592✔
734
  -- while slice[#slice].discardable do table.remove(slice, #slice) end
735
  table.insert(slice, rskip)
592✔
736
  table.insert(slice, SILE.nodefactory.zerohbox())
1,184✔
737
  local lskip = margins[LTR and "lskip" or "rskip"]
592✔
738
  if not lskip then lskip = SILE.nodefactory.glue(0) end
592✔
739
  if hangLeft and hangLeft > 0 then
592✔
740
    lskip = SILE.nodefactory.glue({ width = lskip.width:tonumber() + hangLeft })
48✔
741
  end
742
  lskip.value = "margin"
592✔
743
  while slice[1].discardable do table.remove(slice, 1) end
598✔
744
  table.insert(slice, 1, lskip)
592✔
745
  table.insert(slice, 1, SILE.nodefactory.zerohbox())
1,184✔
746
end
747

748
function typesetter:breakpointsToLines (breakpoints)
64✔
749
  local linestart = 1
359✔
750
  local lines = {}
359✔
751
  local nodes = self.state.nodes
359✔
752

753
  for i = 1, #breakpoints do
955✔
754
    local point = breakpoints[i]
596✔
755
    if point.position ~= 0 then
596✔
756
      local slice = {}
596✔
757
      local seenNonDiscardable = false
596✔
758
      for j = linestart, point.position do
11,509✔
759
        slice[#slice+1] = nodes[j]
10,913✔
760
        if nodes[j] then
10,913✔
761
          if not nodes[j].discardable then
10,913✔
762
            seenNonDiscardable = true
7,198✔
763
          end
764
        end
765
      end
766
      if not seenNonDiscardable then
596✔
767
        -- Slip lines containing only discardable nodes (e.g. glues).
768
        SU.debug("typesetter", "Skipping a line containing only discardable nodes")
4✔
769
        linestart = point.position + 1
4✔
770
      else
771
        -- If the line ends with a discretionary, repeat it on the next line,
772
        -- so as to account for a potential postbreak.
773
        if slice[#slice].is_discretionary then
592✔
774
          linestart = point.position
60✔
775
        else
776
          linestart = point.position + 1
532✔
777
        end
778

779
        -- Then only we can add some extra margin glue...
780
        local mrg = self:getMargins()
592✔
781
        self:addrlskip(slice, mrg, point.left, point.right)
592✔
782

783
        -- And compute the line...
784
        local ratio = self:computeLineRatio(point.width, slice)
592✔
785
        local thisLine = { ratio = ratio, nodes = slice }
592✔
786
        lines[#lines+1] = thisLine
592✔
787
      end
788
    end
789
  end
790
  if linestart < #nodes then
359✔
791
    -- Abnormal, but warn so that one has a chance to check which bits
792
    -- are missing at output.
793
    SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
794
  end
795
  return lines
359✔
796
end
797

798
function typesetter.computeLineRatio (_, breakwidth, slice)
64✔
799
  -- This somewhat wrong, see #1362 and #1528
800
  -- This is a somewhat partial workaround, at least made consistent with
801
  -- the nnode and discretionary outputYourself routines
802
  -- (which are somewhat wrong too, or to put it otherwise, the whole
803
  -- logic here, marking nodes without removing/replacing them, likely makes
804
  -- things more complex than they should).
805
  -- TODO Possibly consider a full rewrite/refactor.
806
  local naturalTotals = SILE.length()
592✔
807

808
  -- From the line end, check if the line is hyphenated (to account for a prebreak)
809
  -- or contains extraneous glues (e.g. to account for spaces to ignore).
810
  local n = #slice
592✔
811
  while n > 1 do
1,943✔
812
    if slice[n].is_glue or slice[n].is_zero then
1,943✔
813
      -- Skip margin glues (they'll be accounted for in the loop below) and
814
      -- zero boxes, so as to reach actual content...
815
      if slice[n].value ~= "margin" then
1,351✔
816
        -- ... but any other glue than a margin, at the end of a line, is actually
817
        -- extraneous. It will however also be accounted for below, so subtract
818
        -- them to cancel their width. Typically, if a line break occurred at
819
        -- a space, the latter is then at the end of the line now, and must be
820
        -- ignored.
821
        naturalTotals:___sub(slice[n].width)
759✔
822
      end
823
    elseif slice[n].is_discretionary then
592✔
824
      -- Stop as we reached an hyphenation, and account for the prebreak.
825
      slice[n].used = true
60✔
826
      if slice[n].parent then
60✔
827
        slice[n].parent.hyphenated = true
59✔
828
      end
829
      naturalTotals:___add(slice[n]:prebreakWidth())
120✔
830
      slice[n].height = slice[n]:prebreakHeight()
120✔
831
      break
60✔
832
    else
833
      -- Stop as we reached actual content.
834
      break
835
    end
836
    n = n - 1
1,351✔
837
  end
838

839
  local seenNodes = {}
592✔
840
  local skipping = true
592✔
841
  for i, node in ipairs(slice) do
13,866✔
842
    if node.is_box then
13,274✔
843
      skipping = false
6,624✔
844
      if node.parent and not node.parent.hyphenated then
6,624✔
845
        if not seenNodes[node.parent] then
1,909✔
846
          naturalTotals:___add(node.parent:lineContribution())
1,602✔
847
        end
848
        seenNodes[node.parent] = true
1,909✔
849
      else
850
        naturalTotals:___add(node:lineContribution())
9,430✔
851
      end
852
    elseif node.is_penalty and node.penalty == -inf_bad then
6,650✔
853
      skipping = false
365✔
854
    elseif node.is_discretionary then
6,285✔
855
      skipping = false
1,273✔
856
      local seen = node.parent and seenNodes[node.parent]
1,273✔
857
      if not seen and not node.used then
1,273✔
858
        naturalTotals:___add(node:replacementWidth():absolute())
135✔
859
        slice[i].height = slice[i]:replacementHeight():absolute()
135✔
860
      end
861
    elseif not skipping then
5,012✔
862
      naturalTotals:___add(node.width)
5,012✔
863
    end
864
  end
865

866
  -- From the line start, skip glues and margins, and check if it then starts
867
  -- with a used discretionary. If so, account for a postbreak.
868
  n = 1
592✔
869
  while n < #slice do
2,517✔
870
    if slice[n].is_discretionary and slice[n].used then
2,517✔
871
      naturalTotals:___add(slice[n]:postbreakWidth())
120✔
872
      slice[n].height = slice[n]:postbreakHeight()
120✔
873
      break
60✔
874
    elseif not (slice[n].is_glue or slice[n].is_zero) then
2,457✔
875
      break
532✔
876
    end
877
    n = n + 1
1,925✔
878
  end
879

880
  local _left = breakwidth:tonumber() - naturalTotals:tonumber()
1,776✔
881
  local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
1,184✔
882
  ratio = math.max(ratio, -1)
592✔
883
  return ratio, naturalTotals
592✔
884
end
885

886
function typesetter:chuck () -- emergency shipout everything
64✔
887
  self:leaveHmode(true)
50✔
888
  if (#self.state.outputQueue > 0) then
50✔
889
    SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
37✔
890
    self:outputLinesToPage(self.state.outputQueue)
37✔
891
    self.state.outputQueue = {}
37✔
892
  end
893
end
894

895
-- Logic for building an hbox from content.
896
-- It returns the hbox and an horizontal list of (migrating) elements
897
-- extracted outside of it.
898
-- None of these are pushed to the typesetter node queue. The caller
899
-- is responsible of doing it, if the hbox is built for anything
900
-- else than e.g. measuring it. Likewise, the call has to decide
901
-- what to do with the migrating content.
902
local _rtl_pre_post = function (box, atypesetter, line)
903
  local advance = function () atypesetter.frame:advanceWritingDirection(box:scaledWidth(line)) end
404✔
904
  if atypesetter.frame:writingDirection() == "RTL" then
202✔
905
    advance()
×
906
    return function () end
×
907
  else
908
    return advance
101✔
909
  end
910
end
911
function typesetter:makeHbox (content)
64✔
912
  local recentContribution = {}
39✔
913
  local migratingNodes = {}
39✔
914

915
  -- HACK HBOX
916
  -- This is from the original implementation.
917
  -- It would be somewhat cleaner to use a temporary typesetter state
918
  -- (pushState/popState) rather than using the current one, removing
919
  -- the processed nodes from it afterwards. However, as long
920
  -- as leaving horizontal mode is not strictly forbidden here, it would
921
  -- lead to a possibly different result (the output queue being skipped).
922
  -- See also HACK HBOX comment in typesetter:leaveHmode().
923
  local index = #(self.state.nodes)+1
39✔
924
  self.state.hmodeOnly = true
39✔
925
  SILE.process(content)
39✔
926
  self.state.hmodeOnly = false -- Wouldn't be needed in a temporary state
39✔
927

928
  local l = SILE.length()
39✔
929
  local h, d = SILE.length(), SILE.length()
78✔
930
  for i = index, #(self.state.nodes) do
67✔
931
    local node = self.state.nodes[i]
28✔
932
    if node.is_migrating then
55✔
933
      migratingNodes[#migratingNodes+1] = node
×
934
    elseif node.is_unshaped then
28✔
935
      local shape = node:shape()
27✔
936
      for _, attr in ipairs(shape) do
69✔
937
        recentContribution[#recentContribution+1] = attr
42✔
938
        h = attr.height > h and attr.height or h
69✔
939
        d = attr.depth > d and attr.depth or d
55✔
940
        l = l + attr:lineContribution():absolute()
126✔
941
      end
942
    elseif node.is_discretionary then
1✔
943
      -- HACK https://github.com/sile-typesetter/sile/issues/583
944
      -- Discretionary nodes have a null line contribution...
945
      -- But if discretionary nodes occur inside an hbox, since the latter
946
      -- is not line-broken, they will never be marked as 'used' and will
947
      -- evaluate to the replacement content (if any)...
948
      recentContribution[#recentContribution+1] = node
×
949
      l = l + node:replacementWidth():absolute()
×
950
      -- The replacement content may have ascenders and descenders...
951
      local hdisc = node:replacementHeight():absolute()
×
952
      local ddisc = node:replacementDepth():absolute()
×
953
      h = hdisc > h and hdisc or h
×
954
      d = ddisc > d and ddisc or d
×
955
      -- By the way it's unclear how this is expected to work in TTB
956
      -- writing direction. For other type of nodes, the line contribution
957
      -- evaluates to the height rather than the width in TTB, but the
958
      -- whole logic might then be dubious there too...
959
    else
960
      recentContribution[#recentContribution+1] = node
1✔
961
      l = l + node:lineContribution():absolute()
3✔
962
      h = node.height > h and node.height or h
2✔
963
      d = node.depth > d and node.depth or d
2✔
964
    end
965
    self.state.nodes[i] = nil -- wouldn't be needed in a temporary state
28✔
966
  end
967

968
  local hbox = SILE.nodefactory.hbox({
78✔
969
      height = h,
39✔
970
      width = l,
39✔
971
      depth = d,
39✔
972
      value = recentContribution,
39✔
973
      outputYourself = function (box, atypesetter, line)
974
        local _post = _rtl_pre_post(box, atypesetter, line)
101✔
975
        local ox = atypesetter.frame.state.cursorX
101✔
976
        local oy = atypesetter.frame.state.cursorY
101✔
977
        SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
101✔
978
        for _, node in ipairs(box.value) do
247✔
979
          node:outputYourself(atypesetter, line)
146✔
980
        end
981
        atypesetter.frame.state.cursorX = ox
101✔
982
        atypesetter.frame.state.cursorY = oy
101✔
983
        _post()
101✔
984
        SU.debug("hboxes", function ()
202✔
985
          SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
986
          return "Drew debug outline around hbox"
×
987
        end)
988
      end
989
    })
990
  return hbox, migratingNodes
39✔
991
end
992

993
function typesetter:pushHlist (hlist)
64✔
994
  for _, h in ipairs(hlist) do
4✔
995
    self:pushHorizontal(h)
×
996
  end
997
end
998

999
return typesetter
64✔
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