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

sile-typesetter / sile / 6713098919

31 Oct 2023 10:21PM UTC coverage: 52.831% (-21.8%) from 74.636%
6713098919

push

github

web-flow
Merge d0a2a1ee9 into b185d4972

45 of 45 new or added lines in 3 files covered. (100.0%)

8173 of 15470 relevant lines covered (52.83%)

6562.28 hits per line

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

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

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

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

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

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

33
  })
34

35
local warned = false
7✔
36

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

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

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

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

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

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

128
  SILE.settings:declare({
13✔
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 ()
7✔
138
  self.state = {
13✔
139
    nodes = {},
13✔
140
    outputQueue = {},
13✔
141
    lastBadness = awful_bad,
13✔
142
  }
13✔
143
end
144

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

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

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

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

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

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

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

181
function typesetter:debugState ()
7✔
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)
7✔
196
  self:initline()
159✔
197
  self.state.nodes[#self.state.nodes+1] = node
159✔
198
  return node
159✔
199
end
200

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

206
function typesetter:pushHbox (spec)
7✔
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)
×
209
  local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.nodefactory.hbox(spec)
×
210
  return self:pushHorizontal(node)
×
211
end
212

213
function typesetter:pushUnshaped (spec)
7✔
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)
80✔
216
  return self:pushHorizontal(node)
40✔
217
end
218

219
function typesetter:pushGlue (spec)
7✔
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)
140✔
222
  return self:pushHorizontal(node)
70✔
223
end
224

225
function typesetter:pushExplicitGlue (spec)
7✔
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)
×
228
  node.explicit = true
×
229
  node.discardable = false
×
230
  return self:pushHorizontal(node)
×
231
end
232

233
function typesetter:pushPenalty (spec)
7✔
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)
70✔
236
  return self:pushHorizontal(node)
35✔
237
end
238

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

244
function typesetter:pushVbox (spec)
7✔
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)
×
247
  return self:pushVertical(node)
×
248
end
249

250
function typesetter:pushVglue (spec)
7✔
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)
112✔
253
  return self:pushVertical(node)
56✔
254
end
255

256
function typesetter:pushExplicitVglue (spec)
7✔
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)
56✔
259
  node.explicit = true
28✔
260
  node.discardable = false
28✔
261
  return self:pushVertical(node)
28✔
262
end
263

264
function typesetter:pushVpenalty (spec)
7✔
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)
14✔
267
  return self:pushVertical(node)
7✔
268
end
269

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

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

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

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

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

318
function typesetter.shapeAllNodes (_, nodelist)
7✔
319
  local newNl = {}
105✔
320
  for i = 1, #nodelist do
1,495✔
321
    if nodelist[i].is_unshaped then
1,390✔
322
      pl.tablex.insertvalues(newNl, nodelist[i]:shape())
120✔
323
    else
