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

sile-typesetter / sile / 7232761547

16 Dec 2023 03:24PM UTC coverage: 74.605% (-0.03%) from 74.636%
7232761547

push

github

web-flow
Merge pull request #1929 from alerque/suggest-luarocks

Change module load error to include suggestions of how to install 3rd party modules

2 of 11 new or added lines in 2 files covered. (18.18%)

34 existing lines in 1 file now uncovered.

11816 of 15838 relevant lines covered (74.61%)

6996.16 hits per line

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

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

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

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

25
    _init = function (self, lskip, rskip)
26
      self.lskip, self.rskip = lskip, rskip
3,409✔
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
175✔
36

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

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

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

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

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

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

128
  SILE.settings:declare({
310✔
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({
310✔
136
    parameter = "typesetter.softHyphen",
137
    type = "boolean",
138
    default = true,
UNCOV
139
    help = "When true, soft hyphens are rendered as discretionary breaks, otherwise they are ignored"
×
140
  })
141

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

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

158
function typesetter:initFrame (frame)
175✔
159
  if frame then
508✔
160
    self.frame = frame
484✔
161
    self.frame:init(self)
484✔
162
  end
163
end
164

165
function typesetter.getMargins ()
175✔
166
  return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
10,227✔
167
end
168

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

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

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

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

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

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

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

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

219
function typesetter:pushHbox (spec)
175✔
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)
260✔
222
  local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.nodefactory.hbox(spec)
260✔
223
  return self:pushHorizontal(node)
260✔
224
end
225

226
function typesetter:pushUnshaped (spec)
175✔
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)
3,112✔
229
  return self:pushHorizontal(node)
1,556✔
230
end
231

232
function typesetter:pushGlue (spec)
175✔
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)
3,446✔
235
  return self:pushHorizontal(node)
1,723✔
236
end
237

238
function typesetter:pushExplicitGlue (spec)
175✔
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)
168✔
241
  node.explicit = true
84✔
242
  node.discardable = false
84✔
243
  return self:pushHorizontal(node)
84✔
244
end
245

246
function typesetter:pushPenalty (spec)
175✔
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)
1,778✔
249
  return self:pushHorizontal(node)
889✔
250
end
251

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

257
function typesetter:pushVbox (spec)
175✔
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)
4✔
260
  return self:pushVertical(node)
2✔
261
end
262

263
function typesetter:pushVglue (spec)
175✔
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)
1,612✔
266
  return self:pushVertical(node)
806✔
267
end
268

269
function typesetter:pushExplicitVglue (spec)
175✔
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)
1,016✔
272
  node.explicit = true
508✔
273
  node.discardable = false
508✔
274
  return self:pushVertical(node)
508✔
275
end
276

277
function typesetter:pushVpenalty (spec)
175✔
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)
446✔
280
  return self:pushVertical(node)
223✔
281
end
282

283
-- Actual typesetting functions
284
function typesetter:typeset (text)
175✔
285
  text = tostring(text)
2,579✔
286
  if text:match("^%\r?\n$") then return end
2,579✔
287
  local pId = SILE.traceStack:pushText(text)
1,782✔
288
  for token in SU.gtoke(text, SILE.settings:get("typesetter.parseppattern")) do
7,345✔
289
    if token.separator then
1,999✔
290
      self:endline()
896✔
291
    else
292
      if SILE.settings:get("typesetter.softHyphen") then
3,102✔
293
        local warnedshy = false
1,550✔
294
        for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
4,680✔
295
          if token2.separator then -- soft hyphen support
1,580✔
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✔
UNCOV
301
              SU.warn("Soft hyphen encountered and replaced with discretionary")
×
302
            end
303
            warnedshy = true
15✔
304
          else
305
            self:setpar(token2.string)
1,565✔
306
          end
307
        end
308
      else
309
        if SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD)) then
2✔
UNCOV
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)
1,782✔
318
end
319

