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

sile-typesetter / sile / 6934957716

20 Nov 2023 07:35PM UTC coverage: 57.468% (-3.2%) from 60.703%
6934957716

push

github

web-flow
Merge c91d9a7d4 into 34e2e5335

60 of 79 new or added lines in 1 file covered. (75.95%)

717 existing lines in 27 files now uncovered.

8957 of 15586 relevant lines covered (57.47%)

5715.38 hits per line

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

71.69
/typesetters/base.lua
1
--- SILE typesetter (default/base) class.
2
--
3
-- @copyright License: MIT
4
-- @module typesetters.base
5
--
6

7
-- Typesetter base class
8

9
local typesetter = pl.class()
13✔
10
typesetter.type = "typesetter"
13✔
11
typesetter._name = "base"
13✔
12

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

20
-- Local helper class to compare pairs of margins
21
local _margins = pl.class({
26✔
22
    lskip = SILE.nodefactory.glue(),
26✔
23
    rskip = SILE.nodefactory.glue(),
26✔
24

25
    _init = function (self, lskip, rskip)
26
      self.lskip, self.rskip = lskip, rskip
430✔
27
    end,
28

29
    __eq = function (self, other)
30
      return self.lskip.width == other.lskip.width and self.rskip.width == other.rskip.width
×
31
    end
32

33
  })
34

35
local warned = false
13✔
36

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

49
function typesetter:_init (frame)
13✔
50
  self:declareSettings()
37✔
51
  self.hooks = {}
37✔
52
  self.breadcrumbs = SU.breadcrumbs()
74✔
53

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

63
function typesetter.declareSettings(_)
13✔
64

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

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

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

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

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

100
  SILE.settings:declare({
74✔
101
    parameter = "typesetter.parfillskip",
102
    type = "glue",
103
    default = SILE.nodefactory.glue("0pt plus 10000pt"),
74✔
104
    help = "Glue added at the end of a paragraph"
×
105
  })
106

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

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

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

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

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

142
end
143

144
function typesetter:initState ()
13✔
145
  self.state = {
50✔
146
    nodes = {},
50✔
147
    outputQueue = {},
50✔
148
    lastBadness = awful_bad,
50✔
149
  }
50✔
150
end
151

152
function typesetter:initFrame (frame)
13✔
153
  if frame then
70✔
154
    self.frame = frame
68✔
155
    self.frame:init(self)
68✔
156
  end
157
end
158

159
function typesetter.getMargins ()
13✔
160
  return _margins(SILE.settings:get("document.lskip"), SILE.settings:get("document.rskip"))
1,290✔
161
end
162

163
function typesetter.setMargins (_, margins)
13✔
164
  SILE.settings:set("document.lskip", margins.lskip)
×
165
  SILE.settings:set("document.rskip", margins.rskip)
×
166
end
167

