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

sile-typesetter / sile / 14860011647

06 May 2025 12:44PM UTC coverage: 67.057% (+32.5%) from 34.559%
14860011647

push

github

alerque
chore(typesetters): Fixup access to class from typesetter functions

7 of 7 new or added lines in 2 files covered. (100.0%)

1344 existing lines in 103 files now uncovered.

14880 of 22190 relevant lines covered (67.06%)

11549.16 hits per line

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

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

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

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

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

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

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

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

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

54
typesetter._language_cache = {}
153✔
55

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

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

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

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

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

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

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

196
function typesetter:initFrame (frame)
153✔
197
   if frame then
460✔
198
      self.frame = frame
438✔
199
      self.frame:init(self)
438✔
200
   end
201
end
202

203
function typesetter:getMargins ()
153✔
204
   if not self then
3,967✔
UNCOV
205
      SU.deprecated("typesetter.getMargins()", "typesetter:getMargins()", "0.16.0", "0.17.0")
×
UNCOV
206
      return typesetter:getMargins()
×
207
   end
208
   return _margins(self.settings:get("document.lskip"), self.settings:get("document.rskip"))
19,835✔
209
end
210

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

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

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

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

236
function typesetter:vmode ()
153✔
237
   return #self.state.nodes == 0
1,757✔
238
end
239

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

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

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

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

271
function typesetter:pushUnshaped (spec)
153✔
272
   local node = SU.type(spec) == "unshaped" and spec or SILE.types.node.unshaped(spec)
2,536✔
273
   return self:pushHorizontal(node)
1,268✔
274
end
275

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

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

288
function typesetter:pushPenalty (spec)
153✔
289
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
1,702✔
290
   return self:pushHorizontal(node)
851✔
291
end
292

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

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

303
function typesetter:pushVglue (spec)
153✔
304
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
1,284✔
305
   return self:pushVertical(node)
642✔
306
end
307

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

315
function typesetter:pushVpenalty (spec)
153✔
316
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
1,030✔
317
   return self:pushVertical(node)
515✔
318
end
319

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

332
   local seenParaContent = true
1,400✔
333
   for token in SU.gtoke(text, parsepattern) do
4,542✔
334
      if token.separator then
1,742✔
335
         if obeylines and not seenParaContent then
477✔
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
471✔
342
         end
343
         self:endline()
954✔
344
      else
345
         seenParaContent = true
1,265✔
346
         if self.settings:get("typesetter.softHyphen") then
3,795✔
347
            local warnedshy = false
1,264✔
348
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
3,822✔
349
               if token2.separator then -- soft hyphen support
1,294✔
350
                  local discretionary = SILE.types.node.discretionary({})
15✔
351
                  local hbox = SILE.typesetter:makeHbox({ self.settings:get("font.hyphenchar") })
45✔
352
                  discretionary.prebreak = { hbox }
15✔
353
                  table.insert(SILE.typesetter.state.nodes, discretionary)
15✔
354
                  if not warnedshy and self.settings:get("typesetter.softHyphenWarning") then
17✔
UNCOV
355
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
356
                  end
357
                  warnedshy = true
15✔
358
               else
359
                  self:setpar(token2.string)
1,279✔
360
               end
361
            end
362
         else
363
            if
364
               self.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD))
3✔
365
            then
UNCOV
366
               SU.warn("Soft hyphen encountered and ignored")
×
367
            end
368
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
1✔
369
            self:setpar(text)
1✔
370
         end
371
      end
372
   end
373
   SILE.traceStack:pop(pId)
1,400✔
374
end
375

376
function typesetter:initline ()
153✔
377
   if self.state.hmodeOnly then
5,814✔
378
      return
365✔
379
   end -- https://github.com/sile-typesetter/sile/issues/1718
380
   if #self.state.nodes == 0 then
5,449✔
381
      table.insert(self.state.nodes, SILE.types.node.zerohbox())
1,592✔
382
      self.class:newPar(self)
796✔
383
   end
384
end
385

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

399
-- Just compute once, to avoid unicode characters in source code.
UNCOV
400
local speakerChangePattern = "^"
×
401
   .. luautf8.char(0x2014) -- emdash
153✔
UNCOV
402
   .. "[ "
×
403
   .. luautf8.char(0x00A0)
