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

sile-typesetter / sile / 9428435077

08 Jun 2024 11:35AM UTC coverage: 64.56% (-9.9%) from 74.46%
9428435077

push

github

web-flow
Merge pull request #2047 from alerque/end-pars

23 of 46 new or added lines in 5 files covered. (50.0%)

1684 existing lines in 60 files now uncovered.

11145 of 17263 relevant lines covered (64.56%)

4562.45 hits per line

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

78.22
/typesetters/base.lua
1
--- SILE typesetter class.
2
-- @interfaces typesetters
3

4
--- @type typesetter
5
local typesetter = pl.class()
60✔
6

7
typesetter.type = "typesetter"
60✔
8
typesetter._name = "base"
60✔
9

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

17
-- Local helper class to compare pairs of margins
18
local _margins = pl.class({
120✔
19
   lskip = SILE.types.node.glue(),
120✔
20
   rskip = SILE.types.node.glue(),
120✔
21

22
   _init = function (self, lskip, rskip)
23
      self.lskip, self.rskip = lskip, rskip
1,717✔
24
   end,
25

26
   __eq = function (self, other)
27
      return self.lskip.width == other.lskip.width and self.rskip.width == other.rskip.width
2✔
28
   end,
29
})
30

31
local warned = false
60✔
32

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

45
--- Constructor
46
-- @param frame A initial frame to attach the typesetter to.
47
function typesetter:_init (frame)
60✔
48
   self:declareSettings()
117✔
49
   self.hooks = {}
117✔
50
   self.breadcrumbs = SU.breadcrumbs()
234✔
51

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

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

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

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

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

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

98
   SILE.settings:declare({
234✔
99
      parameter = "typesetter.parfillskip",
100
      type = "glue",
101
      default = SILE.types.node.glue("0pt plus 10000pt"),
234✔
102
      help = "Glue added at the end of a paragraph",
103
   })
104

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

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

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

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

133
   SILE.settings:declare({
117✔
134
      parameter = "typesetter.italicCorrection",
135
      type = "boolean",
136
      default = false,
137
      help = "Whether italic correction is activated or not",
138
   })
139

140
   SILE.settings:declare({
117✔
141
      parameter = "typesetter.softHyphen",
142
      type = "boolean",
143
      default = true,
144
      help = "When true, soft hyphens are rendered as discretionary breaks, otherwise they are ignored",
145
   })
146

147
   SILE.settings:declare({
117✔
148
      parameter = "typesetter.softHyphenWarning",
149
      type = "boolean",
150
      default = false,
151
      help = "When true, a warning is issued when a soft hyphen is encountered",
152
   })
153

154
   SILE.settings:declare({
117✔
155
      parameter = "typesetter.fixedSpacingAfterInitialEmdash",
156
      type = "boolean",
157
      default = true,
158
      help = "When true, em-dash starting a paragraph is considered as a speaker change in a dialogue",
159
   })
160
end
161

162
function typesetter:initState ()
60✔
163
   self.state = {
147✔
164
      nodes = {},
147✔
165
      outputQueue = {},
147✔
166
      lastBadness = awful_bad,
147✔
167
      liners = {},
147✔
168
   }
147✔
169
end
170

171
function typesetter:initFrame (frame)
60✔
172
   if frame then
187✔
173
      self.frame = frame
181✔
174
      self.frame:init(self)
181✔
175
   end
176
end
177

178
function typesetter.getMargins ()
60✔
179
   return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
5,151✔
180
end
181

182
function typesetter.setMargins (_, margins)
60✔
UNCOV
183
   SILE.settings:set("document.lskip", margins.lskip)
×
UNCOV
184
   SILE.settings:set("document.rskip", margins.rskip)
×
185
end
186

