@zumer/snapdom-plugins 3.0.0-beta.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +54 -20
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +17 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Page-break geometry: where a slice boundary may fall without cutting through
3
+ * something that reads as a single object — a table row, a list item, a card,
4
+ * an image. Fixed bands cut blind; these two functions replace them.
5
+ *
6
+ * Two spaces are in play, and converting between them is the CALLER's job:
7
+ * collectBlocks reads the DOM, so it returns VIEWPORT CSS px; paginate answers
8
+ * in CONTENT px, where 0 is the top of the capture element's box and the unit
9
+ * is whatever the page geometry uses. index.js owns the element rect and the
10
+ * draw scale, so only it can map one onto the other:
11
+ *
12
+ * contentY = (viewportY - rect.top) * PT * drawScale
13
+ *
14
+ * A block is `{ top, bottom, forced?, loose? }`. Only the two coordinates need
15
+ * converting, but the flags have to survive the trip: a caller that rebuilds bare
16
+ * `{ top, bottom }` pairs turns every declared break into a zero-height span and
17
+ * every long paragraph into a constraint no cut may straddle.
18
+ */
19
+
20
+ const EPS = 1e-6
21
+
22
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'LINK', 'META'])
23
+
24
+ /**
25
+ * Elements a break must not cut. Each one is either a row/item of a repeating
26
+ * structure or replaced content: half of it on each page is always wrong, and
27
+ * none of them is a general-purpose container, so treating them as atomic still
28
+ * leaves a legal break between every pair of them. Containers (section, div,
29
+ * table, ul) are deliberately absent — make those atomic and a long document
30
+ * can never break at all.
31
+ */
32
+ const ATOMIC_TAGS = new Set([
33
+ 'TR', 'THEAD', 'TFOOT', 'LI', 'DT', 'DD', 'FIGURE', 'BLOCKQUOTE',
34
+ 'IMG', 'SVG', 'CANVAS', 'VIDEO', 'IFRAME', 'INPUT', 'SELECT', 'TEXTAREA', 'BUTTON',
35
+ ])
36
+
37
+ /** Subtrees with no flow of their own to break: descending costs time and yields nothing. */
38
+ const OPAQUE_TAGS = new Set(['SVG', 'CANVAS', 'VIDEO', 'IFRAME', 'SELECT', 'TEXTAREA'])
39
+
40
+ const FORCED = /^(always|page|left|right|recto|verso)$/
41
+ const AVOID = /^avoid/
42
+ const CONTAIN_PAINT = /\b(paint|strict|content)\b/
43
+
44
+ /**
45
+ * Boxes whose children are all inline — a heading, a caption, a short paragraph.
46
+ * The inline-level box types count: a chip or a KPI card is `inline-block` far
47
+ * more often than `block`, and it tears exactly as badly.
48
+ */
49
+ function isLeafBlock(el, style, styleOf) {
50
+ if (!/^((inline-)?(block|flex|grid)|flow-root|list-item|table-caption)$/.test(style.display)) return false
51
+ for (const child of el.children) {
52
+ const d = styleOf(child).display
53
+ if (!/^inline/.test(d) && d !== 'contents' && d !== 'none') return false
54
+ }
55
+ return true
56
+ }
57
+
58
+ /**
59
+ * `true` — a break must not cut this box. `false` — it may, but the box is still
60
+ * evidence that something is painted there, which is all paginate needs to tell a
61
+ * blank tail from a long one. `null` — a container: evidence of nothing.
62
+ */
63
+ function atomicity(el, tag, style, height, maxLeaf, styleOf) {
64
+ if (AVOID.test(style.breakInside) || AVOID.test(style.pageBreakInside)) return true
65
+ if (ATOMIC_TAGS.has(tag)) return true
66
+ if (!isLeafBlock(el, style, styleOf)) return null
67
+ // Judgement call: a short leaf block split across pages reads as a defect,
68
+ // but past maxLeaf it is just a long paragraph, and breaking those is normal
69
+ // typography — plus every one kept whole is a break point taken away.
70
+ return height <= maxLeaf
71
+ }
72
+
73
+ // ——— clipping ———
74
+ //
75
+ // A rect only means what it says while nothing crops it. An overflow ancestor
76
+ // shows a window onto its content, and a scrolled or overflowing descendant
77
+ // reports a rect for a place nothing is painted — refusing a legal cut, or
78
+ // dragging one up, for content that is not on the page at all.
79
+ //
80
+ // DUPLICATED from text-layer.js, which runs the same tests to prune clipped
81
+ // runs. Its versions are the reference (they also map through the accumulated
82
+ // transform); these should become imports the moment that module exports a clip
83
+ // rect built PER AXIS — an axis left 'visible' has to come back unbounded, and a
84
+ // rect snapped to the padding box on both would crop content that paints.
85
+
86
+ const NO_CLIP = { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity }
87
+
88
+ function intersect(a, b) {
89
+ if (a === NO_CLIP) return b
90
+ return {
91
+ left: Math.max(a.left, b.left), top: Math.max(a.top, b.top),
92
+ right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom),
93
+ }
94
+ }
95
+
96
+ function clipsContent(s) {
97
+ // A 'visible' axis beside a non-visible one COMPUTES to 'auto' — except beside
98
+ // 'clip', which leaves it visible. So this is the gate only; WHICH axes crop is
99
+ // paddingBox's answer.
100
+ return s.overflowX !== 'visible' || s.overflowY !== 'visible' || CONTAIN_PAINT.test(s.contain)
101
+ }
102
+
103
+ /**
104
+ * Anything here makes the element the containing block of its absolute
105
+ * descendants. The individual transform properties are guarded like text-layer's
106
+ * copy: where the engine does not have them, `undefined !== 'none'` would make
107
+ * EVERY element a containing block and collapse clipAbs to the nearest clipper.
108
+ */
109
+ function isContainingBlock(s) {
110
+ return s.position !== 'static' || s.transform !== 'none' || s.filter !== 'none' ||
111
+ s.perspective !== 'none' || (s.rotate || 'none') !== 'none' || (s.scale || 'none') !== 'none' ||
112
+ (s.translate || 'none') !== 'none' ||
113
+ (s.backdropFilter || 'none') !== 'none' || s.willChange.includes('transform') ||
114
+ CONTAIN_PAINT.test(s.contain)
115
+ }
116
+
117
+ /**
118
+ * The computed value is `<visual-box> || <length>`, serialised keyword-first with
119
+ * the default `padding-box` dropped — `20px content-box` computes to the string
120
+ * `content-box 20px`, `padding-box 30px` to `30px`. So the length is the LAST
121
+ * token, and reading the whole string gives NaN for every two-value form: the
122
+ * clip would snap back to the padding box and refuse cuts through content that
123
+ * paints. The keyword itself is not modelled, because the residual is smaller
124
+ * than the boxes this decides between: measured in Chromium on a 10px padding /
125
+ * 3px border box, `content-box 20px` puts this edge 10px outside the true one and
126
+ * `border-box 5px` 3px inside it — the box's own padding or border, either way.
127
+ */
128
+ function clipMargin(style) {
129
+ const parts = (style.overflowClipMargin || '').trim().split(/\s+/)
130
+ return parseFloat(parts[parts.length - 1]) || 0
131
+ }
132
+
133
+ /**
134
+ * The clip is the PADDING box; getBoundingClientRect gives the border box, and
135
+ * client* are the transform-free layout metrics that inset it — exact for the
136
+ * axis-aligned boxes this module models. Inline boxes report clientWidth 0 and
137
+ * SVG/MathML roots report no offsetWidth at all; both take the border box — a clip
138
+ * one border wider than it should be — rather than an empty one that swallows the
139
+ * subtree. The offsetWidth half of that test earns its place in text-layer, whose
140
+ * mapped box cannot be placed without it, and stays here so both take the same
141
+ * branch on the same elements.
142
+ */
143
+ function paddingBox(el, style, rect) {
144
+ const paint = CONTAIN_PAINT.test(style.contain)
145
+ // The margin only widens a clip that HAS an outward edge: 'hidden' crops at the
146
+ // padding box and ignores it, though Chromium reports a length there all the
147
+ // same. Among the overflow values the cropping axes are then exactly the 'clip'
148
+ // ones, since 'clip' beside any other non-visible value computes to 'hidden'.
149
+ // Paint containment is the other way in: it crops both axes while `overflow`
150
+ // still computes 'visible', which is why it gates the margin on its own.
151
+ const m = paint || style.overflowX === 'clip' || style.overflowY === 'clip'
152
+ ? clipMargin(style)
153
+ : 0
154
+ const w = el.clientWidth
155
+ let box
156
+ if (!w || !el.offsetWidth) {
157
+ box = { left: rect.left - m, top: rect.top - m, right: rect.right + m, bottom: rect.bottom + m }
158
+ } else {
159
+ const left = rect.left + el.clientLeft - m
160
+ const top = rect.top + el.clientTop - m
161
+ box = { left, top, right: left + w + 2 * m, bottom: top + el.clientHeight + 2 * m }
162
+ }
163
+ // An axis still 'visible' crops nothing — 'overflow-x: clip' next to
164
+ // 'overflow-y: visible' is a real computed pair, and cropping Y there would
165
+ // discard blocks that are painted and breakable. contain:paint crops both.
166
+ if (!paint && style.overflowX === 'visible') { box.left = -Infinity; box.right = Infinity }
167
+ if (!paint && style.overflowY === 'visible') { box.top = -Infinity; box.bottom = Infinity }
168
+ return box
169
+ }
170
+
171
+ /** Inclusive, because a zero-size break marker still sits inside its container. */
172
+ function inClip(rect, clip) {
173
+ return clip === NO_CLIP || (
174
+ rect.right >= clip.left - EPS && rect.left <= clip.right + EPS &&
175
+ rect.bottom >= clip.top - EPS && rect.top <= clip.bottom + EPS)
176
+ }
177
+
178
+ /** Only the painted part of a box is breakable, so spans and points both clamp. */
179
+ const clampY = (y, clip) => Math.min(Math.max(y, clip.top), clip.bottom)
180
+
181
+ /**
182
+ * Vertical spans, in VIEWPORT CSS px, that a page break must not cut.
183
+ *
184
+ * A plain `{ top, bottom }` entry is such a span, and always has height. The two
185
+ * flags are the exceptions at either extreme. `forced: true` has `top === bottom`
186
+ * and is not a span at all but a point: a break-before / break-after: always|page
187
+ * the author declared, which paginate honours first — the zero height makes it
188
+ * inert to the straddle test. `loose: true` is a leaf block too tall to protect:
189
+ * a break MAY cut it, it constrains nothing, and it is here only because a long
190
+ * paragraph is otherwise indistinguishable from empty space to a caller deciding
191
+ * whether a trailing region is worth a page.
192
+ *
193
+ * Nothing here is capped by page size — collectBlocks does not know it. A block
194
+ * taller than a page is paginate's problem, and it cuts it.
195
+ *
196
+ * @param {Element} root
197
+ * @param {object} [options]
198
+ * @param {number} [options.maxLeaf=200] CSS px; above this a leaf block may be cut.
199
+ * @param {boolean} [options.shadow=true] Walk shadow roots too.
200
+ * @returns {Array<{top: number, bottom: number, forced?: boolean, loose?: boolean}>} unsorted
201
+ */
202
+ export function collectBlocks(root, options = {}) {
203
+ const { maxLeaf = 200, shadow = true } = options
204
+ const blocks = []
205
+ const seen = new Set()
206
+ const styles = new WeakMap()
207
+ const styleOf = (el) => {
208
+ let s = styles.get(el)
209
+ if (!s) { s = getComputedStyle(el); styles.set(el, s) }
210
+ return s
211
+ }
212
+
213
+ // Nested elements share edges constantly (a td inside a tr inside a table);
214
+ // half-pixel keys keep the boundary list to the distinct ones.
215
+ const push = (top, bottom, kind = '') => {
216
+ if (!Number.isFinite(top) || !Number.isFinite(bottom) || bottom < top) return
217
+ const key = `${kind}${Math.round(top * 2)}:${Math.round(bottom * 2)}`
218
+ if (seen.has(key)) return
219
+ seen.add(key)
220
+ blocks.push(kind ? { top, bottom, [kind]: true } : { top, bottom })
221
+ }
222
+
223
+ /**
224
+ * A long paragraph may be split, but never through a painted line. Range gives
225
+ * one rect per line fragment for a whole text node, which is precisely the set
226
+ * of small atomic spans paginate needs. This is paid only for loose leaf blocks;
227
+ * short prose is already protected by its block box and containers never enter.
228
+ */
229
+ const protectLines = (leaf, clip) => {
230
+ if ((styleOf(leaf).writingMode || 'horizontal-tb') !== 'horizontal-tb') return
231
+ const range = leaf.ownerDocument.createRange()
232
+ const seenNodes = new Set()
233
+ const scan = (node) => {
234
+ if (!node || seenNodes.has(node)) return
235
+ seenNodes.add(node)
236
+ if (node.nodeType === Node.TEXT_NODE) {
237
+ if (!/\S/.test(node.data || '')) return
238
+ const parent = node.parentElement || (node.getRootNode() instanceof ShadowRoot
239
+ ? node.getRootNode().host : null)
240
+ if (parent) {
241
+ const cs = styleOf(parent)
242
+ if (cs.display === 'none' || cs.visibility !== 'visible' || cs.opacity === '0') return
243
+ }
244
+ range.selectNodeContents(node)
245
+ for (const rect of range.getClientRects()) {
246
+ if (!(rect.width > EPS && rect.height > EPS) || !inClip(rect, clip)) continue
247
+ const top = clampY(rect.top, clip)
248
+ const bottom = clampY(rect.bottom, clip)
249
+ if (bottom - top > EPS) push(top, bottom)
250
+ }
251
+ return
252
+ }
253
+ if (node.nodeType !== Node.ELEMENT_NODE) return
254
+ const tag = node.tagName.toUpperCase()
255
+ if (SKIP_TAGS.has(tag)) return
256
+ const cs = styleOf(node)
257
+ if (cs.display === 'none' || cs.opacity === '0') return
258
+
259
+ let kids
260
+ if (node.shadowRoot) kids = node.shadowRoot.childNodes
261
+ else if (tag === 'SLOT') kids = node.assignedNodes({ flatten: true })
262
+ else kids = node.childNodes
263
+ for (const kid of kids) scan(kid)
264
+ }
265
+ scan(leaf)
266
+ }
267
+
268
+ const walk = (el, clip, clipAbs) => {
269
+ const tag = el.tagName.toUpperCase()
270
+ if (SKIP_TAGS.has(tag)) return
271
+ const style = styleOf(el)
272
+ // display:none and opacity:0 hide the whole subtree; visibility:hidden does
273
+ // not, since a descendant can turn it back on.
274
+ if (style.display === 'none' || style.opacity === '0') return
275
+ // Out of flow: a break "through" one of these is meaningless, and the rect it
276
+ // reports has nothing to do with where its content sits in the page — a stuck
277
+ // header is parked somewhere its flow position never was.
278
+ if (style.position === 'fixed' || style.position === 'sticky') return
279
+
280
+ // An absolute box escapes every clipper outside its containing-block chain.
281
+ const own = style.position === 'absolute' ? clipAbs : clip
282
+ let inner = own
283
+
284
+ if (style.display !== 'contents') {
285
+ const rect = el.getBoundingClientRect()
286
+ if (inClip(rect, own)) {
287
+ const top = clampY(rect.top, own)
288
+ const bottom = clampY(rect.bottom, own)
289
+ // Not behind the size guard: `<div style="page-break-after:always">` is
290
+ // the idiomatic marker and it is deliberately empty. An author who put a
291
+ // break on an invisible element still meant the break.
292
+ if (FORCED.test(style.breakBefore) || FORCED.test(style.pageBreakBefore)) push(top, top, 'forced')
293
+ if (FORCED.test(style.breakAfter) || FORCED.test(style.pageBreakAfter)) push(bottom, bottom, 'forced')
294
+ // maxLeaf is measured on the PAINTED extent, not the layout one: what a
295
+ // break can tear is what shows, so a 600px card cropped to 60 visible px
296
+ // is as protectable as a 60px one — and one cropped to nothing is not a
297
+ // span at all, since a zero height is the forced points' signature.
298
+ if (bottom - top > EPS && rect.width > 0 && style.visibility !== 'hidden') {
299
+ const atomic = atomicity(el, tag, style, bottom - top, maxLeaf, styleOf)
300
+ if (atomic !== null) {
301
+ push(top, bottom, atomic ? '' : 'loose')
302
+ if (!atomic) protectLines(el, own)
303
+ }
304
+ }
305
+ }
306
+ if (clipsContent(style)) inner = intersect(own, paddingBox(el, style, rect))
307
+ }
308
+
309
+ if (OPAQUE_TAGS.has(tag)) return
310
+ const innerAbs = isContainingBlock(style) ? inner : clipAbs
311
+ for (const child of el.children) walk(child, inner, innerAbs)
312
+ if (shadow && el.shadowRoot) for (const child of el.shadowRoot.children) walk(child, inner, innerAbs)
313
+ }
314
+
315
+ // root itself is skipped: the capture element is the container by definition,
316
+ // and a break before or after the whole capture has nowhere to go. Its clip is
317
+ // not skipped — snapdom crops the raster to it just as the screen does.
318
+ const rootStyle = styleOf(root)
319
+ const rootClip = clipsContent(rootStyle)
320
+ ? paddingBox(root, rootStyle, root.getBoundingClientRect())
321
+ : NO_CLIP
322
+ for (const child of root.children) walk(child, rootClip, rootClip)
323
+ if (shadow && root.shadowRoot) for (const child of root.shadowRoot.children) walk(child, rootClip, rootClip)
324
+
325
+ return blocks
326
+ }
327
+
328
+ // ——— repeated header bands ———
329
+ //
330
+ // A `<thead>` is the one band of a document that belongs at the top of every page
331
+ // its table reaches, and the browser's own print path already does this. It is
332
+ // modelled here rather than at the paint, because it is not decoration over the
333
+ // page: a repeated header TAKES ROOM, so pagination has to know about it or the
334
+ // rows underneath end up behind it.
335
+
336
+ /**
337
+ * Header bands, in VIEWPORT CSS px: a `<thead>` whose table continues below it.
338
+ *
339
+ * `bodyBottom` is the table's own bottom edge, and it is what decides where
340
+ * repeating stops — a header goes on every page the TABLE reaches and not one
341
+ * more, which is a fact about the table and not about where the cuts fell.
342
+ *
343
+ * @param {Element} root
344
+ * @param {object} [options]
345
+ * @param {boolean} [options.shadow=true]
346
+ * @returns {Array<{top: number, bottom: number, bodyBottom: number}>} in document order
347
+ */
348
+ export function collectRepeats(root, options = {}) {
349
+ const { shadow = true } = options
350
+ const bands = []
351
+
352
+ const record = (table) => {
353
+ const head = table.tHead
354
+ if (!head) return
355
+ const style = getComputedStyle(head)
356
+ if (style.display === 'none' || style.visibility === 'hidden') return
357
+ const rect = head.getBoundingClientRect()
358
+ const box = table.getBoundingClientRect()
359
+ if (!(rect.height > EPS) || !(rect.width > EPS)) return
360
+ // A table whose body ends at its header has nothing to continue onto a second
361
+ // page, so there is nothing to repeat and no room to take.
362
+ if (box.bottom <= rect.bottom + EPS) return
363
+ bands.push({ top: rect.top, bottom: rect.bottom, bodyBottom: box.bottom })
364
+ }
365
+
366
+ const walk = (el) => {
367
+ const tag = el.tagName.toUpperCase()
368
+ if (SKIP_TAGS.has(tag)) return
369
+ if (tag === 'TABLE') record(el)
370
+ for (const child of el.children) walk(child)
371
+ if (shadow && el.shadowRoot) for (const child of el.shadowRoot.children) walk(child)
372
+ }
373
+ for (const child of root.children) walk(child)
374
+ if (shadow && root.shadowRoot) for (const child of root.shadowRoot.children) walk(child)
375
+
376
+ return bands.sort((a, b) => a.top - b.top)
377
+ }
378
+
379
+ /**
380
+ * Which header bands belong at the top of a page starting at `top` — the ONE
381
+ * answer both the pagination and the paint read, so the room reserved and the
382
+ * room used cannot drift apart.
383
+ *
384
+ * All or nothing against `maxHeight`: repeating an inner table's header while
385
+ * dropping the outer one's produces a page that is worse than either choice, and
386
+ * a header that will not fit is a fact worth reporting rather than trimming.
387
+ *
388
+ * @param {Array<{top:number, bottom:number, bodyBottom:number}>} bands CONTENT px
389
+ * @param {number} top content y the page starts at
390
+ * @param {number} maxHeight the most a page may give up to repeated headers
391
+ * @returns {{bands: Array, height: number, refused: boolean}}
392
+ */
393
+ export function repeatsAt(bands, top, maxHeight) {
394
+ const none = { bands: [], height: 0, refused: false }
395
+ if (!bands || !bands.length) return none
396
+ const on = []
397
+ let height = 0
398
+ for (const b of bands) {
399
+ // Entirely above this page (so it was drawn on an earlier one) and its table
400
+ // still has rows down here.
401
+ if (b.bottom <= top + EPS && b.bodyBottom > top + EPS) {
402
+ on.push(b)
403
+ height += b.bottom - b.top
404
+ }
405
+ }
406
+ if (!on.length) return none
407
+ if (height > maxHeight) return { bands: [], height: 0, refused: true }
408
+ return { bands: on, height, refused: false }
409
+ }
410
+
411
+ /**
412
+ * The `reserve` callback `paginate` takes, built from header bands.
413
+ * @param {Array} bands CONTENT px, as `repeatsAt` expects
414
+ * @param {number} maxHeight
415
+ * @returns {((top: number) => number)|null} null when nothing can ever repeat
416
+ */
417
+ export function headerReserve(bands, maxHeight) {
418
+ if (!bands || !bands.length) return null
419
+ return (top) => repeatsAt(bands, top, maxHeight).height
420
+ }
421
+
422
+ /** Below this a slice is a blank page, and rounding noise could make it zero. */
423
+ const MIN_SLICE = 1
424
+
425
+ /**
426
+ * Cut [0, contentHeight) into page slices, in CONTENT px.
427
+ *
428
+ * Greedy: the ideal cut is top + availHeight, pulled UP to the highest block
429
+ * boundary that no atomic block straddles. A break the author declared beats
430
+ * both, wherever in the window it falls, unless all it would buy is a blank final
431
+ * page. The slices tile exactly — no gaps, no overlaps, the last one ends at
432
+ * contentHeight, every height in (0, that page's own room].
433
+ *
434
+ * @param {object} args
435
+ * @param {Array<{top: number, bottom: number, forced?: boolean, loose?: boolean}>} [args.blocks]
436
+ * @param {number} args.contentHeight
437
+ * @param {number} args.availHeight
438
+ * @param {number} [args.minFill=0.2] a cut may not leave a page emptier than this fraction; below
439
+ * it, cutting an atomic block beats a nearly blank page
440
+ * @param {(top: number) => number} [args.reserve] room, in content px, this page gives up before
441
+ * its own content starts — repeated table headers. It is a function of the page's TOP alone,
442
+ * which is what keeps this a single pass: what repeats on a page depends on where the page
443
+ * BEGINS, never on where it ends, so there is no fixed point to iterate towards. Clamped to
444
+ * half of `availHeight`, so a caller cannot starve a page into an infinite loop
445
+ * @returns {{slices: Array<{top: number, height: number, cut: number}>, forcedIgnored: number}}
446
+ * `cut` is how many atomic blocks this slice's BOTTOM edge tears — 0 is a clean break, and the
447
+ * last slice is always 0. Sum them for the damage report. `forcedIgnored` counts declared breaks
448
+ * dropped because they landed within MIN_SLICE of a cut already taken, or because the page they
449
+ * would open is both under minFill and free of any block. A break outside
450
+ * `(EPS, contentHeight - MIN_SLICE)` is dropped WITHOUT being counted, and for two different
451
+ * reasons: at either end of the document the boundary it asks for is already the edge, while
452
+ * above or below the document there is no page for it to open — an absolute box parked past
453
+ * contentHeight is not in the raster either, so a warning would name content the export never
454
+ * had.
455
+ */
456
+ export function paginate({ blocks = [], contentHeight, availHeight, minFill = 0.2, reserve = null }) {
457
+ if (!Number.isFinite(contentHeight) || contentHeight <= 0) throw new Error('paginate: contentHeight must be a positive finite number')
458
+ if (!Number.isFinite(availHeight) || availHeight <= 0) throw new Error('paginate: availHeight must be a positive finite number')
459
+ const fill = Math.min(0.9, Math.max(0, minFill))
460
+
461
+ const atoms = []
462
+ const forced = []
463
+ // Deepest recorded box, protected or not — the only answer to "is anything down
464
+ // there?" this function can get.
465
+ let ink = -Infinity
466
+ for (const b of blocks) {
467
+ const { top, bottom } = b
468
+ if (b.forced) {
469
+ // At or past either end of the document, or inside a final band too short
470
+ // to be a page: the boundary is already there, or there is no page for it
471
+ // to open. Neither is a loss, so neither is counted below.
472
+ if (top > EPS && top < contentHeight - MIN_SLICE) forced.push(top)
473
+ continue
474
+ }
475
+ // Degenerate, out-of-page and non-finite spans can only add noise to the
476
+ // edge list; the negated comparison is what excludes NaN.
477
+ if (!(bottom - top > EPS) || bottom <= EPS || top >= contentHeight - EPS) continue
478
+ if (bottom > ink) ink = bottom
479
+ // A loose block is content, never a constraint: in `edges` it would move cuts,
480
+ // in `atoms` it would refuse them.
481
+ if (!b.loose) atoms.push({ top, bottom })
482
+ }
483
+ forced.sort((a, b) => a - b)
484
+
485
+ const edges = [...new Set(atoms.flatMap(a => [a.top, a.bottom]))].sort((a, b) => a - b)
486
+
487
+ // Half a page is the most repeated headers may ever take. Past that the page is
488
+ // more furniture than content, and — since `room` is what every advance below is
489
+ // measured against — a reserve free to approach availHeight could stall the walk.
490
+ const MAX_RESERVE = 0.5
491
+ const roomAt = (y) => (reserve
492
+ ? availHeight - Math.min(Math.max(0, reserve(y)), availHeight * MAX_RESERVE)
493
+ : availHeight)
494
+ // The smallest page any reserve can produce, which is what the iteration belt
495
+ // has to be sized against.
496
+ const minRoom = reserve ? availHeight * (1 - MAX_RESERVE) : availHeight
497
+
498
+ // A block taller than a page has to be cut wherever it lands, so it is left out
499
+ // of both the straddle test — honouring it would drag every cut back to top —
500
+ // and the damage count, where it would report unavoidable breakage every time.
501
+ // Measured against THIS page's room: a block that fits on a full page but not on
502
+ // one carrying a repeated header is genuinely uncuttable there.
503
+ const breakable = (a, room) => a.bottom - a.top < room - EPS
504
+ /**
505
+ * figure > blockquote > p torn by one break is ONE visible tear, not three, so
506
+ * only spans no other straddling span contains are counted. Sorted by (top asc,
507
+ * bottom desc), a span is contained exactly when it ends no lower than the
508
+ * deepest reach so far.
509
+ */
510
+ const damage = (y, room) => {
511
+ const hit = atoms
512
+ .filter(a => a.top < y - EPS && a.bottom > y + EPS && breakable(a, room))
513
+ .sort((p, q) => p.top - q.top || q.bottom - p.bottom)
514
+ let n = 0
515
+ let reach = -Infinity
516
+ for (const a of hit) if (a.bottom > reach + EPS) { n++; reach = a.bottom }
517
+ return n
518
+ }
519
+
520
+ const slices = []
521
+ let top = 0
522
+ // Every slice advances by at least min(fill * room, MIN_SLICE), and only a
523
+ // forced cut can advance by that little — one per declared break. Sized on the
524
+ // SMALLEST room any page can have, so the bound stays true under a reserve.
525
+ const limit = Math.ceil(contentHeight / Math.max(minRoom * fill, MIN_SLICE)) + forced.length + 8
526
+
527
+ while (top < contentHeight - EPS && slices.length < limit) {
528
+ const room = roomAt(top)
529
+ const minPage = Math.max(room * fill, MIN_SLICE)
530
+ const ideal = top + room
531
+ const floor = top + minPage
532
+ // Looked up before the tail: a break declared in the last page-and-a-bit is
533
+ // the one that separates the final two sections of most reports.
534
+ const declared = forced.find(y => y >= top + MIN_SLICE && y <= ideal + EPS)
535
+
536
+ // Everything left fits on one page, so a break here is worth a page of its own
537
+ // only if something is on it. Drop one whose page would be BOTH emptier than
538
+ // minFill allows and free of any block: `break-after: page` on the LAST section
539
+ // sits the capture element's bottom padding — or a shadow's bleed — above the
540
+ // end, and ships a page of pure background. Both tests are needed, and neither
541
+ // is more than the block list knows: a region holding only what collectBlocks
542
+ // declines to record reads as empty here, which is why it records loose ones.
543
+ if (contentHeight - top <= room + EPS &&
544
+ (declared === undefined || (contentHeight - declared < minPage && ink <= declared + EPS))) {
545
+ slices.push({ top, height: contentHeight - top, cut: 0 })
546
+ top = contentHeight
547
+ break
548
+ }
549
+
550
+ let cut = declared // the author said break here
551
+ if (cut === undefined) {
552
+ const active = atoms.filter(a => a.top < ideal - EPS && a.bottom > floor + EPS && breakable(a, room))
553
+ const straddled = (y) => active.some(a => a.top < y - EPS && a.bottom > y + EPS)
554
+ cut = null
555
+ if (!straddled(ideal)) cut = ideal
556
+ else {
557
+ for (let i = edges.length - 1; i >= 0; i--) {
558
+ const y = edges[i]
559
+ if (y > ideal + EPS) continue
560
+ if (y < floor - EPS) break
561
+ if (!straddled(y)) { cut = y; break }
562
+ }
563
+ }
564
+ // No legal boundary that still fills the page: cut through, and report it.
565
+ if (cut === null) cut = ideal
566
+ }
567
+
568
+ slices.push({ top, height: cut - top, cut: damage(cut, room) })
569
+ top = cut
570
+ }
571
+
572
+ // Only reachable through the iteration belt: tile the rest blind rather than
573
+ // silently losing the tail of the document.
574
+ while (top < contentHeight - EPS) {
575
+ const height = Math.min(roomAt(top), contentHeight - top)
576
+ slices.push({ top, height, cut: 0 })
577
+ top += height
578
+ }
579
+
580
+ // Cuts are assigned from `forced` verbatim, so identity is exact.
581
+ const taken = new Set(slices.map(s => s.top))
582
+ return { slices, forcedIgnored: forced.reduce((n, y) => n + (taken.has(y) ? 0 : 1), 0) }
583
+ }