153✔
404
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
153✔
405
   .. "]+"
153✔
406
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
153✔
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)
153✔
411
function speakerChangeNode:shape ()
153✔
412
   local node = self._base.shape(self)
3✔
413
   local spc = node[2]
3✔
414
   if spc and spc.is_glue then
3✔
415
      -- Switch the variable space glue to a fixed kern
416
      node[2] = SILE.types.node.kern({ width = spc.width.length })
6✔
417
      node[2].parent = self.parent
3✔
418
   else
419
      -- Should not occur:
420
      -- How could it possibly be shaped differently?
UNCOV
421
      SU.warn("Speaker change logic met an unexpected case, this might be a bug")
×
422
   end
423
   return node
3✔
424
end
425

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

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

456
function typesetter:breakIntoLines (nodelist, breakWidth)
153✔
457
   self:shapeAllNodes(nodelist)
800✔
458
   local breakpoints = self.linebreaker:doBreak(nodelist, breakWidth)
800✔
459
   return self:breakpointsToLines(breakpoints)
800✔
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
52✔
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
64✔
475
         local n = nodelist[i]
64✔
476
         if n.is_nnode then
64✔
477
            local items = n.nodes[#n.nodes].value.items
52✔
478
            lastShape = items[#items]
52✔
479
            break
52✔
480
         end
481
         if n.is_kern and n.subtype == "punctspace" then
12✔
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()
4✔
487
         end
488
         if n.is_glue then
12✔
489
            hasGlue = true
10✔
490
         end
491
      end
492
   end
493
   return lastShape, hasGlue, punctSpaceWidth
52✔
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
52✔
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
60✔
509
         local n = nodelist[i]
60✔
510
         if n.is_nnode then
60✔
511
            local items = n.nodes[1].value.items
52✔
512
            firstShape = items[1]
52✔
513
            break
52✔
514
         end
515
         if n.is_kern and n.subtype == "punctspace" then
8✔
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()
8✔
521
         end
522
         if n.is_glue then
8✔
523
            hasGlue = true
4✔
524
         end
525
      end
526
   end
527
   return firstShape, hasGlue, punctSpaceWidth
52✔
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)
153✔
544
   local xOffset
545
   if not curShape or not precShape then
26✔
546
      xOffset = 0
×
547
   elseif precShape.height <= 0 then
26✔
UNCOV
548
      xOffset = 0
×
549
   else
550
      local d = precShape.glyphWidth + precShape.x_bearing
26✔
551
      local delta = d > precShape.width and d - precShape.width or 0
26✔
552
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
26✔
553
      if punctSpaceWidth and self.settings:get("typesetter.italicCorrection.punctuation") then
34✔
554
         xOffset = xOffset - punctSpaceWidth > 0 and (xOffset - punctSpaceWidth) or 0
4✔
555
      end
556
   end
557
   return xOffset
26✔
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)
153✔
567
   local xOffset
568
   if not curShape or not precShape then
26✔
UNCOV
569
      xOffset = 0
×
570
   elseif precShape.depth <= 0 then
26✔
571
      xOffset = 0
6✔
572
   else
573
      local d = curShape.x_bearing
20✔
574
      local delta = d < 0 and -d or 0
20✔
575
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
20✔
576
      if punctSpaceWidth and self.settings:get("typesetter.italicCorrection.punctuation") then
20✔
577
         xOffset = punctSpaceWidth - xOffset > 0 and xOffset or 0
×
578
      end
579
   end
580
   return xOffset
26✔
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")
130✔
589
   local face = SILE.font.cache(nnode.options, SILE.shaper:_getFaceCallback())
260✔
590
   local font = ot.parseFont(face)
130✔
591
   return font.post.italicAngle ~= 0
130✔
592
end
593

594
function typesetter:shapeAllNodes (nodelist, inplace)
153✔
595
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
5,138✔
596
   local newNodelist = {}
2,569✔
597
   local prec
598
   local precShapedNodes
599
   local isItalicCorrectionEnabled = self.settings:get("typesetter.italicCorrection")
5,138✔
600
   for _, current in ipairs(nodelist) do
40,525✔
601
      if current.is_unshaped then
37,956✔
602
         local shapedNodes = current:shape()
1,292✔
603

604
         if isItalicCorrectionEnabled and prec then