187
function typesetter:pushState ()
60✔
188
   self.stateQueue[#self.stateQueue + 1] = self.state
30✔
189
   self:initState()
30✔
190
end
191

192
function typesetter:popState (ncount)
60✔
193
   local offset = ncount and #self.stateQueue - ncount or nil
30✔
194
   self.state = table.remove(self.stateQueue, offset)
60✔
195
   if not self.state then
30✔
196
      SU.error("Typesetter state queue empty")
×
197
   end
198
end
199

200
function typesetter:isQueueEmpty ()
60✔
201
   if not self.state then
1,317✔
202
      return nil
×
203
   end
204
   return #self.state.nodes == 0 and #self.state.outputQueue == 0
1,317✔
205
end
206

207
function typesetter:vmode ()
60✔
208
   return #self.state.nodes == 0
524✔
209
end
210

211
function typesetter:debugState ()
60✔
212
   print("\n---\nI am in " .. (self:vmode() and "vertical" or "horizontal") .. " mode")
×
213
   print("Writing into " .. tostring(self.frame))
×
214
   print("Recent contributions: ")
×
215
   for i = 1, #self.state.nodes do
×
216
      io.stderr:write(self.state.nodes[i] .. " ")
×
217
   end
218
   print("\nVertical list: ")
×
219
   for i = 1, #self.state.outputQueue do
×
220
      print("  " .. self.state.outputQueue[i])
×
221
   end
222
end
223

224
-- Boxy stuff
225
function typesetter:pushHorizontal (node)
60✔
226
   self:initline()
1,837✔
227
   self.state.nodes[#self.state.nodes + 1] = node
1,837✔
228
   return node
1,837✔
229
end
230

231
function typesetter:pushVertical (vbox)
60✔
232
   self.state.outputQueue[#self.state.outputQueue + 1] = vbox
1,900✔
233
   return vbox
1,900✔
234
end
235

236
function typesetter:pushHbox (spec)
60✔
237
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
238
   local ntype = SU.type(spec)
64✔
239
   local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.types.node.hbox(spec)
64✔
240
   return self:pushHorizontal(node)
64✔
241
end
242

243
function typesetter:pushUnshaped (spec)
60✔
244
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
245
   local node = SU.type(spec) == "unshaped" and spec or SILE.types.node.unshaped(spec)
806✔
246
   return self:pushHorizontal(node)
403✔
247
end
248

249
function typesetter:pushGlue (spec)
60✔
250
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
251
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
1,368✔
252
   return self:pushHorizontal(node)
684✔
253
end
254

255
function typesetter:pushExplicitGlue (spec)
60✔
256
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
257
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
86✔
258
   node.explicit = true
43✔
259
   node.discardable = false
43✔
260
   return self:pushHorizontal(node)
43✔
261
end
262

263
function typesetter:pushPenalty (spec)
60✔
264
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
265
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
688✔
266
   return self:pushHorizontal(node)
344✔
267
end
268

269
function typesetter:pushMigratingMaterial (material)
60✔
270
   local node = SILE.types.node.migrating({ material = material })
22✔
271
   return self:pushHorizontal(node)
22✔
272
end
273

274
function typesetter:pushVbox (spec)
60✔
275
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
276
   local node = SU.type(spec) == "vbox" and spec or SILE.types.node.vbox(spec)
2✔
277
   return self:pushVertical(node)
1✔
278
end
279

280
function typesetter:pushVglue (spec)
60✔
281
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
282
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
614✔
283
   return self:pushVertical(node)
307✔
284
end
285

286
function typesetter:pushExplicitVglue (spec)
60✔
287
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
288
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
450✔
289
   node.explicit = true
225✔
290
   node.discardable = false
225✔
291
   return self:pushVertical(node)
225✔
292
end
293

294
function typesetter:pushVpenalty (spec)
60✔
295
   -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
296
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
172✔
297
   return self:pushVertical(node)
86✔
298
end
299

300
-- Actual typesetting functions
301
function typesetter:typeset (text)
60✔
302
   text = tostring(text)
782✔
303
   if text:match("^%\r?\n$") then
782✔
304
      return
305✔
305
   end
306
   local pId = SILE.traceStack:pushText(text)
477✔
307
   for token in SU.gtoke(text, SILE.settings:get("typesetter.parseppattern")) do
2,038✔
308
      if token.separator then
607✔
309
         self:endline()
400✔
310
      else
311
         if SILE.settings:get("typesetter.softHyphen") then
814✔
312
            local warnedshy = false
407✔
313
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
1,221✔
314
               if token2.separator then -- soft hyphen support
407✔
UNCOV
315
                  local discretionary = SILE.types.node.discretionary({})
×
UNCOV
316
                  local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
×
UNCOV
317
                  discretionary.prebreak = { hbox }
×
UNCOV
318
                  table.insert(SILE.typesetter.state.nodes, discretionary)
×
UNCOV
319
                  if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
×
320
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
321
                  end
UNCOV
322
                  warnedshy = true
×
323
               else
324
                  self:setpar(token2.string)
407✔
325
               end
326
            end
327
         else
328
            if
UNCOV
329
               SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD))
×
330
            then
331
               SU.warn("Soft hyphen encountered and ignored")
×
332
            end
UNCOV
333
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
×
UNCOV
334
            self:setpar(text)
×
335
         end
336
      end
337
   end
338
   SILE.traceStack:pop(pId)
477✔
339
end
340

341
function typesetter:initline ()
60✔
342
   if self.state.hmodeOnly then
2,098✔
343
      return
7✔
344
   end -- https://github.com/sile-typesetter/sile/issues/1718
345
   if #self.state.nodes == 0 then
2,091✔
346
      table.insert(self.state.nodes, SILE.types.node.zerohbox())
672✔
347
      SILE.documentState.documentClass.newPar(self)
336✔
348
   end
349
end
350

351
function typesetter:endline ()
60✔
352
   SILE.documentState.documentClass.endPar(self)
413✔
353
   self:leaveHmode()
413✔
354
   if SILE.settings:get("current.hangIndent") then
826✔
355
      SILE.settings:set("current.hangIndent", nil)
4✔
356
      SILE.settings:set("linebreak.hangIndent", nil)
4✔
357
   end
358
   if SILE.settings:get("current.hangAfter") then
826✔
359
      SILE.settings:set("current.hangAfter", nil)
4✔
360
      SILE.settings:set("linebreak.hangAfter", nil)
4✔
361
   end
362
end
363

364
-- Just compute once, to avoid unicode characters in source code.
365
local speakerChangePattern = "^"
×
366
   .. luautf8.char(0x2014) -- emdash
60✔
367
   .. "[ "
×
368
   .. luautf8.char(0x00A0)
60✔
369
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
60✔
370
   .. "]+"
60✔
371
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
60✔
372

373
-- Special unshaped node subclass to handle space after a speaker change in dialogues
374
-- introduced by an em-dash.
375
local speakerChangeNode = pl.class(SILE.types.node.unshaped)
60✔
376
function speakerChangeNode:shape ()
60✔
UNCOV
377
   local node = self._base.shape(self)
×
UNCOV
378
   local spc = node[2]
×
UNCOV
379
   if spc and spc.is_glue then
×
380
      -- Switch the variable space glue to a fixed kern
UNCOV
381
      node[2] = SILE.types.node.kern({ width = spc.width.length })
×
UNCOV
382
      node[2].parent = self.parent
×
383
   else
384
      -- Should not occur:
385
      -- How could it possibly be shaped differently?
386
      SU.warn("Speaker change logic met an unexpected case, this might be a bug.")
×
387
   end
UNCOV
388
   return node
×
389
end
390

391
-- Takes string, writes onto self.state.nodes
392
function typesetter:setpar (text)
60✔
393
   text = text:gsub("\r?\n", " "):gsub("\t", " ")
407✔
394
   if #self.state.nodes == 0 then
407✔
395
      if not SILE.settings:get("typesetter.obeyspaces") then
522✔
396
         text = text:gsub("^%s+", "")
261✔
397
      end
398
      self:initline()
261✔
399

400
      if
401
         SILE.settings:get("typesetter.fixedSpacingAfterInitialEmdash")
261✔
402
         and not SILE.settings:get("typesetter.obeyspaces")
522✔
403
      then
404
         local speakerChange = false
261✔
405
         local dialogue = luautf8.gsub(text, speakerChangePattern, function ()
522✔
UNCOV
406
            speakerChange = true
×
UNCOV
407
            return speakerChangeReplacement
×
408
         end)
409
         if speakerChange then
261✔
UNCOV
410
            local node = speakerChangeNode({ text = dialogue, options = SILE.font.loadDefaults({}) })
×
UNCOV
411
            self:pushHorizontal(node)
×
UNCOV
412
            return -- done here: speaker change space handling is done after nnode shaping
×
413
         end
414
      end
415
   end
416
   if #text > 0 then
407✔
417
      self:pushUnshaped({ text = text, options = SILE.font.loadDefaults({}) })
806✔
418
   end
419
end
420

421
function typesetter:breakIntoLines (nodelist, breakWidth)
60✔
422
   self:shapeAllNodes(nodelist)
335✔
423
   local breakpoints = SILE.linebreak:doBreak(nodelist, breakWidth)
335✔
424
   return self:breakpointsToLines(breakpoints)
335✔
425
end
426

427
local function getLastShape (nodelist)
428
   local hasGlue
429
   local last
UNCOV
430
   if nodelist then
×
431
      -- The node list may contain nnodes, penalties, kern and glue
432
      -- We skip the latter, and retrieve the last shaped item.
UNCOV
433
      for i = #nodelist, 1, -1 do
×
UNCOV
434
         local n = nodelist[i]
×
UNCOV
435
         if n.is_nnode then
×
UNCOV
436
            local items = n.nodes[#n.nodes].value.items
×
UNCOV
437
            last = items[#items]
×
438
            break
439
         end
UNCOV
440
         if n.is_kern and n.subtype == "punctspace" then
×
441
            -- Some languages such as French insert a special space around
442
            -- punctuations. In those case, we should not need italic correction.
443
            break
444
         end
UNCOV
445
         if n.is_glue then
×
UNCOV
446
            hasGlue = true
×
447
         end
448
      end
449
   end
UNCOV
450
   return last, hasGlue
×
451
end
452
local function getFirstShape (nodelist)
453
   local first
454
   local hasGlue
UNCOV
455
   if nodelist then
×
456
      -- The node list may contain nnodes, penalties, kern and glue
457
      -- We skip the latter, and retrieve the first shaped item.
UNCOV
458
      for i = 1, #nodelist do
×
UNCOV
459
         local n = nodelist[i]
×
UNCOV
460
         if n.is_nnode then
×
UNCOV
461
            local items = n.nodes[1].value.items
×
UNCOV
462
            first = items[1]
×
463
            break
464
         end
UNCOV
465
         if n.is_kern and n.subtype == "punctspace" then
×
466
            -- Some languages such as French insert a special space around
467
            -- punctuations. In those case, we should not need italic correction.
468
            break
469
         end
UNCOV
470
         if n.is_glue then
×
UNCOV
471
            hasGlue = true
×
472
         end
473
      end
474
   end
UNCOV
475
   return first, hasGlue
×
476
end
477

478
local function fromItalicCorrection (precShape, curShape)
479
   local xOffset
UNCOV
480
   if not curShape or not precShape then
×
UNCOV
481
      xOffset = 0
×
482
   else
483
      -- Computing italic correction is at best heuristics.
484
      -- The strong assumption is that italic is slanted to the right.
485
      -- Thus, the part of the character that goes beyond its width is usually
486
      -- maximal at the top of the glyph.
487
      -- E.g. consider a "f", that would be the top hook extent.
488
      -- Pathological cases exist, such as fonts with a Q with a long tail,
489
      -- but these will rarely occur in usual languages. For instance, Klingon's
490
      -- "QaQ" might be an issue, but there's not much we can do...
491
      -- Another assumption is that we can distribute that extent in proportion
492
      -- with the next character's height.
493
      -- This might not work that well with non-Latin scripts.
UNCOV
494
      local d = precShape.glyphWidth + precShape.x_bearing
×
UNCOV
495
      local delta = d > precShape.width and d - precShape.width or 0
×
UNCOV
496
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
×
497
   end
UNCOV
498
   return xOffset
×
499
end
500

501
local function toItalicCorrection (precShape, curShape)
UNCOV
502
   if not SILE.settings:get("typesetter.italicCorrection") then
×
503
      return
×
504
   end
505
   local xOffset
UNCOV
506
   if not curShape or not precShape then
×
UNCOV
507
      xOffset = 0
×
508
   else
509
      -- Same assumptions as fromItalicCorrection(), but on the starting side of
510
      -- the glyph.
UNCOV
511
      local d = curShape.x_bearing
×
UNCOV
512
      local delta = d < 0 and -d or 0
×
UNCOV
513
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
×
514
   end
UNCOV
515
   return xOffset
×
516
end
517

518
local function isItalicLike (nnode)
519
   -- We could do...
520
   --  return nnode and string.lower(nnode.options.style) == "italic"
521
   -- But it's probably more robust to use the italic angle, so that
522
   -- thin italic, oblique or slanted fonts etc. may work too.
UNCOV
523
   local ot = require("core.opentype-parser")
×
UNCOV
524
   local face = SILE.font.cache(nnode.options, SILE.shaper.getFace)
×
UNCOV
525
   local font = ot.parseFont(face)
×
UNCOV
526
   return font.post.italicAngle ~= 0
×
527
end
528

529
function typesetter.shapeAllNodes (_, nodelist, inplace)
60✔
530
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
2,036✔
531
   local newNodelist = {}
1,018✔
532
   local prec
533
   local precShapedNodes
534
   for _, current in ipairs(nodelist) do
18,059✔
535
      if current.is_unshaped then
17,041✔
536
         local shapedNodes = current:shape()
422✔
537

538
         if SILE.settings:get("typesetter.italicCorrection") and prec then
844✔
539
            local itCorrOffset
540
            local isGlue
UNCOV
541
            if isItalicLike(prec) and not isItalicLike(current) then
×
UNCOV
542
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
UNCOV
543
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
UNCOV
544
               isGlue = precHasGlue or curHasGlue
×
UNCOV
545
               itCorrOffset = fromItalicCorrection(precShape, curShape)
×
UNCOV
546
            elseif not isItalicLike(prec) and isItalicLike(current) then
×
UNCOV
547
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
UNCOV
548
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
UNCOV
549
               isGlue = precHasGlue or curHasGlue
×
UNCOV
550
               itCorrOffset = toItalicCorrection(precShape, curShape)
×
551
            end
UNCOV
552
            if itCorrOffset and itCorrOffset ~= 0 then
×
553
               -- If one of the node contains a glue (e.g. "a \em{proof} is..."),
554
               -- line breaking may occur between them, so our correction shall be
555
               -- a glue too.
556
               -- Otherwise, the font change is considered to occur at a non-breaking
557
               -- point (e.g. "\em{proof}!") and the correction shall be a kern.
UNCOV
558
               local makeItCorrNode = isGlue and SILE.types.node.glue or SILE.types.node.kern
×
UNCOV
559
               newNodelist[#newNodelist + 1] = makeItCorrNode({
×
560
                  width = SILE.types.length(itCorrOffset),
561
                  subtype = "itcorr",
562
               })
563
            end
564
         end
565

566
         pl.tablex.insertvalues(newNodelist, shapedNodes)
422✔
567

568
         prec = current
422✔
569
         precShapedNodes = shapedNodes
422✔
570
      else
571
         prec = nil
16,619✔
572
         newNodelist[#newNodelist + 1] = current
16,619✔
573
      end
574
   end
575

576
   if not inplace then
1,018✔
577
      return newNodelist
15✔
578
   end
579

580
   for i = 1, #newNodelist do
24,017✔
581
      nodelist[i] = newNodelist[i]
23,014✔
582
   end
583
   if #nodelist > #newNodelist then
1,003✔
584
      for i = #newNodelist + 1, #nodelist do
×
585
         nodelist[i] = nil
×
586
      end
587
   end
588
end
589

590
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
591
-- Turns a node list into a list of vboxes
592
function typesetter:boxUpNodes ()
60✔
593
   local nodelist = self.state.nodes
1,149✔
594
   if #nodelist == 0 then
1,149✔
595
      return {}
814✔
596
   end
597
   for j = #nodelist, 1, -1 do
412✔
598
      if not nodelist[j].is_migrating then
414✔
599
         if nodelist[j].discardable then
394✔
600
            table.remove(nodelist, j)
118✔
601
         else
602
            break
603
         end
604
      end
605
   end
606
   while #nodelist > 0 and nodelist[1].is_penalty do
335✔
607
      table.remove(nodelist, 1)
×
608
   end
609
   if #nodelist == 0 then
335✔
610
      return {}
×
611
   end
612
   self:shapeAllNodes(nodelist)
335✔
613
   local parfillskip = SILE.settings:get("typesetter.parfillskip")
335✔
614
   parfillskip.discardable = false
335✔
615
   self:pushGlue(parfillskip)
335✔
616
   self:pushPenalty(-inf_bad)
335✔
617
   SU.debug("typesetter", function ()
670✔
618
      return "Boxed up " .. (#nodelist > 500 and #nodelist .. " nodes" or SU.ast.contentToString(nodelist))
×
619
   end)
620
   local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
670✔
621
   local lines = self:breakIntoLines(nodelist, breakWidth)
335✔
622
   local vboxes = {}
335✔
623
   for index = 1, #lines do
899✔
624
      local line = lines[index]
564✔
625
      local migrating = {}
564✔
626
      -- Move any migrating material
627
      local nodes = {}
564✔
628
      for i = 1, #line.nodes do
13,476✔
629
         local node = line.nodes[i]
12,912✔
630
         if node.is_migrating then
12,912✔
631
            for j = 1, #node.material do
44✔
632
               migrating[#migrating + 1] = node.material[j]
22✔
633
            end
634
         else
635
            nodes[#nodes + 1] = node
12,890✔
636
         end
637
      end
638
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
564✔
639
      local pageBreakPenalty = 0
564✔
640
      if #lines > 1 and index == 1 then
564✔
641
         pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
130✔
642
      elseif #lines > 1 and index == (#lines - 1) then
499✔
643
         pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
102✔
644
      end
645
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
1,128✔
646
      vboxes[#vboxes + 1] = vbox
564✔
647
      for i = 1, #migrating do
586✔
648
         vboxes[#vboxes + 1] = migrating[i]
22✔
649
      end
650
      self.state.previousVbox = vbox
564✔
651
      if pageBreakPenalty > 0 then
564✔
652
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
116✔
653
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
232✔
654
      end
655
   end
656
   return vboxes
335✔
657
end
658

659
function typesetter.pageTarget (_)
60✔
660
   SU.deprecated("SILE.typesetter:pageTarget", "SILE.typesetter:getTargetLength", "0.13.0", "0.14.0")
×
661
end
662

663
function typesetter:getTargetLength ()
60✔
664
   return self.frame:getTargetLength()
1,119✔
665
end
666

667
function typesetter:registerHook (category, func)
60✔
668
   if not self.hooks[category] then
88✔
669
      self.hooks[category] = {}
73✔
670
   end
671
   table.insert(self.hooks[category], func)
88✔
672
end
673

674
function typesetter:runHooks (category, data)
60✔
675
   if not self.hooks[category] then
1,149✔
676
      return data
1,035✔
677
   end
678
   for _, func in ipairs(self.hooks[category]) do
262✔
679
      data = func(self, data)
296✔
680
   end
681
   return data
114✔
682
end
683

684
function typesetter:registerFrameBreakHook (func)
60✔
685
   self:registerHook("framebreak", func)
13✔
686
end
687

688
function typesetter:registerNewFrameHook (func)
60✔
689
   self:registerHook("newframe", func)
2✔
690
end
691

692
function typesetter:registerPageEndHook (func)
60✔
693
   self:registerHook("pageend", func)
73✔
694
end
695

696
function typesetter:buildPage ()
60✔
697
   local pageNodeList
698
   local res
699
   if self:isQueueEmpty() then
2,154✔
700
      return false
56✔
701
   end
702
   if SILE.scratch.insertions then
1,021✔
703
      SILE.scratch.insertions.thisPage = {}
246✔
704
   end
705
   pageNodeList, res = SILE.pagebuilder:findBestBreak({
2,042✔
706
      vboxlist = self.state.outputQueue,
1,021✔
707
      target = self:getTargetLength(),
2,042✔
708
      restart = self.frame.state.pageRestart,
1,021✔
709
   })
1,021✔
710
   if not pageNodeList then -- No break yet
1,021✔
711
      -- self.frame.state.pageRestart = res
712
      self:runHooks("noframebreak")
923✔
713
      return false
923✔
714
   end
715
   SU.debug("pagebuilder", "Buildding page for", self.frame.id)
98✔
716
   self.state.lastPenalty = res
98✔
717
   self.frame.state.pageRestart = nil
98✔
718
   pageNodeList = self:runHooks("framebreak", pageNodeList)
196✔
719
   self:setVerticalGlue(pageNodeList, self:getTargetLength())
196✔
720
   self:outputLinesToPage(pageNodeList)
98✔
721
   return true
98✔
722
end
723

724
function typesetter:setVerticalGlue (pageNodeList, target)
60✔
725
   local glues = {}
98✔
726
   local gTotal = SILE.types.length()
98✔
727
   local totalHeight = SILE.types.length()
98✔
728

729
   local pastTop = false
98✔
730
   for _, node in ipairs(pageNodeList) do
1,651✔
731
      if not pastTop and not node.discardable and not node.explicit then
1,553✔
732
         -- "Ignore discardable and explicit glues at the top of a frame."
733
         -- See typesetter:outputLinesToPage()
734
         -- Note the test here doesn't check is_vglue, so will skip other
735
         -- discardable nodes (e.g. penalties), but it shouldn't matter
736
         -- for the type of computing performed here.
737
         pastTop = true
95✔
738
      end
739
      if pastTop then
1,553✔
740
         if not node.is_insertion then
1,426✔
741
            totalHeight:___add(node.height)
1,417✔
742
            totalHeight:___add(node.depth)
1,417✔
743
         end
744
         if node.is_vglue then
1,426✔
745
            table.insert(glues, node)
834✔
746
            gTotal:___add(node.height)
834✔
747
         end
748
      end
749
   end
750

751
   if totalHeight:tonumber() == 0 then
196✔
752
      return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
6✔
753
   end
754

755
   local adjustment = target - totalHeight
92✔
756
   if adjustment:tonumber() > 0 then
184✔
757
      if adjustment > gTotal.stretch then
76✔
758
         if
759
            (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber()
45✔
760
         then
761
            SU.warn(
14✔
762
               "Underfull frame "
763
                  .. self.frame.id
7✔
764
                  .. ": "
7✔
765
                  .. adjustment
7✔
766
                  .. " stretchiness required to fill but only "
7✔
767
                  .. gTotal.stretch
7✔
768
                  .. " available"
7✔
769
            )
770
         end
771
         adjustment = gTotal.stretch
9✔
772
      end
773
      if gTotal.stretch:tonumber() > 0 then
152✔
774
         for i = 1, #glues do
821✔
775
            local g = glues[i]
748✔
776
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
3,740✔
777
         end
778
      end
779
   elseif adjustment:tonumber() < 0 then
32✔
780
      adjustment = 0 - adjustment
16✔
781
      if adjustment > gTotal.shrink then
16✔
782
         if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
80✔
783
            SU.warn(
4✔
784
               "Overfull frame "
785
                  .. self.frame.id
2✔
786
                  .. ": "
2✔
787
                  .. adjustment
2✔
788
                  .. " shrinkability required to fit but only "
2✔
789
                  .. gTotal.shrink
2✔
790
                  .. " available"
2✔
791
            )
792
         end
793
         adjustment = gTotal.shrink
16✔
794
      end
795
      if gTotal.shrink:tonumber() > 0 then
32✔
796
         for i = 1, #glues do
×
797
            local g = glues[i]
×
798
            g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
799
         end
800
      end
801
   end
802
   SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
92✔
803
end
804

805
function typesetter:initNextFrame ()
60✔
806
   local oldframe = self.frame
41✔
807
   self.frame:leave(self)
41✔
808
   if #self.state.outputQueue == 0 then
41✔
809
      self.state.previousVbox = nil
25✔
810
   end
811
   if self.frame.next and self.state.lastPenalty > supereject_penalty then
41✔
812
      self:initFrame(SILE.getFrame(self.frame.next))
9✔
813
   elseif not self.frame:isMainContentFrame() then
76✔
814
      if #self.state.outputQueue > 0 then
14✔
815
         SU.warn("Overfull content for frame " .. self.frame.id)
1✔
816
         self:chuck()
1✔
817
      end
818
   else
819
      self:runHooks("pageend")
24✔
820
      SILE.documentState.documentClass:endPage()
24✔
821
      self:initFrame(SILE.documentState.documentClass:newPage())
48✔
822
   end
823

824
   if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
164✔
825
      self:pushBack()
4✔
826
      -- Some what of a hack below.
827
      -- Before calling this method, we were in vertical mode...
828
      -- pushback occurred, and it seems it messes up a bit...
829
      -- Regardless what it does, at the end, we ought to be in vertical mode
830
      -- again:
831
      self:leaveHmode()
8✔
832
   else
833
      -- If I have some things on the vertical list already, they need
834
      -- proper top-of-frame leading applied.
835
      if #self.state.outputQueue > 0 then
37✔
836
         local lead = self:leadingFor(self.state.outputQueue[1], nil)
13✔
837
         if lead then
13✔
838
            table.insert(self.state.outputQueue, 1, lead)
12✔
839
         end
840
      end
841
   end
842
   self:runHooks("newframe")
41✔
843
end
844

845
function typesetter:pushBack ()
60✔
846
   SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
4✔
847
   local oldqueue = self.state.outputQueue
4✔
848
   self.state.outputQueue = {}
4✔
849
   self.state.previousVbox = nil
4✔
850
   local lastMargins = self:getMargins()
4✔
851
   for _, vbox in ipairs(oldqueue) do
20✔
852
      SU.debug("pushback", "process box", vbox)
16✔
853
      if vbox.margins and vbox.margins ~= lastMargins then
16✔
UNCOV
854
         SU.debug("pushback", "new margins", lastMargins, vbox.margins)
×
UNCOV
855
         if not self.state.grid then
×
UNCOV
856
            self:endline()
×
857
         end
UNCOV
858
         self:setMargins(vbox.margins)
×
859
      end
860
      if vbox.explicit then
16✔
861
         SU.debug("pushback", "explicit", vbox)
×
862
         self:endline()
×
863
         self:pushExplicitVglue(vbox)
×
864
      elseif vbox.is_insertion then
16✔
865
         SU.debug("pushback", "pushBack", "insertion", vbox)
×
866
         SILE.typesetter:pushMigratingMaterial({ material = { vbox } })
×
867
      elseif not vbox.is_vglue and not vbox.is_penalty then
16✔
868
         SU.debug("pushback", "not vglue or penalty", vbox.type)
6✔
869
         local discardedFistInitLine = false
6✔
870
         if #self.state.nodes == 0 then
6✔
871
            -- Setup queue but avoid calling newPar
872
            self.state.nodes[#self.state.nodes + 1] = SILE.types.node.zerohbox()
4✔
873
         end
874
         for i, node in ipairs(vbox.nodes) do
236✔
875
            if node.is_glue and not node.discardable then
230✔
876
               self:pushHorizontal(node)
4✔
877
            elseif node.is_glue and node.value == "margin" then
228✔
878
               SU.debug("pushback", "discard", node.value, node)
24✔
879
            elseif node.is_discretionary then
216✔
UNCOV
880
               SU.debug("pushback", "re-mark discretionary as unused", node)
×
UNCOV
881
               node.used = false
×
UNCOV
882
               if i == 1 then
×
883
                  SU.debug("pushback", "keep first discretionary", node)
×
884
                  self:pushHorizontal(node)
×
885
               else
UNCOV
886
                  SU.debug("pushback", "discard all other discretionaries", node)
×
887
               end
888
            elseif node.is_zero then
216✔
889
               if discardedFistInitLine then
14✔
890
                  self:pushHorizontal(node)
8✔
891
               end
892
               discardedFistInitLine = true
14✔
893
            elseif node.is_penalty then
202✔
894
               if not discardedFistInitLine then
2✔
895
                  self:pushHorizontal(node)
×
896
               end
897
            else
898
               node.bidiDone = true
200✔
899
               self:pushHorizontal(node)
200✔
900
            end
901
         end
902
      else
903
         SU.debug("pushback", "discard", vbox.type)
10✔
904
      end
905
      lastMargins = vbox.margins
16✔
906
      -- self:debugState()
907
   end
908
   while
×
909
      self.state.nodes[#self.state.nodes]
6✔
910
      and (self.state.nodes[#self.state.nodes].is_penalty or self.state.nodes[#self.state.nodes].is_zero)
6✔
911
   do
912
      self.state.nodes[#self.state.nodes] = nil
2✔
913
   end
914
end
915

916
function typesetter:outputLinesToPage (lines)
60✔
917
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
137✔
918
   -- It would have been nice to avoid storing this "pastTop" into a frame
919
   -- state, to keep things less entangled. There are situations, though,
920
   -- we will have left horizontal mode (triggering output), but will later
921
   -- call typesetter:chuck() do deal with any remaining content, and we need
922
   -- to know whether some content has been output already.
923
   local pastTop = self.frame.state.totals.pastTop
137✔
924
   for _, line in ipairs(lines) do
1,805✔
925
      -- Ignore discardable and explicit glues at the top of a frame:
926
      -- Annoyingly, explicit glue *should* disappear at the top of a page.
927
      -- if you don't want that, add an empty vbox or something.
928
      if not pastTop and not line.discardable and not line.explicit then
1,668✔
929
         -- Note the test here doesn't check is_vglue, so will skip other
930
         -- discardable nodes (e.g. penalties), but it shouldn't matter
931
         -- for outputting.
932
         pastTop = true
132✔
933
      end
934
      if pastTop then
1,668✔
935
         line:outputYourself(self, line)
1,499✔
936
      end
937
   end
938
   self.frame.state.totals.pastTop = pastTop
137✔
939
end
940

941
function typesetter:leaveHmode (independent)
60✔
942
   if self.state.hmodeOnly then
1,149✔
943
      SU.error([[Paragraphs are forbidden in restricted horizontal mode.]])
×
944
   end
945
   SU.debug("typesetter", "Leaving hmode")
1,149✔
946
   local margins = self:getMargins()
1,149✔
947
   local vboxlist = self:boxUpNodes()
1,149✔
948
   self.state.nodes = {}
1,149✔
949
   -- Push output lines into boxes and ship them to the page builder
950
   for _, vbox in ipairs(vboxlist) do
2,414✔
951
      vbox.margins = margins
1,265✔
952
      self:pushVertical(vbox)
1,265✔
953
   end
954
   if independent then
1,149✔
955
      return
126✔
956
   end
957
   if self:buildPage() then
2,046✔
958
      self:initNextFrame()
39✔
959
   end
960
end
961

962
function typesetter:inhibitLeading ()
60✔
UNCOV
963
   self.state.previousVbox = nil
×
964
end
965

966
function typesetter.leadingFor (_, vbox, previous)
60✔
967
   -- Insert leading
968
   SU.debug("typesetter", "   Considering leading between two lines:")
522✔
969
   SU.debug("typesetter", "   1)", previous)
522✔
970
   SU.debug("typesetter", "   2)", vbox)
522✔
971
   if not previous then
522✔
972
      return SILE.types.node.vglue()
138✔
973
   end
974
   local prevDepth = previous.depth
384✔
975
   SU.debug("typesetter", "   Depth of previous line was", prevDepth)
384✔
976
   local bls = SILE.settings:get("document.baselineskip")
384✔
977
   local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
1,920✔
978
   SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
384✔
979

980
   -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
981
   local lead = SILE.settings:get("document.lineskip").height:absolute()
768✔
982
   if depth > lead then
384✔
983
      return SILE.types.node.vglue(SILE.types.length(depth.length, bls.height.stretch, bls.height.shrink))
630✔
984
   else
985
      return SILE.types.node.vglue(lead)
69✔
986
   end
987
end
988

989
-- Beggining of liner logic (contructs spanning over several lines)
990

991
-- These two special nodes are used to track the current liner entry and exit.
992
-- As Sith Lords, they are always two: they are local here, so no one can
993
-- use one alone and break the balance of the Force.
994
local linerEnterNode = pl.class(SILE.types.node.hbox)
60✔
995
function linerEnterNode:_init (name, outputMethod)
60✔
996
   SILE.types.node.hbox._init(self)
1✔
997
   self.outputMethod = outputMethod
1✔
998
   self.name = name
1✔
999
   self.is_enter = true
1✔
1000
end
1001
function linerEnterNode:clone ()
60✔
UNCOV
1002
   return linerEnterNode(self.name, self.outputMethod)
×
1003
end
1004
function linerEnterNode:outputYourself ()
60✔
1005
   SU.error("A liner enter node " .. tostring(self) .. "'' made it to output", true)
×
1006
end
1007
function linerEnterNode:__tostring ()
60✔
1008
   return "+L[" .. self.name .. "]"
×
1009
end
1010
local linerLeaveNode = pl.class(SILE.types.node.hbox)
60✔
1011
function linerLeaveNode:_init (name)
60✔
1012
   SILE.types.node.hbox._init(self)
1✔
1013
   self.name = name
1✔
1014
   self.is_leave = true
1✔
1015
end
1016
function linerLeaveNode:clone ()
60✔
1017
   return linerLeaveNode(self.name)
×
1018
end
1019
function linerLeaveNode:outputYourself ()
60✔
1020
   SU.error("A liner leave node " .. tostring(self) .. "'' made it to output", true)
×
1021
end
1022
function linerLeaveNode:__tostring ()
60✔
1023
   return "-L[" .. self.name .. "]"
×
1024
end
1025

1026
local linerBox = pl.class(SILE.types.node.hbox)
60✔
1027
function linerBox:_init (name, outputMethod)
60✔
1028
   SILE.types.node.hbox._init(self)
1✔
1029
   self.width = SILE.types.length()
2✔
1030
   self.height = SILE.types.length()
2✔
1031
   self.depth = SILE.types.length()
2✔
1032
   self.name = name
1✔
1033
   self.inner = {}
1✔
1034
   self.outputYourself = outputMethod
1✔
1035
end
1036
function linerBox:append (node)
60✔
1037
   self.inner[#self.inner + 1] = node
1✔
1038
   if node.is_discretionary then
1✔
1039
      -- Discretionary nodes don't have a width of their own.
UNCOV
1040
      if node.used then
×
UNCOV
1041
         if node.is_prebreak then
×
UNCOV
1042
            self.width:___add(node:prebreakWidth())
×
1043
         else
UNCOV
1044
            self.width:___add(node:postbreakWidth())
×
1045
         end
1046
      else
UNCOV
1047
         self.width:___add(node:replacementWidth())
×
1048
      end
1049
   else
1050
      self.width:___add(node.width:absolute())
2✔
1051
   end
1052
   self.height = SU.max(self.height, node.height)
2✔
1053
   self.depth = SU.max(self.depth, node.depth)
2✔
1054
end
1055
function linerBox:count ()
60✔
1056
   return #self.inner
2✔
1057
end
1058
function linerBox:outputContent (tsetter, line)
60✔
1059
   for _, node in ipairs(self.inner) do
2✔
1060
      node.outputYourself(node, tsetter, line)
1✔
1061
   end
1062
end
1063
function linerBox:__tostring ()
60✔
1064
   return "*L["
×
1065
      .. self.name
×
1066
      .. "]H<"
×
1067
      .. tostring(self.width)
×
1068
      .. ">^"
×
1069
      .. tostring(self.height)
×
1070
      .. "-"
×
1071
      .. tostring(self.depth)
×
1072
      .. "v"
×
1073
end
1074

1075
--- Any unclosed liner is reopened on the current line, so we clone and repeat it.
1076
-- An assumption is that the inserts are done after the current slice content,
1077
-- supposed to be just before meaningful (visible) content.
1078
-- @tparam slice slice
1079
-- @treturn boolean Whether a liner was reopened
1080
function typesetter:_repeatEnterLiners (slice)
60✔
1081
   local m = self.state.liners
9,972✔
1082
   if #m > 0 then
9,972✔
UNCOV
1083
      for i = 1, #m do
×
UNCOV
1084
         local n = m[i]:clone()
×
UNCOV
1085
         slice[#slice + 1] = n
×
UNCOV
1086
         SU.debug("typesetter.liner", "Reopening liner", n)
×
1087
      end
UNCOV
1088
      return true
×
1089
   end
1090
   return false
9,972✔
1091
end
1092

1093
--- All pairs of liners are rebuilt as hboxes wrapping their content.
1094
-- Migrating content, however, must be kept outside the hboxes at top slice level.
1095
-- @tparam table slice Flat nodes from current line
1096
-- @treturn table New reboxed slice
1097
function typesetter._reboxLiners (_, slice)
60✔
1098
   local outSlice = {}
1✔
1099
   local migratingList = {}
1✔
1100
   local lboxStack = {}
1✔
1101
   for i = 1, #slice do
33✔
1102
      local node = slice[i]
32✔
1103
      if node.is_enter then
32✔
1104
         SU.debug("typesetter.liner", "Start reboxing", node)
1✔
1105
         local n = linerBox(node.name, node.outputMethod)
1✔
1106
         lboxStack[#lboxStack + 1] = n
1✔
1107
      elseif node.is_leave then
31✔
1108
         if #lboxStack == 0 then
1✔
1109
            SU.error("Multiliner box stacking mismatch" .. node)
×
1110
         elseif #lboxStack == 1 then
1✔
1111
            SU.debug("typesetter.liner", "End reboxing", node, "(toplevel) =", lboxStack[1]:count(), "nodes")
2✔
1112
            if lboxStack[1]:count() > 0 then
2✔
1113
               outSlice[#outSlice + 1] = lboxStack[1]
1✔
1114
            end
1115
         else
UNCOV
1116
            SU.debug("typesetter.liner", "End reboxing", node, "(sublevel) =", lboxStack[#lboxStack]:count(), "nodes")
×
UNCOV
1117
            if lboxStack[#lboxStack]:count() > 0 then
×
UNCOV
1118
               local hbox = lboxStack[#lboxStack - 1]
×
UNCOV
1119
               hbox:append(lboxStack[#lboxStack])
×
1120
            end
1121
         end
1122
         lboxStack[#lboxStack] = nil
1✔
1123
         pl.tablex.insertvalues(outSlice, migratingList)
1✔
1124
         migratingList = {}
1✔
1125
      else
1126
         if #lboxStack > 0 then
30✔
1127
            if not node.is_migrating then
1✔
1128
               local lbox = lboxStack[#lboxStack]
1✔
1129
               lbox:append(node)
2✔
1130
            else
1131
               migratingList[#migratingList + 1] = node
×
1132
            end
1133
         else
1134
            outSlice[#outSlice + 1] = node
29✔
1135
         end
1136
      end
1137
   end
1138
   return outSlice -- new reboxed slice
1✔
1139
end
1140

1141
--- Check if a node is a liner, and process it if so, in a stack.
1142
-- @tparam table node Current node (any type)
1143
-- @treturn boolean Whether a liner was opened
1144
function typesetter:_processIfLiner (node)
60✔
1145
   local entered = false
10,665✔
1146
   if node.is_enter then
10,665✔
1147
      SU.debug("typesetter.liner", "Enter liner", node)
1✔
1148
      self.state.liners[#self.state.liners + 1] = node
1✔
1149
      entered = true
1✔
1150
   elseif node.is_leave then
10,664✔
1151
      SU.debug("typesetter.liner", "Leave liner", node)
1✔
1152
      if #self.state.liners == 0 then
1✔
1153
         SU.error("Multiliner stack mismatch" .. node)
×
1154
      elseif self.state.liners[#self.state.liners].name == node.name then
1✔
1155
         self.state.liners[#self.state.liners].link = node -- for consistency check
1✔
1156
         self.state.liners[#self.state.liners] = nil
1✔
1157
      else
1158
         SU.error("Multiliner stack inconsistency" .. self.state.liners[#self.state.liners] .. "vs. " .. node)
×
1159
      end
1160
   end
1161
   return entered
10,665✔
1162
end
1163

1164
function typesetter:_repeatLeaveLiners (slice, insertIndex)
60✔
1165
   for _, v in ipairs(self.state.liners) do
563✔
UNCOV
1166
      if not v.link then
×
UNCOV
1167
         local n = linerLeaveNode(v.name)
×
UNCOV
1168
         SU.debug("typesetter.liner", "Closing liner", n)
×
UNCOV
1169
         table.insert(slice, insertIndex, n)
×
1170
      else
1171
         SU.error("Multiliner stack inconsistency" .. v)
×
1172
      end
1173
   end
1174
end
1175
-- End of liner logic
1176

1177
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
60✔
1178
   local LTR = self.frame:writingDirection() == "LTR"
1,128✔
1179
   local rskip = margins[LTR and "rskip" or "lskip"]
564✔
1180
   if not rskip then
564✔
1181
      rskip = SILE.types.node.glue(0)
×
1182
   end
1183
   if hangRight and hangRight > 0 then
564✔
1184
      rskip = SILE.types.node.glue({ width = rskip.width:tonumber() + hangRight })
63✔
1185
   end
1186
   rskip.value = "margin"
564✔
1187
   -- while slice[#slice].discardable do table.remove(slice, #slice) end
1188
   table.insert(slice, rskip)
564✔
1189
   table.insert(slice, SILE.types.node.zerohbox())
1,128✔
1190
   local lskip = margins[LTR and "lskip" or "rskip"]
564✔
1191
   if not lskip then
564✔
1192
      lskip = SILE.types.node.glue(0)
×
1193
   end
1194
   if hangLeft and hangLeft > 0 then
564✔
1195
      lskip = SILE.types.node.glue({ width = lskip.width:tonumber() + hangLeft })
48✔
1196
   end
1197
   lskip.value = "margin"
564✔
1198
   while slice[1].discardable do
566✔
1199
      table.remove(slice, 1)
4✔
1200
   end
1201
   table.insert(slice, 1, lskip)
564✔
1202
   table.insert(slice, 1, SILE.types.node.zerohbox())
1,128✔
1203
end
1204

1205
function typesetter:breakpointsToLines (breakpoints)
60✔
1206
   local linestart = 1
335✔
1207
   local lines = {}
335✔
1208
   local nodes = self.state.nodes
335✔
1209

1210
   for i = 1, #breakpoints do
904✔
1211
      local point = breakpoints[i]
569✔
1212
      if point.position ~= 0 then
569✔
1213
         local slice = {}
569✔
1214
         local seenNonDiscardable = false
569✔
1215
         local seenLiner = false
569✔
1216
         local lastContentNodeIndex
1217

1218
         for j = linestart, point.position do
11,234✔
1219
            local currentNode = nodes[j]
10,665✔
1220
            if
1221
               not currentNode.discardable
10,665✔
1222
               and not (currentNode.is_glue and not currentNode.explicit)
7,065✔
1223
               and not currentNode.is_zero
6,728✔
1224
            then
1225
               -- actual visible content starts here
1226
               lastContentNodeIndex = #slice + 1
6,387✔
1227
            end
1228
            if not seenLiner and lastContentNodeIndex then
10,665✔
1229
               -- Any stacked liner (unclosed from a previous line) is reopened on
1230
               -- the current line.
1231
               seenLiner = self:_repeatEnterLiners(slice)
19,944✔
1232
               lastContentNodeIndex = #slice + 1
9,972✔
1233
            end
1234
            if currentNode.is_discretionary and currentNode.used then
10,665✔
1235
               -- This is the used (prebreak) discretionary from a previous line,
1236
               -- repeated. Replace it with a clone, changed to a postbreak.
1237
               currentNode = currentNode:cloneAsPostbreak()
136✔
1238
            end
1239
            slice[#slice + 1] = currentNode
10,665✔
1240
            if currentNode then
10,665✔
1241
               if not currentNode.discardable then
10,665✔
1242
                  seenNonDiscardable = true
7,065✔
1243
               end
1244
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
21,330✔
1245
            end
1246
         end
1247
         if not seenNonDiscardable then
569✔
1248
            -- Slip lines containing only discardable nodes (e.g. glues).
1249
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
5✔
1250
            linestart = point.position + 1
5✔
1251
         else
1252
            if slice[#slice].is_discretionary then
564✔
1253
               -- The line ends, with a discretionary:
1254
               -- repeat it on the next line, so as to account for a potential postbreak.
1255
               linestart = point.position
68✔
1256
               -- And mark it as used as prebreak for now.
1257
               slice[#slice]:markAsPrebreak()
136✔
1258
            else
1259
               linestart = point.position + 1
496✔
1260
            end
1261

1262
            -- Any unclosed liner is closed on the next line in reverse order.
1263
            if lastContentNodeIndex then
564✔
1264
               self:_repeatLeaveLiners(slice, lastContentNodeIndex + 1)
563✔
1265
            end
1266

1267
            -- Then only we can add some extra margin glue...
1268
            local mrg = self:getMargins()
564✔
1269
            self:addrlskip(slice, mrg, point.left, point.right)
564✔
1270

1271
            -- And compute the line...
1272
            local ratio = self:computeLineRatio(point.width, slice)
564✔
1273

1274
            -- Re-shuffle liners, if any, into their own boxes.
1275
            if seenLiner then
564✔
1276
               slice = self:_reboxLiners(slice)
2✔
1277
            end
1278

1279
            local thisLine = { ratio = ratio, nodes = slice }
564✔
1280
            lines[#lines + 1] = thisLine
564✔
1281
         end
1282
      end
1283
   end
1284
   if linestart < #nodes then
335✔
1285
      -- Abnormal, but warn so that one has a chance to check which bits
1286
      -- are missing at output.
1287
      SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
1288
   end
1289
   return lines
335✔
1290
end
1291

1292
function typesetter.computeLineRatio (_, breakwidth, slice)
60✔
1293
   local naturalTotals = SILE.types.length()
564✔
1294

1295
   -- From the line end, account for the margin but skip any trailing
1296
   -- glues (spaces to ignore) and zero boxes until we reach actual content.
1297
   local npos = #slice
564✔
1298
   while npos > 1 do
1,850✔
1299
      if slice[npos].is_glue or slice[npos].is_zero then
1,849✔
1300
         if slice[npos].value == "margin" then
1,286✔
1301
            naturalTotals:___add(slice[npos].width)
565✔
1302
         end
1303
      else
1304
         break
1305
      end
1306
      npos = npos - 1
1,286✔
1307
   end
1308

1309
   -- Due to discretionaries, keep track of seen parent nodes
1310
   local seenNodes = {}
564✔
1311
   -- CODE SMELL: Not sure which node types were supposed to be skipped
1312
   -- at initial positions in the line!
1313
   local skipping = true
564✔
1314

1315
   -- Until end of actual content
1316
   for i = 1, npos do
12,192✔
1317
      local node = slice[i]
11,628✔
1318
      if node.is_box then
11,628✔
1319
         skipping = false
5,870✔
1320
         if node.parent and not node.parent.hyphenated then
5,870✔
1321
            if not seenNodes[node.parent] then
1,948✔
1322
               naturalTotals:___add(node.parent:lineContribution())
1,632✔
1323
            end
1324
            seenNodes[node.parent] = true
1,948✔
1325
         else
1326
            naturalTotals:___add(node:lineContribution())
7,844✔
1327
         end
1328
      elseif node.is_penalty and node.penalty == -inf_bad then
5,758✔
1329
         skipping = false
340✔
1330
      elseif node.is_discretionary then
5,418✔
1331
         skipping = false
1,315✔
1332
         local seen = node.parent and seenNodes[node.parent]
1,315✔
1333
         if not seen then
1,315✔
1334
            if node.used then
183✔
1335
               if node.is_prebreak then
136✔
1336
                  naturalTotals:___add(node:prebreakWidth())
136✔
1337
                  node.height = node:prebreakHeight()
136✔
1338
               else
1339
                  naturalTotals:___add(node:postbreakWidth())
136✔
1340
                  node.height = node:postbreakHeight()
136✔
1341
               end
1342
            else
1343
               naturalTotals:___add(node:replacementWidth():absolute())
141✔
1344
               node.height = node:replacementHeight():absolute()
141✔
1345
            end
1346
         end
1347
      elseif not skipping then
4,103✔
1348
         naturalTotals:___add(node.width)
4,103✔
1349
      end
1350
   end
1351

1352
   local _left = breakwidth:tonumber() - naturalTotals:tonumber()
1,692✔
1353
   local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
1,128✔
1354
   ratio = math.max(ratio, -1)
564✔
1355
   return ratio, naturalTotals
564✔
1356
end
1357

1358
function typesetter:chuck () -- emergency shipout everything
60✔
1359
   self:leaveHmode(true)
50✔
1360
   if #self.state.outputQueue > 0 then
50✔
1361
      SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
37✔
1362
      self:outputLinesToPage(self.state.outputQueue)
37✔
1363
      self.state.outputQueue = {}
37✔
1364
   end
1365
end
1366

1367
-- Logic for building an hbox from content.
1368
-- It returns the hbox and an horizontal list of (migrating) elements
1369
-- extracted outside of it.
1370
-- None of these are pushed to the typesetter node queue. The caller
1371
-- is responsible of doing it, if the hbox is built for anything
1372
-- else than e.g. measuring it. Likewise, the call has to decide
1373
-- what to do with the migrating content.
1374
local _rtl_pre_post = function (box, atypesetter, line)
1375
   local advance = function ()
1376
      atypesetter.frame:advanceWritingDirection(box:scaledWidth(line))
28✔
1377
   end
1378
   if atypesetter.frame:writingDirection() == "RTL" then
28✔
1379
      advance()
×
1380
      return function () end
×
1381
   else
1382
      return advance
14✔
1383
   end
1384
end
1385
function typesetter:makeHbox (content)
60✔
1386
   local recentContribution = {}
15✔
1387
   local migratingNodes = {}
15✔
1388

1389
   self:pushState()
15✔
1390
   self.state.hmodeOnly = true
15✔
1391
   SILE.process(content)
15✔
1392

1393
   -- We must do a first pass for shaping the nnodes:
1394
   -- This is also where italic correction may occur.
1395
   local nodes = self:shapeAllNodes(self.state.nodes, false)
15✔
1396

1397
   -- Then we can process and measure the nodes.
1398
   local l = SILE.types.length()
15✔
1399
   local h, d = SILE.types.length(), SILE.types.length()
30✔
1400
   for i = 1, #nodes do
22✔
1401
      local node = nodes[i]
7✔
1402
      if node.is_migrating then
7✔
1403
         migratingNodes[#migratingNodes + 1] = node
×
1404
      elseif node.is_discretionary then
7✔
1405
         -- HACK https://github.com/sile-typesetter/sile/issues/583
1406
         -- Discretionary nodes have a null line contribution...
1407
         -- But if discretionary nodes occur inside an hbox, since the latter
1408
         -- is not line-broken, they will never be marked as 'used' and will
1409
         -- evaluate to the replacement content (if any)...
UNCOV
1410
         recentContribution[#recentContribution + 1] = node
×
UNCOV
1411
         l = l + node:replacementWidth():absolute()
×
1412
         -- The replacement content may have ascenders and descenders...
UNCOV
1413
         local hdisc = node:replacementHeight():absolute()
×
UNCOV
1414
         local ddisc = node:replacementDepth():absolute()
×
UNCOV
1415
         h = hdisc > h and hdisc or h
×
UNCOV
1416
         d = ddisc > d and ddisc or d
×
1417
      -- By the way it's unclear how this is expected to work in TTB
1418
      -- writing direction. For other type of nodes, the line contribution
1419
      -- evaluates to the height rather than the width in TTB, but the
1420
      -- whole logic might then be dubious there too...
1421
      else
1422
         recentContribution[#recentContribution + 1] = node
7✔
1423
         l = l + node:lineContribution():absolute()
21✔
1424
         h = node.height > h and node.height or h
11✔
1425
         d = node.depth > d and node.depth or d
12✔
1426
      end
1427
   end
1428
   self:popState()
15✔
1429

1430
   local hbox = SILE.types.node.hbox({
30✔
1431
      height = h,
15✔
1432
      width = l,
15✔
1433
      depth = d,
15✔
1434
      value = recentContribution,
15✔
1435
      outputYourself = function (box, atypesetter, line)
1436
         local _post = _rtl_pre_post(box, atypesetter, line)
14✔
1437
         local ox = atypesetter.frame.state.cursorX
14✔
1438
         local oy = atypesetter.frame.state.cursorY
14✔
1439
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
14✔
1440
         SU.debug("hboxes", function ()
28✔
1441
            -- setCursor is also invoked by the internal (wrapped) hboxes etc.
1442
            -- so we must show our debug box before outputting its content.
1443
            SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
1444
            return "Drew debug outline around hbox"
×
1445
         end)
1446
         for _, node in ipairs(box.value) do
21✔
1447
            node:outputYourself(atypesetter, line)
7✔
1448
         end
1449
         atypesetter.frame.state.cursorX = ox
14✔
1450
         atypesetter.frame.state.cursorY = oy
14✔
1451
         _post()
14✔
1452
      end,
1453
   })
1454
   return hbox, migratingNodes
15✔
1455
end
1456

1457
function typesetter:pushHlist (hlist)
60✔
1458
   for _, h in ipairs(hlist) do
3✔
1459
      self:pushHorizontal(h)
×
1460
   end
1461
end
1462

1463
--- A liner is a construct that may span multiple lines.
1464
-- This is the user-facing method for creating such liners in packages.
1465
-- The content may be line-broken, and each bit on each line will be wrapped
1466
-- into a box.
1467
-- These boxes will be formatted according to some output logic.
1468
-- The output method has the same signature as the outputYourself method
1469
-- of a box, and is responsible for outputting the liner inner content with the
1470
-- outputContent(typesetter, line) method, possibly surrounded by some additional
1471
-- effects.
1472
-- If we are already in horizontal-restricted mode, the liner is processed
1473
-- immediately, since line breaking won't occur then.
1474
-- @tparam string name Name of the liner (usefull for debugging)
1475
-- @tparam table content SILE AST to process
1476
-- @tparam function outputYourself Output method for wrapped boxes
1477
function typesetter:liner (name, content, outputYourself)
60✔
1478
   if self.state.hmodeOnly then
1✔
1479
      SU.debug("typesetter.liner", "Applying liner in horizontal-restricted mode")
×
1480
      local hbox, hlist = self:makeHbox(content)
×
1481
      local lbox = linerBox(name, outputYourself)
×
1482
      lbox:append(hbox)
×
1483
      self:pushHorizontal(lbox)
×
1484
      self:pushHlist(hlist)
×
1485
   else
1486
      self.state.linerCount = (self.state.linerCount or 0) + 1
1✔
1487
      local uname = name .. "_" .. self.state.linerCount
1✔
1488
      SU.debug("typesetter.liner", "Applying liner in standard mode")
1✔
1489
      local enter = linerEnterNode(uname, outputYourself)
1✔
1490
      local leave = linerLeaveNode(uname)
1✔
1491
      self:pushHorizontal(enter)
1✔
1492
      SILE.process(content)
1✔
1493
      self:pushHorizontal(leave)
1✔
1494
   end
1495
end
1496

1497
return typesetter
60✔
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