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

sile-typesetter / sile / 15507594683

07 Jun 2025 11:54AM UTC coverage: 30.951% (-30.4%) from 61.309%
15507594683

push

github

alerque
chore(tooling): Add post-checkout hook to clear makedeps on branch switch

6363 of 20558 relevant lines covered (30.95%)

3445.44 hits per line

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

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

4
--- @type typesetter
5
local module = require("types.module")
11✔
6
local typesetter = pl.class(module)
11✔
7
typesetter.type = "typesetter"
11✔
8

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

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

21
   _init = function (self, lskip, rskip)
22
      self.lskip, self.rskip = lskip, rskip
364✔
23
   end,
24

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

30
--- Constructor
31
-- @param frame A initial frame to attach the typesetter to.
32
function typesetter:_init (frame)
11✔
33
   -- TODO: make class first arg of typesetter init, ditch globals hack
34
   self.class = SILE.documentState.documentClass
27✔
35
   self.linebreaker = SILE.linebreakers.default(self)
65✔
36
   self.pagebuilder = SILE.pagebuilders.default(self)
65✔
37
   module._init(self)
27✔
38
   self.hooks = {}
27✔
39
   self.breadcrumbs = SU.breadcrumbs()
54✔
40
   self.frame = frame
27✔
41
   self.stateQueue = {}
27✔
42
end
43

44
function typesetter:_post_init ()
11✔
45
   module._post_init(self)
27✔
46
   self:initFrame(self.frame)
27✔
47
   self:initState()
27✔
48
   self.language = SILE.languages.en(self)
65✔
49
   -- Since it is the default and will get created as an instance before the callback triggers for the first *change*,
50
   -- we need to force the first load here.
51
   self:switchLanguage("en", true)
27✔
52
end
53

54
typesetter._language_cache = {}
11✔
55

56
function typesetter:_cacheLanguage (lang)
11✔
57
   if not self._language_cache[lang] then
1,403✔
58
      self._language_cache[lang] = SILE.languages[lang](self)
34✔
59
      SU.debug("typesetter", "Caching language in typesetter", lang)
15✔
60
   end
61
   return self._language_cache[lang]
1,403✔
62
end
63

64
function typesetter:switchLanguage (lang, force)
11✔
65
   local current = self.language:_getLegacyCode()
455✔
66
   if force or current ~= lang then
455✔
67
      self.language = self:_cacheLanguage(lang)
78✔
68
      self.language:activate()
39✔
69
      SU.debug("typesetter", "Switching active language from", current, "to", self.language._name)
39✔
70
   end
71
end
72

73
--- Declare new setting types
74
function typesetter:_declareSettings ()
11✔
75
   -- Settings common to any typesetter instance.
76
   -- These shouldn't be re-declared and overwritten/reset in the typesetter