1,292✔
605
            local itCorrOffset
606
            local isGlue
607
            if isItalicLike(prec) and not isItalicLike(current) then
130✔
608
               local precShape, precHasGlue = getLastShape(precShapedNodes)
26✔
609
               local curShape, curHasGlue, curPunctSpaceWidth = getFirstShape(shapedNodes)
26✔
610
               isGlue = precHasGlue or curHasGlue
26✔
611
               itCorrOffset = self:_fromItalicCorrection(precShape, curShape, curPunctSpaceWidth)
52✔
612
            elseif not isItalicLike(prec) and isItalicLike(current) then
78✔
613
               local precShape, precHasGlue, precPunctSpaceWidth = getLastShape(precShapedNodes)
26✔
614
               local curShape, curHasGlue = getFirstShape(shapedNodes)
26✔
615
               isGlue = precHasGlue or curHasGlue
26✔
616
               itCorrOffset = self:_toItalicCorrection(precShape, curShape, precPunctSpaceWidth)
52✔
617
            end
618
            if itCorrOffset and itCorrOffset ~= 0 then
52✔
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
43✔
625
               newNodelist[#newNodelist + 1] = makeItCorrNode({
86✔
626
                  width = SILE.types.length(itCorrOffset),
86✔
627
                  subtype = "itcorr",
628
               })
43✔
629
            end
630
         end
631

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

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

642
   if not inplace then
2,569✔
643
      return newNodelist
176✔
644
   end
645

646
   for i = 1, #newNodelist do
52,207✔
647
      nodelist[i] = newNodelist[i]
49,814✔
648
   end
649
   if #nodelist > #newNodelist then
2,393✔
UNCOV
650
      for i = #newNodelist + 1, #nodelist do
×
UNCOV
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 ()
153✔
659
   local nodelist = self.state.nodes
2,592✔
660
   if #nodelist == 0 then
2,592✔
661
      return {}
1,790✔
662
   end
663
   for j = #nodelist, 1, -1 do
964✔
664
      if not nodelist[j].is_migrating then
969✔
665
         if nodelist[j].discardable then
934✔
666
            table.remove(nodelist, j)
264✔
667
         else
668
            break
669
         end
670
      end
671
   end
672
   while #nodelist > 0 and nodelist[1].is_penalty do
802✔
UNCOV
673
      table.remove(nodelist, 1)
×
674
   end
675
   if #nodelist == 0 then
802✔
UNCOV
676
      return {}
×
677
   end
678
   self:shapeAllNodes(nodelist)
802✔
679
   local parfillskip = self.settings:get("typesetter.parfillskip")
1,604✔
680
   parfillskip.discardable = false
802✔
681
   self:pushGlue(parfillskip)
802✔
682
   self:pushPenalty(-inf_bad)
