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

sile-typesetter / sile / 14908152066

08 May 2025 01:49PM UTC coverage: 61.309% (-5.7%) from 67.057%
14908152066

push

github

alerque
chore(registries): Touchup command registry deprecation shims

1 of 2 new or added lines in 2 files covered. (50.0%)

1379 existing lines in 46 files now uncovered.

13556 of 22111 relevant lines covered (61.31%)

11834.23 hits per line

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

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

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

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

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

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

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

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

44
function typesetter:_post_init ()
42✔
45
   module._post_init(self)
81✔
46
   self:initFrame(self.frame)
81✔
47
   self:initState()
81✔
48
   self.language = SILE.languages.en(self)
204✔
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)
81✔
52
end
53

54
typesetter._language_cache = {}
42✔
55

56
function typesetter:_cacheLanguage (lang)
42✔
57
   if not self._language_cache[lang] then
2,716✔
58
      self._language_cache[lang] = SILE.languages[lang](self)
114✔
59
      SU.debug("typesetter", "Caching language in typesetter", lang)
52✔
60
   end
61
   return self._language_cache[lang]
2,716✔
62
end
63

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

73
--- Declare new setting types
74
function typesetter:_declareSettings ()
42✔
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({
84✔
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({
84✔
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({
84✔
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({
84✔
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({
84✔
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({
126✔
117
      parameter = "typesetter.parfillskip",
118
      type = "glue",
119
      default = SILE.types.node.glue("0pt plus 10000pt"),
84✔
120
      help = "Glue added at the end of a paragraph",
121
   })
122

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

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

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

144
   self.settings:declare({
84✔
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({
84✔
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({
84✔
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({
84✔
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({
84✔
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({
84✔
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 ()
42✔
188
   self.state = {
100✔
189
      nodes = {},
100✔
190
      outputQueue = {},
100✔
191
      lastBadness = awful_bad,
100✔
192
      liners = {},
100✔
193
   }
100✔
194
end
195

196
function typesetter:initFrame (frame)
42✔
197
   if frame then
129✔
198
      self.frame = frame
127✔
199
      self.frame:init(self)
127✔
200
   end
201
end
202

203
function typesetter:getMargins ()
42✔
204
   if not self then
1,271✔
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"))
6,355✔
209
end
210

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

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

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

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

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

240
function typesetter:debugState ()
42✔
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)
42✔
255
   self:initline()
1,548✔
256
   self.state.nodes[#self.state.nodes + 1] = node
1,548✔
257
   return node
1,548✔
258
end
259

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

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

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

276
function typesetter:pushGlue (spec)
42✔
277
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
1,148✔
278
   return self:pushHorizontal(node)
574✔
279
end
280

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

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

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

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

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

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

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

320
-- Actual typesetting functions
321
function typesetter:typeset (text)
42✔
322
   text = tostring(text)
544✔
323
   if text:match("^%\r?\n$") then
544✔
324
      return
234✔
325
   end
326
   local pId = SILE.traceStack:pushText(text)
310✔
327
   local parsepattern = self.settings:get("typesetter.parseppattern")
620✔
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"
310✔
331

332
   local seenParaContent = true
310✔
333
   for token in SU.gtoke(text, parsepattern) do
1,058✔
334
      if token.separator then
438✔
335
         if obeylines and not seenParaContent then
187✔
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
181✔
342
         end
343
         self:endline()
374✔
344
      else
345
         seenParaContent = true
251✔
346
         if self.settings:get("typesetter.softHyphen") then
753✔
347
            local warnedshy = false
251✔
348
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
753✔
349
               if token2.separator then -- soft hyphen support
251✔
UNCOV
350
                  local discretionary = SILE.types.node.discretionary({})
×
UNCOV
351
                  local hbox = SILE.typesetter:makeHbox({ self.settings:get("font.hyphenchar") })
×
UNCOV
352
                  discretionary.prebreak = { hbox }
×
UNCOV
353
                  table.insert(SILE.typesetter.state.nodes, discretionary)
×
UNCOV
354
                  if not warnedshy and self.settings:get("typesetter.softHyphenWarning") then
×
355
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
356
                  end
UNCOV
357
                  warnedshy = true
×
358
               else
359
                  self:setpar(token2.string)
251✔
360
               end
361
            end
362
         else
363
            if
UNCOV
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
UNCOV
368
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
×
UNCOV
369
            self:setpar(text)
×
370
         end
371
      end
372
   end
373
   SILE.traceStack:pop(pId)
310✔
374
end
375

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

386
function typesetter:endline ()
42✔
387
   self.class:endPar(self)
286✔
388
   self:leaveHmode()
286✔
389
   if self.settings:get("current.hangIndent") then
858✔
UNCOV
390
      self.settings:set("current.hangIndent", nil)
×
UNCOV
391
      self.settings:set("linebreak.hangIndent", nil)
×
392
   end
393
   if self.settings:get("current.hangAfter") then
858✔
UNCOV
394
      self.settings:set("current.hangAfter", nil)
×
UNCOV
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
42✔
402
   .. "[ "
×
403
   .. luautf8.char(0x00A0)
42✔
404
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
42✔
405
   .. "]+"
42✔
406
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
42✔
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)
42✔
411
function speakerChangeNode:shape ()
42✔
UNCOV
412
   local node = self._base.shape(self)
×
UNCOV
413
   local spc = node[2]
×
UNCOV
414
   if spc and spc.is_glue then
×
415
      -- Switch the variable space glue to a fixed kern
UNCOV
416
      node[2] = SILE.types.node.kern({ width = spc.width.length })
×
UNCOV
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
UNCOV
423
   return node
×
424
end
425

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

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

456
function typesetter:breakIntoLines (nodelist, breakWidth)
42✔
457
   self:shapeAllNodes(nodelist)
285✔
458
   local breakpoints = self.linebreaker:doBreak(nodelist, breakWidth)
285✔
459
   return self:breakpointsToLines(breakpoints)
285✔
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
UNCOV
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.
UNCOV
474
      for i = #nodelist, 1, -1 do
×
UNCOV
475
         local n = nodelist[i]
×
UNCOV
476
         if n.is_nnode then
×
UNCOV
477
            local items = n.nodes[#n.nodes].value.items
×
UNCOV
478
            lastShape = items[#items]
×
479
            break
480
         end
UNCOV
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.
UNCOV
486
            punctSpaceWidth = n.width:tonumber()
×
487
         end
UNCOV
488
         if n.is_glue then
×
UNCOV
489
            hasGlue = true
×
490
         end
491
      end
492
   end
UNCOV
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
UNCOV
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.
UNCOV
508
      for i = 1, #nodelist do
×
UNCOV
509
         local n = nodelist[i]
×
UNCOV
510
         if n.is_nnode then
×
UNCOV
511
            local items = n.nodes[1].value.items
×
UNCOV
512
            firstShape = items[1]
×
513
            break
514
         end
UNCOV
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.
UNCOV
520
            punctSpaceWidth = n.width:tonumber()
×
521
         end
UNCOV
522
         if n.is_glue then
×
UNCOV
523
            hasGlue = true
×
524
         end
525
      end
526
   end
UNCOV
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)
42✔
544
   local xOffset
UNCOV
545
   if not curShape or not precShape then
×
546
      xOffset = 0
×
UNCOV
547
   elseif precShape.height <= 0 then
×
548
      xOffset = 0
×
549
   else
UNCOV
550
      local d = precShape.glyphWidth + precShape.x_bearing
×
UNCOV
551
      local delta = d > precShape.width and d - precShape.width or 0
×
UNCOV
552
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
×
UNCOV
553
      if punctSpaceWidth and self.settings:get("typesetter.italicCorrection.punctuation") then
×
UNCOV
554
         xOffset = xOffset - punctSpaceWidth > 0 and (xOffset - punctSpaceWidth) or 0
×
555
      end
556
   end
UNCOV
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)
42✔
567
   local xOffset
UNCOV
568
   if not curShape or not precShape then
×
569
      xOffset = 0
×
UNCOV
570
   elseif precShape.depth <= 0 then
×
UNCOV
571
      xOffset = 0
×
572
   else
UNCOV
573
      local d = curShape.x_bearing
×
UNCOV
574
      local delta = d < 0 and -d or 0
×
UNCOV
575
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
×
UNCOV
576
      if punctSpaceWidth and self.settings:get("typesetter.italicCorrection.punctuation") then
×
577
         xOffset = punctSpaceWidth - xOffset > 0 and xOffset or 0
×
578
      end
579
   end
UNCOV
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.
UNCOV
588
   local ot = require("core.opentype-parser")
×
UNCOV
589
   local face = SILE.font.cache(nnode.options, SILE.shaper:_getFaceCallback())
×
UNCOV
590
   local font = ot.parseFont(face)
×
UNCOV
591
   return font.post.italicAngle ~= 0
×
592
end
593

594
function typesetter:shapeAllNodes (nodelist, inplace)
42✔
595
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
1,742✔
596
   local newNodelist = {}
871✔
597
   local prec
598
   local precShapedNodes
599
   local isItalicCorrectionEnabled = self.settings:get("typesetter.italicCorrection")
1,742✔
600
   for _, current in ipairs(nodelist) do
10,865✔
601
      if current.is_unshaped then
9,994✔
602
         local shapedNodes = current:shape()
266✔
603

604
         if isItalicCorrectionEnabled and prec then
266✔
605
            local itCorrOffset
606
            local isGlue
UNCOV
607
            if isItalicLike(prec) and not isItalicLike(current) then
×
UNCOV
608
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
UNCOV
609
               local curShape, curHasGlue, curPunctSpaceWidth = getFirstShape(shapedNodes)
×
UNCOV
610
               isGlue = precHasGlue or curHasGlue
×
UNCOV
611
               itCorrOffset = self:_fromItalicCorrection(precShape, curShape, curPunctSpaceWidth)
×
UNCOV
612
            elseif not isItalicLike(prec) and isItalicLike(current) then
×
UNCOV
613
               local precShape, precHasGlue, precPunctSpaceWidth = getLastShape(precShapedNodes)
×
UNCOV
614
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
UNCOV
615
               isGlue = precHasGlue or curHasGlue
×
UNCOV
616
               itCorrOffset = self:_toItalicCorrection(precShape, curShape, precPunctSpaceWidth)
×
617
            end
UNCOV
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.
UNCOV
624
               local makeItCorrNode = isGlue and SILE.types.node.glue or SILE.types.node.kern
×
UNCOV
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)
266✔
633

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

642
   if not inplace then
871✔
643
      return newNodelist
15✔
644
   end
645

646
   for i = 1, #newNodelist do
13,698✔
647
      nodelist[i] = newNodelist[i]
12,842✔
648
   end
649
   if #nodelist > #newNodelist then
856✔
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 ()
42✔
659
   local nodelist = self.state.nodes
872✔
660
   if #nodelist == 0 then
872✔
661
      return {}
586✔
662
   end
663
   for j = #nodelist, 1, -1 do
335✔
664
      if not nodelist[j].is_migrating then
336✔
665
         if nodelist[j].discardable then
335✔
666
            table.remove(nodelist, j)
98✔
667
         else
668
            break
669
         end
670
      end
671
   end
672
   while #nodelist > 0 and nodelist[1].is_penalty do
286✔
673
      table.remove(nodelist, 1)
×
674
   end
675
   if #nodelist == 0 then
286✔
676
      return {}
×
677
   end
678
   self:shapeAllNodes(nodelist)
286✔
679
   local parfillskip = self.settings:get("typesetter.parfillskip")
572✔
680
   parfillskip.discardable = false
286✔
681
   self:pushGlue(parfillskip)
286✔
682
   self:pushPenalty(-inf_bad)
286✔
683
   SU.debug("typesetter", function ()
572✔
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()
858✔
687
   local lines = self:breakIntoLines(nodelist, breakWidth)
286✔
688
   local vboxes = {}
286✔
689
   for index = 1, #lines do
681✔
690
      local line = lines[index]
395✔
691
      local migrating = {}
395✔
692
      -- Move any migrating material
693
      local nodes = {}
395✔
694
      for i = 1, #line.nodes do
7,652✔
695
         local node = line.nodes[i]
7,257✔
696
         if node.is_migrating then
7,257✔
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
7,255✔
702
         end
703
      end
704
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
395✔
705
      local pageBreakPenalty = 0
395✔
706
      if #lines > 1 and index == 1 then
395✔
707
         pageBreakPenalty = self.settings:get("typesetter.widowpenalty")
102✔
708
      elseif #lines > 1 and index == (#lines - 1) then
361✔
709
         pageBreakPenalty = self.settings:get("typesetter.orphanpenalty")
66✔
710
      elseif line.is_broken then
339✔
711
         pageBreakPenalty = self.settings:get("typesetter.brokenpenalty")
42✔
712
      end
713
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
790✔
714
      vboxes[#vboxes + 1] = vbox
395✔
715
      for i = 1, #migrating do
397✔
716
         vboxes[#vboxes + 1] = migrating[i]
2✔
717
      end
718
      self.state.previousVbox = vbox
395✔
719
      if pageBreakPenalty > 0 then
395✔
720
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
56✔
721
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
112✔
722
      end
723
   end
724
   return vboxes
286✔
725
end
726

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

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

738
function typesetter:runHooks (category, data)
42✔
739
   if not self.hooks[category] then
871✔
740
      return data
791✔
741
   end
742
   for _, func in ipairs(self.hooks[category]) do
180✔
743
      data = func(self, data)
200✔
744
   end
745
   return data
80✔
746
end
747

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

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

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

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

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

793
   local pastTop = false
76✔
794
   for _, node in ipairs(pageNodeList) do
1,475✔
795
      if not pastTop and not node.discardable and not node.explicit then
1,399✔
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
75✔
802
      end
803
      if pastTop then
1,399✔
804
         if not node.is_insertion then
1,280✔
805
            totalHeight:___add(node.height)
1,280✔
806
            totalHeight:___add(node.depth)
1,280✔
807
         end
808
         if node.is_vglue then
1,280✔
809
            table.insert(glues, node)
635✔
810
            gTotal:___add(node.height)
635✔
811
         end
812
      end
813
   end
814

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

819
   local adjustment = target - totalHeight
72✔
820
   if adjustment:tonumber() > 0 then
144✔
821
      if adjustment > gTotal.stretch then
56✔
822
         if
823
            (adjustment - gTotal.stretch):tonumber() > self.settings:get("typesetter.underfulltolerance"):tonumber()
36✔
824
         then
825
            SU.warn(
10✔
826
               "Underfull frame "
827
                  .. self.frame.id
5✔
828
                  .. ": "
5✔
829
                  .. adjustment
5✔
830
                  .. " stretchiness required to fill but only "
5✔
831
                  .. gTotal.stretch
5✔
832
                  .. " available"
5✔
833
            )
834
         end
835
         adjustment = gTotal.stretch
6✔
836
      end
837
      if gTotal.stretch:tonumber() > 0 then
112✔
838
         for i = 1, #glues do
643✔
839
            local g = glues[i]
588✔
840
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
2,940✔
841
         end
842
      end
843
   elseif adjustment:tonumber() < 0 then
32✔
844
      adjustment = 0 - adjustment
16✔
845
      if adjustment > gTotal.shrink then
16✔
846
         if (adjustment - gTotal.shrink):tonumber() > self.settings:get("typesetter.overfulltolerance"):tonumber() then
96✔
847
            SU.warn(
4✔
848
               "Overfull frame "
849
                  .. self.frame.id
2✔
850
                  .. ": "
2✔
851
                  .. adjustment
2✔
852
                  .. " shrinkability required to fit but only "
2✔
853
                  .. gTotal.shrink
2✔
854
                  .. " available"
2✔
855
            )
856
         end
857
         adjustment = gTotal.shrink
16✔
858
      end
859
      if gTotal.shrink:tonumber() > 0 then
32✔
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)
72✔
867
end
868

869
function typesetter:initNextFrame ()
42✔
870
   local oldframe = self.frame
36✔
871
   self.frame:leave(self)
36✔
872
   if #self.state.outputQueue == 0 then
36✔
873
      self.state.previousVbox = nil
24✔
874
   end
875
   if self.frame.next and self.state.lastPenalty > supereject_penalty then
36✔
876
      self:initFrame(SILE.getFrame(self.frame.next))
9✔
877
   elseif not self.frame:isMainContentFrame() then
66✔
878
      if #self.state.outputQueue > 0 then
14✔
879
         SU.warn("Overfull content for frame " .. self.frame.id)
1✔
880
         self:chuck()
1✔
881
      end
882
   else
883
      self:runHooks("pageend")
19✔
884
      self.class:endPage()
19✔
885
      self:initFrame(self.class:newPage())
38✔
886
   end
887

888
   if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
144✔
889
      self:pushBack()
4✔
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()
8✔
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
32✔
900
         local lead = self:leadingFor(self.state.outputQueue[1], nil)
9✔
901
         if lead then
9✔
902
            table.insert(self.state.outputQueue, 1, lead)
8✔
903
         end
904
      end
905
   end
906
   self:runHooks("newframe")
36✔
907
end
908

909
function typesetter:pushBack ()
42✔
910
   SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
4✔
911
   local oldqueue = self.state.outputQueue
4✔
912
   self.state.outputQueue = {}
4✔
913
   self.state.previousVbox = nil
4✔
914
   local lastMargins = self:getMargins()
4✔
915
   for _, vbox in ipairs(oldqueue) do
20✔
916
      SU.debug("pushback", "process box", vbox)
16✔
917
      if vbox.margins and vbox.margins ~= lastMargins then
16✔
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
16✔
925
         SU.debug("pushback", "explicit", vbox)
×
926
         self:endline()
×
927
         self:pushExplicitVglue(vbox)
×
928
      elseif vbox.is_insertion then
16✔
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
16✔
932
         SU.debug("pushback", "not vglue or penalty", vbox.type)
6✔
933
         local discardedFistInitLine = false
6✔
934
         if #self.state.nodes == 0 then
6✔
935
            -- Setup queue but avoid calling newPar
936
            self.state.nodes[#self.state.nodes + 1] = SILE.types.node.zerohbox()
4✔
937
         end
938
         for i, node in ipairs(vbox.nodes) do
236✔
939
            if node.is_glue and not node.discardable then
230✔
940
               self:pushHorizontal(node)
4✔
941
            elseif node.is_glue and node.value == "margin" then
228✔
942
               SU.debug("pushback", "discard", node.value, node)
24✔
943
            elseif node.is_discretionary then
216✔
UNCOV
944
               SU.debug("pushback", "re-mark discretionary as unused", node)
×
UNCOV
945
               node.used = false
×
UNCOV
946
               if i == 1 then
×
947
                  SU.debug("pushback", "keep first discretionary", node)
×
948
                  self:pushHorizontal(node)
×
949
               else
UNCOV
950
                  SU.debug("pushback", "discard all other discretionaries", node)
×
951
               end
952
            elseif node.is_zero then
216✔
953
               if discardedFistInitLine then
14✔
954
                  self:pushHorizontal(node)
8✔
955
               end
956
               discardedFistInitLine = true
14✔
957
            elseif node.is_penalty then
202✔
958
               if not discardedFistInitLine then
2✔
959
                  self:pushHorizontal(node)
×
960
               end
961
            else
962
               node.bidiDone = true
200✔
963
               self:pushHorizontal(node)
200✔
964
            end
965
         end
966
      else
967
         SU.debug("pushback", "discard", vbox.type)
10✔
968
      end
969
      lastMargins = vbox.margins
16✔
970
      -- self:debugState()
971
   end
972
   while
×
973
      self.state.nodes[#self.state.nodes]
6✔
974
      and (self.state.nodes[#self.state.nodes].is_penalty or self.state.nodes[#self.state.nodes].is_zero)
6✔
975
   do
976
      self.state.nodes[#self.state.nodes] = nil
2✔
977
   end
978
end
979

980
function typesetter:outputLinesToPage (lines)
42✔
981
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
101✔
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
101✔
988
   for _, line in ipairs(lines) do
1,573✔
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
1,472✔
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
98✔
997
      end
998
      if pastTop then
1,472✔
999
         line:outputYourself(self, line)
1,325✔
1000
      end
1001
   end
1002
   self.frame.state.totals.pastTop = pastTop
101✔
1003
end
1004

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

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

1030
function typesetter:leadingFor (vbox, previous)
42✔
1031
   -- Insert leading
1032
   SU.debug("typesetter", "   Considering leading between two lines:")
372✔
1033
   SU.debug("typesetter", "   1)", previous)
372✔
1034
   SU.debug("typesetter", "   2)", vbox)
372✔
1035
   if not previous then
372✔
1036
      return SILE.types.node.vglue()
94✔
1037
   end
1038
   local prevDepth = previous.depth
278✔
1039
   SU.debug("typesetter", "   Depth of previous line was", prevDepth)
278✔
1040
   local bls = self.settings:get("document.baselineskip")
556✔
1041
   local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
1,390✔
1042
   SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
278✔
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()
834✔
1046
   if depth > lead then
278✔
1047
      return SILE.types.node.vglue(SILE.types.length(depth.length, bls.height.stretch, bls.height.shrink))
390✔
1048
   else
1049
      return SILE.types.node.vglue(lead)
83✔
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)
42✔
1059
function linerEnterNode:_init (name, outputMethod)
42✔
1060
   SILE.types.node.hbox._init(self)
1✔
1061
   self.outputMethod = outputMethod
1✔
1062
   self.name = name
1✔
1063
   self.is_enter = true
1✔
1064
end
1065
function linerEnterNode:clone ()
42✔
UNCOV
1066
   return linerEnterNode(self.name, self.outputMethod)
×
1067
end
1068
function linerEnterNode:outputYourself ()
42✔
1069
   SU.error("A liner enter node " .. tostring(self) .. "'' made it to output", true)
×
1070
end
1071
function linerEnterNode:__tostring ()
42✔
1072
   return "+L[" .. self.name .. "]"
×
1073
end
1074
local linerLeaveNode = pl.class(SILE.types.node.hbox)
42✔
1075
function linerLeaveNode:_init (name)
42✔
1076
   SILE.types.node.hbox._init(self)
1✔
1077
   self.name = name
1✔
1078
   self.is_leave = true
1✔
1079
end
1080
function linerLeaveNode:clone ()
42✔
1081
   return linerLeaveNode(self.name)
×
1082
end
1083
function linerLeaveNode:outputYourself ()
42✔
1084
   SU.error("A liner leave node " .. tostring(self) .. "'' made it to output", true)
×
1085
end
1086
function linerLeaveNode:__tostring ()
42✔
1087
   return "-L[" .. self.name .. "]"
×
1088
end
1089

1090
local linerBox = pl.class(SILE.types.node.hbox)
42✔
1091
function linerBox:_init (name, outputMethod)
42✔
1092
   SILE.types.node.hbox._init(self)
1✔
1093
   self.width = SILE.types.length()
2✔
1094
   self.height = SILE.types.length()
2✔
1095
   self.depth = SILE.types.length()
2✔
1096
   self.name = name
1✔
1097
   self.inner = {}
1✔
1098
   self.outputYourself = outputMethod
1✔
1099
end
1100
function linerBox:append (node)
42✔
1101
   self.inner[#self.inner + 1] = node
1✔
1102
   if node.is_discretionary then
1✔
1103
      -- Discretionary nodes don't have a width of their own.
UNCOV
1104
      if node.used then
×
UNCOV
1105
         if node.is_prebreak then
×
UNCOV
1106
            self.width:___add(node:prebreakWidth())
×
1107
         else
UNCOV
1108
            self.width:___add(node:postbreakWidth())
×
1109
         end
1110
      else
UNCOV
1111
         self.width:___add(node:replacementWidth())
×
1112
      end
1113
   else
1114
      self.width:___add(node.width:absolute())
2✔
1115
   end
1116
   self.height = SU.max(self.height, node.height)
2✔
1117
   self.depth = SU.max(self.depth, node.depth)
2✔
1118
end
1119
function linerBox:count ()
42✔
1120
   return #self.inner
2✔
1121
end
1122
function linerBox:outputContent (tsetter, line)
42✔
1123
   for _, node in ipairs(self.inner) do
2✔
1124
      node.outputYourself(node, tsetter, line)
1✔
1125
   end
1126
end
1127
function linerBox:__tostring ()
42✔
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)
42✔
1145
   local m = self.state.liners
5,066✔
1146
   if #m > 0 then
5,066✔
UNCOV
1147
      for i = 1, #m do
×
UNCOV
1148
         local n = m[i]:clone()
×
UNCOV
1149
         slice[#slice + 1] = n
×
UNCOV
1150
         SU.debug("typesetter.liner", "Reopening liner", n)
×
1151
      end
UNCOV
1152
      return true
×
1153
   end
1154
   return false
5,066✔
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)
42✔
1162
   local outSlice = {}
1✔
1163
   local migratingList = {}
1✔
1164
   local lboxStack = {}
1✔
1165
   for i = 1, #slice do
33✔
1166
      local node = slice[i]
32✔
1167
      if node.is_enter then
32✔
1168
         SU.debug("typesetter.liner", "Start reboxing", node)
1✔
1169
         local n = linerBox(node.name, node.outputMethod)
1✔
1170
         lboxStack[#lboxStack + 1] = n
1✔
1171
      elseif node.is_leave then
31✔
1172
         if #lboxStack == 0 then
1✔
1173
            SU.error("Multiliner box stacking mismatch" .. node)
×
1174
         elseif #lboxStack == 1 then
1✔
1175
            SU.debug("typesetter.liner", "End reboxing", node, "(toplevel) =", lboxStack[1]:count(), "nodes")
2✔
1176
            if lboxStack[1]:count() > 0 then
2✔
1177
               outSlice[#outSlice + 1] = lboxStack[1]
1✔
1178
            end
1179
         else
UNCOV
1180
            SU.debug("typesetter.liner", "End reboxing", node, "(sublevel) =", lboxStack[#lboxStack]:count(), "nodes")
×
UNCOV
1181
            if lboxStack[#lboxStack]:count() > 0 then
×
UNCOV
1182
               local hbox = lboxStack[#lboxStack - 1]
×
UNCOV
1183
               hbox:append(lboxStack[#lboxStack])
×
1184
            end
1185
         end
1186
         lboxStack[#lboxStack] = nil
1✔
1187
         pl.tablex.insertvalues(outSlice, migratingList)
1✔
1188
         migratingList = {}
1✔
1189
      else
1190
         if #lboxStack > 0 then
30✔
1191
            if not node.is_migrating then
1✔
1192
               local lbox = lboxStack[#lboxStack]
1✔
1193
               lbox:append(node)
2✔
1194
            else
1195
               migratingList[#migratingList + 1] = node
×
1196
            end
1197
         else
1198
            outSlice[#outSlice + 1] = node
29✔
1199
         end
1200
      end
1201
   end
1202
   return outSlice -- new reboxed slice
1✔
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)
42✔
1209
   local entered = false
5,682✔
1210
   if node.is_enter then
5,682✔
1211
      SU.debug("typesetter.liner", "Enter liner", node)
1✔
1212
      self.state.liners[#self.state.liners + 1] = node
1✔
1213
      entered = true
1✔
1214
   elseif node.is_leave then
5,681✔
1215
      SU.debug("typesetter.liner", "Leave liner", node)
1✔
1216
      if #self.state.liners == 0 then
1✔
1217
         SU.error("Multiliner stack mismatch" .. node)
×
1218
      elseif self.state.liners[#self.state.liners].name == node.name then
1✔
1219
         self.state.liners[#self.state.liners].link = node -- for consistency check
1✔
1220
         self.state.liners[#self.state.liners] = nil
1✔
1221
      else
1222
         SU.error("Multiliner stack inconsistency" .. self.state.liners[#self.state.liners] .. "vs. " .. node)
×
1223
      end
1224
   end
1225
   return entered
5,682✔
1226
end
1227

1228
function typesetter:_repeatLeaveLiners (slice, insertIndex)
42✔
1229
   for _, v in ipairs(self.state.liners) do
389✔
UNCOV
1230
      if not v.link then
×
UNCOV
1231
         local n = linerLeaveNode(v.name)
×
UNCOV
1232
         SU.debug("typesetter.liner", "Closing liner", n)
×
UNCOV
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)
42✔
1242
   local LTR = self.frame:writingDirection() == "LTR"
790✔
1243
   local rskip = margins[LTR and "rskip" or "lskip"]
395✔
1244
   if not rskip then
395✔
1245
      rskip = SILE.types.node.glue(0)
×
1246
   end
1247
   if hangRight and hangRight > 0 then
395✔
1248
      rskip = SILE.types.node.glue({ width = rskip.width:tonumber() + hangRight })
30✔
1249
   end
1250
   rskip.value = "margin"
395✔
1251
   -- while slice[#slice].discardable do table.remove(slice, #slice) end
1252
   table.insert(slice, rskip)
395✔
1253
   table.insert(slice, SILE.types.node.zerohbox())
790✔
1254
   local lskip = margins[LTR and "lskip" or "rskip"]
395✔
1255
   if not lskip then
395✔
1256
      lskip = SILE.types.node.glue(0)
×
1257
   end
1258
   if hangLeft and hangLeft > 0 then
395✔
1259
      lskip = SILE.types.node.glue({ width = lskip.width:tonumber() + hangLeft })
24✔
1260
   end
1261
   lskip.value = "margin"
395✔
1262
   while slice[1].discardable do
395✔
UNCOV
1263
      table.remove(slice, 1)
×
1264
   end
1265
   table.insert(slice, 1, lskip)
395✔
1266
   table.insert(slice, 1, SILE.types.node.zerohbox())
790✔
1267
end
1268

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

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

1282
         for j = linestart, point.position do
6,080✔
1283
            local currentNode = nodes[j]
5,682✔
1284
            if
1285
               not currentNode.discardable
5,682✔
1286
               and not (currentNode.is_glue and not currentNode.explicit)
3,697✔
1287
               and not currentNode.is_zero
3,409✔
1288
            then
1289
               -- actual visible content starts here
1290
               lastContentNodeIndex = #slice + 1
3,111✔
1291
            end
1292
            if not seenLiner and lastContentNodeIndex then
5,682✔
1293
               -- Any stacked liner (unclosed from a previous line) is reopened on
1294
               -- the current line.
1295
               seenLiner = self:_repeatEnterLiners(slice)
10,132✔
1296
               lastContentNodeIndex = #slice + 1
5,066✔
1297
            end
1298
            if currentNode.is_discretionary and currentNode.used then
5,682✔
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()
54✔
1302
            end
1303
            slice[#slice + 1] = currentNode
5,682✔
1304
            if currentNode then
5,682✔
1305
               if not currentNode.discardable then
5,682✔
1306
                  seenNonDiscardable = true
3,697✔
1307
               end
1308
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
11,364✔
1309
            end
1310
         end
1311
         if not seenNonDiscardable then
398✔
1312
            -- Slip lines containing only discardable nodes (e.g. glues).
1313
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
3✔
1314
            linestart = point.position + 1
3✔
1315
         else
1316
            local is_broken = false
395✔
1317
            if slice[#slice].is_discretionary then
395✔
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
27✔
1321
               -- And mark it as used as prebreak for now.
1322
               slice[#slice]:markAsPrebreak()
27✔
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
27✔
1326
            else
1327
               linestart = point.position + 1
368✔
1328
            end
1329

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

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

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

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

1347
            local thisLine = { ratio = ratio, nodes = slice, is_broken = is_broken }
395✔
1348
            lines[#lines + 1] = thisLine
395✔
1349
         end
1350
      end
1351
   end
1352
   if linestart < #nodes then
286✔
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
286✔
1358
end
1359

1360
function typesetter:computeLineRatio (breakwidth, slice)
42✔
1361
   local naturalTotals = SILE.types.length()
395✔
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
395✔
1366
   while npos > 1 do
1,263✔
1367
      if slice[npos].is_glue or slice[npos].is_zero then
1,263✔
1368
         if slice[npos].value == "margin" then
868✔
1369
            naturalTotals:___add(slice[npos].width)
395✔
1370
         end
1371
      else
1372
         break
1373
      end
1374
      npos = npos - 1
868✔
1375
   end
1376

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

1383
   -- Until end of actual content
1384
   for i = 1, npos do
6,786✔
1385
      local node = slice[i]
6,391✔
1386
      if node.is_box then
6,391✔
1387
         skipping = false
3,175✔
1388
         if node.parent and not node.parent.hyphenated then
3,175✔
1389
            if not seenNodes[node.parent] then
739✔
1390
               naturalTotals:___add(node.parent:lineContribution())
588✔
1391
            end
1392
            seenNodes[node.parent] = true
739✔
1393
         else
1394
            naturalTotals:___add(node:lineContribution())
4,872✔
1395
         end
1396
      elseif node.is_penalty and node.penalty == -inf_bad then
3,216✔
1397
         skipping = false
290✔
1398
      elseif node.is_discretionary then
2,926✔
1399
         skipping = false
529✔
1400
         local seen = node.parent and seenNodes[node.parent]
529✔
1401
         if not seen then
529✔
1402
            if node.used then
84✔
1403
               if node.is_prebreak then
54✔
1404
                  naturalTotals:___add(node:prebreakWidth())
54✔
1405
                  node.height = node:prebreakHeight()
54✔
1406
               else
1407
                  naturalTotals:___add(node:postbreakWidth())
54✔
1408
                  node.height = node:postbreakHeight()
54✔
1409
               end
1410
            else
1411
               naturalTotals:___add(node:replacementWidth():absolute())
90✔
1412
               node.height = node:replacementHeight():absolute()
90✔
1413
            end
1414
         end
1415
      elseif not skipping then
2,397✔
1416
         naturalTotals:___add(node.width)
2,397✔
1417
      end
1418
   end
1419

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

1426
function typesetter:chuck () -- emergency shipout everything
42✔
1427
   self:leaveHmode(true)
36✔
1428
   if #self.state.outputQueue > 0 then
36✔
1429
      SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
23✔
1430
      self:outputLinesToPage(self.state.outputQueue)
23✔
1431
      self.state.outputQueue = {}
23✔
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))
28✔
1445
   end
1446
   if atypesetter.frame:writingDirection() == "RTL" then
28✔
1447
      advance()
×
1448
      return function () end
×
1449
   else
1450
      return advance
14✔
1451
   end
1452
end
1453
function typesetter:makeHbox (content)
42✔
1454
   local recentContribution = {}
15✔
1455
   local migratingNodes = {}
15✔
1456

1457
   self:pushState()
15✔
1458
   self.state.hmodeOnly = true
15✔
1459
   SILE.process(content)
15✔
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)
15✔
1464

1465
   -- Then we can process and measure the nodes.
1466
   local l = SILE.types.length()
15✔
1467
   local h, d = SILE.types.length(), SILE.types.length()
30✔
1468
   for i = 1, #nodes do
22✔
1469
      local node = nodes[i]
7✔
1470
      if node.is_migrating then
7✔
1471
         migratingNodes[#migratingNodes + 1] = node
×
1472
      elseif node.is_discretionary then
7✔
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)...
UNCOV
1478
         recentContribution[#recentContribution + 1] = node
×
UNCOV
1479
         l = l + node:replacementWidth():absolute()
×
1480
         -- The replacement content may have ascenders and descenders...
UNCOV
1481
         local hdisc = node:replacementHeight():absolute()
×
UNCOV
1482
         local ddisc = node:replacementDepth():absolute()
×
UNCOV
1483
         h = hdisc > h and hdisc or h
×
UNCOV
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
7✔
1491
         l = l + node:lineContribution():absolute()
21✔
1492
         h = node.height > h and node.height or h
11✔
1493
         d = node.depth > d and node.depth or d
12✔
1494
      end
1495
   end
1496
   self:popState()
15✔
1497

1498
   local hbox = SILE.types.node.hbox({
30✔
1499
      height = h,
15✔
1500
      width = l,
15✔
1501
      depth = d,
15✔
1502
      value = recentContribution,
15✔
1503
      outputYourself = function (box, atypesetter, line)
1504
         local _post = _rtl_pre_post(box, atypesetter, line)
14✔
1505
         local ox = atypesetter.frame.state.cursorX
14✔
1506
         local oy = atypesetter.frame.state.cursorY
14✔
1507
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
14✔
1508
         SU.debug("hboxes", function ()
28✔
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
21✔
1515
            node:outputYourself(atypesetter, line)
7✔
1516
         end
1517
         atypesetter.frame.state.cursorX = ox
14✔
1518
         atypesetter.frame.state.cursorY = oy
14✔
1519
         _post()
14✔
1520
      end,
1521
   })
1522
   return hbox, migratingNodes
15✔
1523
end
1524

1525
function typesetter:pushHlist (hlist)
42✔
1526
   for _, h in ipairs(hlist) do
3✔
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)
42✔
1546
   if self.state.hmodeOnly then
1✔
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
1✔
1555
      local uname = name .. "_" .. self.state.linerCount
1✔
1556
      SU.debug("typesetter.liner", "Applying liner in standard mode")
1✔
1557
      local enter = linerEnterNode(uname, outputYourself)
1✔
1558
      local leave = linerLeaveNode(uname)
1✔
1559
      self:pushHorizontal(enter)
1✔
1560
      SILE.process(content)
1✔
1561
      self:pushHorizontal(leave)
1✔
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({}))
4✔
1573
   local iwspcmin = (iwspc.length - iwspc.shrink):tonumber()
4✔
1574

1575
   local string = ""
2✔
1576
   for i = 1, #nodes do
4✔
1577
      local node = nodes[i]
2✔
1578
      if node.is_nnode or node.is_unshaped then
4✔
1579
         string = string .. node:toText()
4✔
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+", " ")
4✔
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)
42✔
1611
   self:pushState()
2✔
1612
   self.state.hmodeOnly = true
2✔
1613
   SILE.process(content)
2✔
1614
   local text = _nodesToText(self.state.nodes)
2✔
1615
   self:popState()
2✔
1616
   return text
2✔
1617
end
1618

1619
return typesetter
42✔
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