77
   -- constructor (see issue https://github.com/sile-typesetter/sile/issues/1708).
78
   -- On the other hand, it's fairly acceptable to have them made global:
79
   -- Any derived typesetter, whatever its implementation, should likely provide
80
   -- some logic for them (= widows, orphans, spacing, etc.)
81
   self.settings:declare({
22✔
82
      parameter = "typesetter.widowpenalty",
83
      type = "integer",
84
      default = 3000,
85
      help = "Penalty to be applied to widow lines (at the start of a paragraph)",
86
   })
87

88
   self.settings:declare({
22✔
89
      parameter = "typesetter.parseppattern",
90
      type = "string or integer",
91
      default = "\r?\n[\r\n]+",
92
      help = "Lua pattern used to separate paragraphs",
93
   })
94

95
   self.settings:declare({
22✔
96
      parameter = "typesetter.obeyspaces",
97
      type = "boolean or nil",
98
      default = nil,
99
      help = "Whether to ignore paragraph initial spaces",
100
   })
101

102
   self.settings:declare({
22✔
103
      parameter = "typesetter.brokenpenalty",
104
      type = "integer",
105
      default = 100,
106
      help = "Penalty to be applied to broken (hyphenated) lines",
107
   })
108

109
   self.settings:declare({
22✔
110
      parameter = "typesetter.orphanpenalty",
111
      type = "integer",
112
      default = 3000,
113
      help = "Penalty to be applied to orphan lines (at the end of a paragraph)",
114
   })
115

116
   self.settings:declare({
33✔
117
      parameter = "typesetter.parfillskip",
118
      type = "glue",
119
      default = SILE.types.node.glue("0pt plus 10000pt"),
22✔
120
      help = "Glue added at the end of a paragraph",
121
   })
122

123
   self.settings:declare({
22✔
124
      parameter = "document.letterspaceglue",
125
      type = "glue or nil",
126
      default = nil,
127
      help = "Glue added between tokens",
128
   })
129

130
   self.settings:declare({
33✔
131
      parameter = "typesetter.underfulltolerance",
132
      type = "length or nil",
133
      default = SILE.types.length("1em"),
22✔
134
      help = "Amount a page can be underfull without warning",
135
   })
136

137
   self.settings:declare({
33✔
138
      parameter = "typesetter.overfulltolerance",
139
      type = "length or nil",
140
      default = SILE.types.length("5pt"),
22✔
141
      help = "Amount a page can be overfull without warning",
142
   })
143

144
   self.settings:declare({
22✔
145
      parameter = "typesetter.breakwidth",
146
      type = "measurement or nil",
147
      default = nil,
148
      help = "Width to break lines at",
149
   })
150

151
   self.settings:declare({
22✔
152
      parameter = "typesetter.italicCorrection",
153
      type = "boolean",
154
      default = false,
155
      help = "Whether italic correction is activated or not",
156
   })
157

158
   self.settings:declare({
22✔
159
      parameter = "typesetter.italicCorrection.punctuation",
160
      type = "boolean",
161
      default = true,
162
      help = "Whether italic correction is compensated on special punctuation spaces (e.g. in French)",
163
   })
164

165
   self.settings:declare({
22✔
166
      parameter = "typesetter.softHyphen",
167
      type = "boolean",
168
      default = true,
169
      help = "When true, soft hyphens are rendered as discretionary breaks, otherwise they are ignored",
170
   })
171

172
   self.settings:declare({
22✔
173
      parameter = "typesetter.softHyphenWarning",
174
      type = "boolean",
175
      default = false,
176
      help = "When true, a warning is issued when a soft hyphen is encountered",
177
   })
178

179
   self.settings:declare({
22✔
180
      parameter = "typesetter.fixedSpacingAfterInitialEmdash",
181
      type = "boolean",
182
      default = true,
183
      help = "When true, em-dash starting a paragraph is considered as a speaker change in a dialogue",
184
   })
185
end
186

187
function typesetter:initState ()
11✔
188
   self.state = {
40✔
189
      nodes = {},
40✔
190
      outputQueue = {},
40✔
191
      lastBadness = awful_bad,
40✔
192
      liners = {},
40✔
193
   }
40✔
194
end
195

196
function typesetter:initFrame (frame)
11✔
197
   if frame then
61✔
198
      self.frame = frame
59✔
199
      self.frame:init(self)
59✔
200
   end
201
end
202

203
function typesetter:getMargins ()
11✔
204
   if not self then
364✔
205
      SU.deprecated("typesetter.getMargins()", "typesetter:getMargins()", "0.16.0", "0.17.0")
×
206
      return typesetter:getMargins()
×
207
   end
208
   return _margins(self.settings:get("document.lskip"), self.settings:get("document.rskip"))
1,820✔
209
end
210

211
function typesetter:setMargins (margins)
11✔
212
   self.settings:set("document.lskip", margins.lskip)
×
213
   self.settings:set("document.rskip", margins.rskip)
×
214
end
215

216
function typesetter:pushState ()
11✔
217
   self.stateQueue[#self.stateQueue + 1] = self.state
13✔
218
   self:initState()
13✔
219
end
220

221
function typesetter:popState (ncount)
11✔
222
   local offset = ncount and #self.stateQueue - ncount or nil
13✔
223
   self.state = table.remove(self.stateQueue, offset)
26✔
224
   if not self.state then
13✔
225
      SU.error("Typesetter state queue empty")
×
226
   end
227
end
228

229
function typesetter:isQueueEmpty ()
11✔
230
   if not self.state then
273✔
231
      return nil
×
232
   end
233
   return #self.state.nodes == 0 and #self.state.outputQueue == 0
273✔
234
end
235

236
function typesetter:vmode ()
11✔
237
   return #self.state.nodes == 0
89✔
238
end
239

240
function typesetter:debugState ()
11✔
241
   print("\n---\nI am in " .. (self:vmode() and "vertical" or "horizontal") .. " mode")
×
242
   print("Writing into " .. tostring(self.frame))
×
243
   print("Recent contributions: ")
×
244
   for i = 1, #self.state.nodes do
×
245
      io.stderr:write(self.state.nodes[i] .. " ")
×
246
   end
247
   print("\nVertical list: ")
×
248
   for i = 1, #self.state.outputQueue do
×
249
      print("  " .. self.state.outputQueue[i])
×
250
   end
251
end
252

253
-- Boxy stuff
254
function typesetter:pushHorizontal (node)
11✔
255
   self:initline()
349✔
256
   self.state.nodes[#self.state.nodes + 1] = node
349✔
257
   return node
349✔
258
end
259

260
function typesetter:pushVertical (vbox)
11✔
261
   self.state.outputQueue[#self.state.outputQueue + 1] = vbox
374✔
262
   return vbox
374✔
263
end
264

265
function typesetter:pushHbox (spec)
11✔
266
   local ntype = SU.type(spec)
20✔
267
   local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.types.node.hbox(spec)
20✔
268
   return self:pushHorizontal(node)
20✔
269
end
270

271
function typesetter:pushUnshaped (spec)
11✔
272
   local node = SU.type(spec) == "unshaped" and spec or SILE.types.node.unshaped(spec)
150✔
273
   return self:pushHorizontal(node)
75✔
274
end
275

276
function typesetter:pushGlue (spec)
11✔
277
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
326✔
278
   return self:pushHorizontal(node)
163✔
279
end
280

281
function typesetter:pushExplicitGlue (spec)
11✔
282
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
×
283
   node.explicit = true
×
284
   node.discardable = false
×
285
   return self:pushHorizontal(node)
×
286
end
287

288
function typesetter:pushPenalty (spec)
11✔
289
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
160✔
290
   return self:pushHorizontal(node)
80✔
291
end
292

293
function typesetter:pushMigratingMaterial (material)
11✔
294
   local node = SILE.types.node.migrating({ material = material })
2✔
295
   return self:pushHorizontal(node)
2✔
296
end
297

298
function typesetter:pushVbox (spec)
11✔
299
   local node = SU.type(spec) == "vbox" and spec or SILE.types.node.vbox(spec)
×
300
   return self:pushVertical(node)
×
301
end
302

303
function typesetter:pushVglue (spec)
11✔
304
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
116✔
305
   return self:pushVertical(node)
58✔
306
end
307

308
function typesetter:pushExplicitVglue (spec)
11✔
309
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
72✔
310
   node.explicit = true
36✔
311
   node.discardable = false
36✔
312
   return self:pushVertical(node)
36✔
313
end
314

315
function typesetter:pushVpenalty (spec)
11✔
316
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
50✔
317
   return self:pushVertical(node)
25✔
318
end
319

320
-- Actual typesetting functions
321
function typesetter:typeset (text)
11✔
322
   text = tostring(text)
121✔
323
   if text:match("^%\r?\n$") then
121✔
324
      return
51✔
325
   end
326
   local pId = SILE.traceStack:pushText(text)
70✔
327
   local parsepattern = self.settings:get("typesetter.parseppattern")
140✔
328
   -- NOTE: Big assumption on how to guess were are in "obeylines" mode.
329
   -- See https://github.com/sile-typesetter/sile/issues/2128
330
   local obeylines = parsepattern == "\n"
70✔
331

332
   local seenParaContent = true
70✔
333
   for token in SU.gtoke(text, parsepattern) do
252✔
334
      if token.separator then
112✔
335
         if obeylines and not seenParaContent then
37✔
336
            -- In obeylines mode, each standalone line must be kept.
337
            -- The zerohbox is not discardable, so it will be kept in the output,
338
            -- and the baseline skip will do the rest.
339
            self:pushHorizontal(SILE.types.node.zerohbox())
18✔
340
         else
341
            seenParaContent = false
31✔
342
         end
343
         self:endline()
74✔
344
      else
345
         seenParaContent = true
75✔
346
         if self.settings:get("typesetter.softHyphen") then
225✔
347
            local warnedshy = false
75✔
348
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
225✔
349
               if token2.separator then -- soft hyphen support
75✔
350
                  local discretionary = SILE.types.node.discretionary({})
×
351
                  local hbox = SILE.typesetter:makeHbox({ self.settings:get("font.hyphenchar") })
×
352
                  discretionary.prebreak = { hbox }
×
353
                  table.insert(SILE.typesetter.state.nodes, discretionary)
×
354
                  if not warnedshy and self.settings:get("typesetter.softHyphenWarning") then
×
355
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
356
                  end
357
                  warnedshy = true
×
358
               else
359
                  self:setpar(token2.string)
75✔
360
               end
361
            end
362
         else
363
            if
364
               self.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD))
×
365
            then
366
               SU.warn("Soft hyphen encountered and ignored")
×
367
            end
368
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
×
369
            self:setpar(text)
×
370
         end
371
      end
372
   end
373
   SILE.traceStack:pop(pId)
70✔
374
end
375

376
function typesetter:initline ()
11✔
377
   if self.state.hmodeOnly then
404✔
378
      return
×
379
   end -- https://github.com/sile-typesetter/sile/issues/1718
380
   if #self.state.nodes == 0 then
404✔
381
      table.insert(self.state.nodes, SILE.types.node.zerohbox())
160✔
382
      self.class:newPar(self)
80✔
383
   end
384
end
385

386
function typesetter:endline ()
11✔
387
   self.class:endPar(self)
62✔
388
   self:leaveHmode()
62✔
389
   if self.settings:get("current.hangIndent") then
186✔
390
      self.settings:set("current.hangIndent", nil)
×
391
      self.settings:set("linebreak.hangIndent", nil)
×
392
   end
393
   if self.settings:get("current.hangAfter") then
186✔
394
      self.settings:set("current.hangAfter", nil)
×
395
      self.settings:set("linebreak.hangAfter", nil)
×
396
   end
397
end
398

399
-- Just compute once, to avoid unicode characters in source code.
400
local speakerChangePattern = "^"
×
401
   .. luautf8.char(0x2014) -- emdash
11✔
402
   .. "[ "
×
403
   .. luautf8.char(0x00A0)
11✔
404
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
11✔
405
   .. "]+"
11✔
406
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
11✔
407

408
-- Special unshaped node subclass to handle space after a speaker change in dialogues
409
-- introduced by an em-dash.
410
local speakerChangeNode = pl.class(SILE.types.node.unshaped)
11✔
411
function speakerChangeNode:shape ()
11✔
412
   local node = self._base.shape(self)
×
413
   local spc = node[2]
×
414
   if spc and spc.is_glue then
×
415
      -- Switch the variable space glue to a fixed kern
416
      node[2] = SILE.types.node.kern({ width = spc.width.length })
×
417
      node[2].parent = self.parent
×
418
   else
419
      -- Should not occur:
420
      -- How could it possibly be shaped differently?
421
      SU.warn("Speaker change logic met an unexpected case, this might be a bug")
×
422
   end
423
   return node
×
424
end
425

426
-- Takes string, writes onto self.state.nodes
427
function typesetter:setpar (text)
11✔
428
   text = text:gsub("\r?\n", " "):gsub("\t", " ")
75✔
429
   if #self.state.nodes == 0 then
75✔
430
      if not self.settings:get("typesetter.obeyspaces") then
165✔
431
         text = text:gsub("^%s+", "")
45✔
432
      end
433
      self:initline()
55✔
434

435
      if
436
         self.settings:get("typesetter.fixedSpacingAfterInitialEmdash")
110✔
437
         and not self.settings:get("typesetter.obeyspaces")
165✔
438
      then
439
         local speakerChange = false
45✔
440
         local dialogue = luautf8.gsub(text, speakerChangePattern, function ()
90✔
441
            speakerChange = true
×
442
            return speakerChangeReplacement
×
443
         end)
444
         if speakerChange then
45✔
445
            local node = speakerChangeNode({ text = dialogue, options = SILE.font.loadDefaults({}) })
×
446
            self:pushHorizontal(node)
×
447
            return -- done here: speaker change space handling is done after nnode shaping
×
448
         end
449
      end
450
   end
451
   if #text > 0 then
75✔
452
      self:pushUnshaped({ text = text, options = SILE.font.loadDefaults({}) })
150✔
453
   end
454
end
455

456
function typesetter:breakIntoLines (nodelist, breakWidth)
11✔
457
   self:shapeAllNodes(nodelist)
79✔
458
   local breakpoints = self.linebreaker:doBreak(nodelist, breakWidth)
79✔
459
   return self:breakpointsToLines(breakpoints)
79✔
460
end
461

462
--- Extract the last shaped item from a node list.
463
-- @tparam table nodelist A list of nodes.
464
-- @treturn table The last shaped item.
465
-- @treturn boolean Whether the list contains a glue after the last shaped item.
466
-- @treturn number|nil The width of a punctuation kern after the last shaped item, if any.
467
local function getLastShape (nodelist)
468
   local lastShape
469
   local hasGlue
470
   local punctSpaceWidth
471
   if nodelist then
×
472
      -- The node list may contain nnodes, penalties, kern and glue
473
      -- We skip the latter, and retrieve the last shaped item.
474
      for i = #nodelist, 1, -1 do
×
475
         local n = nodelist[i]
×
476
         if n.is_nnode then
×
477
            local items = n.nodes[#n.nodes].value.items
×
478
            lastShape = items[#items]
×
479
            break
480
         end
481
         if n.is_kern and n.subtype == "punctspace" then
×
482
            -- Some languages such as French insert a special space around
483
            -- punctuations.
484
            -- In those case, we have different strategies for handling
485
            -- italic correction.
486
            punctSpaceWidth = n.width:tonumber()
×
487
         end
488
         if n.is_glue then
×
489
            hasGlue = true
×
490
         end
491
      end
492
   end
493
   return lastShape, hasGlue, punctSpaceWidth
×
494
end
495

496
--- Extract the first shaped item from a node list.
497
-- @tparam table nodelist A list of nodes.
498
-- @treturn table The first shaped item.
499
-- @treturn boolean Whether the list contains a glue before the first shaped item.
500
-- @treturn number|nil The width of a punctuation kern before the first shaped item, if any.
501
local function getFirstShape (nodelist)
502
   local firstShape
503
   local hasGlue
504
   local punctSpaceWidth
505
   if nodelist then
×
506
      -- The node list may contain nnodes, penalties, kern and glue
507
      -- We skip the latter, and retrieve the first shaped item.
508
      for i = 1, #nodelist do
×
509
         local n = nodelist[i]
×
510
         if n.is_nnode then
×
511
            local items = n.nodes[1].value.items
×
512
            firstShape = items[1]
×
513
            break
514
         end
515
         if n.is_kern and n.subtype == "punctspace" then
×
516
            -- Some languages such as French insert a special space around
517
            -- punctuations.
518
            -- In those case, we have different strategies for handling
519
            -- italic correction.
520
            punctSpaceWidth = n.width:tonumber()
×
521
         end
522
         if n.is_glue then
×
523
            hasGlue = true
×
524
         end
525
      end
526
   end
527
   return firstShape, hasGlue, punctSpaceWidth
×
528
end
529

530
--- Compute the italic correction when switching from italic to non-italic.
531
-- Computing italic correction is at best heuristics.
532
-- The strong assumption is that italic is slanted to the right.
533
-- Thus, the part of the character that goes beyond its width is usually maximal at the top of the glyph.
534
-- E.g. consider a "f", that would be the top hook extent.
535
-- Pathological cases exist, such as fonts with a Q with a long tail, but these will rarely occur in usual languages.
536
-- For instance, Klingon's "QaQ" might be an issue, but there's not much we can do...
537
-- Another assumption is that we can distribute that extent in proportion with the next character's height.
538
-- This might not work that well with non-Latin scripts.
539
--
540
-- @tparam table precShape The last shaped item (italic).
541
-- @tparam table curShape The first shaped item (non-italic).
542
-- @tparam number|nil punctSpaceWidth The width of a punctuation kern between the two items, if any.
543
function typesetter:_fromItalicCorrection (precShape, curShape, punctSpaceWidth)
11✔
544
   local xOffset
545
   if not curShape or not precShape then
×
546
      xOffset = 0
×
547
   elseif precShape.height <= 0 then
×
548
      xOffset = 0
×
549
   else
550
      local d = precShape.glyphWidth + precShape.x_bearing
×
551
      local delta = d > precShape.width and d - precShape.width or 0
×
552
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
×
553
      if punctSpaceWidth and self.settings:get("typesetter.italicCorrection.punctuation") then
×
554
         xOffset = xOffset - punctSpaceWidth > 0 and (xOffset - punctSpaceWidth) or 0
×
555
      end
556
   end
557
   return xOffset
×
558
end
559

560
--- Compute the italic correction when switching from non-italic to italic.
561
-- Same assumptions as typesetter:_fromItalicCorrection(), but on the starting side of the glyph.
562
--
563
-- @tparam table precShape The last shaped item (non-italic).
564
-- @tparam table curShape The first shaped item (italic).
565
-- @tparam number|nil punctSpaceWidth The width of a punctuation kern between the two items, if any.
566
function typesetter:_toItalicCorrection (precShape, curShape, punctSpaceWidth)
11✔
567
   local xOffset
568
   if not curShape or not precShape then
×
569
      xOffset = 0
×
570
   elseif precShape.depth <= 0 then
×
571
      xOffset = 0
×
572
   else
573
      local d = curShape.x_bearing
×
574
      local delta = d < 0 and -d or 0
×
575
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
×
576
      if punctSpaceWidth and self.settings:get("typesetter.italicCorrection.punctuation") then
×
577
         xOffset = punctSpaceWidth - xOffset > 0 and xOffset or 0
×
578
      end
579
   end
580
   return xOffset
×
581
end
582

583
local function isItalicLike (nnode)
584
   -- We could do...
585
   --  return nnode and string.lower(nnode.options.style) == "italic"
586
   -- But it's probably more robust to use the italic angle, so that
587
   -- thin italic, oblique or slanted fonts etc. may work too.
588
   local ot = require("core.opentype-parser")
×
589
   local face = SILE.font.cache(nnode.options, SILE.shaper:_getFaceCallback())
×
590
   local font = ot.parseFont(face)
×
591
   return font.post.italicAngle ~= 0
×
592
end
593

594
function typesetter:shapeAllNodes (nodelist, inplace)
11✔
595
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
500✔
596
   local newNodelist = {}
250✔
597
   local prec
598
   local precShapedNodes
599
   local isItalicCorrectionEnabled = self.settings:get("typesetter.italicCorrection")
500✔
600
   for _, current in ipairs(nodelist) do
2,921✔
601
      if current.is_unshaped then
2,671✔
602
         local shapedNodes = current:shape()
87✔
603

604
         if isItalicCorrectionEnabled and prec then
87✔
605
            local itCorrOffset
606
            local isGlue
607
            if isItalicLike(prec) and not isItalicLike(current) then
×
608
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
609
               local curShape, curHasGlue, curPunctSpaceWidth = getFirstShape(shapedNodes)
×
610
               isGlue = precHasGlue or curHasGlue
×
611
               itCorrOffset = self:_fromItalicCorrection(precShape, curShape, curPunctSpaceWidth)
×
612
            elseif not isItalicLike(prec) and isItalicLike(current) then
×
613
               local precShape, precHasGlue, precPunctSpaceWidth = getLastShape(precShapedNodes)
×
614
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
615
               isGlue = precHasGlue or curHasGlue
×
616
               itCorrOffset = self:_toItalicCorrection(precShape, curShape, precPunctSpaceWidth)
×
617
            end
618
            if itCorrOffset and itCorrOffset ~= 0 then
×
619
               -- If one of the node contains a glue (e.g. "a \em{proof} is..."),
620
               -- line breaking may occur between them, so our correction shall be
621
               -- a glue too.
622
               -- Otherwise, the font change is considered to occur at a non-breaking
623
               -- point (e.g. "\em{proof}!") and the correction shall be a kern.
624
               local makeItCorrNode = isGlue and SILE.types.node.glue or SILE.types.node.kern
×
625
               newNodelist[#newNodelist + 1] = makeItCorrNode({
×
626
                  width = SILE.types.length(itCorrOffset),
627
                  subtype = "itcorr",
628
               })
629
            end
630
         end
631

632
         pl.tablex.insertvalues(newNodelist, shapedNodes)
87✔
633

634
         prec = current
87✔
635
         precShapedNodes = shapedNodes
87✔
636
      else
637
         prec = nil
2,584✔
638
         newNodelist[#newNodelist + 1] = current
2,584✔
639
      end
640
   end
641

642
   if not inplace then
250✔
643
      return newNodelist
12✔
644
   end
645

646
   for i = 1, #newNodelist do
3,769✔
647
      nodelist[i] = newNodelist[i]
3,531✔
648
   end
649
   if #nodelist > #newNodelist then
238✔
650
      for i = #newNodelist + 1, #nodelist do
×
651
         nodelist[i] = nil
×
652
      end
653
   end
654
end
655

656
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
657
-- Turns a node list into a list of vboxes
658
function typesetter:boxUpNodes ()
11✔
659
   local nodelist = self.state.nodes
244✔
660
   if #nodelist == 0 then
244✔
661
      return {}
164✔
662
   end
663
   for j = #nodelist, 1, -1 do
98✔
664
      if not nodelist[j].is_migrating then
99✔
665
         if nodelist[j].discardable then
98✔
666
            table.remove(nodelist, j)
36✔
667
         else
668
            break
669
         end
670
      end
671
   end
672
   while #nodelist > 0 and nodelist[1].is_penalty do
80✔
673
      table.remove(nodelist, 1)
×
674
   end
675
   if #nodelist == 0 then
80✔
676
      return {}
×
677
   end
678
   self:shapeAllNodes(nodelist)
80✔
679
   local parfillskip = self.settings:get("typesetter.parfillskip")
160✔
680
   parfillskip.discardable = false
80✔
681
   self:pushGlue(parfillskip)
80✔
682
   self:pushPenalty(-inf_bad)
80✔
683
   SU.debug("typesetter", function ()
160✔
684
      return "Boxed up " .. (#nodelist > 500 and #nodelist .. " nodes" or SU.ast.contentToString(nodelist))
×
685
   end)
686
   local breakWidth = self.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
240✔
687
   local lines = self:breakIntoLines(nodelist, breakWidth)
80✔
688
   local vboxes = {}
80✔
689
   for index = 1, #lines do
200✔
690
      local line = lines[index]
120✔
691
      local migrating = {}
120✔
692
      -- Move any migrating material
693
      local nodes = {}
120✔
694
      for i = 1, #line.nodes do
2,440✔
695
         local node = line.nodes[i]
2,320✔
696
         if node.is_migrating then
2,320✔
697
            for j = 1, #node.material do
4✔
698
               migrating[#migrating + 1] = node.material[j]
2✔
699
            end
700
         else
701
            nodes[#nodes + 1] = node
2,318✔
702
         end
703
      end
704
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
120✔
705
      local pageBreakPenalty = 0
120✔
706
      if #lines > 1 and index == 1 then
120✔
707
         pageBreakPenalty = self.settings:get("typesetter.widowpenalty")
27✔
708
      elseif #lines > 1 and index == (#lines - 1) then
111✔
709
         pageBreakPenalty = self.settings:get("typesetter.orphanpenalty")
15✔
710
      elseif line.is_broken then
106✔
711
         pageBreakPenalty = self.settings:get("typesetter.brokenpenalty")
15✔
712
      end
713
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
240✔
714
      vboxes[#vboxes + 1] = vbox
120✔
715
      for i = 1, #migrating do
122✔
716
         vboxes[#vboxes + 1] = migrating[i]
2✔
717
      end
718
      self.state.previousVbox = vbox
120✔
719
      if pageBreakPenalty > 0 then
120✔
720
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
14✔
721
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
28✔
722
      end
723
   end
724
   return vboxes
80✔
725
end
726

727
function typesetter:getTargetLength ()
11✔
728
   return self.frame:getTargetLength()
237✔
729
end
730

731
function typesetter:registerHook (category, func)
11✔
732
   if not self.hooks[category] then
15✔
733
      self.hooks[category] = {}
13✔
734
   end
735
   table.insert(self.hooks[category], func)
15✔
736
end
737

738
function typesetter:runHooks (category, data)
11✔
739
   if not self.hooks[category] then
257✔
740
      return data
213✔
741
   end
742
   for _, func in ipairs(self.hooks[category]) do
105✔
743
      data = func(self, data)
122✔
744
   end
745
   return data
44✔
746
end
747

748
function typesetter:registerFrameBreakHook (func)
11✔
749
   self:registerHook("framebreak", func)
2✔
750
end
751

752
function typesetter:registerNewFrameHook (func)
11✔
753
   self:registerHook("newframe", func)
×
754
end
755

756
function typesetter:registerPageEndHook (func)
11✔
757
   self:registerHook("pageend", func)
13✔
758
end
759

760
function typesetter:buildPage ()
11✔
761
   local pageNodeList
762
   local res
763
   if self:isQueueEmpty() then
458✔
764
      return false
27✔
765
   end
766
   if SILE.scratch.insertions then
202✔
767
      SILE.scratch.insertions.thisPage = {}
80✔
768
   end
769
   pageNodeList, res = self.pagebuilder:findBestBreak({
404✔
770
      vboxlist = self.state.outputQueue,
202✔
771
      target = self:getTargetLength(),
404✔
772
      restart = self.frame.state.pageRestart,
202✔
773
   })
202✔
774
   if not pageNodeList then -- No break yet
202✔
775
      -- self.frame.state.pageRestart = res
776
      self:runHooks("noframebreak")
163✔
777
      return false
163✔
778
   end
779
   SU.debug("pagebuilder", "Buildding page for", self.frame.id)
39✔
780
   self.state.lastPenalty = res
39✔
781
   self.frame.state.pageRestart = nil
39✔
782
   pageNodeList = self:runHooks("framebreak", pageNodeList)
78✔
783
   self:setVerticalGlue(pageNodeList, self:getTargetLength())
78✔
784
   self:outputLinesToPage(pageNodeList)
39✔
785
   return true
39✔
786
end
787

788
function typesetter:setVerticalGlue (pageNodeList, target)
11✔
789
   local glues = {}
39✔
790
   local gTotal = SILE.types.length()
39✔
791
   local totalHeight = SILE.types.length()
39✔
792

793
   local pastTop = false
39✔
794
   for _, node in ipairs(pageNodeList) do
312✔
795
      if not pastTop and not node.discardable and not node.explicit then
273✔
796
         -- "Ignore discardable and explicit glues at the top of a frame."
797
         -- See typesetter:outputLinesToPage()
798
         -- Note the test here doesn't check is_vglue, so will skip other
799
         -- discardable nodes (e.g. penalties), but it shouldn't matter
800
         -- for the type of computing performed here.
801
         pastTop = true
39✔
802
      end
803
      if pastTop then
273✔
804
         if not node.is_insertion then
227✔
805
            totalHeight:___add(node.height)
227✔
806
            totalHeight:___add(node.depth)
227✔
807
         end
808
         if node.is_vglue then
227✔
809
            table.insert(glues, node)
126✔
810
            gTotal:___add(node.height)
126✔
811
         end
812
      end
813
   end
814

815
   if totalHeight:tonumber() == 0 then
78✔
816
      return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
3✔
817
   end
818

819
   local adjustment = target - totalHeight
36✔
820
   if adjustment:tonumber() > 0 then
72✔
821
      if adjustment > gTotal.stretch then
22✔
822
         if
823
            (adjustment - gTotal.stretch):tonumber() > self.settings:get("typesetter.underfulltolerance"):tonumber()
12✔
824
         then
825
            SU.warn(
2✔
826
               "Underfull frame "
827
                  .. self.frame.id
1✔
828
                  .. ": "
1✔
829
                  .. adjustment
1✔
830
                  .. " stretchiness required to fill but only "
1✔
831
                  .. gTotal.stretch
1✔
832
                  .. " available"
1✔
833
            )
834
         end
835
         adjustment = gTotal.stretch
2✔
836
      end
837
      if gTotal.stretch:tonumber() > 0 then
44✔
838
         for i = 1, #glues do
136✔
839
            local g = glues[i]
115✔
840
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
575✔
841
         end
842
      end
843
   elseif adjustment:tonumber() < 0 then
28✔
844
      adjustment = 0 - adjustment
14✔
845
      if adjustment > gTotal.shrink then
14✔
846
         if (adjustment - gTotal.shrink):tonumber() > self.settings:get("typesetter.overfulltolerance"):tonumber() then
84✔
847
            SU.warn(
×
848
               "Overfull frame "
849
                  .. self.frame.id
×
850
                  .. ": "
×
851
                  .. adjustment
×
852
                  .. " shrinkability required to fit but only "
×
853
                  .. gTotal.shrink
×
854
                  .. " available"
×
855
            )
856
         end
857
         adjustment = gTotal.shrink
14✔
858
      end
859
      if gTotal.shrink:tonumber() > 0 then
28✔
860
         for i = 1, #glues do
×
861
            local g = glues[i]
×
862
            g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
863
         end
864
      end
865
   end
866
   SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
36✔
867
end
868

869
function typesetter:initNextFrame ()
11✔
870
   local oldframe = self.frame
28✔
871
   self.frame:leave(self)
28✔
872
   if #self.state.outputQueue == 0 then
28✔
873
      self.state.previousVbox = nil
21✔
874
   end
875
   if self.frame.next and self.state.lastPenalty > supereject_penalty then
28✔
876
      self:initFrame(SILE.getFrame(self.frame.next))
×
877
   elseif not self.frame:isMainContentFrame() then
56✔
878
      if #self.state.outputQueue > 0 then
12✔
879
         SU.warn("Overfull content for frame " .. self.frame.id)
×
880
         self:chuck()
×
881
      end
882
   else
883
      self:runHooks("pageend")
16✔
884
      self.class:endPage()
16✔
885
      self:initFrame(self.class:newPage())
32✔
886
   end
887

888
   if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
112✔
889
      self:pushBack()
×
890
      -- Some what of a hack below.
891
      -- Before calling this method, we were in vertical mode...
892
      -- pushback occurred, and it seems it messes up a bit...
893
      -- Regardless what it does, at the end, we ought to be in vertical mode
894
      -- again:
895
      self:leaveHmode()
×
896
   else
897
      -- If I have some things on the vertical list already, they need
898
      -- proper top-of-frame leading applied.
899
      if #self.state.outputQueue > 0 then
28✔
900
         local lead = self:leadingFor(self.state.outputQueue[1], nil)
7✔
901
         if lead then
7✔
902
            table.insert(self.state.outputQueue, 1, lead)
6✔
903
         end
904
      end
905
   end
906
   self:runHooks("newframe")
28✔
907
end
908

909
function typesetter:pushBack ()
11✔
910
   SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
×
911
   local oldqueue = self.state.outputQueue
×
912
   self.state.outputQueue = {}
×
913
   self.state.previousVbox = nil
×
914
   local lastMargins = self:getMargins()
×
915
   for _, vbox in ipairs(oldqueue) do
×
916
      SU.debug("pushback", "process box", vbox)
×
917
      if vbox.margins and vbox.margins ~= lastMargins then
×
918
         SU.debug("pushback", "new margins", lastMargins, vbox.margins)
×
919
         if not self.state.grid then
×
920
            self:endline()
×
921
         end
922
         self:setMargins(vbox.margins)
×
923
      end
924
      if vbox.explicit then
×
925
         SU.debug("pushback", "explicit", vbox)
×
926
         self:endline()
×
927
         self:pushExplicitVglue(vbox)
×
928
      elseif vbox.is_insertion then
×
929
         SU.debug("pushback", "pushBack", "insertion", vbox)
×
930
         SILE.typesetter:pushMigratingMaterial({ material = { vbox } })
×
931
      elseif not vbox.is_vglue and not vbox.is_penalty then
×
932
         SU.debug("pushback", "not vglue or penalty", vbox.type)
×
933
         local discardedFistInitLine = false
×
934
         if #self.state.nodes == 0 then
×
935
            -- Setup queue but avoid calling newPar
936
            self.state.nodes[#self.state.nodes + 1] = SILE.types.node.zerohbox()
×
937
         end
938
         for i, node in ipairs(vbox.nodes) do
×
939
            if node.is_glue and not node.discardable then
×
940
               self:pushHorizontal(node)
×
941
            elseif node.is_glue and node.value == "margin" then
×
942
               SU.debug("pushback", "discard", node.value, node)
×
943
            elseif node.is_discretionary then
×
944
               SU.debug("pushback", "re-mark discretionary as unused", node)
×
945
               node.used = false
×
946
               if i == 1 then
×
947
                  SU.debug("pushback", "keep first discretionary", node)
×
948
                  self:pushHorizontal(node)
×
949
               else
950
                  SU.debug("pushback", "discard all other discretionaries", node)
×
951
               end
952
            elseif node.is_zero then
×
953
               if discardedFistInitLine then
×
954
                  self:pushHorizontal(node)
×
955
               end
956
               discardedFistInitLine = true
×
957
            elseif node.is_penalty then
×
958
               if not discardedFistInitLine then
×
959
                  self:pushHorizontal(node)
×
960
               end
961
            else
962
               node.bidiDone = true
×
963
               self:pushHorizontal(node)
×
964
            end
965
         end
966
      else
967
         SU.debug("pushback", "discard", vbox.type)
×
968
      end
969
      lastMargins = vbox.margins
×
970
      -- self:debugState()
971
   end
972
   while
×
973
      self.state.nodes[#self.state.nodes]
×
974
      and (self.state.nodes[#self.state.nodes].is_penalty or self.state.nodes[#self.state.nodes].is_zero)
×
975
   do
976
      self.state.nodes[#self.state.nodes] = nil
×
977
   end
978
end
979

980
function typesetter:outputLinesToPage (lines)
11✔
981
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
41✔
982
   -- It would have been nice to avoid storing this "pastTop" into a frame
983
   -- state, to keep things less entangled. There are situations, though,
984
   -- we will have left horizontal mode (triggering output), but will later
985
   -- call typesetter:chuck() do deal with any remaining content, and we need
986
   -- to know whether some content has been output already.
987
   local pastTop = self.frame.state.totals.pastTop
41✔
988
   for _, line in ipairs(lines) do
320✔
989
      -- Ignore discardable and explicit glues at the top of a frame:
990
      -- Annoyingly, explicit glue *should* disappear at the top of a page.
991
      -- if you don't want that, add an empty vbox or something.
992
      if not pastTop and not line.discardable and not line.explicit then
279✔
993
         -- Note the test here doesn't check is_vglue, so will skip other
994
         -- discardable nodes (e.g. penalties), but it shouldn't matter
995
         -- for outputting.
996
         pastTop = true
41✔
997
      end
998
      if pastTop then
279✔
999
         line:outputYourself(self, line)
231✔
1000
      end
1001
   end
1002
   self.frame.state.totals.pastTop = pastTop
41✔
1003
end
1004

1005
function typesetter:leaveHmode (independent)
11✔
1006
   if self.state.hmodeOnly then
244✔
1007
      SU.error("Paragraphs are forbidden in restricted horizontal mode")
×
1008
   end
1009
   SU.debug("typesetter", "Leaving hmode")
244✔
1010
   local margins = self:getMargins()
244✔
1011
   local vboxlist = self:boxUpNodes()
244✔
1012
   self.state.nodes = {}
244✔
1013
   -- Push output lines into boxes and ship them to the page builder
1014
   for _, vbox in ipairs(vboxlist) do
499✔
1015
      vbox.margins = margins
255✔
1016
      self:pushVertical(vbox)
255✔
1017
   end
1018
   if independent then
244✔
1019
      return
26✔
1020
   end
1021
   if self:buildPage() then
436✔
1022
      self:initNextFrame()
28✔
1023
   end
1024
end
1025

1026
function typesetter:inhibitLeading ()
11✔
1027
   self.state.previousVbox = nil
×
1028
end
1029

1030
function typesetter:leadingFor (vbox, previous)
11✔
1031
   -- Insert leading
1032
   SU.debug("typesetter", "   Considering leading between two lines:")
117✔
1033
   SU.debug("typesetter", "   1)", previous)
117✔
1034
   SU.debug("typesetter", "   2)", vbox)
117✔
1035
   if not previous then
117✔
1036
      return SILE.types.node.vglue()
39✔
1037
   end
1038
   local prevDepth = previous.depth
78✔
1039
   SU.debug("typesetter", "   Depth of previous line was", prevDepth)
78✔
1040
   local bls = self.settings:get("document.baselineskip")
156✔
1041
   local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
390✔
1042
   SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
78✔
1043

1044
   -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
1045
   local lead = self.settings:get("document.lineskip").height:absolute()
234✔
1046
   if depth > lead then
78✔
1047
      return SILE.types.node.vglue(SILE.types.length(depth.length, bls.height.stretch, bls.height.shrink))
156✔
1048
   else
1049
      return SILE.types.node.vglue(lead)
×
1050
   end
1051
end
1052

1053
-- Beginning of liner logic (constructs spanning over several lines)
1054

1055
-- These two special nodes are used to track the current liner entry and exit.
1056
-- As Sith Lords, they are always two: they are local here, so no one can
1057
-- use one alone and break the balance of the Force.
1058
local linerEnterNode = pl.class(SILE.types.node.hbox)
11✔
1059
function linerEnterNode:_init (name, outputMethod)
11✔
1060
   SILE.types.node.hbox._init(self)
×
1061
   self.outputMethod = outputMethod
×
1062
   self.name = name
×
1063
   self.is_enter = true
×
1064
end
1065
function linerEnterNode:clone ()
11✔
1066
   return linerEnterNode(self.name, self.outputMethod)
×
1067
end
1068
function linerEnterNode:outputYourself ()
11✔
1069
   SU.error("A liner enter node " .. tostring(self) .. "'' made it to output", true)
×
1070
end
1071
function linerEnterNode:__tostring ()
11✔
1072
   return "+L[" .. self.name .. "]"
×
1073
end
1074
local linerLeaveNode = pl.class(SILE.types.node.hbox)
11✔
1075
function linerLeaveNode:_init (name)
11✔
1076
   SILE.types.node.hbox._init(self)
×
1077
   self.name = name
×
1078
   self.is_leave = true
×
1079
end
1080
function linerLeaveNode:clone ()
11✔
1081
   return linerLeaveNode(self.name)
×
1082
end
1083
function linerLeaveNode:outputYourself ()
11✔
1084
   SU.error("A liner leave node " .. tostring(self) .. "'' made it to output", true)
×
1085
end
1086
function linerLeaveNode:__tostring ()
11✔
1087
   return "-L[" .. self.name .. "]"
×
1088
end
1089

1090
local linerBox = pl.class(SILE.types.node.hbox)
11✔
1091
function linerBox:_init (name, outputMethod)
11✔
1092
   SILE.types.node.hbox._init(self)
×
1093
   self.width = SILE.types.length()
×
1094
   self.height = SILE.types.length()
×
1095
   self.depth = SILE.types.length()
×
1096
   self.name = name
×
1097
   self.inner = {}
×
1098
   self.outputYourself = outputMethod
×
1099
end
1100
function linerBox:append (node)
11✔
1101
   self.inner[#self.inner + 1] = node
×
1102
   if node.is_discretionary then
×
1103
      -- Discretionary nodes don't have a width of their own.
1104
      if node.used then
×
1105
         if node.is_prebreak then
×
1106
            self.width:___add(node:prebreakWidth())
×
1107
         else
1108
            self.width:___add(node:postbreakWidth())
×
1109
         end
1110
      else
1111
         self.width:___add(node:replacementWidth())
×
1112
      end
1113
   else
1114
      self.width:___add(node.width:absolute())
×
1115
   end
1116
   self.height = SU.max(self.height, node.height)
×
1117
   self.depth = SU.max(self.depth, node.depth)
×
1118
end
1119
function linerBox:count ()
11✔
1120
   return #self.inner
×
1121
end
1122
function linerBox:outputContent (tsetter, line)
11✔
1123
   for _, node in ipairs(self.inner) do
×
1124
      node.outputYourself(node, tsetter, line)
×
1125
   end
1126
end
1127
function linerBox:__tostring ()
11✔
1128
   return "*L["
×
1129
      .. self.name
×
1130
      .. "]H<"
×
1131
      .. tostring(self.width)
×
1132
      .. ">^"
×
1133
      .. tostring(self.height)
×
1134
      .. "-"
×
1135
      .. tostring(self.depth)
×
1136
      .. "v"
×
1137
end
1138

1139
--- Any unclosed liner is reopened on the current line, so we clone and repeat it.
1140
-- An assumption is that the inserts are done after the current slice content,
1141
-- supposed to be just before meaningful (visible) content.
1142
-- @tparam table slice Flat nodes from current line
1143
-- @treturn boolean Whether a liner was reopened
1144
function typesetter:_repeatEnterLiners (slice)
11✔
1145
   local m = self.state.liners
1,651✔
1146
   if #m > 0 then
1,651✔
1147
      for i = 1, #m do
×
1148
         local n = m[i]:clone()
×
1149
         slice[#slice + 1] = n
×
1150
         SU.debug("typesetter.liner", "Reopening liner", n)
×
1151
      end
1152
      return true
×
1153
   end
1154
   return false
1,651✔
1155
end
1156

1157
--- All pairs of liners are rebuilt as hboxes wrapping their content.
1158
-- Migrating content, however, must be kept outside the hboxes at top slice level.
1159
-- @tparam table slice Flat nodes from current line
1160
-- @treturn table New reboxed slice
1161
function typesetter:_reboxLiners (slice)
11✔
1162
   local outSlice = {}
×
1163
   local migratingList = {}
×
1164
   local lboxStack = {}
×
1165
   for i = 1, #slice do
×
1166
      local node = slice[i]
×
1167
      if node.is_enter then
×
1168
         SU.debug("typesetter.liner", "Start reboxing", node)
×
1169
         local n = linerBox(node.name, node.outputMethod)
×
1170
         lboxStack[#lboxStack + 1] = n
×
1171
      elseif node.is_leave then
×
1172
         if #lboxStack == 0 then
×
1173
            SU.error("Multiliner box stacking mismatch" .. node)
×
1174
         elseif #lboxStack == 1 then
×
1175
            SU.debug("typesetter.liner", "End reboxing", node, "(toplevel) =", lboxStack[1]:count(), "nodes")
×
1176
            if lboxStack[1]:count() > 0 then
×
1177
               outSlice[#outSlice + 1] = lboxStack[1]
×
1178
            end
1179
         else
1180
            SU.debug("typesetter.liner", "End reboxing", node, "(sublevel) =", lboxStack[#lboxStack]:count(), "nodes")
×
1181
            if lboxStack[#lboxStack]:count() > 0 then
×
1182
               local hbox = lboxStack[#lboxStack - 1]
×
1183
               hbox:append(lboxStack[#lboxStack])
×
1184
            end
1185
         end
1186
         lboxStack[#lboxStack] = nil
×
1187
         pl.tablex.insertvalues(outSlice, migratingList)
×
1188
         migratingList = {}
×
1189
      else
1190
         if #lboxStack > 0 then
×
1191
            if not node.is_migrating then
×
1192
               local lbox = lboxStack[#lboxStack]
×
1193
               lbox:append(node)
×
1194
            else
1195
               migratingList[#migratingList + 1] = node
×
1196
            end
1197
         else
1198
            outSlice[#outSlice + 1] = node
×
1199
         end
1200
      end
1201
   end
1202
   return outSlice -- new reboxed slice
×
1203
end
1204

1205
--- Check if a node is a liner, and process it if so, in a stack.
1206
-- @tparam table node Current node (any type)
1207
-- @treturn boolean Whether a liner was opened
1208
function typesetter:_processIfLiner (node)
11✔
1209
   local entered = false
1,842✔
1210
   if node.is_enter then
1,842✔
1211
      SU.debug("typesetter.liner", "Enter liner", node)
×
1212
      self.state.liners[#self.state.liners + 1] = node
×
1213
      entered = true
×
1214
   elseif node.is_leave then
1,842✔
1215
      SU.debug("typesetter.liner", "Leave liner", node)
×
1216
      if #self.state.liners == 0 then
×
1217
         SU.error("Multiliner stack mismatch" .. node)
×
1218
      elseif self.state.liners[#self.state.liners].name == node.name then
×
1219
         self.state.liners[#self.state.liners].link = node -- for consistency check
×
1220
         self.state.liners[#self.state.liners] = nil
×
1221
      else
1222
         SU.error("Multiliner stack inconsistency" .. self.state.liners[#self.state.liners] .. "vs. " .. node)
×
1223
      end
1224
   end
1225
   return entered
1,842✔
1226
end
1227

1228
function typesetter:_repeatLeaveLiners (slice, insertIndex)
11✔
1229
   for _, v in ipairs(self.state.liners) do
114✔
1230
      if not v.link then
×
1231
         local n = linerLeaveNode(v.name)
×
1232
         SU.debug("typesetter.liner", "Closing liner", n)
×
1233
         table.insert(slice, insertIndex, n)
×
1234
      else
1235
         SU.error("Multiliner stack inconsistency" .. v)
×
1236
      end
1237
   end
1238
end
1239
-- End of liner logic
1240

1241
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
11✔
1242
   local LTR = self.frame:writingDirection() == "LTR"
240✔
1243
   local rskip = margins[LTR and "rskip" or "lskip"]
120✔
1244
   if not rskip then
120✔
1245
      rskip = SILE.types.node.glue(0)
×
1246
   end
1247
   if hangRight and hangRight > 0 then
120✔
1248
      rskip = SILE.types.node.glue({ width = rskip.width:tonumber() + hangRight })
×
1249
   end
1250
   rskip.value = "margin"
120✔
1251
   -- while slice[#slice].discardable do table.remove(slice, #slice) end
1252
   table.insert(slice, rskip)
120✔
1253
   table.insert(slice, SILE.types.node.zerohbox())
240✔
1254
   local lskip = margins[LTR and "lskip" or "rskip"]
120✔
1255
   if not lskip then
120✔
1256
      lskip = SILE.types.node.glue(0)
×
1257
   end
1258
   if hangLeft and hangLeft > 0 then
120✔
1259
      lskip = SILE.types.node.glue({ width = lskip.width:tonumber() + hangLeft })
×
1260
   end
1261
   lskip.value = "margin"
120✔
1262
   while slice[1].discardable do
120✔
1263
      table.remove(slice, 1)
×
1264
   end
1265
   table.insert(slice, 1, lskip)
120✔
1266
   table.insert(slice, 1, SILE.types.node.zerohbox())
240✔
1267
end
1268

1269
function typesetter:breakpointsToLines (breakpoints)
11✔
1270
   local linestart = 1
80✔
1271
   local lines = {}
80✔
1272
   local nodes = self.state.nodes
80✔
1273

1274
   for i = 1, #breakpoints do
202✔
1275
      local point = breakpoints[i]
122✔
1276
      if point.position ~= 0 then
122✔
1277
         local slice = {}
122✔
1278
         local seenNonDiscardable = false
122✔
1279
         local seenLiner = false
122✔
1280
         local lastContentNodeIndex
1281

1282
         for j = linestart, point.position do
1,964✔
1283
            local currentNode = nodes[j]
1,842✔
1284
            if
1285
               not currentNode.discardable
1,842✔
1286
               and not (currentNode.is_glue and not currentNode.explicit)
1,286✔
1287
               and not currentNode.is_zero
1,206✔
1288
            then
1289
               -- actual visible content starts here
1290
               lastContentNodeIndex = #slice + 1
1,120✔
1291
            end
1292
            if not seenLiner and lastContentNodeIndex then
1,842✔
1293
               -- Any stacked liner (unclosed from a previous line) is reopened on
1294
               -- the current line.
1295
               seenLiner = self:_repeatEnterLiners(slice)
3,302✔
1296
               lastContentNodeIndex = #slice + 1
1,651✔
1297
            end
1298
            if currentNode.is_discretionary and currentNode.used then
1,842✔
1299
               -- This is the used (prebreak) discretionary from a previous line,
1300
               -- repeated. Replace it with a clone, changed to a postbreak.
1301
               currentNode = currentNode:cloneAsPostbreak()
22✔
1302
            end
1303
            slice[#slice + 1] = currentNode
1,842✔
1304
            if currentNode then
1,842✔
1305
               if not currentNode.discardable then
1,842✔
1306
                  seenNonDiscardable = true
1,286✔
1307
               end
1308
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
3,684✔
1309
            end
1310
         end
1311
         if not seenNonDiscardable then
122✔
1312
            -- Slip lines containing only discardable nodes (e.g. glues).
1313
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
2✔
1314
            linestart = point.position + 1
2✔
1315
         else
1316
            local is_broken = false
120✔
1317
            if slice[#slice].is_discretionary then
120✔
1318
               -- The line ends, with a discretionary:
1319
               -- repeat it on the next line, so as to account for a potential postbreak.
1320
               linestart = point.position
11✔
1321
               -- And mark it as used as prebreak for now.
1322
               slice[#slice]:markAsPrebreak()
11✔
1323
               -- We'll want a "brokenpenalty" eventually (if not an orphan or widow)
1324
               -- to discourage page breaking after this line.
1325
               is_broken = true
11✔
1326
            else
1327
               linestart = point.position + 1
109✔
1328
            end
1329

1330
            -- Any unclosed liner is closed on the next line in reverse order.
1331
            if lastContentNodeIndex then
120✔
1332
               self:_repeatLeaveLiners(slice, lastContentNodeIndex + 1)
114✔
1333
            end
1334

1335
            -- Then only we can add some extra margin glue...
1336
            local mrg = self:getMargins()
120✔
1337
            self:addrlskip(slice, mrg, point.left, point.right)
120✔
1338

1339
            -- And compute the line...
1340
            local ratio = self:computeLineRatio(point.width, slice)
120✔
1341

1342
            -- Re-shuffle liners, if any, into their own boxes.
1343
            if seenLiner then
120✔
1344
               slice = self:_reboxLiners(slice)
×
1345
            end
1346

1347
            local thisLine = { ratio = ratio, nodes = slice, is_broken = is_broken }
120✔
1348
            lines[#lines + 1] = thisLine
120✔
1349
         end
1350
      end
1351
   end
1352
   if linestart < #nodes then
80✔
1353
      -- Abnormal, but warn so that one has a chance to check which bits
1354
      -- are missing at output.
1355
      SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
1356
   end
1357
   return lines
80✔
1358
end
1359

1360
function typesetter:computeLineRatio (breakwidth, slice)
11✔
1361
   local naturalTotals = SILE.types.length()
120✔
1362

1363
   -- From the line end, account for the margin but skip any trailing
1364
   -- glues (spaces to ignore) and zero boxes until we reach actual content.
1365
   local npos = #slice
120✔
1366
   while npos > 1 do
391✔
1367
      if slice[npos].is_glue or slice[npos].is_zero then
391✔
1368
         if slice[npos].value == "margin" then
271✔
1369
            naturalTotals:___add(slice[npos].width)
120✔
1370
         end
1371
      else
1372
         break
1373
      end
1374
      npos = npos - 1
271✔
1375
   end
1376

1377
   -- Due to discretionaries, keep track of seen parent nodes
1378
   local seenNodes = {}
120✔
1379
   -- CODE SMELL: Not sure which node types were supposed to be skipped
1380
   -- at initial positions in the line!
1381
   local skipping = true
120✔
1382

1383
   -- Until end of actual content
1384
   for i = 1, npos do
2,169✔
1385
      local node = slice[i]
2,049✔
1386
      if node.is_box then
2,049✔
1387
         skipping = false
1,016✔
1388
         if node.parent and not node.parent.hyphenated then
1,016✔
1389
            if not seenNodes[node.parent] then
399✔
1390
               naturalTotals:___add(node.parent:lineContribution())
320✔
1391
            end
1392
            seenNodes[node.parent] = true
399✔
1393
         else
1394
            naturalTotals:___add(node:lineContribution())
1,234✔
1395
         end
1396
      elseif node.is_penalty and node.penalty == -inf_bad then
1,033✔
1397
         skipping = false
78✔
1398
      elseif node.is_discretionary then
955✔
1399
         skipping = false
285✔
1400
         local seen = node.parent and seenNodes[node.parent]
285✔
1401
         if not seen then
285✔
1402
            if node.used then
46✔
1403
               if node.is_prebreak then
22✔
1404
                  naturalTotals:___add(node:prebreakWidth())
22✔
1405
                  node.height = node:prebreakHeight()
22✔
1406
               else
1407
                  naturalTotals:___add(node:postbreakWidth())
22✔
1408
                  node.height = node:postbreakHeight()
22✔
1409
               end
1410
            else
1411
               naturalTotals:___add(node:replacementWidth():absolute())
72✔
1412
               node.height = node:replacementHeight():absolute()
72✔
1413
            end
1414
         end
1415
      elseif not skipping then
670✔
1416
         naturalTotals:___add(node.width)
670✔
1417
      end
1418
   end
1419

1420
   local _left = breakwidth:tonumber() - naturalTotals:tonumber()
360✔
1421
   local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
240✔
1422
   ratio = math.max(ratio, -1)
120✔
1423
   return ratio, naturalTotals
120✔
1424
end
1425

1426
function typesetter:chuck () -- emergency shipout everything
11✔
1427
   self:leaveHmode(true)
14✔
1428
   if #self.state.outputQueue > 0 then
14✔
1429
      SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
2✔
1430
      self:outputLinesToPage(self.state.outputQueue)
2✔
1431
      self.state.outputQueue = {}
2✔
1432
   end
1433
end
1434

1435
-- Logic for building an hbox from content.
1436
-- It returns the hbox and an horizontal list of (migrating) elements
1437
-- extracted outside of it.
1438
-- None of these are pushed to the typesetter node queue. The caller
1439
-- is responsible of doing it, if the hbox is built for anything
1440
-- else than e.g. measuring it. Likewise, the call has to decide
1441
-- what to do with the migrating content.
1442
local _rtl_pre_post = function (box, atypesetter, line)
1443
   local advance = function ()
1444
      atypesetter.frame:advanceWritingDirection(box:scaledWidth(line))
22✔
1445
   end
1446
   if atypesetter.frame:writingDirection() == "RTL" then
22✔
1447
      advance()
×
1448
      return function () end
×
1449
   else
1450
      return advance
11✔
1451
   end
1452
end
1453
function typesetter:makeHbox (content)
11✔
1454
   local recentContribution = {}
12✔
1455
   local migratingNodes = {}
12✔
1456

1457
   self:pushState()
12✔
1458
   self.state.hmodeOnly = true
12✔
1459
   SILE.process(content)
12✔
1460

1461
   -- We must do a first pass for shaping the nnodes:
1462
   -- This is also where italic correction may occur.
1463
   local nodes = self:shapeAllNodes(self.state.nodes, false)
12✔
1464

1465
   -- Then we can process and measure the nodes.
1466
   local l = SILE.types.length()
12✔
1467
   local h, d = SILE.types.length(), SILE.types.length()
24✔
1468
   for i = 1, #nodes do
12✔
1469
      local node = nodes[i]
×
1470
      if node.is_migrating then
×
1471
         migratingNodes[#migratingNodes + 1] = node
×
1472
      elseif node.is_discretionary then
×
1473
         -- HACK https://github.com/sile-typesetter/sile/issues/583
1474
         -- Discretionary nodes have a null line contribution...
1475
         -- But if discretionary nodes occur inside an hbox, since the latter
1476
         -- is not line-broken, they will never be marked as 'used' and will
1477
         -- evaluate to the replacement content (if any)...
1478
         recentContribution[#recentContribution + 1] = node
×
1479
         l = l + node:replacementWidth():absolute()
×
1480
         -- The replacement content may have ascenders and descenders...
1481
         local hdisc = node:replacementHeight():absolute()
×
1482
         local ddisc = node:replacementDepth():absolute()
×
1483
         h = hdisc > h and hdisc or h
×
1484
         d = ddisc > d and ddisc or d
×
1485
      -- By the way it's unclear how this is expected to work in TTB
1486
      -- writing direction. For other type of nodes, the line contribution
1487
      -- evaluates to the height rather than the width in TTB, but the
1488
      -- whole logic might then be dubious there too...
1489
      else
1490
         recentContribution[#recentContribution + 1] = node
×
1491
         l = l + node:lineContribution():absolute()
×
1492
         h = node.height > h and node.height or h
×
1493
         d = node.depth > d and node.depth or d
×
1494
      end
1495
   end
1496
   self:popState()
12✔
1497

1498
   local hbox = SILE.types.node.hbox({
24✔
1499
      height = h,
12✔
1500
      width = l,
12✔
1501
      depth = d,
12✔
1502
      value = recentContribution,
12✔
1503
      outputYourself = function (box, atypesetter, line)
1504
         local _post = _rtl_pre_post(box, atypesetter, line)
11✔
1505
         local ox = atypesetter.frame.state.cursorX
11✔
1506
         local oy = atypesetter.frame.state.cursorY
11✔
1507
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
11✔
1508
         SU.debug("hboxes", function ()
22✔
1509
            -- setCursor is also invoked by the internal (wrapped) hboxes etc.
1510
            -- so we must show our debug box before outputting its content.
1511
            SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
1512
            return "Drew debug outline around hbox"
×
1513
         end)
1514
         for _, node in ipairs(box.value) do
11✔
1515
            node:outputYourself(atypesetter, line)
×
1516
         end
1517
         atypesetter.frame.state.cursorX = ox
11✔
1518
         atypesetter.frame.state.cursorY = oy
11✔
1519
         _post()
11✔
1520
      end,
1521
   })
1522
   return hbox, migratingNodes
12✔
1523
end
1524

1525
function typesetter:pushHlist (hlist)
11✔
1526
   for _, h in ipairs(hlist) do
×
1527
      self:pushHorizontal(h)
×
1528
   end
1529
end
1530

1531
--- A liner is a construct that may span multiple lines.
1532
-- This is the user-facing method for creating such liners in packages.
1533
-- The content may be line-broken, and each bit on each line will be wrapped
1534
-- into a box.
1535
-- These boxes will be formatted according to some output logic.
1536
-- The output method has the same signature as the outputYourself method
1537
-- of a box, and is responsible for outputting the liner inner content with the
1538
-- outputContent(typesetter, line) method, possibly surrounded by some additional
1539
-- effects.
1540
-- If we are already in horizontal-restricted mode, the liner is processed
1541
-- immediately, since line breaking won't occur then.
1542
-- @tparam string name Name of the liner (useful for debugging)
1543
-- @tparam table content SILE AST to process
1544
-- @tparam function outputYourself Output method for wrapped boxes
1545
function typesetter:liner (name, content, outputYourself)
11✔
1546
   if self.state.hmodeOnly then
×
1547
      SU.debug("typesetter.liner", "Applying liner in horizontal-restricted mode")
×
1548
      local hbox, hlist = self:makeHbox(content)
×
1549
      local lbox = linerBox(name, outputYourself)
×
1550
      lbox:append(hbox)
×
1551
      self:pushHorizontal(lbox)
×
1552
      self:pushHlist(hlist)
×
1553
   else
1554
      self.state.linerCount = (self.state.linerCount or 0) + 1
×
1555
      local uname = name .. "_" .. self.state.linerCount
×
1556
      SU.debug("typesetter.liner", "Applying liner in standard mode")
×
1557
      local enter = linerEnterNode(uname, outputYourself)
×
1558
      local leave = linerLeaveNode(uname)
×
1559
      self:pushHorizontal(enter)
×
1560
      SILE.process(content)
×
1561
      self:pushHorizontal(leave)
×
1562
   end
1563
end
1564

1565
--- Flatten a node list into just its string representation.
1566
-- @tparam table nodes Typeset nodes
1567
-- @treturn string Text reconstruction of the nodes
1568
local function _nodesToText (nodes)
1569
   -- A real interword space width depends on several settings (depending on variable
1570
   -- spaces being enabled or not, etc.), and the computation below takes that into
1571
   -- account.
1572
   local iwspc = SILE.shaper:measureSpace(SILE.font.loadDefaults({}))
×
1573
   local iwspcmin = (iwspc.length - iwspc.shrink):tonumber()
×
1574

1575
   local string = ""
×
1576
   for i = 1, #nodes do
×
1577
      local node = nodes[i]
×
1578
      if node.is_nnode or node.is_unshaped then
×
1579
         string = string .. node:toText()
×
1580
      elseif node.is_glue or node.is_kern then
×
1581
         -- What we want to avoid is "small" glues or kerns to be expanded as full
1582
         -- spaces.
1583
         -- Comparing them to half of the smallest width of a possibly shrinkable
1584
         -- interword space is fairly fragile and empirical: the content could contain
1585
         -- font changes, so the comparison is wrong in the general case.
1586
         -- It's a simplistic approach. We cannot really be sure what a "space" meant
1587
         -- at the point where the kern or glue got absolutized.
1588
         if node.width:tonumber() > iwspcmin * 0.5 then
×
1589
            string = string .. " "
×
1590
         end
1591
      elseif not (node.is_zerohbox or node.is_migrating) then
×
1592
         -- Here, typically, the main case is an hbox.
1593
         -- Even if extracting its content could be possible in some regular cases
1594
         -- we cannot take a general decision, as it is a versatile object  and its
1595
         -- outputYourself() method could moreover have been redefined to do fancy
1596
         -- things. Better warn and skip.
1597
         SU.warn("Some content could not be converted to text: " .. node)
×
1598
      end
1599
   end
1600
   -- Trim leading and trailing spaces, and simplify internal spaces.
1601
   return pl.stringx.strip(string):gsub("%s%s+", " ")
×
1602
end
1603

1604
--- Convert a SILE AST to a textual representation.
1605
-- This is similar to SU.ast.contentToString(), but it performs a full
1606
-- typesetting of the content, and then reconstructs the text from the
1607
-- typeset nodes.
1608
-- @tparam table content SILE AST to process
1609
-- @treturn string Textual representation of the content
1610
function typesetter:contentToText (content)
11✔
1611
   self:pushState()
×
1612
   self.state.hmodeOnly = true
×
1613
   SILE.process(content)
×
1614
   local text = _nodesToText(self.state.nodes)
×
1615
   self:popState()
×
1616
   return text
×
1617
end
1618

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

© 2026 Coveralls, Inc