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

sile-typesetter / sile / 6713098919

31 Oct 2023 10:21PM UTC coverage: 52.831% (-21.8%) from 74.636%
6713098919

push

github

web-flow
Merge d0a2a1ee9 into b185d4972

45 of 45 new or added lines in 3 files covered. (100.0%)

8173 of 15470 relevant lines covered (52.83%)

6562.28 hits per line

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

0.0
/packages/insertions/init.lua
1
local base = require("packages.base")
×
2

3
local package = pl.class(base)
×
4
package._name = "insertions"
×
5

6
--[[
7
 Insertions handling is the most complicated and bug-prone part of
8
 SILE, and thus deserves to be well documented. If you plan to work
9
 on this, it would help you a lot to read chapter 29 of "TeX By Topic"
10
 by Victor Eijkhout.
11

12
 Note: While most SILE packages are completely self-contained, this one
13
 requires some support from the SILE core. We'll get to it later, but
14
 when the page builder comes across some of the magic boxes that are
15
 defined as part of insertions handling, it triggers a routine here
16
 which can alter the page builder's operation. So it is not really an
17
 optional package.
18

19
--
20

21
The typical insertion is a footnote but we provide a generic mechanism for
22
handling any kind of insertion. An insertion is material which is added from
23
the main flow of the document into a specific area on the page, and where
24
moving such material alters the shape of the page.
25

26
Because this is a generic mechanism, each insertion is part of a "class"
27
(just like in TeX.); you may have several different classes putting insertions
28
in different places on the page. These classes also define how the page shape
29
is altered by the insertion. So, a class using the footnotes package may specify
30
that insertions get added into the `footnotes' frame and the space used up by
31
those insertions also reduces the 'content' frame. If you have two columns but
32
one footnote frame, then an insertion will steal space equal to half the
33
insertion's height from each column's frame. This is specified as:
34

35
  insertInto = "footnotes",
36
  stealFrom = { leftColumn = 0.5, rightColumn = 0.5 }
37

38
Insertion classes also need to define the maximum height they can stretch to,
39
the skip or box placed before the first insertion on a page, and the skip placed
40
between insertions.
41

42
--]]
43

44
local initInsertionClass = function (_, classname, options)
45
  SU.required(options, "insertInto", "initializing insertions")
×
46
  SU.required(options, "stealFrom", "initializing insertions")
×
47
  SU.required(options, "maxHeight", "initializing insertions")
×
48
  if not options.topSkip and not options.topBox then
×
49
    SU.required(options, "topSkip", "initializing insertions")
×
50
  end
51

52
  -- Turn stealFrom into a hash, if it isn't one.
53
  if SU.type(options.stealFrom) ~= "table" then
×
54
    options.stealFrom = { options.stealFrom }
×
55
  end
56
  if options.stealFrom[1] then
×
57
    local rl = {}
×
58
    for i = 1, #(options.stealFrom) do rl[options.stealFrom[i]] = 1 end
×
59
    options.stealFrom = rl
×
60
  end
61

62
  if SU.type(options.insertInto) ~= "table" then
×
63
    options.insertInto = { frame = options.insertInto, ratio = 1 }
×
64
  end
65

66
  options.maxHeight = SILE.length(options.maxHeight)
×
67

68
  SILE.scratch.insertions.classes[classname] = options
×
69
end
70

71
--[[
72

73
Each insertion class stores a page's worth of content in a box.
74
In some ways it's a fairly standard vbox, but it also knows its own
75
typesetter and frame.
76

77
--]]
78

79
local insertionsThisPage = {}
×
80
SILE.nodefactory.insertionlist = pl.class(SILE.nodefactory.vbox)
×
81

82
SILE.nodefactory.insertionlist.type = "insertionlist"
×
83
SILE.nodefactory.insertionlist.frame = nil
×
84

85
function SILE.nodefactory.insertionlist:_init (spec)
×
86
  SILE.nodefactory.vbox._init(self, spec)
×
87
  self.typesetter = SILE.typesetters.base()
×
88
end
89

90
function SILE.nodefactory.insertionlist:__tostring ()
×
91
  return "PI<" .. self.nodes .. ">"
×
92
end
93

94
function SILE.nodefactory.insertionlist:outputYourself ()
×
95
  self.typesetter:initFrame(SILE.getFrame(self.frame))
×
96
  for _, node in ipairs(self.nodes) do
×
97
    node:outputYourself(self.typesetter, node)
×
98
  end
99
end
100

101
local thisPageInsertionBoxForClass = function (class)
102
  if not insertionsThisPage[class] then
×
103
    insertionsThisPage[class] = SILE.nodefactory.insertionlist({
×
104
      frame = SILE.scratch.insertions.classes[class].insertInto.frame
×
105
    })
106
  end
107
  return insertionsThisPage[class]
×
108
end
109

110
--[[
111

112
An insertion vbox, on the other hand, is a place where insertion material
113
is held until we are sure it is going to end up on the current page. (We
114
might be consuming material that will eventually end up on a future page.)
115
So we stick the material into an insertion vbox, and when the pagebuilder
116
sees this, magic will happen.
117

118
--]]
119
SILE.nodefactory.insertion = pl.class(SILE.nodefactory.vbox)
×
120

121
SILE.nodefactory.insertion.discardable = true
×
122
SILE.nodefactory.insertion.type = "insertion"
×
123
SILE.nodefactory.insertion.seen = false
×
124

125
function SILE.nodefactory.insertion:__tostring ()
×
126
  return "I<"..self.nodes[1].."...>"
×
127
end
128

129
function SILE.nodefactory.insertion.outputYourself (_)
×
130
end
131

132
-- And some utility methods to make the insertion processing code
133
-- easier to read.
134
function SILE.nodefactory.insertion:dropDiscardables ()
×
135
  while #self.nodes > 1 and self.nodes[#self.nodes].discardable do
×
136
    self.nodes[#self.nodes] = nil
×
137
  end
138
end
139

140
function SILE.nodefactory.insertion:split (materialToSplit, maxsize)
×
141
  local firstpage = SILE.pagebuilder:findBestBreak({
×
142
      vboxlist = materialToSplit,
143
      target   = maxsize,
144
      restart  = false,
145
      force    = true
×
146
    })
147
  if firstpage then
×
148
    self.nodes = {}
×
149
    self:append(materialToSplit)
×
150
    self.contentHeight = self.height
×
151
    self.contentDepth = self.depth
×
152
    self.depth = SILE.length(0)
×
153
    self.height = SILE.length(0)
×
154
    return SILE.pagebuilder:collateVboxes(firstpage)
×
155
  end
156
end
157

158
--[[
159

160
Set up a value to track how much smaller/larger to make a frame.
161
We have to track this on the frame, because different insertion
162
classes might affect the same frame; so we can't track it per class.
163
We also have to ensure it's initialized every time because we might
164
be shrinking a frame further down the page that the typesetter hasn't
165
entered yet.
166

167
--]]
168

169
local initShrinkage = function (frame)
170
  if not frame.state or not frame.state.totals then frame:init() end
×
171
  if not frame.state.totals.shrinkage then frame.state.totals.shrinkage = SILE.measurement(0) end
×
172
end
173

174
local nextInterInsertionSkip = function (class)
175
  local options = SILE.scratch.insertions.classes[class]
×
176
  local stuffSoFar = thisPageInsertionBoxForClass(class)
×
177
  if #stuffSoFar.nodes == 0 then
×
178
    if options["topBox"] then
×
179
      return options["topBox"]:absolute()
×
180
    elseif options["topSkip"] then
×
181
      return SILE.nodefactory.vglue(options["topSkip"]:tonumber())
×
182
    end
183
  else
184
    local skipSize = options["interInsertionSkip"]:tonumber()
×
185
    skipSize = skipSize - stuffSoFar.nodes[#stuffSoFar.nodes].depth:tonumber()
×
186
    return SILE.nodefactory.vglue(skipSize)
×
187
  end
188
end
189

190
local debugInsertion = function (ins, insbox, topBox, target, targetFrame, totalHeight)
191
  local insertionsHeight = ins.contentHeight:absolute() + topBox.height:absolute() + topBox.depth:absolute() + ins.contentDepth:absolute()
×
192
  SU.debug("insertions", "## Incoming insertion")
×
193
  SU.debug("insertions", "Top box height", topBox.height)
×
194
  SU.debug("insertions", "Insertion", ins, ins.height, ins.depth)
×
195
  SU.debug("insertions", "Total incoming height", insertionsHeight)
×
196
  SU.debug("insertions", "Insertions already in this class", insbox.height, insbox.depth)
×
197
  SU.debug("insertions", "Page target", target)
×
198
  SU.debug("insertions", "Page frame", targetFrame)
×
199
  SU.debug("insertions", totalHeight, "worth of content on page so far")
×
200
end
201

202
-- This just puts the insertion vbox into the typesetter's queues.
203
local insert = function (_, classname, vbox)
204
  local insertion = SILE.scratch.insertions.classes[classname]
×
205
  if not insertion then SU.error("Uninitialized insertion class " .. classname) end
×
206
  SILE.typesetter:pushMigratingMaterial({
×
207
      SILE.nodefactory.penalty(SILE.settings:get("insertion.penalty"))
×
208
    })
209
  SILE.typesetter:pushMigratingMaterial({
×
210
      SILE.nodefactory.insertion({
×
211
          class = classname,
212
          nodes = vbox.nodes,
213
          -- actual height and depth must remain zero for page glue calculations
214
          contentHeight = vbox.height,
215
          contentDepth = vbox.depth,
216
          frame = insertion.insertInto.frame,
217
          parent = SILE.typesetter.frame
×
218
        })
219
    })
220
end
221

222
function package:_init ()
×
223
  base._init(self)
×
224
  if not SILE.scratch.insertions then
×
225
    SILE.scratch.insertions = { classes = {} }
×
226
  end
227
  if not SILE.insertions then
×
228
    SILE.insertions = {}
×
229
  end
230

231
  --[[ Mark a frame for reduction. --]]
232

233
  SILE.insertions.setShrinkage = function (classname, amount)
×
234
    local reduceList = SILE.scratch.insertions.classes[classname].stealFrom
×
235
    for fName, ratio in pairs(reduceList) do
×
236
      local frame = SILE.getFrame(fName)
×
237
      if frame then
×
238
        initShrinkage(frame)
×
239
        SU.debug("insertions", "Shrinking", fName, "by", amount * ratio)
×
240
        frame.state.totals.shrinkage = frame.state.totals.shrinkage + amount * ratio
×
241
      end
242
    end
243
  end
244

245
  --[[ Actually shrink the frame. --]]
246

247
  SILE.insertions.commitShrinkage = function (_, classname)
×
248
    local opts = SILE.scratch.insertions.classes[classname]
×
249
    local reduceList = opts["stealFrom"]
×
250
    local stealPosition = opts["steal-position"] or "bottom"
×
251
    for fName, _ in pairs(reduceList) do
×
252
      local frame = SILE.getFrame(fName)
×
253
      if frame then
×
254
        initShrinkage(frame)
×
255
        local newHeight = frame:height() - frame.state.totals.shrinkage
×
256
        if stealPosition == "bottom" then frame:relax("bottom") else frame:relax("top") end
×
257
        SU.debug("insertions", "Constraining height of", fName, "by", frame.state.totals.shrinkage, "to", newHeight)
×
258
        frame:constrain("height", newHeight)
×
259
        frame.state.totals.shrinkage = SILE.measurement(0)
×
260
      end
261
    end
262
  end
263

264
  SILE.insertions.increaseInsertionFrame = function (insertionvbox, classname)
×
265
    local amount = insertionvbox.height + insertionvbox.depth
×
266
    local opts = SILE.scratch.insertions.classes[classname]
×
267
    SU.debug("insertions", "Increasing insertion frame by", amount)
×
268
    local stealPosition = opts["steal-position"] or "bottom"
×
269
    local insertionFrame = SILE.getFrame(opts["insertInto"].frame)
×
270
    local oldHeight = insertionFrame:height()
×
271
    amount = amount * opts["insertInto"].ratio
×
272
    insertionFrame:constrain("height", oldHeight + amount)
×
273
    if stealPosition == "bottom" then insertionFrame:relax("top") end
×
274
    SU.debug("insertions", "New height is now", insertionFrame:height())
×
275
  end
276

277
  --[[
278
  So, this is the magic routine called by the page builder to determine what
279
  do to when an insertion is seen in the vertical list. The key design issue
280
  about this routine is that it needs to be very careful about state; it may
281
  end up processing the same list different times. (if the current list of
282
  vertical items is not tall enough to cause a page break yet) So it should
283
  not commit itself to anything yet. Another interesting complication is that
284
  when the page builder restarts, for optimization purposes it is at liberty
285
  to restart its calculations half-way through the list. So you can't
286
  completely forget the insertions that you've seen either.
287

288
  However, one mitigating factor is: if an insertion fits on the current page,
289
  it will end up on the current page. So if you've seen an insertion and it
290
  fits, you can commit to it at this point. If at some later date we have
291
  page builders which reflow multiple pages, then this may not be true.
292

293
  The main job is this routine is to make a decision about whether the
294
  upcoming insertion can fit on the page; if it needs to be split; or if it
295
  should not appear on this page at all (and hence force the line which
296
  caused the insertion off the page as well).
297
  --]]
298
  SILE.insertions.processInsertion = function (vboxlist, i, totalHeight, target)
×
299
    local ins = vboxlist[i]
×
300
    if ins.seen then return target end
×
301
    local targetFrame = SILE.getFrame(ins.frame)
×
302
    local options = SILE.scratch.insertions.classes[ins.class]
×
303

304
    ins:dropDiscardables()
×
305

306
    -- We look into the page's insertion box and choose the appropriate skip,
307
    -- so we know how high the whole insertion is.
308
    local topBox = nextInterInsertionSkip(ins.class)
×
309
    local insertionsHeight = SILE.length()
×
310
    insertionsHeight:___add(ins.contentHeight)
×
311
    insertionsHeight:___add(topBox.height)
×
312
    insertionsHeight:___add(topBox.depth)
×
313
    insertionsHeight:___add(ins.contentDepth)
×
314

315
    local insbox = thisPageInsertionBoxForClass(ins.class)
×
316
    initShrinkage(targetFrame)
×
317
    initShrinkage(SILE.typesetter.frame)
×
318

319
    if SU.debugging("insertions") then
×
320
      debugInsertion(ins, insbox, topBox, target, targetFrame, totalHeight)
×
321
    end
322

323
    local effectOnThisFrame = options.stealFrom[SILE.typesetter.frame.id]
×
324
    if effectOnThisFrame then effectOnThisFrame = insertionsHeight * effectOnThisFrame
×
325
    else effectOnThisFrame = SILE.measurement(0) end
×
326

327
    local newTarget = target - effectOnThisFrame
×
328

329
    -- We only fit if:
330
    -- the effect of the insertion on this frame doesn't take us over the page target
331
    -- and this doesn't take the target frame over the max height.
332

333
    if totalHeight + effectOnThisFrame <= target and
×
334
      insbox.height + insertionsHeight <= options.maxHeight then
×
335
      SU.debug("insertions", "fits")
×
336
      SILE.insertions.setShrinkage(ins.class, insertionsHeight)
×
337
      insbox:append(topBox)
×
338
      insbox:append(ins)
×
339
      ins.seen = true
×
340
      return newTarget
×
341
    end
342

343
    -- OK, we didn't fit. So now we have to split the insertion to fit the height
344
    -- we have within the insertion frame.
345
    SU.debug("insertions", "splitting")
×
346
    local maxsize = SU.min(target - totalHeight, options.maxHeight)
×
347

348
    -- If we're going to fit this insertion on the page, we will use the
349
    -- whole of topbox, so let's subtract the height of that now.
350
    -- The remaining height will be the amount of inserted material that we
351
    -- intend to put on this page.
352
    maxsize = maxsize - topBox.height
×
353
    local materialToSplit = {}
×
354
    pl.tablex.insertvalues(materialToSplit, ins:unbox())
×
355
    local deferredInsertions = ins:split(materialToSplit, maxsize)
×
356

357
    if deferredInsertions then
×
358
      SU.debug("insertions", "Split. Remaining insertion is", ins)
×
359
      SILE.insertions.setShrinkage(ins.class, topBox.height:absolute() + deferredInsertions.height:absolute() + deferredInsertions.depth:absolute())
×
360
      insbox:append(topBox)
×
361
      -- deferredInsertions.contentHeight = deferredInsertions.height
362
      -- deferredInsertions.contentDepth = deferredInsertions.depth
363
      insbox:append(deferredInsertions)
×
364
      deferredInsertions.seen = true
×
365

366
      --[[ The insertion we're dealing with is currently vboxlist[i], and it
367
      now contains all the material that *didn't* make it onto the current
368
      page. We've dealt with the material that did fit on the page. We want
369
      the page builder to a) break the page immediately here - it's full by
370
      definition, or else we would not have needed to split (XXX This is not
371
      true, because we might not be stealing from the current frame.) and
372
      then b) retry the remaining material. By inserting a penalty into the
373
      vboxlist, when we return from here the pagebuilder will first consider
374
      the penalty (and break the page) and then consider the rest of the
375
      insertion. --]]
376

377
      table.insert(vboxlist, i, SILE.nodefactory.penalty(-20000))
×
378
      return target -- Who cares? The penalty is going to cause a split.
×
379
    end
380

381
    --[[ We couldn't even split the insertion.
382

383
    Assume that previously we have seen these nodes on the vboxlist:
384

385
      i-2 \vbox{Hello world. I am a footnote mark: 1}
386
      i-1 \vglue
387
      i   \insertionbox{1. Footnote}
388

389
    This insertion couldn't fit on the page at all. Therefore we need to
390
    take both the footnote *and* the previous vbox (from which the
391
    insertion has been migrated) onto a new page. The way we achieve this
392
    is by finding the most recent vbox and then continually inserting page
393
    break penalties, pushing the insertion and vbox further down the list
394
    until they are the next things to be considered. The end result of the
395
    vboxlist will be:
396

397
      i-2 \penalty
398
      i-1 \penalty
399
      i   \penalty <- considered now when we return to pagebuilder
400
      i+1 \vbox{Hello world. I am a footnote mark: 1}
401
      i+2 \vglue
402
      i+3 \insertionbox{1. Footnote}
403

404
    The page will be broken right now, and the old vbox will no longer
405
    be part of the material to be output on the page. The new page will
406
    start with the vbox and the insertion.
407

408
    --]]
409
    local lastbox = i
×
410
    while not vboxlist[lastbox].is_vbox do lastbox = lastbox - 1 end
×
411
    while not (vboxlist[i].is_penalty and vboxlist[i].penalty == -20000) do
×
412
      table.insert(vboxlist, lastbox, SILE.nodefactory.penalty(-20000))
×
413
    end
414
    return target
×
415
  end
416

417
  self.class:registerPostinit(function (_)
×
418

419
    local typesetter = SILE.typesetter
×
420

421
    if not typesetter.noinsertion_getTargetLength then
×
422
      typesetter.noinsertion_getTargetLength = typesetter.getTargetLength
×
423
      typesetter.getTargetLength = function (self_)
424
        initShrinkage(self_.frame)
×
425
        return typesetter.noinsertion_getTargetLength(self_) - self_.frame.state.totals.shrinkage
×
426
      end
427
    end
428

429
    typesetter:registerFrameBreakHook(function (_, nodelist)
×
430
      pl.tablex.foreach(insertionsThisPage, SILE.insertions.commitShrinkage)
×
431
      return nodelist
×
432
    end)
433

434
    typesetter:registerPageEndHook(function (_)
×
435
      pl.tablex.foreach(insertionsThisPage, SILE.insertions.increaseInsertionFrame)
×
436
      for insertionclass, insertionlist in pairs(insertionsThisPage) do
×
437
        insertionlist:outputYourself()
×
438
        insertionsThisPage[insertionclass] = nil
×
439
      end
440
      if SU.debugging("insertions") then
×
441
        for _, frame in pairs(SILE.frames) do SILE.outputter:debugFrame(frame) end
×
442
      end
443
    end)
444

445
  end)
446

447
  self:export("initInsertionClass", initInsertionClass)
×
448
  self:export("thisPageInsertionBoxForClass", thisPageInsertionBoxForClass)
×
449
  self:export("insert", insert)
×
450

451
end
452

453
function package.declareSettings (_)
×
454

455
  SILE.settings:declare({
×
456
    parameter = "insertion.penalty",
457
    type = "integer",
458
    default = -3000,
459
    help = "Penalty to be applied before insertion"
×
460
  })
461

462
end
463

464
package.documentation = [[
465
\begin{document}
466
The \autodoc:package{footnotes} package works by taking auxiliary material (the footnote content), shrinking the current frame and inserting it into the footnote frame.
467
This is powered by the \autodoc:package{insertions} package; it doesn’t provide any user-visible SILE commands, but provides Lua functionality to other packages.
468
TeX wizards may be interested to realise that insertions are implemented by an external add-on package, rather than being part of the SILE core.
469
\end{document}
470
]]
×
471

472
return package
×
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