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

sile-typesetter / sile / 14502192980

16 Apr 2025 08:26PM UTC coverage: 57.267% (-5.4%) from 62.627%
14502192980

push

github

alerque
chore(packages): Remove unused package interdependency, url doesn't need verbatim

Reported-by: Omikhleia <didier.willis@gmail.com>

12352 of 21569 relevant lines covered (57.27%)

871.56 hits per line

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

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

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

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

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

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

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

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

31
local warned = false
27✔
32

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

45
--- Constructor
46
-- @param frame A initial frame to attach the typesetter to.
47
function typesetter:_init (frame)
27✔
48
   SU._avoid_base_class_use(self)
55✔
49
   self:declareSettings()
55✔
50
   self.hooks = {}
55✔
51
   self.breadcrumbs = SU.breadcrumbs()
110✔
52
   self.frame = frame
55✔
53
   self.stateQueue = {}
55✔
54
end
55

56
function typesetter:_post_init ()
27✔
57
   self:initFrame(self.frame)
55✔
58
   self:initState()
55✔
59
end
60

61
--- Declare new setting types
62
function typesetter:declareSettings ()
27✔
63
   -- Settings common to any typesetter instance.
64
   -- These shouldn't be re-declared and overwritten/reset in the typesetter
65
   -- constructor (see issue https://github.com/sile-typesetter/sile/issues/1708).
66
   -- On the other hand, it's fairly acceptable to have them made global:
67
   -- Any derived typesetter, whatever its implementation, should likely provide
68
   -- some logic for them (= widows, orphans, spacing, etc.)
69
   SILE.settings:declare({
55✔
70
      parameter = "typesetter.widowpenalty",
71
      type = "integer",
72
      default = 3000,
73
      help = "Penalty to be applied to widow lines (at the start of a paragraph)",
74
   })
75

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

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

90
   SILE.settings:declare({
55✔
91
      parameter = "typesetter.brokenpenalty",
92
      type = "integer",
93
      default = 100,
94
      help = "Penalty to be applied to broken (hyphenated) lines",
95
   })
96

97
   SILE.settings:declare({
55✔
98
      parameter = "typesetter.orphanpenalty",
99
      type = "integer",
100
      default = 3000,
101
      help = "Penalty to be applied to orphan lines (at the end of a paragraph)",
102
   })
103

104
   SILE.settings:declare({
110✔
105
      parameter = "typesetter.parfillskip",
106
      type = "glue",
107
      default = SILE.types.node.glue("0pt plus 10000pt"),
110✔
108
      help = "Glue added at the end of a paragraph",
109
   })
110

111
   SILE.settings:declare({
55✔
112
      parameter = "document.letterspaceglue",
113
      type = "glue or nil",
114
      default = nil,
115
      help = "Glue added between tokens",
116
   })
117

118
   SILE.settings:declare({
110✔
119
      parameter = "typesetter.underfulltolerance",
120
      type = "length or nil",
121
      default = SILE.types.length("1em"),
110✔
122
      help = "Amount a page can be underfull without warning",
123
   })
124

125
   SILE.settings:declare({
110✔
126
      parameter = "typesetter.overfulltolerance",
127
      type = "length or nil",
128
      default = SILE.types.length("5pt"),
110✔
129
      help = "Amount a page can be overfull without warning",
130
   })
131

132
   SILE.settings:declare({
55✔
133
      parameter = "typesetter.breakwidth",
134
      type = "measurement or nil",
135
      default = nil,
136
      help = "Width to break lines at",
137
   })
138

139
   SILE.settings:declare({
55✔
140
      parameter = "typesetter.italicCorrection",
141
      type = "boolean",
142
      default = false,
143
      help = "Whether italic correction is activated or not",
144
   })
145

146
   SILE.settings:declare({
55✔
147
      parameter = "typesetter.italicCorrection.punctuation",
148
      type = "boolean",
149
      default = true,
150
      help = "Whether italic correction is compensated on special punctuation spaces (e.g. in French)",
151
   })
152

153
   SILE.settings:declare({
55✔
154
      parameter = "typesetter.softHyphen",
155
      type = "boolean",
156
      default = true,
157
      help = "When true, soft hyphens are rendered as discretionary breaks, otherwise they are ignored",
158
   })
159

160
   SILE.settings:declare({
55✔
161
      parameter = "typesetter.softHyphenWarning",
162
      type = "boolean",
163
      default = false,
164
      help = "When true, a warning is issued when a soft hyphen is encountered",
165
   })
166

167
   SILE.settings:declare({
55✔
168
      parameter = "typesetter.fixedSpacingAfterInitialEmdash",
169
      type = "boolean",
170
      default = true,
171
      help = "When true, em-dash starting a paragraph is considered as a speaker change in a dialogue",
172
   })
173
end
174

175
function typesetter:initState ()
27✔
176
   self.state = {
73✔
177
      nodes = {},
73✔
178
      outputQueue = {},
73✔
179
      lastBadness = awful_bad,
73✔
180
      liners = {},
73✔
181
   }
73✔
182
end
183

184
function typesetter:initFrame (frame)
27✔
185
   if frame then
91✔
186
      self.frame = frame
89✔
187
      self.frame:init(self)
89✔
188
   end
189
end
190

191
function typesetter.getMargins ()
27✔
192
   return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
2,289✔
193
end
194

195
function typesetter:setMargins (margins)
27✔
196
   SILE.settings:set("document.lskip", margins.lskip)
×
197
   SILE.settings:set("document.rskip", margins.rskip)
×
198
end
199

200
function typesetter:pushState ()
27✔
201
   self.stateQueue[#self.stateQueue + 1] = self.state
18✔
202
   self:initState()
18✔
203
end
204

205
function typesetter:popState (ncount)
27✔
206
   local offset = ncount and #self.stateQueue - ncount or nil
18✔
207
   self.state = table.remove(self.stateQueue, offset)
36✔
208
   if not self.state then
18✔
209
      SU.error("Typesetter state queue empty")
×
210
   end
211
end
212

213
function typesetter:isQueueEmpty ()
27✔
214
   if not self.state then
602✔
215
      return nil
×
216
   end
217
   return #self.state.nodes == 0 and #self.state.outputQueue == 0
602✔
218
end
219

220
function typesetter:vmode ()
27✔
221
   return #self.state.nodes == 0
366✔
222
end
223

224
function typesetter:debugState ()
27✔
225
   print("\n---\nI am in " .. (self:vmode() and "vertical" or "horizontal") .. " mode")
×
226
   print("Writing into " .. tostring(self.frame))
×
227
   print("Recent contributions: ")
×
228
   for i = 1, #self.state.nodes do
×
229
      io.stderr:write(self.state.nodes[i] .. " ")
×
230
   end
231
   print("\nVertical list: ")
×
232
   for i = 1, #self.state.outputQueue do
×
233
      print("  " .. self.state.outputQueue[i])
×
234
   end
235
end
236

237
-- Boxy stuff
238
function typesetter:pushHorizontal (node)
27✔
239
   self:initline()
778✔
240
   self.state.nodes[#self.state.nodes + 1] = node
778✔
241
   return node
778✔
242
end
243

244
function typesetter:pushVertical (vbox)
27✔
245
   self.state.outputQueue[#self.state.outputQueue + 1] = vbox
866✔
246
   return vbox
866✔
247
end
248

249
function typesetter:pushHbox (spec)
27✔
250
   local ntype = SU.type(spec)
44✔
251
   local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.types.node.hbox(spec)
44✔
252
   return self:pushHorizontal(node)
44✔
253
end
254

255
function typesetter:pushUnshaped (spec)
27✔
256
   local node = SU.type(spec) == "unshaped" and spec or SILE.types.node.unshaped(spec)
308✔
257
   return self:pushHorizontal(node)
154✔
258
end
259

260
function typesetter:pushGlue (spec)
27✔
261
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
690✔
262
   return self:pushHorizontal(node)
345✔
263
end
264

265
function typesetter:pushExplicitGlue (spec)
27✔
266
   local node = SU.type(spec) == "glue" and spec or SILE.types.node.glue(spec)
38✔
267
   node.explicit = true
19✔
268
   node.discardable = false
19✔
269
   return self:pushHorizontal(node)
19✔
270
end
271

272
function typesetter:pushPenalty (spec)
27✔
273
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
342✔
274
   return self:pushHorizontal(node)
171✔
275
end
276

277
function typesetter:pushMigratingMaterial (material)
27✔
278
   local node = SILE.types.node.migrating({ material = material })
2✔
279
   return self:pushHorizontal(node)
2✔
280
end
281

282
function typesetter:pushVbox (spec)
27✔
283
   local node = SU.type(spec) == "vbox" and spec or SILE.types.node.vbox(spec)
×
284
   return self:pushVertical(node)
×
285
end
286

287
function typesetter:pushVglue (spec)
27✔
288
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
260✔
289
   return self:pushVertical(node)
130✔
290
end
291

292
function typesetter:pushExplicitVglue (spec)
27✔
293
   local node = SU.type(spec) == "vglue" and spec or SILE.types.node.vglue(spec)
232✔
294
   node.explicit = true
116✔
295
   node.discardable = false
116✔
296
   return self:pushVertical(node)
116✔
297
end
298

299
function typesetter:pushVpenalty (spec)
27✔
300
   local node = SU.type(spec) == "penalty" and spec or SILE.types.node.penalty(spec)
246✔
301
   return self:pushVertical(node)
123✔
302
end
303

304
-- Actual typesetting functions
305
function typesetter:typeset (text)
27✔
306
   text = tostring(text)
308✔
307
   if text:match("^%\r?\n$") then
308✔
308
      return
136✔
309
   end
310
   local pId = SILE.traceStack:pushText(text)
172✔
311
   local parsepattern = SILE.settings:get("typesetter.parseppattern")
172✔
312
   -- NOTE: Big assumption on how to guess were are in "obeylines" mode.
313
   -- See https://github.com/sile-typesetter/sile/issues/2128
314
   local obeylines = parsepattern == "\n"
172✔
315

316
   local seenParaContent = true
172✔
317
   for token in SU.gtoke(text, parsepattern) do
599✔
318
      if token.separator then
255✔
319
         if obeylines and not seenParaContent then
99✔
320
            -- In obeylines mode, each standalone line must be kept.
321
            -- The zerohbox is not discardable, so it will be kept in the output,
322
            -- and the baseline skip will do the rest.
323
            self:pushHorizontal(SILE.types.node.zerohbox())
18✔
324
         else
325
            seenParaContent = false
93✔
326
         end
327
         self:endline()
198✔
328
      else
329
         seenParaContent = true
156✔
330
         if SILE.settings:get("typesetter.softHyphen") then
312✔
331
            local warnedshy = false
156✔
332
            for token2 in SU.gtoke(token.string, luautf8.char(0x00AD)) do
468✔
333
               if token2.separator then -- soft hyphen support
156✔
334
                  local discretionary = SILE.types.node.discretionary({})
×
335
                  local hbox = SILE.typesetter:makeHbox({ SILE.settings:get("font.hyphenchar") })
×
336
                  discretionary.prebreak = { hbox }
×
337
                  table.insert(SILE.typesetter.state.nodes, discretionary)
×
338
                  if not warnedshy and SILE.settings:get("typesetter.softHyphenWarning") then
×
339
                     SU.warn("Soft hyphen encountered and replaced with discretionary")
×
340
                  end
341
                  warnedshy = true
×
342
               else
343
                  self:setpar(token2.string)
156✔
344
               end
345
            end
346
         else
347
            if
348
               SILE.settings:get("typesetter.softHyphenWarning") and luautf8.match(token.string, luautf8.char(0x00AD))
×
349
            then
350
               SU.warn("Soft hyphen encountered and ignored")
×
351
            end
352
            text = luautf8.gsub(token.string, luautf8.char(0x00AD), "")
×
353
            self:setpar(text)
×
354
         end
355
      end
356
   end
357
   SILE.traceStack:pop(pId)
172✔
358
end
359

360
function typesetter:initline ()
27✔
361
   if self.state.hmodeOnly then
886✔
362
      return
11✔
363
   end -- https://github.com/sile-typesetter/sile/issues/1718
364
   if #self.state.nodes == 0 then
875✔
365
      table.insert(self.state.nodes, SILE.types.node.zerohbox())
342✔
366
      SILE.documentState.documentClass.newPar(self)
171✔
367
   end
368
end
369

370
function typesetter:endline ()
27✔
371
   SILE.documentState.documentClass.endPar(self)
161✔
372
   self:leaveHmode()
161✔
373
   if SILE.settings:get("current.hangIndent") then
322✔
374
      SILE.settings:set("current.hangIndent", nil)
×
375
      SILE.settings:set("linebreak.hangIndent", nil)
×
376
   end
377
   if SILE.settings:get("current.hangAfter") then
322✔
378
      SILE.settings:set("current.hangAfter", nil)
×
379
      SILE.settings:set("linebreak.hangAfter", nil)
×
380
   end
381
end
382

383
-- Just compute once, to avoid unicode characters in source code.
384
local speakerChangePattern = "^"
×
385
   .. luautf8.char(0x2014) -- emdash
27✔
386
   .. "[ "
×
387
   .. luautf8.char(0x00A0)
27✔
388
   .. luautf8.char(0x202F) -- regular space or NBSP or NNBSP
27✔
389
   .. "]+"
27✔
390
local speakerChangeReplacement = luautf8.char(0x2014) .. " "
27✔
391

392
-- Special unshaped node subclass to handle space after a speaker change in dialogues
393
-- introduced by an em-dash.
394
local speakerChangeNode = pl.class(SILE.types.node.unshaped)
27✔
395
function speakerChangeNode:shape ()
27✔
396
   local node = self._base.shape(self)
×
397
   local spc = node[2]
×
398
   if spc and spc.is_glue then
×
399
      -- Switch the variable space glue to a fixed kern
400
      node[2] = SILE.types.node.kern({ width = spc.width.length })
×
401
      node[2].parent = self.parent
×
402
   else
403
      -- Should not occur:
404
      -- How could it possibly be shaped differently?
405
      SU.warn("Speaker change logic met an unexpected case, this might be a bug")
×
406
   end
407
   return node
×
408
end
409

410
-- Takes string, writes onto self.state.nodes
411
function typesetter:setpar (text)
27✔
412
   text = text:gsub("\r?\n", " "):gsub("\t", " ")
156✔
413
   if #self.state.nodes == 0 then
156✔
414
      if not SILE.settings:get("typesetter.obeyspaces") then
216✔
415
         text = text:gsub("^%s+", "")
98✔
416
      end
417
      self:initline()
108✔
418

419
      if
420
         SILE.settings:get("typesetter.fixedSpacingAfterInitialEmdash")
108✔
421
         and not SILE.settings:get("typesetter.obeyspaces")
216✔
422
      then
423
         local speakerChange = false
98✔
424
         local dialogue = luautf8.gsub(text, speakerChangePattern, function ()
196✔
425
            speakerChange = true
×
426
            return speakerChangeReplacement
×
427
         end)
428
         if speakerChange then
98✔
429
            local node = speakerChangeNode({ text = dialogue, options = SILE.font.loadDefaults({}) })
×
430
            self:pushHorizontal(node)
×
431
            return -- done here: speaker change space handling is done after nnode shaping
×
432
         end
433
      end
434
   end
435
   if #text > 0 then
156✔
436
      self:pushUnshaped({ text = text, options = SILE.font.loadDefaults({}) })
308✔
437
   end
438
end
439

440
function typesetter:breakIntoLines (nodelist, breakWidth)
27✔
441
   self:shapeAllNodes(nodelist)
170✔
442
   local breakpoints = SILE.linebreak:doBreak(nodelist, breakWidth)
170✔
443
   return self:breakpointsToLines(breakpoints)
170✔
444
end
445

446
--- Extract the last shaped item from a node list.
447
-- @tparam table nodelist A list of nodes.
448
-- @treturn table The last shaped item.
449
-- @treturn boolean Whether the list contains a glue after the last shaped item.
450
-- @treturn number|nil The width of a punctuation kern after the last shaped item, if any.
451
local function getLastShape (nodelist)
452
   local lastShape
453
   local hasGlue
454
   local punctSpaceWidth
455
   if nodelist then
×
456
      -- The node list may contain nnodes, penalties, kern and glue
457
      -- We skip the latter, and retrieve the last shaped item.
458
      for i = #nodelist, 1, -1 do
×
459
         local n = nodelist[i]
×
460
         if n.is_nnode then
×
461
            local items = n.nodes[#n.nodes].value.items
×
462
            lastShape = items[#items]
×
463
            break
464
         end
465
         if n.is_kern and n.subtype == "punctspace" then
×
466
            -- Some languages such as French insert a special space around
467
            -- punctuations.
468
            -- In those case, we have different strategies for handling
469
            -- italic correction.
470
            punctSpaceWidth = n.width:tonumber()
×
471
         end
472
         if n.is_glue then
×
473
            hasGlue = true
×
474
         end
475
      end
476
   end
477
   return lastShape, hasGlue, punctSpaceWidth
×
478
end
479

480
--- Extract the first shaped item from a node list.
481
-- @tparam table nodelist A list of nodes.
482
-- @treturn table The first shaped item.
483
-- @treturn boolean Whether the list contains a glue before the first shaped item.
484
-- @treturn number|nil The width of a punctuation kern before the first shaped item, if any.
485
local function getFirstShape (nodelist)
486
   local firstShape
487
   local hasGlue
488
   local punctSpaceWidth
489
   if nodelist then
×
490
      -- The node list may contain nnodes, penalties, kern and glue
491
      -- We skip the latter, and retrieve the first shaped item.
492
      for i = 1, #nodelist do
×
493
         local n = nodelist[i]
×
494
         if n.is_nnode then
×
495
            local items = n.nodes[1].value.items
×
496
            firstShape = items[1]
×
497
            break
498
         end
499
         if n.is_kern and n.subtype == "punctspace" then
×
500
            -- Some languages such as French insert a special space around
501
            -- punctuations.
502
            -- In those case, we have different strategies for handling
503
            -- italic correction.
504
            punctSpaceWidth = n.width:tonumber()
×
505
         end
506
         if n.is_glue then
×
507
            hasGlue = true
×
508
         end
509
      end
510
   end
511
   return firstShape, hasGlue, punctSpaceWidth
×
512
end
513

514
--- Compute the italic correction when switching from italic to non-italic.
515
-- Computing italic correction is at best heuristics.
516
-- The strong assumption is that italic is slanted to the right.
517
-- Thus, the part of the character that goes beyond its width is usually maximal at the top of the glyph.
518
-- E.g. consider a "f", that would be the top hook extent.
519
-- Pathological cases exist, such as fonts with a Q with a long tail, but these will rarely occur in usual languages.
520
-- For instance, Klingon's "QaQ" might be an issue, but there's not much we can do...
521
-- Another assumption is that we can distribute that extent in proportion with the next character's height.
522
-- This might not work that well with non-Latin scripts.
523
--
524
-- @tparam table precShape The last shaped item (italic).
525
-- @tparam table curShape The first shaped item (non-italic).
526
-- @tparam number|nil punctSpaceWidth The width of a punctuation kern between the two items, if any.
527
local function fromItalicCorrection (precShape, curShape, punctSpaceWidth)
528
   local xOffset
529
   if not curShape or not precShape then
×
530
      xOffset = 0
×
531
   elseif precShape.height <= 0 then
×
532
      xOffset = 0
×
533
   else
534
      local d = precShape.glyphWidth + precShape.x_bearing
×
535
      local delta = d > precShape.width and d - precShape.width or 0
×
536
      xOffset = precShape.height <= curShape.height and delta or delta * curShape.height / precShape.height
×
537
      if punctSpaceWidth and SILE.settings:get("typesetter.italicCorrection.punctuation") then
×
538
         xOffset = xOffset - punctSpaceWidth > 0 and (xOffset - punctSpaceWidth) or 0
×
539
      end
540
   end
541
   return xOffset
×
542
end
543

544
--- Compute the italic correction when switching from non-italic to italic.
545
-- Same assumptions as fromItalicCorrection(), but on the starting side of the glyph.
546
--
547
-- @tparam table precShape The last shaped item (non-italic).
548
-- @tparam table curShape The first shaped item (italic).
549
-- @tparam number|nil punctSpaceWidth The width of a punctuation kern between the two items, if any.
550
local function toItalicCorrection (precShape, curShape, punctSpaceWidth)
551
   local xOffset
552
   if not curShape or not precShape then
×
553
      xOffset = 0
×
554
   elseif precShape.depth <= 0 then
×
555
      xOffset = 0
×
556
   else
557
      local d = curShape.x_bearing
×
558
      local delta = d < 0 and -d or 0
×
559
      xOffset = precShape.depth >= curShape.depth and delta or delta * precShape.depth / curShape.depth
×
560
      if punctSpaceWidth and SILE.settings:get("typesetter.italicCorrection.punctuation") then
×
561
         xOffset = punctSpaceWidth - xOffset > 0 and xOffset or 0
×
562
      end
563
   end
564
   return xOffset
×
565
end
566

567
local function isItalicLike (nnode)
568
   -- We could do...
569
   --  return nnode and string.lower(nnode.options.style) == "italic"
570
   -- But it's probably more robust to use the italic angle, so that
571
   -- thin italic, oblique or slanted fonts etc. may work too.
572
   local ot = require("core.opentype-parser")
×
573
   local face = SILE.font.cache(nnode.options, SILE.shaper.getFace)
×
574
   local font = ot.parseFont(face)
×
575
   return font.post.italicAngle ~= 0
×
576
end
577

578
function typesetter:shapeAllNodes (nodelist, inplace)
27✔
579
   inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
1,052✔
580
   local newNodelist = {}
526✔
581
   local prec
582
   local precShapedNodes
583
   local isItalicCorrectionEnabled = SILE.settings:get("typesetter.italicCorrection")
526✔
584
   for _, current in ipairs(nodelist) do
5,704✔
585
      if current.is_unshaped then
5,178✔
586
         local shapedNodes = current:shape()
175✔
587

588
         if isItalicCorrectionEnabled and prec then
175✔
589
            local itCorrOffset
590
            local isGlue
591
            if isItalicLike(prec) and not isItalicLike(current) then
×
592
               local precShape, precHasGlue = getLastShape(precShapedNodes)
×
593
               local curShape, curHasGlue, curPunctSpaceWidth = getFirstShape(shapedNodes)
×
594
               isGlue = precHasGlue or curHasGlue
×
595
               itCorrOffset = fromItalicCorrection(precShape, curShape, curPunctSpaceWidth)
×
596
            elseif not isItalicLike(prec) and isItalicLike(current) then
×
597
               local precShape, precHasGlue, precPunctSpaceWidth = getLastShape(precShapedNodes)
×
598
               local curShape, curHasGlue = getFirstShape(shapedNodes)
×
599
               isGlue = precHasGlue or curHasGlue
×
600
               itCorrOffset = toItalicCorrection(precShape, curShape, precPunctSpaceWidth)
×
601
            end
602
            if itCorrOffset and itCorrOffset ~= 0 then
×
603
               -- If one of the node contains a glue (e.g. "a \em{proof} is..."),
604
               -- line breaking may occur between them, so our correction shall be
605
               -- a glue too.
606
               -- Otherwise, the font change is considered to occur at a non-breaking
607
               -- point (e.g. "\em{proof}!") and the correction shall be a kern.
608
               local makeItCorrNode = isGlue and SILE.types.node.glue or SILE.types.node.kern
×
609
               newNodelist[#newNodelist + 1] = makeItCorrNode({
×
610
                  width = SILE.types.length(itCorrOffset),
611
                  subtype = "itcorr",
612
               })
613
            end
614
         end
615

616
         pl.tablex.insertvalues(newNodelist, shapedNodes)
175✔
617

618
         prec = current
175✔
619
         precShapedNodes = shapedNodes
175✔
620
      else
621
         prec = nil
5,003✔
622
         newNodelist[#newNodelist + 1] = current
5,003✔
623
      end
624
   end
625

626
   if not inplace then
526✔
627
      return newNodelist
15✔
628
   end
629

630
   for i = 1, #newNodelist do
7,202✔
631
      nodelist[i] = newNodelist[i]
6,691✔
632
   end
633
   if #nodelist > #newNodelist then
511✔
634
      for i = #newNodelist + 1, #nodelist do
×
635
         nodelist[i] = nil
×
636
      end
637
   end
638
end
639

640
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
641
-- Turns a node list into a list of vboxes
642
function typesetter:boxUpNodes ()
27✔
643
   local nodelist = self.state.nodes
528✔
644
   if #nodelist == 0 then
528✔
645
      return {}
357✔
646
   end
647
   for j = #nodelist, 1, -1 do
203✔
648
      if not nodelist[j].is_migrating then
204✔
649
         if nodelist[j].discardable then
203✔
650
            table.remove(nodelist, j)
64✔
651
         else
652
            break
653
         end
654
      end
655
   end
656
   while #nodelist > 0 and nodelist[1].is_penalty do
171✔
657
      table.remove(nodelist, 1)
×
658
   end
659
   if #nodelist == 0 then
171✔
660
      return {}
×
661
   end
662
   self:shapeAllNodes(nodelist)
171✔
663
   local parfillskip = SILE.settings:get("typesetter.parfillskip")
171✔
664
   parfillskip.discardable = false
171✔
665
   self:pushGlue(parfillskip)
171✔
666
   self:pushPenalty(-inf_bad)
171✔
667
   SU.debug("typesetter", function ()
342✔
668
      return "Boxed up " .. (#nodelist > 500 and #nodelist .. " nodes" or SU.ast.contentToString(nodelist))
×
669
   end)
670
   local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
342✔
671
   local lines = self:breakIntoLines(nodelist, breakWidth)
171✔
672
   local vboxes = {}
171✔
673
   for index = 1, #lines do
406✔
674
      local line = lines[index]
235✔
675
      local migrating = {}
235✔
676
      -- Move any migrating material
677
      local nodes = {}
235✔
678
      for i = 1, #line.nodes do
4,280✔
679
         local node = line.nodes[i]
4,045✔
680
         if node.is_migrating then
4,045✔
681
            for j = 1, #node.material do
4✔
682
               migrating[#migrating + 1] = node.material[j]
2✔
683
            end
684
         else
685
            nodes[#nodes + 1] = node
4,043✔
686
         end
687
      end
688
      local vbox = SILE.types.node.vbox({ nodes = nodes, ratio = line.ratio })
235✔
689
      local pageBreakPenalty = 0
235✔
690
      if #lines > 1 and index == 1 then
235✔
691
         pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
32✔
692
      elseif #lines > 1 and index == (#lines - 1) then
219✔
693
         pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
20✔
694
      elseif line.is_broken then
209✔
695
         pageBreakPenalty = SILE.settings:get("typesetter.brokenpenalty")
16✔
696
      end
697
      vboxes[#vboxes + 1] = self:leadingFor(vbox, self.state.previousVbox)
470✔
698
      vboxes[#vboxes + 1] = vbox
235✔
699
      for i = 1, #migrating do
237✔
700
         vboxes[#vboxes + 1] = migrating[i]
2✔
701
      end
702
      self.state.previousVbox = vbox
235✔
703
      if pageBreakPenalty > 0 then
235✔
704
         SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
26✔
705
         vboxes[#vboxes + 1] = SILE.types.node.penalty(pageBreakPenalty)
52✔
706
      end
707
   end
708
   return vboxes
171✔
709
end
710

711
function typesetter:pageTarget ()
27✔
712
   SU.deprecated("SILE.typesetter:pageTarget", "SILE.typesetter:getTargetLength", "0.13.0", "0.14.0")
×
713
end
714

715
function typesetter:getTargetLength ()
27✔
716
   return self.frame:getTargetLength()
514✔
717
end
718

719
function typesetter:registerHook (category, func)
27✔
720
   if not self.hooks[category] then
35✔
721
      self.hooks[category] = {}
31✔
722
   end
723
   table.insert(self.hooks[category], func)
35✔
724
end
725

726
function typesetter:runHooks (category, data)
27✔
727
   if not self.hooks[category] then
532✔
728
      return data
471✔
729
   end
730
   for _, func in ipairs(self.hooks[category]) do
141✔
731
      data = func(self, data)
160✔
732
   end
733
   return data
61✔
734
end
735

736
function typesetter:registerFrameBreakHook (func)
27✔
737
   self:registerHook("framebreak", func)
4✔
738
end
739

740
function typesetter:registerNewFrameHook (func)
27✔
741
   self:registerHook("newframe", func)
×
742
end
743

744
function typesetter:registerPageEndHook (func)
27✔
745
   self:registerHook("pageend", func)
31✔
746
end
747

748
function typesetter:buildPage ()
27✔
749
   local pageNodeList
750
   local res
751
   if self:isQueueEmpty() then
988✔
752
      return false
35✔
753
   end
754
   if SILE.scratch.insertions then
459✔
755
      SILE.scratch.insertions.thisPage = {}
124✔
756
   end
757
   pageNodeList, res = SILE.pagebuilder:findBestBreak({
918✔
758
      vboxlist = self.state.outputQueue,
459✔
759
      target = self:getTargetLength(),
918✔
760
      restart = self.frame.state.pageRestart,
459✔
761
   })
459✔
762
   if not pageNodeList then -- No break yet
459✔
763
      -- self.frame.state.pageRestart = res
764
      self:runHooks("noframebreak")
404✔
765
      return false
404✔
766
   end
767
   SU.debug("pagebuilder", "Buildding page for", self.frame.id)
55✔
768
   self.state.lastPenalty = res
55✔
769
   self.frame.state.pageRestart = nil
55✔
770
   pageNodeList = self:runHooks("framebreak", pageNodeList)
110✔
771
   self:setVerticalGlue(pageNodeList, self:getTargetLength())
110✔
772
   self:outputLinesToPage(pageNodeList)
55✔
773
   return true
55✔
774
end
775

776
function typesetter:setVerticalGlue (pageNodeList, target)
27✔
777
   local glues = {}
55✔
778
   local gTotal = SILE.types.length()
55✔
779
   local totalHeight = SILE.types.length()
55✔
780

781
   local pastTop = false
55✔
782
   for _, node in ipairs(pageNodeList) do
771✔
783
      if not pastTop and not node.discardable and not node.explicit then
716✔
784
         -- "Ignore discardable and explicit glues at the top of a frame."
785
         -- See typesetter:outputLinesToPage()
786
         -- Note the test here doesn't check is_vglue, so will skip other
787
         -- discardable nodes (e.g. penalties), but it shouldn't matter
788
         -- for the type of computing performed here.
789
         pastTop = true
55✔
790
      end
791
      if pastTop then
716✔
792
         if not node.is_insertion then
643✔
793
            totalHeight:___add(node.height)
643✔
794
            totalHeight:___add(node.depth)
643✔
795
         end
796
         if node.is_vglue then
643✔
797
            table.insert(glues, node)
347✔
798
            gTotal:___add(node.height)
347✔
799
         end
800
      end
801
   end
802

803
   if totalHeight:tonumber() == 0 then
110✔
804
      return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
3✔
805
   end
806

807
   local adjustment = target - totalHeight
52✔
808
   if adjustment:tonumber() > 0 then
104✔
809
      if adjustment > gTotal.stretch then
37✔
810
         if
811
            (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber()
10✔
812
         then
813
            SU.warn(
2✔
814
               "Underfull frame "
815
                  .. self.frame.id
1✔
816
                  .. ": "
1✔
817
                  .. adjustment
1✔
818
                  .. " stretchiness required to fill but only "
1✔
819
                  .. gTotal.stretch
1✔
820
                  .. " available"
1✔
821
            )
822
         end
823
         adjustment = gTotal.stretch
2✔
824
      end
825
      if gTotal.stretch:tonumber() > 0 then
74✔
826
         for i = 1, #glues do
371✔
827
            local g = glues[i]
335✔
828
            g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
1,675✔
829
         end
830
      end
831
   elseif adjustment:tonumber() < 0 then
30✔
832
      adjustment = 0 - adjustment
15✔
833
      if adjustment > gTotal.shrink then
15✔
834
         if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
75✔
835
            SU.warn(
2✔
836
               "Overfull frame "
837
                  .. self.frame.id
1✔
838
                  .. ": "
1✔
839
                  .. adjustment
1✔
840
                  .. " shrinkability required to fit but only "
1✔
841
                  .. gTotal.shrink
1✔
842
                  .. " available"
1✔
843
            )
844
         end
845
         adjustment = gTotal.shrink
15✔
846
      end
847
      if gTotal.shrink:tonumber() > 0 then
30✔
848
         for i = 1, #glues do
×
849
            local g = glues[i]
×
850
            g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
851
         end
852
      end
853
   end
854
   SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
52✔
855
end
856

857
function typesetter:initNextFrame ()
27✔
858
   local oldframe = self.frame
30✔
859
   self.frame:leave(self)
30✔
860
   if #self.state.outputQueue == 0 then
30✔
861
      self.state.previousVbox = nil
22✔
862
   end
863
   if self.frame.next and self.state.lastPenalty > supereject_penalty then
30✔
864
      self:initFrame(SILE.getFrame(self.frame.next))
×
865
   elseif not self.frame:isMainContentFrame() then
60✔
866
      if #self.state.outputQueue > 0 then
14✔
867
         SU.warn("Overfull content for frame " .. self.frame.id)
1✔
868
         self:chuck()
1✔
869
      end
870
   else
871
      self:runHooks("pageend")
16✔
872
      SILE.documentState.documentClass:endPage()
16✔
873
      self:initFrame(SILE.documentState.documentClass:newPage())
32✔
874
   end
875

876
   if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
120✔
877
      self:pushBack()
×
878
      -- Some what of a hack below.
879
      -- Before calling this method, we were in vertical mode...
880
      -- pushback occurred, and it seems it messes up a bit...
881
      -- Regardless what it does, at the end, we ought to be in vertical mode
882
      -- again:
883
      self:leaveHmode()
×
884
   else
885
      -- If I have some things on the vertical list already, they need
886
      -- proper top-of-frame leading applied.
887
      if #self.state.outputQueue > 0 then
30✔
888
         local lead = self:leadingFor(self.state.outputQueue[1], nil)
7✔
889
         if lead then
7✔
890
            table.insert(self.state.outputQueue, 1, lead)
6✔
891
         end
892
      end
893
   end
894
   self:runHooks("newframe")
30✔
895
end
896

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

968
function typesetter:outputLinesToPage (lines)
27✔
969
   SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
68✔
970
   -- It would have been nice to avoid storing this "pastTop" into a frame
971
   -- state, to keep things less entangled. There are situations, though,
972
   -- we will have left horizontal mode (triggering output), but will later
973
   -- call typesetter:chuck() do deal with any remaining content, and we need
974
   -- to know whether some content has been output already.
975
   local pastTop = self.frame.state.totals.pastTop
68✔
976
   for _, line in ipairs(lines) do
823✔
977
      -- Ignore discardable and explicit glues at the top of a frame:
978
      -- Annoyingly, explicit glue *should* disappear at the top of a page.
979
      -- if you don't want that, add an empty vbox or something.
980
      if not pastTop and not line.discardable and not line.explicit then
755✔
981
         -- Note the test here doesn't check is_vglue, so will skip other
982
         -- discardable nodes (e.g. penalties), but it shouldn't matter
983
         -- for outputting.
984
         pastTop = true
67✔
985
      end
986
      if pastTop then
755✔
987
         line:outputYourself(self, line)
666✔
988
      end
989
   end
990
   self.frame.state.totals.pastTop = pastTop
68✔
991
end
992

993
function typesetter:leaveHmode (independent)
27✔
994
   if self.state.hmodeOnly then
528✔
995
      SU.error("Paragraphs are forbidden in restricted horizontal mode")
×
996
   end
997
   SU.debug("typesetter", "Leaving hmode")
528✔
998
   local margins = self:getMargins()
528✔
999
   local vboxlist = self:boxUpNodes()
528✔
1000
   self.state.nodes = {}
528✔
1001
   -- Push output lines into boxes and ship them to the page builder
1002
   for _, vbox in ipairs(vboxlist) do
1,025✔
1003
      vbox.margins = margins
497✔
1004
      self:pushVertical(vbox)
497✔
1005
   end
1006
   if independent then
528✔
1007
      return
56✔
1008
   end
1009
   if self:buildPage() then
944✔
1010
      self:initNextFrame()
29✔
1011
   end
1012
end
1013

1014
function typesetter:inhibitLeading ()
27✔
1015
   self.state.previousVbox = nil
×
1016
end
1017

1018
function typesetter:leadingFor (vbox, previous)
27✔
1019
   -- Insert leading
1020
   SU.debug("typesetter", "   Considering leading between two lines:")
232✔
1021
   SU.debug("typesetter", "   1)", previous)
232✔
1022
   SU.debug("typesetter", "   2)", vbox)
232✔
1023
   if not previous then
232✔
1024
      return SILE.types.node.vglue()
65✔
1025
   end
1026
   local prevDepth = previous.depth
167✔
1027
   SU.debug("typesetter", "   Depth of previous line was", prevDepth)
167✔
1028
   local bls = SILE.settings:get("document.baselineskip")
167✔
1029
   local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
835✔
1030
   SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
167✔
1031

1032
   -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
1033
   local lead = SILE.settings:get("document.lineskip").height:absolute()
334✔
1034
   if depth > lead then
167✔
1035
      return SILE.types.node.vglue(SILE.types.length(depth.length, bls.height.stretch, bls.height.shrink))
274✔
1036
   else
1037
      return SILE.types.node.vglue(lead)
30✔
1038
   end
1039
end
1040

1041
-- Beginning of liner logic (constructs spanning over several lines)
1042

1043
-- These two special nodes are used to track the current liner entry and exit.
1044
-- As Sith Lords, they are always two: they are local here, so no one can
1045
-- use one alone and break the balance of the Force.
1046
local linerEnterNode = pl.class(SILE.types.node.hbox)
27✔
1047
function linerEnterNode:_init (name, outputMethod)
27✔
1048
   SILE.types.node.hbox._init(self)
1✔
1049
   self.outputMethod = outputMethod
1✔
1050
   self.name = name
1✔
1051
   self.is_enter = true
1✔
1052
end
1053
function linerEnterNode:clone ()
27✔
1054
   return linerEnterNode(self.name, self.outputMethod)
×
1055
end
1056
function linerEnterNode:outputYourself ()
27✔
1057
   SU.error("A liner enter node " .. tostring(self) .. "'' made it to output", true)
×
1058
end
1059
function linerEnterNode:__tostring ()
27✔
1060
   return "+L[" .. self.name .. "]"
×
1061
end
1062
local linerLeaveNode = pl.class(SILE.types.node.hbox)
27✔
1063
function linerLeaveNode:_init (name)
27✔
1064
   SILE.types.node.hbox._init(self)
1✔
1065
   self.name = name
1✔
1066
   self.is_leave = true
1✔
1067
end
1068
function linerLeaveNode:clone ()
27✔
1069
   return linerLeaveNode(self.name)
×
1070
end
1071
function linerLeaveNode:outputYourself ()
27✔
1072
   SU.error("A liner leave node " .. tostring(self) .. "'' made it to output", true)
×
1073
end
1074
function linerLeaveNode:__tostring ()
27✔
1075
   return "-L[" .. self.name .. "]"
×
1076
end
1077

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

1127
--- Any unclosed liner is reopened on the current line, so we clone and repeat it.
1128
-- An assumption is that the inserts are done after the current slice content,
1129
-- supposed to be just before meaningful (visible) content.
1130
-- @tparam table slice Flat nodes from current line
1131
-- @treturn boolean Whether a liner was reopened
1132
function typesetter:_repeatEnterLiners (slice)
27✔
1133
   local m = self.state.liners
2,727✔
1134
   if #m > 0 then
2,727✔
1135
      for i = 1, #m do
×
1136
         local n = m[i]:clone()
×
1137
         slice[#slice + 1] = n
×
1138
         SU.debug("typesetter.liner", "Reopening liner", n)
×
1139
      end
1140
      return true
×
1141
   end
1142
   return false
2,727✔
1143
end
1144

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

1193
--- Check if a node is a liner, and process it if so, in a stack.
1194
-- @tparam table node Current node (any type)
1195
-- @treturn boolean Whether a liner was opened
1196
function typesetter:_processIfLiner (node)
27✔
1197
   local entered = false
3,111✔
1198
   if node.is_enter then
3,111✔
1199
      SU.debug("typesetter.liner", "Enter liner", node)
1✔
1200
      self.state.liners[#self.state.liners + 1] = node
1✔
1201
      entered = true
1✔
1202
   elseif node.is_leave then
3,110✔
1203
      SU.debug("typesetter.liner", "Leave liner", node)
1✔
1204
      if #self.state.liners == 0 then
1✔
1205
         SU.error("Multiliner stack mismatch" .. node)
×
1206
      elseif self.state.liners[#self.state.liners].name == node.name then
1✔
1207
         self.state.liners[#self.state.liners].link = node -- for consistency check
1✔
1208
         self.state.liners[#self.state.liners] = nil
1✔
1209
      else
1210
         SU.error("Multiliner stack inconsistency" .. self.state.liners[#self.state.liners] .. "vs. " .. node)
×
1211
      end
1212
   end
1213
   return entered
3,111✔
1214
end
1215

1216
function typesetter:_repeatLeaveLiners (slice, insertIndex)
27✔
1217
   for _, v in ipairs(self.state.liners) do
228✔
1218
      if not v.link then
×
1219
         local n = linerLeaveNode(v.name)
×
1220
         SU.debug("typesetter.liner", "Closing liner", n)
×
1221
         table.insert(slice, insertIndex, n)
×
1222
      else
1223
         SU.error("Multiliner stack inconsistency" .. v)
×
1224
      end
1225
   end
1226
end
1227
-- End of liner logic
1228

1229
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
27✔
1230
   local LTR = self.frame:writingDirection() == "LTR"
470✔
1231
   local rskip = margins[LTR and "rskip" or "lskip"]
235✔
1232
   if not rskip then
235✔
1233
      rskip = SILE.types.node.glue(0)
×
1234
   end
1235
   if hangRight and hangRight > 0 then
235✔
1236
      rskip = SILE.types.node.glue({ width = rskip.width:tonumber() + hangRight })
30✔
1237
   end
1238
   rskip.value = "margin"
235✔
1239
   -- while slice[#slice].discardable do table.remove(slice, #slice) end
1240
   table.insert(slice, rskip)
235✔
1241
   table.insert(slice, SILE.types.node.zerohbox())
470✔
1242
   local lskip = margins[LTR and "lskip" or "rskip"]
235✔
1243
   if not lskip then
235✔
1244
      lskip = SILE.types.node.glue(0)
×
1245
   end
1246
   if hangLeft and hangLeft > 0 then
235✔
1247
      lskip = SILE.types.node.glue({ width = lskip.width:tonumber() + hangLeft })
24✔
1248
   end
1249
   lskip.value = "margin"
235✔
1250
   while slice[1].discardable do
235✔
1251
      table.remove(slice, 1)
×
1252
   end
1253
   table.insert(slice, 1, lskip)
235✔
1254
   table.insert(slice, 1, SILE.types.node.zerohbox())
470✔
1255
end
1256

1257
function typesetter:breakpointsToLines (breakpoints)
27✔
1258
   local linestart = 1
171✔
1259
   local lines = {}
171✔
1260
   local nodes = self.state.nodes
171✔
1261

1262
   for i = 1, #breakpoints do
410✔
1263
      local point = breakpoints[i]
239✔
1264
      if point.position ~= 0 then
239✔
1265
         local slice = {}
239✔
1266
         local seenNonDiscardable = false
239✔
1267
         local seenLiner = false
239✔
1268
         local lastContentNodeIndex
1269

1270
         for j = linestart, point.position do
3,350✔
1271
            local currentNode = nodes[j]
3,111✔
1272
            if
1273
               not currentNode.discardable
3,111✔
1274
               and not (currentNode.is_glue and not currentNode.explicit)
2,064✔
1275
               and not currentNode.is_zero
1,893✔
1276
            then
1277
               -- actual visible content starts here
1278
               lastContentNodeIndex = #slice + 1
1,716✔
1279
            end
1280
            if not seenLiner and lastContentNodeIndex then
3,111✔
1281
               -- Any stacked liner (unclosed from a previous line) is reopened on
1282
               -- the current line.
1283
               seenLiner = self:_repeatEnterLiners(slice)
5,454✔
1284
               lastContentNodeIndex = #slice + 1
2,727✔
1285
            end
1286
            if currentNode.is_discretionary and currentNode.used then
3,111✔
1287
               -- This is the used (prebreak) discretionary from a previous line,
1288
               -- repeated. Replace it with a clone, changed to a postbreak.
1289
               currentNode = currentNode:cloneAsPostbreak()
34✔
1290
            end
1291
            slice[#slice + 1] = currentNode
3,111✔
1292
            if currentNode then
3,111✔
1293
               if not currentNode.discardable then
3,111✔
1294
                  seenNonDiscardable = true
2,064✔
1295
               end
1296
               seenLiner = self:_processIfLiner(currentNode) or seenLiner
6,222✔
1297
            end
1298
         end
1299
         if not seenNonDiscardable then
239✔
1300
            -- Slip lines containing only discardable nodes (e.g. glues).
1301
            SU.debug("typesetter", "Skipping a line containing only discardable nodes")
4✔
1302
            linestart = point.position + 1
4✔
1303
         else
1304
            local is_broken = false
235✔
1305
            if slice[#slice].is_discretionary then
235✔
1306
               -- The line ends, with a discretionary:
1307
               -- repeat it on the next line, so as to account for a potential postbreak.
1308
               linestart = point.position
17✔
1309
               -- And mark it as used as prebreak for now.
1310
               slice[#slice]:markAsPrebreak()
17✔
1311
               -- We'll want a "brokenpenalty" eventually (if not an orphan or widow)
1312
               -- to discourage page breaking after this line.
1313
               is_broken = true
17✔
1314
            else
1315
               linestart = point.position + 1
218✔
1316
            end
1317

1318
            -- Any unclosed liner is closed on the next line in reverse order.
1319
            if lastContentNodeIndex then
235✔
1320
               self:_repeatLeaveLiners(slice, lastContentNodeIndex + 1)
228✔
1321
            end
1322

1323
            -- Then only we can add some extra margin glue...
1324
            local mrg = self:getMargins()
235✔
1325
            self:addrlskip(slice, mrg, point.left, point.right)
235✔
1326

1327
            -- And compute the line...
1328
            local ratio = self:computeLineRatio(point.width, slice)
235✔
1329

1330
            -- Re-shuffle liners, if any, into their own boxes.
1331
            if seenLiner then
235✔
1332
               slice = self:_reboxLiners(slice)
2✔
1333
            end
1334

1335
            local thisLine = { ratio = ratio, nodes = slice, is_broken = is_broken }
235✔
1336
            lines[#lines + 1] = thisLine
235✔
1337
         end
1338
      end
1339
   end
1340
   if linestart < #nodes then
171✔
1341
      -- Abnormal, but warn so that one has a chance to check which bits
1342
      -- are missing at output.
1343
      SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
1344
   end
1345
   return lines
171✔
1346
end
1347

1348
function typesetter:computeLineRatio (breakwidth, slice)
27✔
1349
   local naturalTotals = SILE.types.length()
235✔
1350

1351
   -- From the line end, account for the margin but skip any trailing
1352
   -- glues (spaces to ignore) and zero boxes until we reach actual content.
1353
   local npos = #slice
235✔
1354
   while npos > 1 do
757✔
1355
      if slice[npos].is_glue or slice[npos].is_zero then
756✔
1356
         if slice[npos].value == "margin" then
522✔
1357
            naturalTotals:___add(slice[npos].width)
236✔
1358
         end
1359
      else
1360
         break
1361
      end
1362
      npos = npos - 1
522✔
1363
   end
1364

1365
   -- Due to discretionaries, keep track of seen parent nodes
1366
   local seenNodes = {}
235✔
1367
   -- CODE SMELL: Not sure which node types were supposed to be skipped
1368
   -- at initial positions in the line!
1369
   local skipping = true
235✔
1370

1371
   -- Until end of actual content
1372
   for i = 1, npos do
3,760✔
1373
      local node = slice[i]
3,525✔
1374
      if node.is_box then
3,525✔
1375
         skipping = false
1,744✔
1376
         if node.parent and not node.parent.hyphenated then
1,744✔
1377
            if not seenNodes[node.parent] then
463✔
1378
               naturalTotals:___add(node.parent:lineContribution())
368✔
1379
            end
1380
            seenNodes[node.parent] = true
463✔
1381
         else
1382
            naturalTotals:___add(node:lineContribution())
2,562✔
1383
         end
1384
      elseif node.is_penalty and node.penalty == -inf_bad then
1,781✔
1385
         skipping = false
168✔
1386
      elseif node.is_discretionary then
1,613✔
1387
         skipping = false
337✔
1388
         local seen = node.parent and seenNodes[node.parent]
337✔
1389
         if not seen then
337✔
1390
            if node.used then
58✔
1391
               if node.is_prebreak then
34✔
1392
                  naturalTotals:___add(node:prebreakWidth())
34✔
1393
                  node.height = node:prebreakHeight()
34✔
1394
               else
1395
                  naturalTotals:___add(node:postbreakWidth())
34✔
1396
                  node.height = node:postbreakHeight()
34✔
1397
               end
1398
            else
1399
               naturalTotals:___add(node:replacementWidth():absolute())
72✔
1400
               node.height = node:replacementHeight():absolute()
72✔
1401
            end
1402
         end
1403
      elseif not skipping then
1,276✔
1404
         naturalTotals:___add(node.width)
1,276✔
1405
      end
1406
   end
1407

1408
   local _left = breakwidth:tonumber() - naturalTotals:tonumber()
705✔
1409
   local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
470✔
1410
   ratio = math.max(ratio, -1)
235✔
1411
   return ratio, naturalTotals
235✔
1412
end
1413

1414
function typesetter:chuck () -- emergency shipout everything
27✔
1415
   self:leaveHmode(true)
24✔
1416
   if #self.state.outputQueue > 0 then
24✔
1417
      SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
11✔
1418
      self:outputLinesToPage(self.state.outputQueue)
11✔
1419
      self.state.outputQueue = {}
11✔
1420
   end
1421
end
1422

1423
-- Logic for building an hbox from content.
1424
-- It returns the hbox and an horizontal list of (migrating) elements
1425
-- extracted outside of it.
1426
-- None of these are pushed to the typesetter node queue. The caller
1427
-- is responsible of doing it, if the hbox is built for anything
1428
-- else than e.g. measuring it. Likewise, the call has to decide
1429
-- what to do with the migrating content.
1430
local _rtl_pre_post = function (box, atypesetter, line)
1431
   local advance = function ()
1432
      atypesetter.frame:advanceWritingDirection(box:scaledWidth(line))
28✔
1433
   end
1434
   if atypesetter.frame:writingDirection() == "RTL" then
28✔
1435
      advance()
×
1436
      return function () end
×
1437
   else
1438
      return advance
14✔
1439
   end
1440
end
1441
function typesetter:makeHbox (content)
27✔
1442
   local recentContribution = {}
15✔
1443
   local migratingNodes = {}
15✔
1444

1445
   self:pushState()
15✔
1446
   self.state.hmodeOnly = true
15✔
1447
   SILE.process(content)
15✔
1448

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

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

1486
   local hbox = SILE.types.node.hbox({
30✔
1487
      height = h,
15✔
1488
      width = l,
15✔
1489
      depth = d,
15✔
1490
      value = recentContribution,
15✔
1491
      outputYourself = function (box, atypesetter, line)
1492
         local _post = _rtl_pre_post(box, atypesetter, line)
14✔
1493
         local ox = atypesetter.frame.state.cursorX
14✔
1494
         local oy = atypesetter.frame.state.cursorY
14✔
1495
         SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
14✔
1496
         SU.debug("hboxes", function ()
28✔
1497
            -- setCursor is also invoked by the internal (wrapped) hboxes etc.
1498
            -- so we must show our debug box before outputting its content.
1499
            SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
1500
            return "Drew debug outline around hbox"
×
1501
         end)
1502
         for _, node in ipairs(box.value) do
21✔
1503
            node:outputYourself(atypesetter, line)
7✔
1504
         end
1505
         atypesetter.frame.state.cursorX = ox
14✔
1506
         atypesetter.frame.state.cursorY = oy
14✔
1507
         _post()
14✔
1508
      end,
1509
   })
1510
   return hbox, migratingNodes
15✔
1511
end
1512

1513
function typesetter:pushHlist (hlist)
27✔
1514
   for _, h in ipairs(hlist) do
3✔
1515
      self:pushHorizontal(h)
×
1516
   end
1517
end
1518

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

1553
--- Flatten a node list into just its string representation.
1554
-- @tparam table nodes Typeset nodes
1555
-- @treturn string Text reconstruction of the nodes
1556
local function _nodesToText (nodes)
1557
   -- A real interword space width depends on several settings (depending on variable
1558
   -- spaces being enabled or not, etc.), and the computation below takes that into
1559
   -- account.
1560
   local iwspc = SILE.shaper:measureSpace(SILE.font.loadDefaults({}))
4✔
1561
   local iwspcmin = (iwspc.length - iwspc.shrink):tonumber()
4✔
1562

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

1592
--- Convert a SILE AST to a textual representation.
1593
-- This is similar to SU.ast.contentToString(), but it performs a full
1594
-- typesetting of the content, and then reconstructs the text from the
1595
-- typeset nodes.
1596
-- @tparam table content SILE AST to process
1597
-- @treturn string Textual representation of the content
1598
function typesetter:contentToText (content)
27✔
1599
   self:pushState()
2✔
1600
   self.state.hmodeOnly = true
2✔
1601
   SILE.process(content)
2✔
1602
   local text = _nodesToText(self.state.nodes)
2✔
1603
   self:popState()
2✔
1604
   return text
2✔
1605
end
1606

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

© 2025 Coveralls, Inc