324
      newNl[#newNl+1] = nodelist[i]
1,350✔
325
    end
326
  end
327
  for i =1, #newNl do nodelist[i]=newNl[i] end
1,874✔
328
  if #nodelist > #newNl then
105✔
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 ()
7✔
336
  local nodelist = self.state.nodes
95✔
337
  if #nodelist == 0 then return {} end
95✔
338
  for j = #nodelist, 1, -1 do
45✔
339
    if not nodelist[j].is_migrating then
45✔
340
      if nodelist[j].discardable then
45✔
341
        table.remove(nodelist, j)
20✔
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
35✔
348
  if #nodelist == 0 then return {} end
35✔
349
  self:shapeAllNodes(nodelist)
35✔
350
  local parfillskip = SILE.settings:get("typesetter.parfillskip")
35✔
351
  parfillskip.discardable = false
35✔
352
  self:pushGlue(parfillskip)
35✔
353
  self:pushPenalty(-inf_bad)
35✔
354
  SU.debug("typesetter", function ()
70✔
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()
70✔
358
  local lines = self:breakIntoLines(nodelist, breakWidth)
35✔
359
  local vboxes = {}
35✔
360
  for index=1, #lines do
84✔
361
    local line = lines[index]
49✔
362
    local migrating = {}
49✔
363
    -- Move any migrating material
364
    local nodes = {}
49✔
365
    for i =1, #line.nodes do
913✔
366
      local node = line.nodes[i]
864✔
367
      if node.is_migrating then
864✔
368
        for j=1, #node.material do migrating[#migrating+1] = node.material[j] end
×
369
      else
370
        nodes[#nodes+1] = node
864✔
371
      end
372
    end
373
    local vbox = SILE.nodefactory.vbox({ nodes = nodes, ratio = line.ratio })
49✔
374
    local pageBreakPenalty = 0
49✔
375
    if (#lines > 1 and index == 1) then
49✔
376
      pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
8✔
377
    elseif (#lines > 1 and index == (#lines-1)) then
45✔
378
      pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
8✔
379
    end
380
    vboxes[#vboxes+1] = self:leadingFor(vbox, self.state.previousVbox)
98✔
381
    vboxes[#vboxes+1] = vbox
49✔
382
    for i=1, #migrating do vboxes[#vboxes+1] = migrating[i] end
49✔
383
    self.state.previousVbox = vbox
49✔
384
    if pageBreakPenalty > 0 then
49✔
385
      SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
8✔
386
      vboxes[#vboxes+1] = SILE.nodefactory.penalty(pageBreakPenalty)
16✔
387
    end
388
  end
389
  return vboxes
35✔
390
end
391

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

396
function typesetter:getTargetLength ()
7✔
397
  return self.frame:getTargetLength()
92✔
398
end
399

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

405
function typesetter:runHooks (category, data)
7✔
406
  if not self.hooks[category] then return data end
92✔
407
  for _, func in ipairs(self.hooks[category]) do
14✔
408
    data = func(self, data)
14✔
409
  end
410
  return data
7✔
411
end
412

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

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

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

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

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

454
  local pastTop = false
7✔
455
  for _, node in ipairs(pageNodeList) do
179✔
456
    if not pastTop and not node.discardable and not node.explicit then
172✔
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
7✔
463
    end
464
    if pastTop then
172✔
465
      if not node.is_insertion then
161✔
466
        totalHeight:___add(node.height)
161✔
467
        totalHeight:___add(node.depth)
161✔
468
      end
469
      if node.is_vglue then
161✔
470
        table.insert(glues, node)
110✔
471
        gTotal:___add(node.height)
110✔
472
      end
473
    end
474
  end
475

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

480
  local adjustment = target - totalHeight
7✔
481
  if adjustment:tonumber() > 0 then
14✔
482
    if adjustment > gTotal.stretch then
7✔
483
      if (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber() then
×
484
        SU.warn("Underfull frame " .. self.frame.id .. ": " .. adjustment .. " stretchiness required to fill but only " .. gTotal.stretch .. " available")
×
485
      end
486
      adjustment = gTotal.stretch
×
487
    end
488
    if gTotal.stretch:tonumber() > 0 then
14✔
489
      for i = 1, #glues do
117✔
490
        local g = glues[i]
110✔
491
        g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
550✔
492
      end
493
    end
494
  elseif adjustment:tonumber() < 0 then
×
495
    adjustment = 0 - adjustment
×
496
    if adjustment > gTotal.shrink then
×
497
      if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
×
498
        SU.warn("Overfull frame " .. self.frame.id .. ": " .. adjustment .. " shrinkability required to fit but only " .. gTotal.shrink .. " available")
×
499
      end
500
      adjustment = gTotal.shrink
×
501
    end
502
    if gTotal.shrink:tonumber() > 0 then
×
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)
7✔
510
end
511

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

531
  if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
×
532
    self:pushBack()
×
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()
×
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
×
543
      local lead = self:leadingFor(self.state.outputQueue[1], nil)
×
544
      if lead then
×
545
        table.insert(self.state.outputQueue, 1, lead)
×
546
      end
547
    end
548
  end
549
  self:runHooks("newframe")
×
550

551
end
552

553
function typesetter:pushBack ()
7✔
554
  SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
×
555
  local oldqueue = self.state.outputQueue
×
556
  self.state.outputQueue = {}
×
557
  self.state.previousVbox = nil
×
558
  local lastMargins = self:getMargins()
×
559
  for _, vbox in ipairs(oldqueue) do
×
560
    SU.debug("pushback", "process box", vbox)
×
561
    if vbox.margins and vbox.margins ~= lastMargins then
×
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
×
567
      SU.debug("pushback", "explicit", vbox)
×
568
      self:endline()
×
569
      self:pushExplicitVglue(vbox)
×
570
    elseif vbox.is_insertion then
×
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
×
574
      SU.debug("pushback", "not vglue or penalty", vbox.type)
×
575
      local discardedFistInitLine = false
×
576
      if (#self.state.nodes == 0) then
×
577
        -- Setup queue but avoid calling newPar
578
        self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
×
579
      end
580
      for i, node in ipairs(vbox.nodes) do
×
581
        if node.is_glue and not node.discardable then
×
582
          self:pushHorizontal(node)
×
583
        elseif node.is_glue and node.value == "margin" then
×
584
          SU.debug("pushback", "discard", node.value, node)
×
585
        elseif node.is_discretionary then
×
586
          SU.debug("pushback", "re-mark discretionary as unused", node)
×
587
          node.used = false
×
588
          if i == 1 then
×
589
            SU.debug("pushback", "keep first discretionary", node)
×
590
            self:pushHorizontal(node)
×
591
          else
592
            SU.debug("pushback", "discard all other discretionaries", node)
×
593
          end
594
        elseif node.is_zero then
×
595
          if discardedFistInitLine then self:pushHorizontal(node) end
×
596
          discardedFistInitLine = true
×
597
        elseif node.is_penalty then
×
598
          if not discardedFistInitLine then self:pushHorizontal(node) end
×
599
        else
600
          node.bidiDone = true
×
601
          self:pushHorizontal(node)
×
602
        end
603
      end
604
    else
605
      SU.debug("pushback", "discard", vbox.type)
×
606
    end
607
    lastMargins = vbox.margins
×
608
    -- self:debugState()
609
  end
610
  while self.state.nodes[#self.state.nodes]
×
611
  and (self.state.nodes[#self.state.nodes].is_penalty
×
612
    or self.state.nodes[#self.state.nodes].is_zero) do
×
613
    self.state.nodes[#self.state.nodes] = nil
×
614
  end
615
end
616

617
function typesetter:outputLinesToPage (lines)
7✔
618
  SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
13✔
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
13✔
625
  for _, line in ipairs(lines) do
203✔
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
190✔
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
13✔
634
    end
635
    if pastTop then
190✔
636
      line:outputYourself(self, line)
173✔
637
    end
638
  end
639
  self.frame.state.totals.pastTop = pastTop
13✔
640
end
641

642
function typesetter:leaveHmode (independent)
7✔
643
  if self.state.hmodeOnly then
95✔
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")
95✔
652
  local margins = self:getMargins()
95✔
653
  local vboxlist = self:boxUpNodes()
95✔
654
  self.state.nodes = {}
95✔
655
  -- Push output lines into boxes and ship them to the page builder
656
  for _, vbox in ipairs(vboxlist) do
201✔
657
    vbox.margins = margins
106✔
658
    self:pushVertical(vbox)
106✔
659
  end
660
  if independent then return end
95✔
661
  if self:buildPage() then
164✔
662
    self:initNextFrame()
×
663
  end
664
end
665

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

670
function typesetter.leadingFor (_, vbox, previous)
7✔
671
  -- Insert leading
672
  SU.debug("typesetter", "   Considering leading between two lines:")
49✔
673
  SU.debug("typesetter", "   1)", previous)
49✔
674
  SU.debug("typesetter", "   2)", vbox)
49✔
675
  if not previous then return SILE.nodefactory.vglue() end
49✔
676
  local prevDepth = previous.depth
36✔
677
  SU.debug("typesetter", "   Depth of previous line was", prevDepth)
36✔
678
  local bls = SILE.settings:get("document.baselineskip")
36✔
679
  local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
180✔
680
  SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
36✔
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()
72✔
684
  if depth > lead then
36✔
685
    return SILE.nodefactory.vglue(SILE.length(depth.length, bls.height.stretch, bls.height.shrink))
44✔
686
  else
687
    return SILE.nodefactory.vglue(lead)
14✔
688
  end
689
end
690

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

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

718
  for i = 1, #breakpoints do
84✔
719
    local point = breakpoints[i]
49✔
720
    if point.position ~= 0 then
49✔
721
      local slice = {}
49✔
722
      local seenNonDiscardable = false
49✔
723
      for j = linestart, point.position do
717✔
724
        slice[#slice+1] = nodes[j]
668✔
725
        if nodes[j] then
668✔
726
          if not nodes[j].discardable then
668✔
727
            seenNonDiscardable = true
376✔
728
          end
729
        end
730
      end
731
      if not seenNonDiscardable then
49✔
732
        -- Slip lines containing only discardable nodes (e.g. glues).
733
        SU.debug("typesetter", "Skipping a line containing only discardable nodes")
×
734
        linestart = point.position + 1
×
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
49✔
739
          linestart = point.position
×
740
        else
741
          linestart = point.position + 1
49✔
742
        end
743

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

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

763
function typesetter.computeLineRatio (_, breakwidth, slice)
7✔
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()
49✔
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
49✔
776
  while n > 1 do
161✔
777
    if slice[n].is_glue or slice[n].is_zero then
161✔
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
112✔
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 subtract
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)
63✔
787
      end
788
    elseif slice[n].is_discretionary then
49✔
789
      -- Stop as we reached an hyphenation, and account for the prebreak.
790
      slice[n].used = true
×
791
      if slice[n].parent then
×
792
        slice[n].parent.hyphenated = true
×
793
      end
794
      naturalTotals:___add(slice[n]:prebreakWidth())
×
795
      slice[n].height = slice[n]:prebreakHeight()
×
796
      break
797
    else
798
      -- Stop as we reached actual content.
799
      break
800
    end
801
    n = n - 1
112✔
802
  end
803

804
  local seenNodes = {}
49✔
805
  local skipping = true
49✔
806
  for i, node in ipairs(slice) do
913✔
807
    if node.is_box then
864✔
808
      skipping = false
439✔
809
      if node.parent and not node.parent.hyphenated then
439✔
810
        if not seenNodes[node.parent] then
×
811
          naturalTotals:___add(node.parent:lineContribution())
×
812
        end
813
        seenNodes[node.parent] = true
×
814
      else
815
        naturalTotals:___add(node:lineContribution())
878✔
816
      end
817
    elseif node.is_penalty and node.penalty == -inf_bad then
425✔
818
      skipping = false
35✔
819
    elseif node.is_discretionary then
390✔
820
      skipping = false
×
821
      local seen = node.parent and seenNodes[node.parent]
×
822
      if not seen and not node.used then
×
823
        naturalTotals:___add(node:replacementWidth():absolute())
×
824
        slice[i].height = slice[i]:replacementHeight():absolute()
×
825
      end
826
    elseif not skipping then
390✔
827
      naturalTotals:___add(node.width)
390✔
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
49✔
834
  while n < #slice do
217✔
835
    if slice[n].is_discretionary and slice[n].used then
217✔
836
      naturalTotals:___add(slice[n]:postbreakWidth())
×
837
      slice[n].height = slice[n]:postbreakHeight()
×
838
      break
839
    elseif not (slice[n].is_glue or slice[n].is_zero) then
217✔
840
      break
49✔
841
    end
842
    n = n + 1
168✔
843
  end
844

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

851
function typesetter:chuck () -- emergency shipout everything
7✔
852
  self:leaveHmode(true)
6✔
853
  if (#self.state.outputQueue > 0) then
6✔
854
    SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
6✔
855
    self:outputLinesToPage(self.state.outputQueue)
6✔
856
    self.state.outputQueue = {}
6✔
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
×
869
  if atypesetter.frame:writingDirection() == "RTL" then
×
870
    advance()
×
871
    return function () end
×
872
  else
873
    return advance
×
874
  end
875
end
876
function typesetter:makeHbox (content)
7✔
877
  local recentContribution = {}
×
878
  local migratingNodes = {}
×
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
×
889
  self.state.hmodeOnly = true
×
890
  SILE.process(content)
×
891
  self.state.hmodeOnly = false -- Wouldn't be needed in a temporary state
×
892

893
  local l = SILE.length()
×
894
  local h, d = SILE.length(), SILE.length()
×
895
  for i = index, #(self.state.nodes) do
×
896
    local node = self.state.nodes[i]
×
897
    if node.is_migrating then
×
898
      migratingNodes[#migratingNodes+1] = node
×
899
    elseif node.is_unshaped then
×
900
      local shape = node:shape()
×
901
      for _, attr in ipairs(shape) do
×
902
        recentContribution[#recentContribution+1] = attr
×
903
        h = attr.height > h and attr.height or h
×
904
        d = attr.depth > d and attr.depth or d
×
905
        l = l + attr:lineContribution():absolute()
×
906
      end
907
    elseif node.is_discretionary then
×
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
×
914
      l = l + node:replacementWidth():absolute()
×
915
      -- The replacement content may have ascenders and descenders...
916
      local hdisc = node:replacementHeight():absolute()
×
917
      local ddisc = node:replacementDepth():absolute()
×
918
      h = hdisc > h and hdisc or h
×
919
      d = ddisc > d and ddisc or d
×
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
×
926
      l = l + node:lineContribution():absolute()
×
927
      h = node.height > h and node.height or h
×
928
      d = node.depth > d and node.depth or d
×
929
    end
930
    self.state.nodes[i] = nil -- wouldn't be needed in a temporary state
×
931
  end
932

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

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

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

© 2025 Coveralls, Inc