802✔
683
   SU.debug("typesetter", function ()
1,604✔
UNCOV
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()
2,406✔
687
   local lines = self:breakIntoLines(nodelist, breakWidth)
802✔
688
   local vboxes = {}
802✔
689
   for index = 1, #lines do
2,168✔
690
      local line = lines[index]
1,366✔
691
      local migrating = {}
1,366✔
692
      -- Move any migrating material
693
      local nodes = {}
1,366✔
694
      for i = 1, #line.nodes do
30,882✔
695
         local node = line.nodes[i]
29,516✔
696
         if node.is_migrating then
29,516✔
697
            for j = 1, #node.material do
164✔
698
               migrating[#migrating + 1] = node.material[j]
82✔
699
            end
700
         else
701
            nodes[#nodes + 1] = node
29,434✔
702
         end
703
      end
704
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
1,366✔
705
      local pageBreakPenalty = 0
1,366✔
706
      if #lines > 1 and index == 1 then
1,366✔
707
         pageBreakPenalty = self.settings:get("typesetter.widowpenalty")
483✔
708
      elseif #lines > 1 and index == (#lines - 1) then
1,205✔
709
         pageBreakPenalty = self.settings:get("typesetter.orphanpenalty")
315✔
710
      elseif line.is_broken then
1,100✔
711
         pageBreakPenalty = self.settings:get("typesetter.brokenpenalty")
321✔
712
      end
713
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
2,732✔
714
      vboxes[#vboxes + 1] = vbox
1,366✔
715
      for i = 1, #migrating do
1,448✔
716
         vboxes[#vboxes + 1] = migrating[i]
82✔
717
      end
718
      self.state.previousVbox = vbox
1,366✔
719
      if pageBreakPenalty > 0 then
1,366✔
720
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
279✔
721
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
558✔
722
      end
723
   end
724
   return vboxes
802✔
725
end
726

727
function typesetter:getTargetLength ()
153✔
728
   return self.frame:getTargetLength()
2,533✔
729
end
730

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

738
function typesetter:runHooks (category, data)
153✔
739
   if not self.hooks[category] then
2,591✔
740
      return data
2,325✔
741
   end
742
   for _, func in ipairs(self.hooks[category]) do
608✔
743
      data = func(self, data)
684✔
744
   end
745
   return data
266✔
746
end
747

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

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

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

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

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

793
   local pastTop = false
227✔
794
   for _, node in ipairs(pageNodeList) do
4,292✔
795
      if not pastTop and not node.discardable and not node.explicit then
4,065✔
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
224✔
802
      end
803
      if pastTop then
4,065✔
804
         if not node.is_insertion then
3,755✔
805
            totalHeight:___add(node.height)
3,721✔
806
            totalHeight:___add(node.depth)
3,721✔
807
         end
808
         if node.is_vglue then
3,755✔
809
            table.insert(glues, node)
1,949✔
810
            gTotal:___add(node.height)
1,949✔
811
         end
812
      end
813
   end
814

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

819
   local adjustment = target - totalHeight
220✔
820
   if adjustment:tonumber() > 0 then
440✔
821
      if adjustment > gTotal.stretch then
197✔
822
         if
823
            (adjustment - gTotal.stretch):tonumber() > self.settings:get("typesetter.underfulltolerance"):tonumber()
180✔
824
         then
825
            SU.warn(
42✔
826
               "Underfull frame "
827
                  .. self.frame.id
21✔
828
                  .. ": "
21✔
829
                  .. adjustment
21✔
830
                  .. " stretchiness required to fill but only "
21✔
831
                  .. gTotal.stretch
21✔
832
                  .. " available"
21✔
833
            )
834
         end
835
         adjustment = gTotal.stretch
30✔
836
      end
837
      if gTotal.stretch:tonumber() > 0 then
394✔
838
         for i = 1, #glues do
2,031✔
839
            local g = glues[i]
1,850✔
840
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
9,250✔
841
         end
842
      end
843
   elseif adjustment:tonumber() < 0 then
46✔
844
      adjustment = 0 - adjustment
23✔
845
      if adjustment > gTotal.shrink then
23✔
846
         if (adjustment - gTotal.shrink):tonumber() > self.settings:get("typesetter.overfulltolerance"):tonumber() then
138✔
847
            SU.warn(
8✔
848
               "Overfull frame "
849
                  .. self.frame.id
4✔
850
                  .. ": "
4✔
851
                  .. adjustment
4✔
852
                  .. " shrinkability required to fit but only "
4✔
853
                  .. gTotal.shrink
4✔
854
                  .. " available"
4✔
855
            )
856
         end
857
         adjustment = gTotal.shrink
23✔
858
      end
859
      if gTotal.shrink:tonumber() > 0 then
46✔
UNCOV
860
         for i = 1, #glues do
×
UNCOV
861
            local g = glues[i]
×
UNCOV
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)
220✔
867
end
868

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

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

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

980
function typesetter:outputLinesToPage (lines)
153✔
981
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
312✔
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
312✔
988
   for _, line in ipairs(lines) do
4,615✔
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
4,303✔
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
298✔
997
      end
998
      if pastTop then
4,303✔
999
         line:outputYourself(self, line)
3,905✔
1000
      end
1001
   end
1002
   self.frame.state.totals.pastTop = pastTop
312✔
1003
end
1004

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

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

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

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

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

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

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

1282
         for j = linestart, point.position do
25,514✔
1283
            local currentNode = nodes[j]
24,139✔
1284
            if
1285
               not currentNode.discardable
24,139✔
1286
               and not (currentNode.is_glue and not currentNode.explicit)
16,633✔
1287
               and not currentNode.is_zero
15,827✔
1288
            then
1289
               -- actual visible content starts here
1290
               lastContentNodeIndex = #slice + 1
15,003✔
1291
            end
1292
            if not seenLiner and lastContentNodeIndex then
24,139✔
1293
               -- Any stacked liner (unclosed from a previous line) is reopened on
1294
               -- the current line.
1295
               seenLiner = self:_repeatEnterLiners(slice)
44,770✔
1296
               lastContentNodeIndex = #slice + 1
22,385✔
1297
            end
1298
            if currentNode.is_discretionary and currentNode.used then
24,139✔
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()
376✔
1302
            end
1303
            slice[#slice + 1] = currentNode
24,139✔
1304
            if currentNode then
24,139✔
1305
               if not currentNode.discardable then
24,139✔
1306
                  seenNonDiscardable = true
16,633✔
1307
               end
1308
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
48,278✔
1309
            end
1310
         end
1311
         if not seenNonDiscardable then
1,375✔
1312
            -- Slip lines containing only discardable nodes (e.g. glues).
1313
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
9✔
1314
            linestart = point.position + 1
9✔
1315
         else
1316
            local is_broken = false
1,366✔
1317
            if slice[#slice].is_discretionary then
1,366✔
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
188✔
1321
               -- And mark it as used as prebreak for now.
1322
               slice[#slice]:markAsPrebreak()
188✔
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
188✔
1326
            else
1327
               linestart = point.position + 1
1,178✔
1328
            end
1329

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

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

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

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

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

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

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

1383
   -- Until end of actual content
1384
   for i = 1, npos do
27,866✔
1385
      local node = slice[i]
26,500✔
1386
      if node.is_box then
26,500✔
1387
         skipping = false
13,453✔
1388
         if node.parent and not node.parent.hyphenated then
13,453✔
1389
            if not seenNodes[node.parent] then
4,489✔
1390
               naturalTotals:___add(node.parent:lineContribution())
3,394✔
1391
            end
1392
            seenNodes[node.parent] = true
4,489✔
1393
         else
1394
            naturalTotals:___add(node:lineContribution())
17,928✔
1395
         end
1396
      elseif node.is_penalty and node.penalty == -inf_bad then
13,047✔
1397
         skipping = false
820✔
1398
      elseif node.is_discretionary then
12,227✔
1399
         skipping = false
3,359✔
1400
         local seen = node.parent and seenNodes[node.parent]
3,359✔
1401
         if not seen then
3,359✔
1402
            if node.used then
660✔
1403
               if node.is_prebreak then
376✔
1404
                  naturalTotals:___add(node:prebreakWidth())
376✔
1405
                  node.height = node:prebreakHeight()
376✔
1406
               else
1407
                  naturalTotals:___add(node:postbreakWidth())
376✔
1408
                  node.height = node:postbreakHeight()
376✔
1409
               end
1410
            else
1411
               naturalTotals:___add(node:replacementWidth():absolute())
852✔
1412
               node.height = node:replacementHeight():absolute()
852✔
1413
            end
1414
         end
1415
      elseif not skipping then
8,868✔
1416
         naturalTotals:___add(node.width)
8,868✔
1417
      end
1418
   end
1419

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

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

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

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

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

1525
function typesetter:pushHlist (hlist)
153✔
1526
   for _, h in ipairs(hlist) do
13✔
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)
153✔
1546
   if self.state.hmodeOnly then
7✔
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)
×
UNCOV
1551
      self:pushHorizontal(lbox)
×
UNCOV
1552
      self:pushHlist(hlist)
×
1553
   else
1554
      self.state.linerCount = (self.state.linerCount or 0) + 1
7✔
1555
      local uname = name .. "_" .. self.state.linerCount
7✔
1556
      SU.debug("typesetter.liner", "Applying liner in standard mode")
7✔
1557
      local enter = linerEnterNode(uname, outputYourself)
7✔
1558
      local leave = linerLeaveNode(uname)
7✔
1559
      self:pushHorizontal(enter)
7✔
1560
      SILE.process(content)
7✔
1561
      self:pushHorizontal(leave)
7✔
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✔
UNCOV
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.
UNCOV
1588
         if node.width:tonumber() > iwspcmin * 0.5 then
×
UNCOV
1589
            string = string .. " "
×
1590
         end
UNCOV
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.
UNCOV
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)
153✔
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
153✔
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