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

sile-typesetter / sile / 6348395828

29 Sep 2023 07:25AM UTC coverage: 71.183% (-3.1%) from 74.301%
6348395828

push

github

alerque
chore(classes): Return more informative error message when failing to finish class

2 of 2 new or added lines in 1 file covered. (100.0%)

11200 of 15734 relevant lines covered (71.18%)

5052.08 hits per line

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

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

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

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

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

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

33
  })
34

35
local warned = false
117✔
36

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

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

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

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

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

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

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

135
end
136

137
function typesetter:initState ()
117✔
138
  self.state = {
247✔
139
    nodes = {},
247✔
140
    outputQueue = {},
247✔
141
    lastBadness = awful_bad,
247✔
142
  }
247✔
143
end
144

145
function typesetter:initFrame (frame)
117✔
146
  if frame then
353✔
147
    self.frame = frame
339✔
148
    self.frame:init(self)
339✔
149
  end
150
end
151

152
function typesetter.getMargins ()
117✔
153
  return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
7,395✔
154
end
155

156
function typesetter.setMargins (_, margins)
117✔
157
  SILE.settings:set("document.lskip", margins.lskip)
×
158
  SILE.settings:set("document.rskip", margins.rskip)
×
159
end
160

161
function typesetter:pushState ()
117✔
162
  self.stateQueue[#self.stateQueue+1] = self.state
31✔
163
  self:initState()
31✔
164
end
165

166
function typesetter:popState (ncount)
117✔
167
  local offset = ncount and #self.stateQueue - ncount or nil
31✔
168
  self.state = table.remove(self.stateQueue, offset)
62✔
169
  if not self.state then SU.error("Typesetter state queue empty") end
31✔
170
end
171

172
function typesetter:isQueueEmpty ()
117✔
173
  if not self.state then return nil end
1,743✔
174
  return #self.state.nodes == 0 and #self.state.outputQueue == 0
1,743✔
175
end
176

177
function typesetter:vmode ()
117✔
178
  return #self.state.nodes == 0
368✔
179
end
180

181
function typesetter:debugState ()
117✔
182
  print("\n---\nI am in "..(self:vmode() and "vertical" or "horizontal").." mode")
×
183
  print("Writing into " .. tostring(self.frame))
×
184
  print("Recent contributions: ")
×
185
  for i = 1, #(self.state.nodes) do
×
186
    io.stderr:write(self.state.nodes[i].. " ")
×
187
  end
188
  print("\nVertical list: ")
×
189
  for i = 1, #(self.state.outputQueue) do
×
190
    print("  "..self.state.outputQueue[i])
×
191
  end
192
end
193

194
-- Boxy stuff
195
function typesetter:pushHorizontal (node)
117✔
196
  self:initline()
3,712✔
197
  self.state.nodes[#self.state.nodes+1] = node
3,712✔
198
  return node
3,712✔
199
end
200

201
function typesetter:pushVertical (vbox)
117✔
202
  self.state.outputQueue[#self.state.outputQueue+1] = vbox
3,479✔
203
  return vbox
3,479✔
204
end
205

206
function typesetter:pushHbox (spec)
117✔
207
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
208
  local ntype = SU.type(spec)
196✔
209
  local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.nodefactory.hbox(spec)
196✔
210
  return self:pushHorizontal(node)
196✔
211
end
212

213
function typesetter:pushUnshaped (spec)
117✔
214
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
215
  local node = SU.type(spec) == "unshaped" and spec or SILE.nodefactory.unshaped(spec)
1,702✔
216
  return self:pushHorizontal(node)
851✔
217
end
218

219
function typesetter:pushGlue (spec)
117✔
220
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
221
  local node = SU.type(spec) == "glue" and spec or SILE.nodefactory.glue(spec)
2,426✔
222
  return self:pushHorizontal(node)
1,213✔
223
end
224

225
function typesetter:pushExplicitGlue (spec)
117✔
226
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
227
  local node = SU.type(spec) == "glue" and spec or SILE.nodefactory.glue(spec)
122✔
228
  node.explicit = true
61✔
229
  node.discardable = false
61✔
230
  return self:pushHorizontal(node)
61✔
231
end
232

233
function typesetter:pushPenalty (spec)
117✔
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) == "penalty" and spec or SILE.nodefactory.penalty(spec)
1,272✔
236
  return self:pushHorizontal(node)
636✔
237
end
238

239
function typesetter:pushMigratingMaterial (material)
117✔
240
  local node = SILE.nodefactory.migrating({ material = material })
42✔
241
  return self:pushHorizontal(node)
42✔
242
end
243

244
function typesetter:pushVbox (spec)
117✔
245
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
246
  local node = SU.type(spec) == "vbox" and spec or SILE.nodefactory.vbox(spec)
2✔
247
  return self:pushVertical(node)
1✔
248
end
249

250
function typesetter:pushVglue (spec)
117✔
251
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
252
  local node = SU.type(spec) == "vglue" and spec or SILE.nodefactory.vglue(spec)
1,220✔
253
  return self:pushVertical(node)
610✔
254
end
255

256
function typesetter:pushExplicitVglue (spec)
117✔
257
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
258
  local node = SU.type(spec) == "vglue" and spec or SILE.nodefactory.vglue(spec)
738✔
259
  node.explicit = true
369✔
260
  node.discardable = false
369✔
261
  return self:pushVertical(node)
369✔
262
end
263

264
function typesetter:pushVpenalty (spec)
117✔
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) == "penalty" and spec or SILE.nodefactory.penalty(spec)
322✔
267
  return self:pushVertical(node)
161✔
268
end
269

270
-- Actual typesetting functions
271
function typesetter:typeset (text)
117✔
272
  text = tostring(text)
1,590✔
273
  if text:match("^%\r?\n$") then return end
1,590✔
274
  local pId = SILE.traceStack:pushText(text)
1,032✔
275
  for token in SU.gtoke(text, SILE.settings:get("typesetter.parseppattern")) do
4,277✔
276
    if token.separator then
1,181✔
277
      self:endline()
646✔
278
    else
279
      self:setpar(token.string)
858✔
280
    end
281
  end
282
  SILE.traceStack:pop(pId)
1,032✔
283
end
284

285
function typesetter:initline ()
117✔
286
  if self.state.hmodeOnly then return end -- https://github.com/sile-typesetter/sile/issues/1718
4,203✔
287
  if (#self.state.nodes == 0) then
4,028✔
288
    self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
1,174✔
289
    SILE.documentState.documentClass.newPar(self)
587✔
290
  end
291
end
292

293
function typesetter:endline ()
117✔
294
  self:leaveHmode()
611✔
295
  SILE.documentState.documentClass.endPar(self)
611✔
296
end
297

298
-- Takes string, writes onto self.state.nodes
299
function typesetter:setpar (text)
117✔
300
  text = text:gsub("\r?\n", " "):gsub("\t", " ")
871✔
301
  if (#self.state.nodes == 0) then
871✔
302
    if not SILE.settings:get("typesetter.obeyspaces") then
982✔
303
      text = text:gsub("^%s+", "")
487✔
304
    end
305
    self:initline()
491✔
306
  end
307
  if #text >0 then
871✔
308
    self:pushUnshaped({ text = text, options= SILE.font.loadDefaults({})})
1,702✔
309
  end
310
end
311

312
function typesetter:breakIntoLines (nodelist, breakWidth)
117✔
313
  self:shapeAllNodes(nodelist)
592✔
314
  local breakpoints = SILE.linebreak:doBreak(nodelist, breakWidth)
592✔
315
  return self:breakpointsToLines(breakpoints)
592✔
316
end
317

318
function typesetter.shapeAllNodes (_, nodelist)
117✔
319
  local newNl = {}
1,764✔
320
  for i = 1, #nodelist do
31,543✔
321
    if nodelist[i].is_unshaped then
29,779✔
322
      pl.tablex.insertvalues(newNl, nodelist[i]:shape())
2,286✔
323
    else
324
      newNl[#newNl+1] = nodelist[i]
29,017✔
325
    end
326
  end
327
  for i =1, #newNl do nodelist[i]=newNl[i] end
39,716✔
328
  if #nodelist > #newNl then
1,764✔
329
    for i=#newNl+1, #nodelist do nodelist[i]=nil end
×
330
  end
331
end
332

333
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
334
-- Turns a node list into a list of vboxes
335
function typesetter:boxUpNodes ()
117✔
336
  local nodelist = self.state.nodes
1,411✔
337
  if #nodelist == 0 then return {} end
1,411✔
338
  for j = #nodelist, 1, -1 do
715✔
339
    if not nodelist[j].is_migrating then
721✔
340
      if nodelist[j].discardable then
689✔
341
        table.remove(nodelist, j)
194✔
342
      else
343
        break
344
      end
345
    end
346
  end
347
  while (#nodelist > 0 and nodelist[1].is_penalty) do table.remove(nodelist, 1) end
592✔
348
  if #nodelist == 0 then return {} end
592✔
349
  self:shapeAllNodes(nodelist)
592✔
350
  local parfillskip = SILE.settings:get("typesetter.parfillskip")
592✔
351
  parfillskip.discardable = false
592✔
352
  self:pushGlue(parfillskip)
592✔
353
  self:pushPenalty(-inf_bad)
592✔
354
  SU.debug("typesetter", function ()
1,184✔
355
    return "Boxed up "..(#nodelist > 500 and (#nodelist).." nodes" or SU.contentToString(nodelist))
×
356
  end)
357
  local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
1,184✔
358
  local lines = self:breakIntoLines(nodelist, breakWidth)
592✔
359
  local vboxes = {}
592✔
360
  for index=1, #lines do
1,638✔
361
    local line = lines[index]
1,046✔
362
    local migrating = {}
1,046✔
363
    -- Move any migrating material
364
    local nodes = {}
1,046✔
365
    for i =1, #line.nodes do
24,318✔
366
      local node = line.nodes[i]
23,272✔
367
      if node.is_migrating then
23,272✔
368
        for j=1, #node.material do migrating[#migrating+1] = node.material[j] end
42✔
369
      else
370
        nodes[#nodes+1] = node
23,230✔
371
      end
372
    end
373
    local vbox = SILE.nodefactory.vbox({ nodes = nodes, ratio = line.ratio })
1,046✔
374
    local pageBreakPenalty = 0
1,046✔
375
    if (#lines > 1 and index == 1) then
1,046✔
376
      pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
206✔
377
    elseif (#lines > 1 and index == (#lines-1)) then
943✔
378
      pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
158✔
379
    end
380
    vboxes[#vboxes+1] = self:leadingFor(vbox, self.state.previousVbox)
2,092✔
381
    vboxes[#vboxes+1] = vbox
1,046✔
382
    for i=1, #migrating do vboxes[#vboxes+1] = migrating[i] end
1,068✔
383
    self.state.previousVbox = vbox
1,046✔
384
    if pageBreakPenalty > 0 then
1,046✔
385
      SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
182✔
386
      vboxes[#vboxes+1] = SILE.nodefactory.penalty(pageBreakPenalty)
364✔
387
    end
388
  end
389
  return vboxes
592✔
390
end
391

392
function typesetter.pageTarget (_)
117✔
393
  SU.deprecated("SILE.typesetter:pageTarget", "SILE.typesetter:getTargetLength", "0.13.0", "0.14.0")
×
394
end
395

396
function typesetter:getTargetLength ()
117✔
397
  return self.frame:getTargetLength()
1,400✔
398
end
399

400
function typesetter:registerHook (category, func)
117✔
401
  if not self.hooks[category] then self.hooks[category] = {} end
183✔
402
  table.insert(self.hooks[category], func)
183✔
403
end
404

405
function typesetter:runHooks (category, data)
117✔
406
  if not self.hooks[category] then return data end
1,440✔
407
  for _, func in ipairs(self.hooks[category]) do
481✔
408
    data = func(self, data)
544✔
409
  end
410
  return data
209✔
411
end
412

413
function typesetter:registerFrameBreakHook (func)
117✔
414
  self:registerHook("framebreak", func)
32✔
415
end
416

417
function typesetter:registerNewFrameHook (func)
117✔
418
  self:registerHook("newframe", func)
2✔
419
end
420

421
function typesetter:registerPageEndHook (func)
117✔
422
  self:registerHook("pageend", func)
149✔
423
end
424

425
function typesetter:buildPage ()
117✔
426
  local pageNodeList
427
  local res
428
  if self:isQueueEmpty() then return false end
2,550✔
429
  if SILE.scratch.insertions then SILE.scratch.insertions.thisPage = {} end
1,218✔
430
  pageNodeList, res = SILE.pagebuilder:findBestBreak({
2,436✔
431
    vboxlist = self.state.outputQueue,
1,218✔
432
    target   = self:getTargetLength(),
2,436✔
433
    restart  = self.frame.state.pageRestart
1,218✔
434
  })
1,218✔
435
  if not pageNodeList then -- No break yet
1,218✔
436
    -- self.frame.state.pageRestart = res
437
    self:runHooks("noframebreak")
1,040✔
438
    return false
1,040✔
439
  end
440
  SU.debug("pagebuilder", "Buildding page for", self.frame.id)
178✔
441
  self.state.lastPenalty = res
178✔
442
  self.frame.state.pageRestart = nil
178✔
443
  pageNodeList = self:runHooks("framebreak", pageNodeList)
356✔
444
  self:setVerticalGlue(pageNodeList, self:getTargetLength())
356✔
445
  self:outputLinesToPage(pageNodeList)
178✔
446
  return true
178✔
447
end
448

449
function typesetter:setVerticalGlue (pageNodeList, target)
117✔
450
  local glues = {}
178✔
451
  local gTotal = SILE.length()
178✔
452
  local totalHeight = SILE.length()
178✔
453

454
  local pastTop = false
178✔
455
  for _, node in ipairs(pageNodeList) do
3,050✔
456
    if not pastTop and not node.discardable and not node.explicit then
2,872✔
457
      -- "Ignore discardable and explicit glues at the top of a frame."
458
      -- See typesetter:outputLinesToPage()
459
      -- Note the test here doesn't check is_vglue, so will skip other
460
      -- discardable nodes (e.g. penalties), but it shouldn't matter
461
      -- for the type of computing performed here.
462
      pastTop = true
177✔
463
    end
464
    if pastTop then
2,872✔
465
      if not node.is_insertion then
2,629✔
466
        totalHeight:___add(node.height)
2,612✔
467
        totalHeight:___add(node.depth)
2,612✔
468
      end
469
      if node.is_vglue then
2,629✔
470
        table.insert(glues, node)
1,536✔
471
        gTotal:___add(node.height)
1,536✔
472
      end
473
    end
474
  end
475

476
  if totalHeight:tonumber() == 0 then
356✔
477
   return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
5✔
478
  end
479

480
  local adjustment = target - totalHeight
173✔
481
  if adjustment:tonumber() > 0 then
346✔
482
    if adjustment > gTotal.stretch then
156✔
483
      if (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber() then
145✔
484
        SU.warn("Underfull frame " .. self.frame.id .. ": " .. adjustment .. " stretchiness required to fill but only " .. gTotal.stretch .. " available")
22✔
485
      end
486
      adjustment = gTotal.stretch
29✔
487
    end
488
    if gTotal.stretch:tonumber() > 0 then
312✔
489
      for i = 1, #glues do
1,564✔
490
        local g = glues[i]
1,424✔
491
        g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
7,120✔
492
      end
493
    end
494
  elseif adjustment:tonumber() < 0 then
34✔
495
    adjustment = 0 - adjustment
17✔
496
    if adjustment > gTotal.shrink then
17✔
497
      if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
85✔
498
        SU.warn("Overfull frame " .. self.frame.id .. ": " .. adjustment .. " shrinkability required to fit but only " .. gTotal.shrink .. " available")
2✔
499
      end
500
      adjustment = gTotal.shrink
17✔
501
    end
502
    if gTotal.shrink:tonumber() > 0 then
34✔
503
      for i = 1, #glues do
×
504
        local g  = glues[i]
×
505
        g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
506
      end
507
    end
508
  end
509
  SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
173✔
510
end
511

512
function typesetter:initNextFrame ()
117✔
513
  local oldframe = self.frame
68✔
514
  self.frame:leave(self)
68✔
515
  if #self.state.outputQueue == 0 then
68✔
516
    self.state.previousVbox = nil
39✔
517
  end
518
  if self.frame.next and self.state.lastPenalty > supereject_penalty then
68✔
519
    self:initFrame(SILE.getFrame(self.frame.next))
42✔
520
  elseif not self.frame:isMainContentFrame() then
108✔
521
    if #self.state.outputQueue > 0 then
17✔
522
      SU.warn("Overfull content for frame " .. self.frame.id)
5✔
523
      self:chuck()
5✔
524
    end
525
  else
526
    self:runHooks("pageend")
37✔
527
    SILE.documentState.documentClass:endPage()
37✔
528
    self:initFrame(SILE.documentState.documentClass:newPage())
74✔
529
  end
530

531
  if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
272✔
532
    self:pushBack()
8✔
533
    -- Some what of a hack below.
534
    -- Before calling this method, we were in vertical mode...
535
    -- pushback occurred, and it seems it messes up a bit...
536
    -- Regardless what it does, at the end, we ought to be in vertical mode
537
    -- again:
538
    self:leaveHmode()
16✔
539
  else
540
    -- If I have some things on the vertical list already, they need
541
    -- proper top-of-frame leading applied.
542
    if #self.state.outputQueue > 0 then
60✔
543
      local lead = self:leadingFor(self.state.outputQueue[1], nil)
21✔
544
      if lead then
21✔
545
        table.insert(self.state.outputQueue, 1, lead)
19✔
546
      end
547
    end
548
  end
549
  self:runHooks("newframe")
68✔
550

551
end
552

553
function typesetter:pushBack ()
117✔
554
  SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
8✔
555
  local oldqueue = self.state.outputQueue
8✔
556
  self.state.outputQueue = {}
8✔
557
  self.state.previousVbox = nil
8✔
558
  local lastMargins = self:getMargins()
8✔
559
  for _, vbox in ipairs(oldqueue) do
62✔
560
    SU.debug("pushback", "process box", vbox)
54✔
561
    if vbox.margins and vbox.margins ~= lastMargins then
54✔
562
      SU.debug("pushback", "new margins", lastMargins, vbox.margins)
×
563
      if not self.state.grid then self:endline() end
×
564
      self:setMargins(vbox.margins)
×
565
    end
566
    if vbox.explicit then
54✔
567
      SU.debug("pushback", "explicit", vbox)
×
568
      self:endline()
×
569
      self:pushExplicitVglue(vbox)
×
570
    elseif vbox.is_insertion then
54✔
571
      SU.debug("pushback", "pushBack", "insertion", vbox)
×
572
      SILE.typesetter:pushMigratingMaterial({ material = { vbox } })
×
573
    elseif not vbox.is_vglue and not vbox.is_penalty then
54✔
574
      SU.debug("pushback", "not vglue or penalty", vbox.type)
25✔
575
      local discardedFistInitLine = false
25✔
576
      if (#self.state.nodes == 0) then
25✔
577
        -- Setup queue but avoid calling newPar
578
        self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
6✔
579
      end
580
      for i, node in ipairs(vbox.nodes) do
796✔
581
        if node.is_glue and not node.discardable then
771✔
582
          self:pushHorizontal(node)
6✔
583
        elseif node.is_glue and node.value == "margin" then
768✔
584
          SU.debug("pushback", "discard", node.value, node)
100✔
585
        elseif node.is_discretionary then
718✔
586
          SU.debug("pushback", "re-mark discretionary as unused", node)
97✔
587
          node.used = false
97✔
588
          if i == 1 then
97✔
589
            SU.debug("pushback", "keep first discretionary", node)
×
590
            self:pushHorizontal(node)
×
591
          else
592
            SU.debug("pushback", "discard all other discretionaries", node)
97✔
593
          end
594
        elseif node.is_zero then
621✔
595
          if discardedFistInitLine then self:pushHorizontal(node) end
52✔
596
          discardedFistInitLine = true
52✔
597
        elseif node.is_penalty then
569✔
598
          if not discardedFistInitLine then self:pushHorizontal(node) end
3✔
599
        else
600
          node.bidiDone = true
566✔
601
          self:pushHorizontal(node)
566✔
602
        end
603
      end
604
    else
605
      SU.debug("pushback", "discard", vbox.type)
29✔
606
    end
607
    lastMargins = vbox.margins
54✔
608
    -- self:debugState()
609
  end
610
  while self.state.nodes[#self.state.nodes]
11✔
611
  and (self.state.nodes[#self.state.nodes].is_penalty
11✔
612
    or self.state.nodes[#self.state.nodes].is_zero) do
6✔
613
    self.state.nodes[#self.state.nodes] = nil
3✔
614
  end
615
end
616

617
function typesetter:outputLinesToPage (lines)
117✔
618
  SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
255✔
619
  -- It would have been nice to avoid storing this "pastTop" into a frame
620
  -- state, to keep things less entangled. There are situations, though,
621
  -- we will have left horizontal mode (triggering output), but will later
622
  -- call typesetter:chuck() do deal with any remaining content, and we need
623
  -- to know whether some content has been output already.
624
  local pastTop = self.frame.state.totals.pastTop
255✔
625
  for _, line in ipairs(lines) do
3,335✔
626
    -- Ignore discardable and explicit glues at the top of a frame:
627
    -- Annoyingly, explicit glue *should* disappear at the top of a page.
628
    -- if you don't want that, add an empty vbox or something.
629
    if not pastTop and not line.discardable and not line.explicit then
3,080✔
630
      -- Note the test here doesn't check is_vglue, so will skip other
631
      -- discardable nodes (e.g. penalties), but it shouldn't matter
632
      -- for outputting.
633
      pastTop = true
241✔
634
    end
635
    if pastTop then
3,080✔
636
      line:outputYourself(self, line)
2,761✔
637
    end
638
  end
639
  self.frame.state.totals.pastTop = pastTop
255✔
640
end
641

642
function typesetter:leaveHmode (independent)
117✔
643
  if self.state.hmodeOnly then
1,411✔
644
    -- HACK HBOX
645
    -- This should likely be an error, but may break existing uses
646
    -- (although these are probably already defective).
647
    -- See also comment HACK HBOX in typesetter:makeHbox().
648
    SU.warn([[Building paragraphs in this context may have unpredictable results.
×
649
It will likely break in future versions]])
×
650
  end
651
  SU.debug("typesetter", "Leaving hmode")
1,411✔
652
  local margins = self:getMargins()
1,411✔
653
  local vboxlist = self:boxUpNodes()
1,411✔
654
  self.state.nodes = {}
1,411✔
655
  -- Push output lines into boxes and ship them to the page builder
656
  for _, vbox in ipairs(vboxlist) do
3,725✔
657
    vbox.margins = margins
2,314✔
658
    self:pushVertical(vbox)
2,314✔
659
  end
660
  if independent then return end
1,411✔
661
  if self:buildPage() then
2,334✔
662
    self:initNextFrame()
64✔
663
  end
664
end
665

666
function typesetter:inhibitLeading ()
117✔
667
  self.state.previousVbox = nil
2✔
668
end
669

670
function typesetter.leadingFor (_, vbox, previous)
117✔
671
  -- Insert leading
672
  SU.debug("typesetter", "   Considering leading between two lines:")
1,004✔
673
  SU.debug("typesetter", "   1)", previous)
1,004✔
674
  SU.debug("typesetter", "   2)", vbox)
1,004✔
675
  if not previous then return SILE.nodefactory.vglue() end
1,004✔
676
  local prevDepth = previous.depth
747✔
677
  SU.debug("typesetter", "   Depth of previous line was", prevDepth)
747✔
678
  local bls = SILE.settings:get("document.baselineskip")
747✔
679
  local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
3,735✔
680
  SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
747✔
681

682
  -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
683
  local lead = SILE.settings:get("document.lineskip").height:absolute()
1,494✔
684
  if depth > lead then
747✔
685
    return SILE.nodefactory.vglue(SILE.length(depth.length, bls.height.stretch, bls.height.shrink))
1,300✔
686
  else
687
    return SILE.nodefactory.vglue(lead)
97✔
688
  end
689
end
690

691
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
117✔
692
  local LTR = self.frame:writingDirection() == "LTR"
2,092✔
693
  local rskip = margins[LTR and "rskip" or "lskip"]
1,046✔
694
  if not rskip then rskip = SILE.nodefactory.glue(0) end
1,046✔
695
  if hangRight and hangRight > 0 then
1,046✔
696
    rskip = SILE.nodefactory.glue({ width = rskip.width:tonumber() + hangRight })
63✔
697
  end
698
  rskip.value = "margin"
1,046✔
699
  -- while slice[#slice].discardable do table.remove(slice, #slice) end
700
  table.insert(slice, rskip)
1,046✔
701
  table.insert(slice, SILE.nodefactory.zerohbox())
2,092✔
702
  local lskip = margins[LTR and "lskip" or "rskip"]
1,046✔
703
  if not lskip then lskip = SILE.nodefactory.glue(0) end
1,046✔
704
  if hangLeft and hangLeft > 0 then
1,046✔
705
    lskip = SILE.nodefactory.glue({ width = lskip.width:tonumber() + hangLeft })
54✔
706
  end
707
  lskip.value = "margin"
1,046✔
708
  while slice[1].discardable do table.remove(slice, 1) end
1,058✔
709
  table.insert(slice, 1, lskip)
1,046✔
710
  table.insert(slice, 1, SILE.nodefactory.zerohbox())
2,092✔
711
end
712

713
function typesetter:breakpointsToLines (breakpoints)
117✔
714
  local linestart = 1
592✔
715
  local lines = {}
592✔
716
  local nodes = self.state.nodes
592✔
717

718
  for i = 1, #breakpoints do
1,648✔
719
    local point = breakpoints[i]
1,056✔
720
    if point.position ~= 0 then
1,056✔
721
      local slice = {}
1,056✔
722
      local seenNonDiscardable = false
1,056✔
723
      for j = linestart, point.position do
20,161✔
724
        slice[#slice+1] = nodes[j]
19,105✔
725
        if nodes[j] then
19,105✔
726
          if not nodes[j].discardable then
19,105✔
727
            seenNonDiscardable = true
13,023✔
728
          end
729
        end
730
      end
731
      if not seenNonDiscardable then
1,056✔
732
        -- Slip lines containing only discardable nodes (e.g. glues).
733
        SU.debug("typesetter", "Skipping a line containing only discardable nodes")
10✔
734
        linestart = point.position + 1
10✔
735
      else
736
        -- If the line ends with a discretionary, repeat it on the next line,
737
        -- so as to account for a potential postbreak.
738
        if slice[#slice].is_discretionary then
1,046✔
739
          linestart = point.position
144✔
740
        else
741
          linestart = point.position + 1
902✔
742
        end
743

744
        -- Then only we can add some extra margin glue...
745
        local mrg = self:getMargins()
1,046✔
746
        self:addrlskip(slice, mrg, point.left, point.right)
1,046✔
747

748
        -- And compute the line...
749
        local ratio = self:computeLineRatio(point.width, slice)
1,046✔
750
        local thisLine = { ratio = ratio, nodes = slice }
1,046✔
751
        lines[#lines+1] = thisLine
1,046✔
752
      end
753
    end
754
  end
755
  if linestart < #nodes then
592✔
756
    -- Abnormal, but warn so that one has a chance to check which bits
757
    -- are misssing at output.
758
    SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
759
  end
760
  return lines
592✔
761
end
762

763
function typesetter.computeLineRatio (_, breakwidth, slice)
117✔
764
  -- This somewhat wrong, see #1362 and #1528
765
  -- This is a somewhat partial workaround, at least made consistent with
766
  -- the nnode and discretionary outputYourself routines
767
  -- (which are somewhat wrong too, or to put it otherwise, the whole
768
  -- logic here, marking nodes without removing/replacing them, likely makes
769
  -- things more complex than they should).
770
  -- TODO Possibly consider a full rewrite/refactor.
771
  local naturalTotals = SILE.length()
1,046✔
772

773
  -- From the line end, check if the line is hyphenated (to account for a prebreak)
774
  -- or contains extraneous glues (e.g. to account for spaces to ignore).
775
  local n = #slice
1,046✔
776
  while n > 1 do
3,436✔
777
    if slice[n].is_glue or slice[n].is_zero then
3,435✔
778
      -- Skip margin glues (they'll be accounted for in the loop below) and
779
      -- zero boxes, so as to reach actual content...
780
      if slice[n].value ~= "margin" then
2,390✔
781
        -- ... but any other glue than a margin, at the end of a line, is actually
782
        -- extraneous. It will however also be accounted for below, so substract
783
        -- them to cancel their width. Typically, if a line break occurred at
784
        -- a space, the latter is then at the end of the line now, and must be
785
        -- ignored.
786
        naturalTotals:___sub(slice[n].width)
1,343✔
787
      end
788
    elseif slice[n].is_discretionary then
1,045✔
789
      -- Stop as we reached an hyphenation, and account for the prebreak.
790
      slice[n].used = true
144✔
791
      if slice[n].parent then
144✔
792
        slice[n].parent.hyphenated = true
144✔
793
      end
794
      naturalTotals:___add(slice[n]:prebreakWidth())
288✔
795
      slice[n].height = slice[n]:prebreakHeight()
288✔
796
      break
144✔
797
    else
798
      -- Stop as we reached actual content.
799
      break
800
    end
801
    n = n - 1
2,390✔
802
  end
803

804
  local seenNodes = {}
1,046✔
805
  local skipping = true
1,046✔
806
  for i, node in ipairs(slice) do
24,318✔
807
    if node.is_box then
23,272✔
808
      skipping = false
11,686✔
809
      if node.parent and not node.parent.hyphenated then
11,686✔
810
        if not seenNodes[node.parent] then
3,436✔
811
          naturalTotals:___add(node.parent:lineContribution())
2,738✔
812
        end
813
        seenNodes[node.parent] = true
3,436✔
814
      else
815
        naturalTotals:___add(node:lineContribution())
16,500✔
816
      end
817
    elseif node.is_penalty and node.penalty == -inf_bad then
11,586✔
818
      skipping = false
604✔
819
    elseif node.is_discretionary then
10,982✔
820
      skipping = false
2,615✔
821
      local seen = node.parent and seenNodes[node.parent]
2,615✔
822
      if not seen and not node.used then
2,615✔
823
        naturalTotals:___add(node:replacementWidth():absolute())
1,014✔
824
        slice[i].height = slice[i]:replacementHeight():absolute()
1,014✔
825
      end
826
    elseif not skipping then
8,367✔
827
      naturalTotals:___add(node.width)
8,367✔
828
    end
829
  end
830

831
  -- From the line start, skip glues and margins, and check if it then starts
832
  -- with a used discretionary. If so, account for a postbreak.
833
  n = 1
1,046✔
834
  while n < #slice do
4,343✔
835
    if slice[n].is_discretionary and slice[n].used then
4,342✔
836
      naturalTotals:___add(slice[n]:postbreakWidth())
288✔
837
      slice[n].height = slice[n]:postbreakHeight()
288✔
838
      break
144✔
839
    elseif not (slice[n].is_glue or slice[n].is_zero) then
4,198✔
840
      break
901✔
841
    end
842
    n = n + 1
3,297✔
843
  end
844

845
  local _left = breakwidth:tonumber() - naturalTotals:tonumber()
3,138✔
846
  local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
2,092✔
847
  ratio = math.max(ratio, -1)
1,046✔
848
  return ratio, naturalTotals
1,046✔
849
end
850

851
function typesetter:chuck () -- emergency shipout everything
117✔
852
  self:leaveHmode(true)
83✔
853
  if (#self.state.outputQueue > 0) then
83✔
854
    SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
69✔
855
    self:outputLinesToPage(self.state.outputQueue)
69✔
856
    self.state.outputQueue = {}
69✔
857
  end
858
end
859

860
-- Logic for building an hbox from content.
861
-- It returns the hbox and an horizontal list of (migrating) elements
862
-- extracted outside of it.
863
-- None of these are pushed to the typesetter node queue. The caller
864
-- is responsible of doing it, if the hbox is built for anything
865
-- else than e.g. measuring it. Likewise, the call has to decide
866
-- what to do with the migrating content.
867
local _rtl_pre_post = function (box, atypesetter, line)
868
  local advance = function () atypesetter.frame:advanceWritingDirection(box:scaledWidth(line)) end
1,464✔
869
  if atypesetter.frame:writingDirection() == "RTL" then
732✔
870
    advance()
×
871
    return function () end
×
872
  else
873
    return advance
366✔
874
  end
875
end
876
function typesetter:makeHbox (content)
117✔
877
  local recentContribution = {}
130✔
878
  local migratingNodes = {}
130✔
879

880
  -- HACK HBOX
881
  -- This is from the original implementation.
882
  -- It would be somewhat cleaner to use a temporary typesetter state
883
  -- (pushState/popState) rather than using the current one, removing
884
  -- the processed nodes from it afterwards. However, as long
885
  -- as leaving horizontal mode is not strictly forbidden here, it would
886
  -- lead to a possibly different result (the output queue being skipped).
887
  -- See also HACK HBOX comment in typesetter:leaveHmode().
888
  local index = #(self.state.nodes)+1
130✔
889
  self.state.hmodeOnly = true
130✔
890
  SILE.process(content)
130✔
891
  self.state.hmodeOnly = false -- Wouldn't be needed in a temporary state
130✔
892

893
  local l = SILE.length()
130✔
894
  local h, d = SILE.length(), SILE.length()
260✔
895
  for i = index, #(self.state.nodes) do
273✔
896
    local node = self.state.nodes[i]
143✔
897
    if node.is_migrating then
254✔
898
      migratingNodes[#migratingNodes+1] = node
×
899
    elseif node.is_unshaped then
143✔
900
      local shape = node:shape()
111✔
901
      for _, attr in ipairs(shape) do
319✔
902
        recentContribution[#recentContribution+1] = attr
208✔
903
        h = attr.height > h and attr.height or h
321✔
904
        d = attr.depth > d and attr.depth or d
298✔
905
        l = l + attr:lineContribution():absolute()
624✔
906
      end
907
    elseif node.is_discretionary then
32✔
908
      -- HACK https://github.com/sile-typesetter/sile/issues/583
909
      -- Discretionary nodes have a null line contribution...
910
      -- But if discretionary nodes occur inside an hbox, since the latter
911
      -- is not line-broken, they will never be marked as 'used' and will
912
      -- evaluate to the replacement content (if any)...
913
      recentContribution[#recentContribution+1] = node
1✔
914
      l = l + node:replacementWidth():absolute()
3✔
915
      -- The replacement content may have ascenders and descenders...
916
      local hdisc = node:replacementHeight():absolute()
2✔
917
      local ddisc = node:replacementDepth():absolute()
2✔
918
      h = hdisc > h and hdisc or h
2✔
919
      d = ddisc > d and ddisc or d
1✔
920
      -- By the way it's unclear how this is expected to work in TTB
921
      -- writing direction. For other type of nodes, the line contribution
922
      -- evaluates to the height rather than the width in TTB, but the
923
      -- whole logic might then be dubious there too...
924
    else
925
      recentContribution[#recentContribution+1] = node
31✔
926
      l = l + node:lineContribution():absolute()
93✔
927
      h = node.height > h and node.height or h
33✔
928
      d = node.depth > d and node.depth or d
32✔
929
    end
930
    self.state.nodes[i] = nil -- wouldn't be needed in a temporary state
143✔
931
  end
932

933
  local hbox = SILE.nodefactory.hbox({
260✔
934
      height = h,
130✔
935
      width = l,
130✔
936
      depth = d,
130✔
937
      value = recentContribution,
130✔
938
      outputYourself = function (box, atypesetter, line)
939
        local _post = _rtl_pre_post(box, atypesetter, line)
366✔
940
        local ox = atypesetter.frame.state.cursorX
366✔
941
        local oy = atypesetter.frame.state.cursorY
366✔
942
        SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
366✔
943
        for _, node in ipairs(box.value) do
1,052✔
944
          node:outputYourself(atypesetter, line)
686✔
945
        end
946
        atypesetter.frame.state.cursorX = ox
366✔
947
        atypesetter.frame.state.cursorY = oy
366✔
948
        _post()
366✔
949
        SU.debug("hboxes", function ()
732✔
950
          SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
951
          return "Drew debug outline around hbox"
×
952
        end)
953
      end
954
    })
955
  return hbox, migratingNodes
130✔
956
end
957

958
function typesetter:pushHlist (hlist)
117✔
959
  for _, h in ipairs(hlist) do
7✔
960
    self:pushHorizontal(h)
×
961
  end
962
end
963

964
return typesetter
117✔
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