168
function typesetter:pushState ()
13✔
169
  self.stateQueue[#self.stateQueue+1] = self.state
13✔
170
  self:initState()
13✔
171
end
172

173
function typesetter:popState (ncount)
13✔
174
  local offset = ncount and #self.stateQueue - ncount or nil
13✔
175
  self.state = table.remove(self.stateQueue, offset)
26✔
176
  if not self.state then SU.error("Typesetter state queue empty") end
13✔
177
end
178

179
function typesetter:isQueueEmpty ()
13✔
180
  if not self.state then return nil end
304✔
181
  return #self.state.nodes == 0 and #self.state.outputQueue == 0
304✔
182
end
183

184
function typesetter:vmode ()
13✔
185
  return #self.state.nodes == 0
25✔
186
end
187

188
function typesetter:debugState ()
13✔
189
  print("\n---\nI am in "..(self:vmode() and "vertical" or "horizontal").." mode")
×
190
  print("Writing into " .. tostring(self.frame))
×
191
  print("Recent contributions: ")
×
192
  for i = 1, #(self.state.nodes) do
×
193
    io.stderr:write(self.state.nodes[i].. " ")
×
194
  end
195
  print("\nVertical list: ")
×
196
  for i = 1, #(self.state.outputQueue) do
×
197
    print("  "..self.state.outputQueue[i])
×
198
  end
199
end
200

201
-- Boxy stuff
202
function typesetter:pushHorizontal (node)
13✔
203
  self:initline()
491✔
204
  self.state.nodes[#self.state.nodes+1] = node
491✔
205
  return node
491✔
206
end
207

208
function typesetter:pushVertical (vbox)
13✔
209
  self.state.outputQueue[#self.state.outputQueue+1] = vbox
554✔
210
  return vbox
554✔
211
end
212

213
function typesetter:pushHbox (spec)
13✔
214
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
215
  local ntype = SU.type(spec)
14✔
216
  local node = (ntype == "hbox" or ntype == "zerohbox") and spec or SILE.nodefactory.hbox(spec)
14✔
217
  return self:pushHorizontal(node)
14✔
218
end
219

220
function typesetter:pushUnshaped (spec)
13✔
221
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
222
  local node = SU.type(spec) == "unshaped" and spec or SILE.nodefactory.unshaped(spec)
202✔
223
  return self:pushHorizontal(node)
101✔
224
end
225

226
function typesetter:pushGlue (spec)
13✔
227
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
228
  local node = SU.type(spec) == "glue" and spec or SILE.nodefactory.glue(spec)
454✔
229
  return self:pushHorizontal(node)
227✔
230
end
231

232
function typesetter:pushExplicitGlue (spec)
13✔
233
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
234
  local node = SU.type(spec) == "glue" and spec or SILE.nodefactory.glue(spec)
×
235
  node.explicit = true
×
236
  node.discardable = false
×
237
  return self:pushHorizontal(node)
×
238
end
239

240
function typesetter:pushPenalty (spec)
13✔
241
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushHorizontal() to pass a premade node instead of a spec") end
242
  local node = SU.type(spec) == "penalty" and spec or SILE.nodefactory.penalty(spec)
224✔
243
  return self:pushHorizontal(node)
112✔
244
end
245

246
function typesetter:pushMigratingMaterial (material)
13✔
247
  local node = SILE.nodefactory.migrating({ material = material })
2✔
248
  return self:pushHorizontal(node)
2✔
249
end
250

251
function typesetter:pushVbox (spec)
13✔
252
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
253
  local node = SU.type(spec) == "vbox" and spec or SILE.nodefactory.vbox(spec)
×
254
  return self:pushVertical(node)
×
255
end
256

257
function typesetter:pushVglue (spec)
13✔
258
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
259
  local node = SU.type(spec) == "vglue" and spec or SILE.nodefactory.vglue(spec)
254✔
260
  return self:pushVertical(node)
127✔
261
end
262

263
function typesetter:pushExplicitVglue (spec)
13✔
264
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
265
  local node = SU.type(spec) == "vglue" and spec or SILE.nodefactory.vglue(spec)
148✔
266
  node.explicit = true
74✔
267
  node.discardable = false
74✔
268
  return self:pushVertical(node)
74✔
269
end
270

271
function typesetter:pushVpenalty (spec)
13✔
272
  -- if SU.type(spec) ~= "table" then SU.warn("Please use pushVertical() to pass a premade node instead of a spec") end
273
  local node = SU.type(spec) == "penalty" and spec or SILE.nodefactory.penalty(spec)
50✔
274
  return self:pushVertical(node)
25✔
275
end
276

277
-- Actual typesetting functions
278
function typesetter:typeset (text)
13✔
279
  text = tostring(text)
145✔
280
  if text:match("^%\r?\n$") then return end
145✔
281
  local pId = SILE.traceStack:pushText(text)
117✔
282
  for token in SU.gtoke(text, SILE.settings:get("typesetter.parseppattern")) do
501✔
283
    if token.separator then
150✔
284
      self:endline()
98✔
285
    else
286
      self:setpar(token.string)
101✔
287
    end
288
  end
289
  SILE.traceStack:pop(pId)
117✔
290
end
291

292
function typesetter:initline ()
13✔
293
  if self.state.hmodeOnly then return end -- https://github.com/sile-typesetter/sile/issues/1718
571✔
294
  if (#self.state.nodes == 0) then
571✔
295
    self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
224✔
296
    SILE.documentState.documentClass.newPar(self)
112✔
297
  end
298
end
299

300
function typesetter:endline ()
13✔
301
  self:leaveHmode()
127✔
302
  SILE.documentState.documentClass.endPar(self)
127✔
303
end
304

305
-- Takes string, writes onto self.state.nodes
306
function typesetter:setpar (text)
13✔
307
  text = text:gsub("\r?\n", " "):gsub("\t", " ")
101✔
308
  if (#self.state.nodes == 0) then
101✔
309
    if not SILE.settings:get("typesetter.obeyspaces") then
160✔
310
      text = text:gsub("^%s+", "")
80✔
311
    end
312
    self:initline()
80✔
313
  end
314
  if #text >0 then
101✔
315
    self:pushUnshaped({ text = text, options= SILE.font.loadDefaults({})})
202✔
316
  end
317
end
318

319
function typesetter:breakIntoLines (nodelist, breakWidth)
13✔
320
  self:shapeAllNodes(nodelist)
112✔
321
  local breakpoints = SILE.linebreak:doBreak(nodelist, breakWidth)
112✔
322
  return self:breakpointsToLines(breakpoints)
112✔
323
end
324

325
local function getLastShape(nodelist)
326
  local hasGlue
327
  local last
328
  if nodelist then
×
329
    -- The node list may contain nnodes, penalties, kern and glue
330
    -- We skip the latter, and retrieve the last shaped item.
331
    for i = #nodelist, 1, -1 do
×
332
      local n = nodelist[i]
×
333
      if n.is_nnode then
×
334
        local items = n.nodes[#n.nodes].value.items
×
335
        last = items[#items]
×
336
        break
337
      end
338
      if n.is_kern and n.subtype == "punctspace" then
×
339
        -- Some languages such as French insert a special space around
340
        -- punctuations. In those case, we should not need italic correction.
341
        break
342
      end
343
      if n.is_glue then hasGlue = true end
×
344
    end
345
  end
346
  return last, hasGlue
×
347
end
348
local function getFirstShape(nodelist)
349
  local first
350
  local hasGlue
351
  if nodelist then
×
352
    -- The node list may contain nnodes, penalties, kern and glue
353
    -- We skip the latter, and retrieve the first shaped item.
354
    for i = 1, #nodelist do
×
355
      local n = nodelist[i]
×
356
      if n.is_nnode then
×
357
        local items = n.nodes[1].value.items
×
358
        first = items[1]
×
359
        break
360
      end
361
      if n.is_kern and n.subtype == "punctspace" then
×
362
        -- Some languages such as French insert a special space around
363
        -- punctuations. In those case, we should not need italic correction.
364
        break
365
      end
366
      if n.is_glue then hasGlue = true end
×
367
    end
368
  end
369
  return first, hasGlue
×
370
end
371

372
local function fromItalicCorrection (precShape, curShape)
373
  local xOffset
374
  if not curShape or not precShape then
×
375
    xOffset = 0
×
376
  else
377
    -- Computing italic correction is at best heuristics.
378
    -- The strong assumption is that italic is slanted to the right.
379
    -- Thus, the part of the character that goes beyond its width is usually
380
    -- maximal at the top of the glyph.
381
    -- E.g. consider a "f", that would be the top hook extent.
382
    -- Pathological cases exist, such as fonts with a Q with a long tail,
383
    -- but these will rarely occur in usual languages. For instance, Klingon's
384
    -- "QaQ" might be an issue, but there's not much we can do...
385
    -- Another assumption is that we can distribute that extent in proportion
386
    -- with the next character's height.
387
    -- This might not work that well with non-Latin scripts.
388
    local d = precShape.glyphWidth + precShape.x_bearing
×
389
    local delta = d > precShape.width and d - precShape.width or 0
×
390
    xOffset = precShape.height <= curShape.height
×
391
      and delta
×
392
      or delta * curShape.height / precShape.height
×
393
  end
394
  return xOffset
×
395
end
396

397
local function toItalicCorrection (precShape, curShape)
398
  if not SILE.settings:get("typesetter.italicCorrection") then return end
×
399
  local xOffset
400
  if not curShape or not precShape then
×
401
    xOffset = 0
×
402
  else
403
    -- Same assumptions as fromItalicCorrection(), but on the starting side of
404
    -- the glyph.
405
    local d = curShape.x_bearing
×
406
    local delta = d < 0 and -d or 0
×
407
    xOffset = precShape.depth >= curShape.depth
×
408
      and delta
×
409
      or delta * precShape.depth / curShape.depth
×
410
  end
411
  return xOffset
×
412
end
413

414
local function isItalicLike(nnode)
415
  -- We could do...
416
  --  return nnode and string.lower(nnode.options.style) == "italic"
417
  -- But it's probably more robust to use the italic angle, so that
418
  -- thin italic, oblique or slanted fonts etc. may work too.
419
  local ot = require("core.opentype-parser")
×
420
  local face = SILE.font.cache(nnode.options, SILE.shaper.getFace)
×
421
  local font = ot.parseFont(face)
×
422
  return font.post.italicAngle ~= 0
×
423
end
424

425
function typesetter.shapeAllNodes (_, nodelist, inplace)
13✔
426
  inplace = SU.boolean(inplace, true) -- Compatibility with earlier versions
696✔
427
  local newNodelist = {}
348✔
428
  local prec
429
  local precShapedNodes
430
  for _, current in ipairs(nodelist) do
4,395✔
431
    if current.is_unshaped then
4,047✔
432
      local shapedNodes = current:shape()
101✔
433

434
      if SILE.settings:get("typesetter.italicCorrection") and prec then
202✔
435
        local itCorrOffset
436
        local isGlue
437
        if isItalicLike(prec) and not isItalicLike(current) then
×
438
          local precShape, precHasGlue = getLastShape(precShapedNodes)
×
439
          local curShape, curHasGlue = getFirstShape(shapedNodes)
×
440
          isGlue = precHasGlue or curHasGlue
×
441
          itCorrOffset = fromItalicCorrection(precShape, curShape)
×
442
        elseif not isItalicLike(prec) and isItalicLike(current) then
×
443
          local precShape, precHasGlue = getLastShape(precShapedNodes)
×
444
          local curShape, curHasGlue = getFirstShape(shapedNodes)
×
445
          isGlue = precHasGlue or curHasGlue
×
446
          itCorrOffset = toItalicCorrection(precShape, curShape)
×
447
        end
448
        if itCorrOffset and itCorrOffset ~= 0 then
×
449
          -- If one of the node contains a glue (e.g. "a \em{proof} is..."),
450
          -- line breaking may occur between them, so our correction shall be
451
          -- a glue too.
452
          -- Otherwise, the font change is considered to occur at a non-breaking
453
          -- point (e.g. "\em{proof}!") and the correction shall be a kern.
454
          local makeItCorrNode = isGlue and SILE.nodefactory.glue or SILE.nodefactory.kern
×
455
          newNodelist[#newNodelist+1] = makeItCorrNode({
×
456
            width = SILE.length(itCorrOffset),
457
            subtype = "itcorr"
×
458
          })
459
        end
460
      end
461

462
      pl.tablex.insertvalues(newNodelist, shapedNodes)
101✔
463

464
      prec = current
101✔
465
      precShapedNodes = shapedNodes
101✔
466
    else
467
      prec = nil
3,946✔
468
      newNodelist[#newNodelist+1] = current
3,946✔
469
    end
470
  end
471

472
  if not inplace then
348✔
473
    return newNodelist
12✔
474
  end
475

476
  for i =1, #newNodelist do nodelist[i] = newNodelist[i] end
5,425✔
477
  if #nodelist > #newNodelist then
336✔
478
    for i= #newNodelist + 1, #nodelist do nodelist[i] = nil end
×
479
  end
480
end
481

482
-- Empties self.state.nodes, breaks into lines, puts lines into vbox, adds vbox to
483
-- Turns a node list into a list of vboxes
484
function typesetter:boxUpNodes ()
13✔
485
  local nodelist = self.state.nodes
275✔
486
  if #nodelist == 0 then return {} end
275✔
487
  for j = #nodelist, 1, -1 do
147✔
488
    if not nodelist[j].is_migrating then
147✔
489
      if nodelist[j].discardable then
147✔
490
        table.remove(nodelist, j)
70✔
491
      else
492
        break
493
      end
494
    end
495
  end
496
  while (#nodelist > 0 and nodelist[1].is_penalty) do table.remove(nodelist, 1) end
112✔
497
  if #nodelist == 0 then return {} end
112✔
498
  self:shapeAllNodes(nodelist)
112✔
499
  local parfillskip = SILE.settings:get("typesetter.parfillskip")
112✔
500
  parfillskip.discardable = false
112✔
501
  self:pushGlue(parfillskip)
112✔
502
  self:pushPenalty(-inf_bad)
112✔
503
  SU.debug("typesetter", function ()
224✔
504
    return "Boxed up "..(#nodelist > 500 and (#nodelist).." nodes" or SU.contentToString(nodelist))
×
505
  end)
506
  local breakWidth = SILE.settings:get("typesetter.breakwidth") or self.frame:getLineWidth()
224✔
507
  local lines = self:breakIntoLines(nodelist, breakWidth)
112✔
508
  local vboxes = {}
112✔
509
  for index=1, #lines do
267✔
510
    local line = lines[index]
155✔
511
    local migrating = {}
155✔
512
    -- Move any migrating material
513
    local nodes = {}
155✔
514
    for i =1, #line.nodes do
3,164✔
515
      local node = line.nodes[i]
3,009✔
516
      if node.is_migrating then
3,009✔
517
        for j=1, #node.material do migrating[#migrating+1] = node.material[j] end
2✔
518
      else
519
        nodes[#nodes+1] = node
3,007✔
520
      end
521
    end
522
    local vbox = SILE.nodefactory.vbox({ nodes = nodes, ratio = line.ratio })
155✔
523
    local pageBreakPenalty = 0
155✔
524
    if (#lines > 1 and index == 1) then
155✔
525
      pageBreakPenalty = SILE.settings:get("typesetter.widowpenalty")
16✔
526
    elseif (#lines > 1 and index == (#lines-1)) then
147✔
527
      pageBreakPenalty = SILE.settings:get("typesetter.orphanpenalty")
16✔
528
    end
529
    vboxes[#vboxes+1] = self:leadingFor(vbox, self.state.previousVbox)
310✔
530
    vboxes[#vboxes+1] = vbox
155✔
531
    for i=1, #migrating do vboxes[#vboxes+1] = migrating[i] end
156✔
532
    self.state.previousVbox = vbox
155✔
533
    if pageBreakPenalty > 0 then
155✔
534
      SU.debug("typesetter", "adding penalty of", pageBreakPenalty, "after", vbox)
16✔
535
      vboxes[#vboxes+1] = SILE.nodefactory.penalty(pageBreakPenalty)
32✔
536
    end
537
  end
538
  return vboxes
112✔
539
end
540

541
function typesetter.pageTarget (_)
13✔
542
  SU.deprecated("SILE.typesetter:pageTarget", "SILE.typesetter:getTargetLength", "0.13.0", "0.14.0")
×
543
end
544

545
function typesetter:getTargetLength ()
13✔
546
  return self.frame:getTargetLength()
271✔
547
end
548

549
function typesetter:registerHook (category, func)
13✔
550
  if not self.hooks[category] then self.hooks[category] = {} end
17✔
551
  table.insert(self.hooks[category], func)
17✔
552
end
553

554
function typesetter:runHooks (category, data)
13✔
555
  if not self.hooks[category] then return data end
288✔
556
  for _, func in ipairs(self.hooks[category]) do
107✔
557
    data = func(self, data)
124✔
558
  end
559
  return data
45✔
560
end
561

562
function typesetter:registerFrameBreakHook (func)
13✔
563
  self:registerHook("framebreak", func)
2✔
564
end
565

566
function typesetter:registerNewFrameHook (func)
13✔
567
  self:registerHook("newframe", func)
×
568
end
569

570
function typesetter:registerPageEndHook (func)
13✔
571
  self:registerHook("pageend", func)
15✔
572
end
573

574
function typesetter:buildPage ()
13✔
575
  local pageNodeList
576
  local res
577
  if self:isQueueEmpty() then return false end
504✔
578
  if SILE.scratch.insertions then SILE.scratch.insertions.thisPage = {} end
233✔
579
  pageNodeList, res = SILE.pagebuilder:findBestBreak({
466✔
580
    vboxlist = self.state.outputQueue,
233✔
581
    target   = self:getTargetLength(),
466✔
582
    restart  = self.frame.state.pageRestart
233✔
583
  })
233✔
584
  if not pageNodeList then -- No break yet
233✔
585
    -- self.frame.state.pageRestart = res
586
    self:runHooks("noframebreak")
193✔
587
    return false
193✔
588
  end
589
  SU.debug("pagebuilder", "Buildding page for", self.frame.id)
40✔
590
  self.state.lastPenalty = res
40✔
591
  self.frame.state.pageRestart = nil
40✔
592
  pageNodeList = self:runHooks("framebreak", pageNodeList)
80✔
593
  self:setVerticalGlue(pageNodeList, self:getTargetLength())
80✔
594
  self:outputLinesToPage(pageNodeList)
40✔
595
  return true
40✔
596
end
597

598
function typesetter:setVerticalGlue (pageNodeList, target)
13✔
599
  local glues = {}
40✔
600
  local gTotal = SILE.length()
40✔
601
  local totalHeight = SILE.length()
40✔
602

603
  local pastTop = false
40✔
604
  for _, node in ipairs(pageNodeList) do
468✔
605
    if not pastTop and not node.discardable and not node.explicit then
428✔
606
      -- "Ignore discardable and explicit glues at the top of a frame."
607
      -- See typesetter:outputLinesToPage()
608
      -- Note the test here doesn't check is_vglue, so will skip other
609
      -- discardable nodes (e.g. penalties), but it shouldn't matter
610
      -- for the type of computing performed here.
611
      pastTop = true
40✔
612
    end
613
    if pastTop then
428✔
614
      if not node.is_insertion then
376✔
615
        totalHeight:___add(node.height)
376✔
616
        totalHeight:___add(node.depth)
376✔
617
      end
618
      if node.is_vglue then
376✔
619
        table.insert(glues, node)
248✔
620
        gTotal:___add(node.height)
248✔
621
      end
622
    end
623
  end
624

625
  if totalHeight:tonumber() == 0 then
80✔
626
   return SU.debug("pagebuilder", "No glue adjustment needed on empty page")
3✔
627
  end
628

629
  local adjustment = target - totalHeight
37✔
630
  if adjustment:tonumber() > 0 then
74✔
631
    if adjustment > gTotal.stretch then
23✔
632
      if (adjustment - gTotal.stretch):tonumber() > SILE.settings:get("typesetter.underfulltolerance"):tonumber() then
5✔
633
        SU.warn("Underfull frame " .. self.frame.id .. ": " .. adjustment .. " stretchiness required to fill but only " .. gTotal.stretch .. " available")
1✔
634
      end
635
      adjustment = gTotal.stretch
1✔
636
    end
637
    if gTotal.stretch:tonumber() > 0 then
46✔
638
      for i = 1, #glues do
264✔
639
        local g = glues[i]
241✔
640
        g:adjustGlue(adjustment:tonumber() * g.height.stretch:absolute() / gTotal.stretch)
1,205✔
641
      end
642
    end
643
  elseif adjustment:tonumber() < 0 then
28✔
644
    adjustment = 0 - adjustment
14✔
645
    if adjustment > gTotal.shrink then
14✔
646
      if (adjustment - gTotal.shrink):tonumber() > SILE.settings:get("typesetter.overfulltolerance"):tonumber() then
70✔
647
        SU.warn("Overfull frame " .. self.frame.id .. ": " .. adjustment .. " shrinkability required to fit but only " .. gTotal.shrink .. " available")
×
648
      end
649
      adjustment = gTotal.shrink
14✔
650
    end
651
    if gTotal.shrink:tonumber() > 0 then
28✔
652
      for i = 1, #glues do
×
653
        local g  = glues[i]
×
654
        g:adjustGlue(-adjustment:tonumber() * g.height.shrink:absolute() / gTotal.shrink)
×
655
      end
656
    end
657
  end
658
  SU.debug("pagebuilder", "Glues for this page adjusted by", adjustment, "drawn from", gTotal)
37✔
659
end
660

661
function typesetter:initNextFrame ()
13✔
662
  local oldframe = self.frame
27✔
663
  self.frame:leave(self)
27✔
664
  if #self.state.outputQueue == 0 then
27✔
665
    self.state.previousVbox = nil
21✔
666
  end
667
  if self.frame.next and self.state.lastPenalty > supereject_penalty then
27✔
668
    self:initFrame(SILE.getFrame(self.frame.next))
×
669
  elseif not self.frame:isMainContentFrame() then
54✔
670
    if #self.state.outputQueue > 0 then
12✔
UNCOV
671
      SU.warn("Overfull content for frame " .. self.frame.id)
×
UNCOV
672
      self:chuck()
×
673
    end
674
  else
675
    self:runHooks("pageend")
15✔
676
    SILE.documentState.documentClass:endPage()
15✔
677
    self:initFrame(SILE.documentState.documentClass:newPage())
30✔
678
  end
679

680
  if not SU.feq(oldframe:getLineWidth(), self.frame:getLineWidth()) then
108✔
681
    self:pushBack()
×
682
    -- Some what of a hack below.
683
    -- Before calling this method, we were in vertical mode...
684
    -- pushback occurred, and it seems it messes up a bit...
685
    -- Regardless what it does, at the end, we ought to be in vertical mode
686
    -- again:
687
    self:leaveHmode()
×
688
  else
689
    -- If I have some things on the vertical list already, they need
690
    -- proper top-of-frame leading applied.
691
    if #self.state.outputQueue > 0 then
27✔
692
      local lead = self:leadingFor(self.state.outputQueue[1], nil)
6✔
693
      if lead then
6✔
694
        table.insert(self.state.outputQueue, 1, lead)
6✔
695
      end
696
    end
697
  end
698
  self:runHooks("newframe")
27✔
699

700
end
701

702
function typesetter:pushBack ()
13✔
703
  SU.debug("typesetter", "Pushing back", #self.state.outputQueue, "nodes")
×
704
  local oldqueue = self.state.outputQueue
×
705
  self.state.outputQueue = {}
×
706
  self.state.previousVbox = nil
×
707
  local lastMargins = self:getMargins()
×
708
  for _, vbox in ipairs(oldqueue) do
×
709
    SU.debug("pushback", "process box", vbox)
×
710
    if vbox.margins and vbox.margins ~= lastMargins then
×
711
      SU.debug("pushback", "new margins", lastMargins, vbox.margins)
×
712
      if not self.state.grid then self:endline() end
×
713
      self:setMargins(vbox.margins)
×
714
    end
715
    if vbox.explicit then
×
716
      SU.debug("pushback", "explicit", vbox)
×
717
      self:endline()
×
718
      self:pushExplicitVglue(vbox)
×
719
    elseif vbox.is_insertion then
×
720
      SU.debug("pushback", "pushBack", "insertion", vbox)
×
721
      SILE.typesetter:pushMigratingMaterial({ material = { vbox } })
×
722
    elseif not vbox.is_vglue and not vbox.is_penalty then
×
723
      SU.debug("pushback", "not vglue or penalty", vbox.type)
×
724
      local discardedFistInitLine = false
×
725
      if (#self.state.nodes == 0) then
×
726
        -- Setup queue but avoid calling newPar
727
        self.state.nodes[#self.state.nodes+1] = SILE.nodefactory.zerohbox()
×
728
      end
729
      for i, node in ipairs(vbox.nodes) do
×
730
        if node.is_glue and not node.discardable then
×
731
          self:pushHorizontal(node)
×
732
        elseif node.is_glue and node.value == "margin" then
×
733
          SU.debug("pushback", "discard", node.value, node)
×
734
        elseif node.is_discretionary then
×
735
          SU.debug("pushback", "re-mark discretionary as unused", node)
×
736
          node.used = false
×
737
          if i == 1 then
×
738
            SU.debug("pushback", "keep first discretionary", node)
×
739
            self:pushHorizontal(node)
×
740
          else
741
            SU.debug("pushback", "discard all other discretionaries", node)
×
742
          end
743
        elseif node.is_zero then
×
744
          if discardedFistInitLine then self:pushHorizontal(node) end
×
745
          discardedFistInitLine = true
×
746
        elseif node.is_penalty then
×
747
          if not discardedFistInitLine then self:pushHorizontal(node) end
×
748
        else
749
          node.bidiDone = true
×
750
          self:pushHorizontal(node)
×
751
        end
752
      end
753
    else
754
      SU.debug("pushback", "discard", vbox.type)
×
755
    end
756
    lastMargins = vbox.margins
×
757
    -- self:debugState()
758
  end
759
  while self.state.nodes[#self.state.nodes]
×
760
  and (self.state.nodes[#self.state.nodes].is_penalty
×
761
    or self.state.nodes[#self.state.nodes].is_zero) do
×
762
    self.state.nodes[#self.state.nodes] = nil
×
763
  end
764
end
765

766
function typesetter:outputLinesToPage (lines)
13✔
767
  SU.debug("pagebuilder", "OUTPUTTING frame", self.frame.id)
50✔
768
  -- It would have been nice to avoid storing this "pastTop" into a frame
769
  -- state, to keep things less entangled. There are situations, though,
770
  -- we will have left horizontal mode (triggering output), but will later
771
  -- call typesetter:chuck() do deal with any remaining content, and we need
772
  -- to know whether some content has been output already.
773
  local pastTop = self.frame.state.totals.pastTop
50✔
774
  for _, line in ipairs(lines) do
508✔
775
    -- Ignore discardable and explicit glues at the top of a frame:
776
    -- Annoyingly, explicit glue *should* disappear at the top of a page.
777
    -- if you don't want that, add an empty vbox or something.
778
    if not pastTop and not line.discardable and not line.explicit then
458✔
779
      -- Note the test here doesn't check is_vglue, so will skip other
780
      -- discardable nodes (e.g. penalties), but it shouldn't matter
781
      -- for outputting.
782
      pastTop = true
50✔
783
    end
784
    if pastTop then
458✔
785
      line:outputYourself(self, line)
396✔
786
    end
787
  end
788
  self.frame.state.totals.pastTop = pastTop
50✔
789
end
790

791
function typesetter:leaveHmode (independent)
13✔
792
  if self.state.hmodeOnly then
275✔
793
    SU.error([[Paragraphs are forbidden in restricted horizontal mode.]])
×
794
  end
795
  SU.debug("typesetter", "Leaving hmode")
275✔
796
  local margins = self:getMargins()
275✔
797
  local vboxlist = self:boxUpNodes()
275✔
798
  self.state.nodes = {}
275✔
799
  -- Push output lines into boxes and ship them to the page builder
800
  for _, vbox in ipairs(vboxlist) do
603✔
801
    vbox.margins = margins
328✔
802
    self:pushVertical(vbox)
328✔
803
  end
804
  if independent then return end
275✔
805
  if self:buildPage() then
478✔
806
    self:initNextFrame()
27✔
807
  end
808
end
809

810
function typesetter:inhibitLeading ()
13✔
UNCOV
811
  self.state.previousVbox = nil
×
812
end
813

814
function typesetter.leadingFor (_, vbox, previous)
13✔
815
  -- Insert leading
816
  SU.debug("typesetter", "   Considering leading between two lines:")
161✔
817
  SU.debug("typesetter", "   1)", previous)
161✔
818
  SU.debug("typesetter", "   2)", vbox)
161✔
819
  if not previous then return SILE.nodefactory.vglue() end
161✔
820
  local prevDepth = previous.depth
110✔
821
  SU.debug("typesetter", "   Depth of previous line was", prevDepth)
110✔
822
  local bls = SILE.settings:get("document.baselineskip")
110✔
823
  local depth = bls.height:absolute() - vbox.height:absolute() - prevDepth:absolute()
550✔
824
  SU.debug("typesetter", "   Leading height =", bls.height, "-", vbox.height, "-", prevDepth, "=", depth)
110✔
825

826
  -- the lineskip setting is a vglue, but we need a version absolutized at this point, see #526
827
  local lead = SILE.settings:get("document.lineskip").height:absolute()
220✔
828
  if depth > lead then
110✔
829
    return SILE.nodefactory.vglue(SILE.length(depth.length, bls.height.stretch, bls.height.shrink))
136✔
830
  else
831
    return SILE.nodefactory.vglue(lead)
42✔
832
  end
833
end
834

835
function typesetter:addrlskip (slice, margins, hangLeft, hangRight)
13✔
836
  local LTR = self.frame:writingDirection() == "LTR"
310✔
837
  local rskip = margins[LTR and "rskip" or "lskip"]
155✔
838
  if not rskip then rskip = SILE.nodefactory.glue(0) end
155✔
839
  if hangRight and hangRight > 0 then
155✔
UNCOV
840
    rskip = SILE.nodefactory.glue({ width = rskip.width:tonumber() + hangRight })
×
841
  end
842
  rskip.value = "margin"
155✔
843
  -- while slice[#slice].discardable do table.remove(slice, #slice) end
844
  table.insert(slice, rskip)
155✔
845
  table.insert(slice, SILE.nodefactory.zerohbox())
310✔
846
  local lskip = margins[LTR and "lskip" or "rskip"]
155✔
847
  if not lskip then lskip = SILE.nodefactory.glue(0) end
155✔
848
  if hangLeft and hangLeft > 0 then
155✔
UNCOV
849
    lskip = SILE.nodefactory.glue({ width = lskip.width:tonumber() + hangLeft })
×
850
  end
851
  lskip.value = "margin"
155✔
852
  while slice[1].discardable do table.remove(slice, 1) end
155✔
853
  table.insert(slice, 1, lskip)
155✔
854
  table.insert(slice, 1, SILE.nodefactory.zerohbox())
310✔
855
end
856

857
function typesetter:breakpointsToLines (breakpoints)
13✔
858
  local linestart = 1
112✔
859
  local lines = {}
112✔
860
  local nodes = self.state.nodes
112✔
861

862
  for i = 1, #breakpoints do
269✔
863
    local point = breakpoints[i]
157✔
864
    if point.position ~= 0 then
157✔
865
      local slice = {}
157✔
866
      local seenNonDiscardable = false
157✔
867
      for j = linestart, point.position do
2,548✔
868
        slice[#slice+1] = nodes[j]
2,391✔
869
        if nodes[j] then
2,391✔
870
          if not nodes[j].discardable then
2,391✔
871
            seenNonDiscardable = true
1,551✔
872
          end
873
        end
874
      end
875
      if not seenNonDiscardable then
157✔
876
        -- Slip lines containing only discardable nodes (e.g. glues).
877
        SU.debug("typesetter", "Skipping a line containing only discardable nodes")
2✔
878
        linestart = point.position + 1
2✔
879
      else
880
        -- If the line ends with a discretionary, repeat it on the next line,
881
        -- so as to account for a potential postbreak.
882
        if slice[#slice].is_discretionary then
155✔
883
          linestart = point.position
7✔
884
        else
885
          linestart = point.position + 1
148✔
886
        end
887

888
        -- Then only we can add some extra margin glue...
889
        local mrg = self:getMargins()
155✔
890
        self:addrlskip(slice, mrg, point.left, point.right)
155✔
891

892
        -- And compute the line...
893
        local ratio = self:computeLineRatio(point.width, slice)
155✔
894
        local thisLine = { ratio = ratio, nodes = slice }
155✔
895
        lines[#lines+1] = thisLine
155✔
896
      end
897
    end
898
  end
899
  if linestart < #nodes then
112✔
900
    -- Abnormal, but warn so that one has a chance to check which bits
901
    -- are missing at output.
902
    SU.warn("Internal typesetter error " .. (#nodes - linestart) .. " skipped nodes")
×
903
  end
904
  return lines
112✔
905
end
906

907
function typesetter.computeLineRatio (_, breakwidth, slice)
13✔
908
  -- This somewhat wrong, see #1362 and #1528
909
  -- This is a somewhat partial workaround, at least made consistent with
910
  -- the nnode and discretionary outputYourself routines
911
  -- (which are somewhat wrong too, or to put it otherwise, the whole
912
  -- logic here, marking nodes without removing/replacing them, likely makes
913
  -- things more complex than they should).
914
  -- TODO Possibly consider a full rewrite/refactor.
915
  local naturalTotals = SILE.length()
155✔
916

917
  -- From the line end, check if the line is hyphenated (to account for a prebreak)
918
  -- or contains extraneous glues (e.g. to account for spaces to ignore).
919
  local n = #slice
155✔
920
  while n > 1 do
503✔
921
    if slice[n].is_glue or slice[n].is_zero then
503✔
922
      -- Skip margin glues (they'll be accounted for in the loop below) and
923
      -- zero boxes, so as to reach actual content...
924
      if slice[n].value ~= "margin" then
348✔
925
        -- ... but any other glue than a margin, at the end of a line, is actually
926
        -- extraneous. It will however also be accounted for below, so subtract
927
        -- them to cancel their width. Typically, if a line break occurred at
928
        -- a space, the latter is then at the end of the line now, and must be
929
        -- ignored.
930
        naturalTotals:___sub(slice[n].width)
193✔
931
      end
932
    elseif slice[n].is_discretionary then
155✔
933
      -- Stop as we reached an hyphenation, and account for the prebreak.
934
      slice[n].used = true
7✔
935
      if slice[n].parent then
7✔
936
        slice[n].parent.hyphenated = true
7✔
937
      end
938
      naturalTotals:___add(slice[n]:prebreakWidth())
14✔
939
      slice[n].height = slice[n]:prebreakHeight()
14✔
940
      break
7✔
941
    else
942
      -- Stop as we reached actual content.
943
      break
944
    end
945
    n = n - 1
348✔
946
  end
947

948
  local seenNodes = {}
155✔
949
  local skipping = true
155✔
950
  for i, node in ipairs(slice) do
3,164✔
951
    if node.is_box then
3,009✔
952
      skipping = false
1,523✔
953
      if node.parent and not node.parent.hyphenated then
1,523✔
954
        if not seenNodes[node.parent] then
356✔
955
          naturalTotals:___add(node.parent:lineContribution())
298✔
956
        end
957
        seenNodes[node.parent] = true
356✔
958
      else
959
        naturalTotals:___add(node:lineContribution())
2,334✔
960
      end
961
    elseif node.is_penalty and node.penalty == -inf_bad then
1,486✔
962
      skipping = false
110✔
963
    elseif node.is_discretionary then
1,376✔
964
      skipping = false
226✔
965
      local seen = node.parent and seenNodes[node.parent]
226✔
966
      if not seen and not node.used then
226✔
967
        naturalTotals:___add(node:replacementWidth():absolute())
15✔
968
        slice[i].height = slice[i]:replacementHeight():absolute()
15✔
969
      end
970
    elseif not skipping then
1,150✔
971
      naturalTotals:___add(node.width)
1,150✔
972
    end
973
  end
974

975
  -- From the line start, skip glues and margins, and check if it then starts
976
  -- with a used discretionary. If so, account for a postbreak.
977
  n = 1
155✔
978
  while n < #slice do
691✔
979
    if slice[n].is_discretionary and slice[n].used then
691✔
980
      naturalTotals:___add(slice[n]:postbreakWidth())
14✔
981
      slice[n].height = slice[n]:postbreakHeight()
14✔
982
      break
7✔
983
    elseif not (slice[n].is_glue or slice[n].is_zero) then
684✔
984
      break
148✔
985
    end
986
    n = n + 1
536✔
987
  end
988

989
  local _left = breakwidth:tonumber() - naturalTotals:tonumber()
465✔
990
  local ratio = _left / naturalTotals[_left < 0 and "shrink" or "stretch"]:tonumber()
310✔
991
  ratio = math.max(ratio, -1)
155✔
992
  return ratio, naturalTotals
155✔
993
end
994

995
function typesetter:chuck () -- emergency shipout everything
13✔
996
  self:leaveHmode(true)
22✔
997
  if (#self.state.outputQueue > 0) then
22✔
998
    SU.debug("typesetter", "Emergency shipout", #self.state.outputQueue, "lines in frame", self.frame.id)
10✔
999
    self:outputLinesToPage(self.state.outputQueue)
10✔
1000
    self.state.outputQueue = {}
10✔
1001
  end
1002
end
1003

1004
-- Logic for building an hbox from content.
1005
-- It returns the hbox and an horizontal list of (migrating) elements
1006
-- extracted outside of it.
1007
-- None of these are pushed to the typesetter node queue. The caller
1008
-- is responsible of doing it, if the hbox is built for anything
1009
-- else than e.g. measuring it. Likewise, the call has to decide
1010
-- what to do with the migrating content.
1011
local _rtl_pre_post = function (box, atypesetter, line)
1012
  local advance = function () atypesetter.frame:advanceWritingDirection(box:scaledWidth(line)) end
44✔
1013
  if atypesetter.frame:writingDirection() == "RTL" then
22✔
1014
    advance()
×
1015
    return function () end
×
1016
  else
1017
    return advance
11✔
1018
  end
1019
end
1020
function typesetter:makeHbox (content)
13✔
1021
  local recentContribution = {}
12✔
1022
  local migratingNodes = {}
12✔
1023

1024
  self:pushState()
12✔
1025
  self.state.hmodeOnly = true
12✔
1026
  SILE.process(content)
12✔
1027

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

1032
  -- Then we can process and measure the nodes.
1033
  local l = SILE.length()
12✔
1034
  local h, d = SILE.length(), SILE.length()
24✔
1035
  for i = 1, #nodes do
12✔
UNCOV
1036
    local node = nodes[i]
×
UNCOV
1037
    if node.is_migrating then
×
1038
      migratingNodes[#migratingNodes+1] = node
×
UNCOV
1039
    elseif node.is_discretionary then
×
1040
      -- HACK https://github.com/sile-typesetter/sile/issues/583
1041
      -- Discretionary nodes have a null line contribution...
1042
      -- But if discretionary nodes occur inside an hbox, since the latter
1043
      -- is not line-broken, they will never be marked as 'used' and will
1044
      -- evaluate to the replacement content (if any)...
1045
      recentContribution[#recentContribution+1] = node
×
1046
      l = l + node:replacementWidth():absolute()
×
1047
      -- The replacement content may have ascenders and descenders...
1048
      local hdisc = node:replacementHeight():absolute()
×
1049
      local ddisc = node:replacementDepth():absolute()
×
1050
      h = hdisc > h and hdisc or h
×
1051
      d = ddisc > d and ddisc or d
×
1052
      -- By the way it's unclear how this is expected to work in TTB
1053
      -- writing direction. For other type of nodes, the line contribution
1054
      -- evaluates to the height rather than the width in TTB, but the
1055
      -- whole logic might then be dubious there too...
1056
    else
UNCOV
1057
      recentContribution[#recentContribution+1] = node
×
UNCOV
1058
      l = l + node:lineContribution():absolute()
×
UNCOV
1059
      h = node.height > h and node.height or h
×
UNCOV
1060
      d = node.depth > d and node.depth or d
×
1061
    end
1062
  end
1063
  self:popState()
12✔
1064

1065
  local hbox = SILE.nodefactory.hbox({
24✔
1066
      height = h,
12✔
1067
      width = l,
12✔
1068
      depth = d,
12✔
1069
      value = recentContribution,
12✔
1070
      outputYourself = function (box, atypesetter, line)
1071
        local _post = _rtl_pre_post(box, atypesetter, line)
11✔
1072
        local ox = atypesetter.frame.state.cursorX
11✔
1073
        local oy = atypesetter.frame.state.cursorY
11✔
1074
        SILE.outputter:setCursor(atypesetter.frame.state.cursorX, atypesetter.frame.state.cursorY)
11✔
1075
        for _, node in ipairs(box.value) do
11✔
UNCOV
1076
          node:outputYourself(atypesetter, line)
×
1077
        end
1078
        atypesetter.frame.state.cursorX = ox
11✔
1079
        atypesetter.frame.state.cursorY = oy
11✔
1080
        _post()
11✔
1081
        SU.debug("hboxes", function ()
22✔
1082
          SILE.outputter:debugHbox(box, box:scaledWidth(line))
×
1083
          return "Drew debug outline around hbox"
×
1084
        end)
1085
      end
1086
    })
1087
  return hbox, migratingNodes
12✔
1088
end
1089

1090
function typesetter:pushHlist (hlist)
13✔
UNCOV
1091
  for _, h in ipairs(hlist) do
×
1092
    self:pushHorizontal(h)
×
1093
  end
1094
end
1095

1096
return typesetter
13✔
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