320
function typesetter:initline ()
175✔
321
  if self.state.hmodeOnly then return end -- https://github.com/sile-typesetter/sile/issues/1718
6,112✔
322
  if (#self.state.nodes == 0) then
5,711✔
323
    self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
1,658✔
324
    SILE.documentState.documentClass.newPar(self)
829✔
325
  end
326
end
327

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

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

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

353
function typesetter.shapeAllNodes (_, nodelist)
175✔
354
  local newNl = {}
2,481✔
355
  for i = 1, #nodelist do
44,684✔
356
    if nodelist[i].is_unshaped then
42,203✔
357
      pl.tablex.insertvalues(newNl, nodelist[i]:shape())
3,837✔
358
    else
359
      newNl[#newNl+1] = nodelist[i]
40,924✔
360
    end
361
  end
362
  for i =1, #newNl do nodelist[i]=newNl[i] end
56,246✔
363
  if #nodelist > #newNl then
2,481✔
UNCOV
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 ()
175✔
371
  local nodelist = self.state.nodes
1,924✔
372
  if #nodelist == 0 then return {} end
1,924✔
373
  for j = #nodelist, 1, -1 do
1,026✔
374
    if not nodelist[j].is_migrating then
1,037✔
375
      if nodelist[j].discardable then
992✔
376
        table.remove(nodelist, j)
320✔
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
832✔
383
  if #nodelist == 0 then return {} end
832✔
384
  self:shapeAllNodes(nodelist)
832✔
385
  local parfillskip = SILE.settings:get("typesetter.parfillskip")
832✔
386
  parfillskip.discardable = false
832✔
387
  self:pushGlue(parfillskip)
832✔
388
  self:pushPenalty(-inf_bad)
832✔
389
  SU.debug("typesetter", function ()
1,664✔
UNCOV
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()
1,664✔
393
  local lines = self:breakIntoLines(nodelist, breakWidth)
832✔
394
  local vboxes = {}
832✔
395
  for index=1, #lines do
2,308✔
396
    local line = lines[index]
1,476✔
397
    local migrating = {}
1,476✔
398
    -- Move any migrating material
399
    local nodes = {}
1,476✔
400
    for i =1, #line.nodes do
34,516✔
401
      local node = line.nodes[i]
33,040✔
402
      if node.is_migrating then
33,040✔
403
        for j=1, #node.material do migrating[#migrating+1] = node.material[j] end
86✔
404
      else
405
        nodes[#nodes+1] = node
32,954✔
406
      end
407
    end
408
    local vbox = SILE.nodefactory.vbox({ nodes = nodes, ratio = line.ratio })
1,476✔
409
    local pageBreakPenalty = 0
1,476✔
410
    if (#lines > 1 and index == 1) then
1,476✔
411
      pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
342✔
412
    elseif (#lines > 1 and index == (#lines-1)) then
1,305✔
413
      pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
224✔
414
    end
415
    vboxes[#vboxes+1] = self:leadingFor(vbox, self.state.previousVbox)
2,952✔
416
    vboxes[#vboxes+1] = vbox
1,476✔
417
    for i=1, #migrating do vboxes[#vboxes+1] = migrating[i] end
1,527✔
418
    self.state.previousVbox = vbox
1,476✔
419
    if pageBreakPenalty > 0 then
1,476✔
420
      SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
283✔
421
      vboxes[#vboxes+1] = SILE.nodefactory.penalty(pageBreakPenalty)
566✔
422
    end
423
  end
424
  return vboxes
832✔
425
end
426

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

431
function typesetter:getTargetLength ()
175✔
432
  return self.frame:getTargetLength()
1,912✔
433
end
434

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

440
function typesetter:runHooks (category, data)
175✔
441
  if not self.hooks[category] then return data end
1,973✔
442
  for _, func in ipairs(self.hooks[category]) do
685✔
443
    data = func(self, data)
766✔
444
  end
445
  return data
302✔
446
end
447

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

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

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

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

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

489
  local pastTop = false
258✔
490
  for _, node in ipairs(pageNodeList) do
4,343✔
491
    if not pastTop and not node.discardable and not node.explicit then
4,085✔
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
254✔
498
    end
499
    if pastTop then
4,085✔
500
      if not node.is_insertion then
3,734✔
501
        totalHeight:___add(node.height)
3,698✔
502
        totalHeight:___add(node.depth)
3,698✔
503
      end
504
      if node.is_vglue then
3,734✔
505
        table.insert(glues, node)
2,130✔
506
        gTotal:___add(node.height)
2,130✔
507
      end
508
    end
509
  end
510

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

515
  local adjustment = target - totalHeight
251✔
516
  if adjustment:tonumber() > 0 then
502✔
517
    if adjustment > gTotal.stretch then
227✔
518
      if (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber() then
190✔
519
        SU.warn("Underfull frame " .. self.frame.id .. ": " .. adjustment .. " stretchiness required to fill but only " .. gTotal.stretch .. " available")
26✔
520
      end
521
      adjustment = gTotal.stretch
38✔
522
    end
523
    if gTotal.stretch:tonumber() > 0 then
454✔
524
      for i = 1, #glues do
2,200✔
525
        local g = glues[i]
1,993✔
526
        g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
9,965✔
527
      end
528
    end
529
  elseif adjustment:tonumber() < 0 then
48✔
530
    adjustment = 0 - adjustment
24✔
531
    if adjustment > gTotal.shrink then
24✔
532
      if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
120✔
533
        SU.warn("Overfull frame " .. self.frame.id .. ": " .. adjustment .. " shrinkability required to fit but only " .. gTotal.shrink .. " available")
4✔
534
      end
535
      adjustment = gTotal.shrink
24✔
536
    end
537
    if gTotal.shrink:tonumber() > 0 then
48✔
UNCOV
538
      for i = 1, #glues do
×
UNCOV
539
        local g  = glues[i]
×
UNCOV
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)
251✔
545
end
546

547
function typesetter:initNextFrame ()
175✔
548
  local oldframe = self.frame
91✔
549
  self.frame:leave(self)
91✔
550
  if #self.state.outputQueue == 0 then
91✔
551
    self.state.previousVbox = nil
47✔
552
  end
553
  if self.frame.next and self.state.lastPenalty > supereject_penalty then
91✔
554
    self:initFrame(SILE.getFrame(self.frame.next))
45✔
555
  elseif not self.frame:isMainContentFrame() then
152✔
556
    if #self.state.outputQueue > 0 then
22✔
557
      SU.warn("Overfull content for frame " .. self.frame.id)
4✔
558
      self:chuck()
4✔
559
    end
560
  else
561
    self:runHooks("pageend")
54✔
562
    SILE.documentState.documentClass:endPage()
54✔
563
    self:initFrame(SILE.documentState.documentClass:newPage())
108✔
564
  end
565

566
  if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
364✔
567
    self:pushBack()
9✔
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()
18✔
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
82✔
578
      local lead = self:leadingFor(self.state.outputQueue[1], nil)
36✔
579
      if lead then
36✔
580
        table.insert(self.state.outputQueue, 1, lead)
30✔
581
      end
582
    end
583
  end
584
  self:runHooks("newframe")
91✔
585

586
end
587

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

652
function typesetter:outputLinesToPage (lines)
175✔
653
  SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
352✔
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
352✔
660
  for _, line in ipairs(lines) do
4,718✔
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
4,366✔
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
337✔
669
    end
670
    if pastTop then
4,366✔
671
      line:outputYourself(self, line)
3,920✔
672
    end
673
  end
674
  self.frame.state.totals.pastTop = pastTop
352✔
675
end
676

677
function typesetter:leaveHmode (independent)
175✔
678
  if self.state.hmodeOnly then
1,924✔
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().
UNCOV
683
    SU.warn([[Building paragraphs in this context may have unpredictable results.
×
UNCOV
684
It will likely break in future versions]])
×
685
  end
686
  SU.debug("typesetter", "Leaving hmode")
1,924✔
687
  local margins = self:getMargins()
1,924✔
688
  local vboxlist = self:boxUpNodes()
1,924✔
689
  self.state.nodes = {}
1,924✔
690
  -- Push output lines into boxes and ship them to the page builder
691
  for _, vbox in ipairs(vboxlist) do
5,240✔
692
    vbox.margins = margins
3,316✔
693
    self:pushVertical(vbox)
3,316✔
694
  end
695
  if independent then return end
1,924✔
696
  if self:buildPage() then
3,132✔
697
    self:initNextFrame()
86✔
698
  end
699
end
700

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

705
function typesetter.leadingFor (_, vbox, previous)
175✔
706
  -- Insert leading
707
  SU.debug("typesetter", "   Considering leading between two lines:")
1,416✔
708
  SU.debug("typesetter", "   1)", previous)
1,416✔
709
  SU.debug("typesetter", "   2)", vbox)
1,416✔
710
  if not previous then return SILE.nodefactory.vglue() end
1,416✔
711
  local prevDepth = previous.depth
1,052✔
712
  SU.debug("typesetter", "   Depth of previous line was", prevDepth)
1,052✔
713
  local bls = SILE.settings:get("document.baselineskip")
1,052✔
714
  local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
5,260✔
715
  SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
1,052✔
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()
2,104✔
719
  if depth > lead then
1,052✔
720
    return SILE.nodefactory.vglue(SILE.length(depth.length, bls.height.stretch, bls.height.shrink))
1,886✔
721
  else
722
    return SILE.nodefactory.vglue(lead)
109✔
723
  end
724
end
725

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

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

753
  for i = 1, #breakpoints do
2,320✔
754
    local point = breakpoints[i]
1,488✔
755
    if point.position ~= 0 then
1,488✔
756
      local slice = {}
1,488✔
757
      local seenNonDiscardable = false
1,488✔
758
      for j = linestart, point.position do
28,645✔
759
        slice[#slice+1] = nodes[j]
27,157✔
760
        if nodes[j] then
27,157✔
761
          if not nodes[j].discardable then
27,157✔
762
            seenNonDiscardable = true
18,681✔
763
          end
764
        end
765
      end
766
      if not seenNonDiscardable then
1,488✔
767
        -- Slip lines containing only discardable nodes (e.g. glues).
768
        SU.debug("typesetter", "Skipping a line containing only discardable nodes")
12✔
769
        linestart = point.position + 1
12✔
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
1,476✔
774
          linestart = point.position
215✔
775
        else
776
          linestart = point.position + 1
1,261✔
777
        end
778

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

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

798
function typesetter.computeLineRatio (_, breakwidth, slice)
175✔
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()
1,476✔
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
1,476✔
811
  while n > 1 do
4,837✔
812
    if slice[n].is_glue or slice[n].is_zero then
4,836✔
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
3,361✔
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)
1,884✔
822
      end
823
    elseif slice[n].is_discretionary then
1,475✔
824
      -- Stop as we reached an hyphenation, and account for the prebreak.
825
      slice[n].used = true
215✔
826
      if slice[n].parent then
215✔
827
        slice[n].parent.hyphenated = true
209✔
828
      end
829
      naturalTotals:___add(slice[n]:prebreakWidth())
430✔
830
      slice[n].height = slice[n]:prebreakHeight()
430✔
831
      break
215✔
832
    else
833
      -- Stop as we reached actual content.
834
      break
835
    end
836
    n = n - 1
3,361✔
837
  end
838

839
  local seenNodes = {}
1,476✔
840
  local skipping = true
1,476✔
841
  for i, node in ipairs(slice) do
34,516✔
842
    if node.is_box then
33,040✔
843
      skipping = false
16,680✔
844
      if node.parent and not node.parent.hyphenated then
16,680✔
845
        if not seenNodes[node.parent] then
4,823✔
846
          naturalTotals:___add(node.parent:lineContribution())
3,650✔
847
        end
848
        seenNodes[node.parent] = true
4,823✔
849
      else
850
        naturalTotals:___add(node:lineContribution())
23,714✔
851
      end
852
    elseif node.is_penalty and node.penalty == -inf_bad then
16,360✔
853
      skipping = false
852✔
854
    elseif node.is_discretionary then
15,508✔
855
      skipping = false
3,849✔
856
      local seen = node.parent and seenNodes[node.parent]
3,849✔
857
      if not seen and not node.used then
3,849✔
858
        naturalTotals:___add(node:replacementWidth():absolute())
1,521✔
859
        slice[i].height = slice[i]:replacementHeight():absolute()
1,521✔
860
      end
861
    elseif not skipping then
11,659✔
862
      naturalTotals:___add(node.width)
11,659✔
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
1,476✔
869
  while n < #slice do
6,137✔
870
    if slice[n].is_discretionary and slice[n].used then
6,136✔
871
      naturalTotals:___add(slice[n]:postbreakWidth())
430✔
872
      slice[n].height = slice[n]:postbreakHeight()
430✔
873
      break
215✔
874
    elseif not (slice[n].is_glue or slice[n].is_zero) then
5,921✔
875
      break
1,260✔
876
    end
877
    n = n + 1
4,661✔
878
  end
879

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

886
function typesetter:chuck () -- emergency shipout everything
175✔
887
  self:leaveHmode(true)
106✔
888
  if (#self.state.outputQueue > 0) then
106✔
889
    SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
86✔
890
    self:outputLinesToPage(self.state.outputQueue)
86✔
891
    self.state.outputQueue = {}
86✔
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
2,372✔
904
  if atypesetter.frame:writingDirection() == "RTL" then
1,186✔
UNCOV
905
    advance()
×
UNCOV
906
    return function () end
×
907
  else
908
    return advance
593✔
909
  end
910
end
911
function typesetter:makeHbox (content)
175✔
912
  local recentContribution = {}
339✔
913
  local migratingNodes = {}
339✔
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
339✔
924
  self.state.hmodeOnly = true
339✔
925
  SILE.process(content)
339✔
926
  self.state.hmodeOnly = false -- Wouldn't be needed in a temporary state
339✔
927

928
  local l = SILE.length()
339✔
929
  local h, d = SILE.length(), SILE.length()
678✔
930
  for i = index, #(self.state.nodes) do
708✔
931
    local node = self.state.nodes[i]
369✔
932
    if node.is_migrating then
680✔
UNCOV
933
      migratingNodes[#migratingNodes+1] = node
×
934
    elseif node.is_unshaped then
369✔
935
      local shape = node:shape()
311✔
936
      for _, attr in ipairs(shape) do
780✔
937
        recentContribution[#recentContribution+1] = attr
469✔
938
        h = attr.height > h and attr.height or h
783✔
939
        d = attr.depth > d and attr.depth or d
574✔
940
        l = l + attr:lineContribution():absolute()
1,407✔
941
      end
942
    elseif node.is_discretionary then
58✔
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
1✔
949
      l = l + node:replacementWidth():absolute()
3✔
950
      -- The replacement content may have ascenders and descenders...
951
      local hdisc = node:replacementHeight():absolute()
2✔
952
      local ddisc = node:replacementDepth():absolute()
2✔
953
      h = hdisc > h and hdisc or h
2✔
954
      d = ddisc > d and ddisc or d
1✔
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
57✔
961
      l = l + node:lineContribution():absolute()
171✔
962
      h = node.height > h and node.height or h
64✔
963
      d = node.depth > d and node.depth or d
63✔
964
    end
965
    self.state.nodes[i] = nil -- wouldn't be needed in a temporary state
369✔
966
  end
967

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

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

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