@zumer/snapdom-plugins 3.0.0 → 4.0.1

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 +21 -0
  2. package/README.md +50 -16
  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 +22 -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,1771 @@
1
+ /**
2
+ * Reads the laid-out DOM mounted in an isolated iframe from SnapDOM's FINAL SVG
3
+ * artifact and produces the geometry the PDF text layer needs:
4
+ * one positioned run per word (or source line in preserved white-space), plus
5
+ * link rectangles.
6
+ *
7
+ * Word-level for ordinary prose is deliberate. A line-level run leans on the PDF
8
+ * font's own advance widths to place characters, so selection highlights drift
9
+ * away from the pixels underneath. Per word the drift is bounded by the word.
10
+ *
11
+ * The walk is over the FLATTENED tree (shadow roots entered, slots resolved) and
12
+ * every element carries three accumulated ancestor facts: the linear part of its
13
+ * CSS transform, the opacity product, and the overflow clip. Those three turn
14
+ * "the DOM says this text exists" into "the raster paints it, there, at that
15
+ * angle" — which is the only claim a text layer is allowed to make.
16
+ */
17
+
18
+ // Which elements are semantic objects, and what they are called, is `tagged.js`'s
19
+ // question — this module only needs to know which textless ones deserve a run to
20
+ // hang a tag on, and asking there is what keeps the two answers one answer.
21
+ import { isFigure, semanticAlt } from './tagged.js'
22
+
23
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TITLE', 'TEMPLATE', 'OPTION', 'OPTGROUP', 'DATALIST'])
24
+
25
+ /** Values, not text nodes: these carry their painted text on the element itself. */
26
+ const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])
27
+
28
+ /** Input types whose value is painted as text. The rest paint widgets, not glyphs. */
29
+ const TEXT_INPUTS = new Set([
30
+ 'text', 'search', 'email', 'url', 'tel', 'number', 'password',
31
+ 'date', 'datetime-local', 'month', 'week', 'time', 'submit', 'reset', 'button',
32
+ ])
33
+
34
+ const EPS = 1e-6
35
+ const SINGULAR = 1e-9
36
+ /**
37
+ * Half-window for "this character is the same distance from both of those rects",
38
+ * as a fraction of the coordinate magnitude — 64 float32 ulps. Relative because
39
+ * the thing it absorbs is relative: client rects come back float32-rounded, so two
40
+ * rects on the SAME painted line project to values that differ by ~ulp(coordinate)
41
+ * rather than by 0. An absolute window cannot work, in either direction: 1e-6 px
42
+ * sits INSIDE that rounding rather than above it (the same-line gaps in
43
+ * groupByRect straddle it), and 0.5 px is above a real `line-height: 0.4px`
44
+ * separation, which it then reads as one line. MEASURED over 78840 wrapped words,
45
+ * drawn from a grid of 20 transforms × 3 nested outer transforms × 6 origins out
46
+ * to ±9000 px × 4 font sizes from 3 to 200 px: the worst same-line projection is
47
+ * 49.2 ulps of the (|cx| + |cy|) / 2 this multiplies, 30 cases exceed 32 ulps and
48
+ * none exceeds 64.
49
+ *
50
+ * What it costs at the other end, since a window always costs something there: a
51
+ * REAL separation narrower than it — about 0.003 painted px at a 400 px origin —
52
+ * is read as one line. MEASURED, the split stays exact down to a 0.01 px
53
+ * separation (`line-height: 0.2px` under `scale(1, 0.05)`) and goes wrong at
54
+ * 0.004 px (the same under `scale(1, 0.02)`).
55
+ */
56
+ const TIE_REL = 64 * 2 ** -23
57
+ /** Not an exact-zero test on purpose: '0.0000001' is invisible and serializes as a number. */
58
+ const ALPHA_MIN = 0.01
59
+ /** A sub-pixel numerical fringe is not a hidden character; anything larger is. */
60
+ const CLIP_EPS = 0.25
61
+ const CONTAIN_PAINT = /\b(paint|strict|content)\b/
62
+ /**
63
+ * The `will-change` values that group. Naming a grouping property is NOT the rule,
64
+ * however plausible: MEASURED against what Blink paints, of 23 property names given
65
+ * to `will-change` this Chromium groups for `opacity`, `filter` and
66
+ * `backdrop-filter` and for nothing else — `mix-blend-mode`, `clip-path`,
67
+ * `mask-image`, `isolation` and `-webkit-box-reflect` all group as the property
68
+ * but not as a `will-change` hint. `filter` is the substring that catches
69
+ * `backdrop-filter`; the match is over a comma-separated list.
70
+ */
71
+ const GROUPING = /opacity|filter/
72
+ /** Scripts that stay upright under `text-orientation: mixed`. All outside WinAnsi. */
73
+ const UPRIGHT = /[\u2E80-\uA4CF\uAC00-\uD7FF\uF900-\uFAFF\uFF00-\uFF60]/
74
+ /** 90° clockwise on screen: local +x goes down, local +y (down) goes left. */
75
+ const R90 = { a: 0, b: 1, c: -1, d: 0 }
76
+ /** The other way, for `sideways-lr` — the one vertical mode that reads bottom-up. */
77
+ const R270 = { a: 0, b: -1, c: 1, d: 0 }
78
+
79
+ /**
80
+ * Chromium insets a themed <select>'s value by 4px on the inline-start side and
81
+ * 16px on the end side (1px plus the drop-down arrow), and exposes neither in any
82
+ * computed value: both were MEASURED by ink-scanning the painted value, and are
83
+ * constant across font sizes. Other engines have their own numbers. What IS
84
+ * observable is whether they apply at all — `appearance: none|base-select` drops
85
+ * the widget and both insets with it.
86
+ */
87
+ const MENULIST_START = 4
88
+ const MENULIST_END = 16
89
+ const THEMED = /^(auto|menulist)/
90
+
91
+ /** Schemes safe to hand to a PDF viewer as an external /URI action. */
92
+ const LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:'])
93
+ const SVG_NS = 'http://www.w3.org/2000/svg'
94
+
95
+ const NO_CLIP = { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity }
96
+ // Identity is a sentinel; importing the plugin must also work outside a browser.
97
+ const IDENTITY = Object.freeze({
98
+ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0, is2D: true, isIdentity: true,
99
+ m11: 1, m12: 0, m13: 0, m14: 0,
100
+ m21: 0, m22: 1, m23: 0, m24: 0,
101
+ m31: 0, m32: 0, m33: 1, m34: 0,
102
+ m41: 0, m42: 0, m43: 0, m44: 1,
103
+ })
104
+
105
+ /**
106
+ * `filter: opacity(…)` only changes alpha, which this module can model. Every
107
+ * other filter is a compositing operation whose visible contribution cannot be
108
+ * inferred from a text rect (blur, url(), drop-shadow(), contrast, …), so null is
109
+ * the fail-safe answer and the whole affected branch is excluded from the layer.
110
+ */
111
+ function filterOpacity(value) {
112
+ if (!value || value === 'none') return 1
113
+ const calls = value.match(/opacity\([^)]*\)/gi) || []
114
+ if (!calls.length || value.replace(/opacity\([^)]*\)/gi, '').trim()) return null
115
+ let alpha = 1
116
+ for (const call of calls) {
117
+ const body = call.slice(call.indexOf('(') + 1, -1).trim()
118
+ const n = parseFloat(body)
119
+ if (!Number.isFinite(n)) return null
120
+ alpha *= Math.max(0, Math.min(1, body.endsWith('%') ? n / 100 : n))
121
+ }
122
+ return alpha
123
+ }
124
+
125
+ /** Effects that can hide, reveal only part of, or visually replace glyph paint. */
126
+ function hasUnmodelledPaint(cs, alpha) {
127
+ const value = (name, fallback = 'none') => cs[name] || fallback
128
+ const textClip = String(value('backgroundClip', '')).split(',').some(v => v.trim() === 'text') ||
129
+ String(value('webkitBackgroundClip', '')).split(',').some(v => v.trim() === 'text')
130
+ return alpha === null ||
131
+ value('clipPath') !== 'none' || value('clip', 'auto') !== 'auto' ||
132
+ value('maskImage') !== 'none' || value('webkitMaskImage') !== 'none' ||
133
+ value('maskBorderSource') !== 'none' || value('webkitMaskBoxImageSource') !== 'none' ||
134
+ value('mixBlendMode', 'normal') !== 'normal' ||
135
+ textClip || cs.contentVisibility === 'hidden'
136
+ }
137
+
138
+ const textSecurity = (cs) => (cs.webkitTextSecurity || 'none') !== 'none'
139
+ const shadowPaint = (cs) => (cs.textShadow || 'none') !== 'none'
140
+
141
+ let paintContext = null
142
+ const paintAlphaCache = new Map()
143
+
144
+ /** Ask Chromium's own colour parser whether a computed paint is transparent. */
145
+ function colourAlpha(value) {
146
+ if (!value || value === 'none') return 0
147
+ if (/^(?:url|context-fill|context-stroke)\(/i.test(value)) return 1
148
+ if (paintAlphaCache.has(value)) return paintAlphaCache.get(value)
149
+ if (!paintContext) {
150
+ const canvas = document.createElement('canvas')
151
+ canvas.width = canvas.height = 1
152
+ paintContext = canvas.getContext('2d', { willReadFrequently: true })
153
+ }
154
+ if (!paintContext) return 1
155
+ paintContext.clearRect(0, 0, 1, 1)
156
+ // An unsupported assignment leaves this opaque sentinel in place. Keeping an
157
+ // unknown paint is safer than declaring visible text invisible by accident.
158
+ paintContext.fillStyle = '#000'
159
+ try { paintContext.fillStyle = value } catch { /* computed colours should parse */ }
160
+ paintContext.fillRect(0, 0, 1, 1)
161
+ const alpha = paintContext.getImageData(0, 0, 1, 1).data[3] / 255
162
+ paintAlphaCache.set(value, alpha)
163
+ return alpha
164
+ }
165
+
166
+ function numericAlpha(value) {
167
+ const n = parseFloat(value)
168
+ if (!Number.isFinite(n)) return 1
169
+ return Math.max(0, Math.min(1, String(value).trim().endsWith('%') ? n / 100 : n))
170
+ }
171
+
172
+ /** Whether the element's own glyph fill/stroke contributes visible paint. */
173
+ function textPaints(el, cs) {
174
+ if (el.namespaceURI === SVG_NS) {
175
+ const fill = colourAlpha(cs.fill) * numericAlpha(cs.fillOpacity)
176
+ const stroke = colourAlpha(cs.stroke) * numericAlpha(cs.strokeOpacity)
177
+ return fill >= ALPHA_MIN || (stroke >= ALPHA_MIN && (parseFloat(cs.strokeWidth) || 0) > 0)
178
+ }
179
+ const fillColour = cs.webkitTextFillColor && cs.webkitTextFillColor !== 'currentcolor'
180
+ ? cs.webkitTextFillColor
181
+ : cs.color
182
+ const fill = colourAlpha(fillColour)
183
+ const stroke = colourAlpha(cs.webkitTextStrokeColor || cs.color)
184
+ return fill >= ALPHA_MIN ||
185
+ (stroke >= ALPHA_MIN && (parseFloat(cs.webkitTextStrokeWidth) || 0) > 0)
186
+ }
187
+
188
+ /** Shared by navigation so bookmarks never reveal paint-redacted heading text. */
189
+ export function inspectTextPaint(el, cs = getComputedStyle(el)) {
190
+ const filtered = filterOpacity(cs.filter)
191
+ const paints = textPaints(el, cs)
192
+ const secured = textSecurity(cs)
193
+ const shadowOnly = !paints && shadowPaint(cs)
194
+ const unmodelled = hasUnmodelledPaint(cs, filtered)
195
+ return {
196
+ filterAlpha: filtered,
197
+ paints,
198
+ secured,
199
+ shadowOnly,
200
+ unmodelled,
201
+ unsafe: unmodelled || secured || shadowOnly,
202
+ }
203
+ }
204
+
205
+ /** Canvas advances, scoped to the document that owns each embedded font face. */
206
+ const measureContexts = new WeakMap()
207
+ function measurer(doc = document) {
208
+ let ctx = measureContexts.get(doc)
209
+ if (!ctx) {
210
+ ctx = doc.createElement('canvas').getContext('2d')
211
+ measureContexts.set(doc, ctx)
212
+ }
213
+ return ctx
214
+ }
215
+
216
+ /** Map a CSS font stack onto the base-14 family the PDF will actually carry. */
217
+ function base14Key(style) {
218
+ const family = style.fontFamily.toLowerCase()
219
+ const weight = parseInt(style.fontWeight, 10) || (style.fontWeight === 'bold' ? 700 : 400)
220
+ const italic = style.fontStyle === 'italic' || style.fontStyle === 'oblique'
221
+
222
+ let base = 'sans'
223
+ if (/mono|courier|consolas|menlo/.test(family)) base = 'mono'
224
+ else if (/serif/.test(family) && !/sans-serif/.test(family)) base = 'serif'
225
+ else if (/times|georgia|garamond|cambria/.test(family)) base = 'serif'
226
+
227
+ const bold = weight >= 600
228
+ if (bold && italic) return `${base}-bolditalic`
229
+ if (bold) return `${base}-bold`
230
+ if (italic) return `${base}-italic`
231
+ return base
232
+ }
233
+
234
+ /** Font string for the base-14 equivalent, used to predict the PDF's own advance. */
235
+ function pdfFontShorthand(key, size) {
236
+ const italic = key.includes('italic') ? 'italic ' : ''
237
+ const bold = key.includes('bold') ? 'bold ' : ''
238
+ const family = key.startsWith('mono') ? 'Courier' : key.startsWith('serif') ? 'Times' : 'Helvetica'
239
+ return `${italic}${bold}${size}px ${family}`
240
+ }
241
+
242
+ /**
243
+ * `text-transform` computes to a compound value — 'uppercase full-width',
244
+ * 'capitalize full-size-kana' — so this tests membership, not equality. The
245
+ * width/kana keywords swap glyphs rather than case and are left alone: the raster
246
+ * paints them, the layer stays searchable in the codepoints the DOM has.
247
+ */
248
+ function applyTransform(text, mode) {
249
+ if (mode.includes('uppercase')) return text.toUpperCase()
250
+ if (mode.includes('lowercase')) return text.toLowerCase()
251
+ if (!mode.includes('capitalize')) return text
252
+ return text.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1))
253
+ }
254
+
255
+ /**
256
+ * The painted form of ONE fragment of a word. `capitalize` is the only keyword
257
+ * that needs to know where the word starts — the caller does — and applying it
258
+ * blind to a fragment would upcase every glyph. The case keywords are length-safe
259
+ * here by construction: the Range offsets bounding the fragment index the SOURCE
260
+ * string, so ß → SS moves nothing.
261
+ */
262
+ function shown(info, text, first) {
263
+ if (!info.transform.includes('capitalize')) return applyTransform(text, info.transform)
264
+ return first ? text.replace(/\S/, c => c.toUpperCase()) : text
265
+ }
266
+
267
+ /**
268
+ * The flattened tree, which is the one that gets painted: slotted nodes render
269
+ * inside the SLOT's box, and a shadow root's children have no parentElement.
270
+ * All three ancestor walks below use this — anything else describes a tree the
271
+ * browser never laid out.
272
+ */
273
+ function renderParent(node) {
274
+ if (node.assignedSlot) return node.assignedSlot
275
+ if (node.parentElement) return node.parentElement
276
+ const root = node.getRootNode()
277
+ return root instanceof ShadowRoot ? root.host : null
278
+ }
279
+
280
+ // ——— transforms ———
281
+
282
+ function toDeg(v) {
283
+ const n = parseFloat(v) || 0
284
+ // 'grad' first: it ends with 'rad'.
285
+ if (v.endsWith('grad')) return n * 0.9
286
+ if (v.endsWith('rad')) return (n * 180) / Math.PI
287
+ if (v.endsWith('turn')) return n * 360
288
+ return n
289
+ }
290
+
291
+ function applyRotate(m, value) {
292
+ const parts = value.trim().split(/\s+/)
293
+ const angle = toDeg(parts.pop())
294
+ if (parts.length === 3) return m.rotateAxisAngleSelf(+parts[0], +parts[1], +parts[2], angle)
295
+ const axis = parts.length === 1 ? parts[0] : 'z'
296
+ return m.rotateAxisAngleSelf(axis === 'x' ? 1 : 0, axis === 'y' ? 1 : 0, axis === 'x' || axis === 'y' ? 0 : 1, angle)
297
+ }
298
+
299
+ /**
300
+ * The element's own transform, linear part only.
301
+ * `transform` alone is not enough: the rotate/scale longhands are separate
302
+ * computed values applied outside it, in the order translate → rotate → scale →
303
+ * transform. `translate` is pure translation, so it never reaches the linear part
304
+ * — and neither does `transform-origin`, which is a conjugation by a translation.
305
+ * Its z does reach the PERSPECTIVE question, though, which is what zTranslate is.
306
+ */
307
+ function ownLinear(cs) {
308
+ let m = null
309
+ if (cs.rotate && cs.rotate !== 'none') m = applyRotate(new DOMMatrix(), cs.rotate)
310
+ if (cs.scale && cs.scale !== 'none') {
311
+ const p = cs.scale.trim().split(/\s+/).map(Number)
312
+ m = (m || new DOMMatrix()).scaleSelf(p[0], p.length > 1 ? p[1] : p[0], p.length > 2 ? p[2] : 1)
313
+ }
314
+ // Computed `transform` is always 'none' or matrix()/matrix3d(); DOMMatrix throws on 'none'.
315
+ if (cs.transform.startsWith('matrix')) {
316
+ const t = new DOMMatrix(cs.transform)
317
+ m = m ? m.multiply(t) : t
318
+ }
319
+ return m
320
+ }
321
+
322
+ /**
323
+ * How far the `translate` longhand moves the element along z. It is a
324
+ * translation, so ownLinear drops it — but a translation in z is exactly what an
325
+ * ancestor's perspective divides by, and nothing else here would ever see it.
326
+ */
327
+ function zTranslate(cs) {
328
+ if (!cs.translate || cs.translate === 'none') return 0
329
+ const p = cs.translate.trim().split(/\s+/)
330
+ return p.length > 2 ? parseFloat(p[2]) || 0 : 0
331
+ }
332
+
333
+ /**
334
+ * `transform-origin`'s z. Chromium serializes the computed value with two
335
+ * components when z is zero and three when it is not, so the third is absent far
336
+ * more often than it is present.
337
+ */
338
+ function originZ(cs) {
339
+ const p = cs.transformOrigin.trim().split(/\s+/)
340
+ return p.length > 2 ? parseFloat(p[2]) || 0 : 0
341
+ }
342
+
343
+ /**
344
+ * Whether the element leaves the z = 0 plane AT z = 0. That is exactly when a
345
+ * perspective above it is the identity, because perspective divides by 1 − z/d
346
+ * and reads nothing else — so it is the whole question this file has to ask about
347
+ * any perspective it cannot itself multiply out.
348
+ *
349
+ * Three terms. The z row must not couple x or y into z, or the plane tilts rather
350
+ * than shifts. Its displacement is m43, plus the `translate` longhand's z that
351
+ * ownLinear drops as a translation, plus `oz·(1 − m33)` — the residue of
352
+ * conjugating the transform by a transform-origin that has a z of its own.
353
+ *
354
+ * All three MEASURED against Blink over 20 transform × origin × translate
355
+ * combinations, by painting each with and without an ancestor `perspective:500px`
356
+ * and comparing the linear parts: every case this calls z-neutral paints
357
+ * identically both ways (diff 0), and every case it does not is scaled by exactly
358
+ * 1 / (1 − z/500) to five decimals. `rotateX(180deg)` alone is z-neutral — that is
359
+ * the card flip, and it survives — while the same rotation under
360
+ * `transform-origin: 50% 50% 60px` lands the plane at z = 120 and Blink scales it
361
+ * by 1.31579. The origin term is the one this module got wrong before: without it
362
+ * that flip was shipped as a bare [1, 0, 0, −1] over glyphs painted 1.32× larger.
363
+ */
364
+ function zNeutral(cs, own) {
365
+ let z = zTranslate(cs)
366
+ if (own) {
367
+ if (Math.abs(own.m13) > EPS || Math.abs(own.m23) > EPS) return false
368
+ const oz = originZ(cs)
369
+ // ownLinear reports the matrix UNCONJUGATED by transform-origin, which is exact
370
+ // for an affine one — a translation cannot reach an affine linear part — and
371
+ // wrong for a projective one, where the conjugation divides through. So a
372
+ // projective own with an origin off the z = 0 plane is a map this file does not
373
+ // hold. MEASURED: `transform: perspective(324px)` under
374
+ // `transform-origin: 14px 47px 31px` is painted 0.913× = 1 / (1 + 31/324),
375
+ // where the bare matrix claims 1 and used to be believed.
376
+ if (oz && (own.m34 || own.m14 || own.m24 || own.m44 !== 1)) return false
377
+ z += own.m43 + oz * (1 - own.m33)
378
+ }
379
+ return Math.abs(z) < EPS
380
+ }
381
+
382
+ /**
383
+ * `L · seg`, with `seg` projected onto z = 0 first — which is all a flat ancestor
384
+ * ever composites into its own plane. Dropping the z row and column IS CSS
385
+ * flattening; every entry it clears is one the browser has already discarded.
386
+ */
387
+ function flatten(L, seg) {
388
+ if (seg === IDENTITY) return L
389
+ if (seg.is2D) return L === IDENTITY ? seg : L.multiply(seg)
390
+ const f = DOMMatrix.fromMatrix(seg)
391
+ f.m13 = f.m23 = f.m31 = f.m32 = f.m34 = f.m43 = 0
392
+ f.m33 = 1
393
+ return L === IDENTITY ? f : L.multiply(f)
394
+ }
395
+
396
+ /**
397
+ * Whether the element is a grouping context, which composites its subtree as one
398
+ * flat image and so overrides `transform-style: preserve-3d`. Every term MEASURED
399
+ * against what Blink paints, over 108 property values: `rotateY(30deg)` over
400
+ * `rotateY(30deg)` paints 0.75 of the local advance when the outer element groups
401
+ * and 0.50 when it preserves. The surprises, both ways round: `contain:
402
+ * paint|strict|content` and `content-visibility: auto` measurably do NOT group
403
+ * here, while `-webkit-box-reflect`, `view-transition-name` and
404
+ * `-webkit-mask-box-image` do. Those last three are guarded rather than compared
405
+ * bare — all three are prefixed or recent enough that a computed style need not
406
+ * carry them, and `undefined !== 'none'` would make EVERY element group.
407
+ */
408
+ function groups(cs) {
409
+ return cs.opacity !== '1' || cs.filter !== 'none' || cs.clipPath !== 'none' ||
410
+ cs.mixBlendMode !== 'normal' || cs.overflowX !== 'visible' || cs.overflowY !== 'visible' ||
411
+ cs.maskImage !== 'none' || cs.backdropFilter !== 'none' || cs.isolation !== 'auto' ||
412
+ (cs.webkitBoxReflect || 'none') !== 'none' || (cs.viewTransitionName || 'none') !== 'none' ||
413
+ (cs.webkitMaskBoxImageSource || 'none') !== 'none' || GROUPING.test(cs.willChange)
414
+ }
415
+
416
+ /** 2×2 product in CSS column convention: x' = a·x + c·y, y' = b·x + d·y. */
417
+ function compose(L, M) {
418
+ if (!L) return { ...M }
419
+ return {
420
+ a: L.a * M.a + L.c * M.b,
421
+ b: L.b * M.a + L.d * M.b,
422
+ c: L.a * M.c + L.c * M.d,
423
+ d: L.b * M.c + L.d * M.d,
424
+ }
425
+ }
426
+
427
+ function axisAligned(L) {
428
+ return Math.abs(L.b) < EPS && Math.abs(L.c) < EPS
429
+ }
430
+
431
+ /**
432
+ * The matrix a run should carry: null for the untransformed case, so the common
433
+ * path stays exactly as cheap and as exact as it was before rotation existed.
434
+ * Null too where no 2×2 describes the map — see `affine` in contextOf for what
435
+ * that costs and why. The 2×2 itself is exact whenever it is handed out: it is
436
+ * the accumulated map restricted to the plane the text lives in.
437
+ */
438
+ function runMatrix(ctx) {
439
+ if (!ctx.affine) return null
440
+ const { a, b, c, d } = ctx.L
441
+ if (Math.abs(a - 1) < EPS && Math.abs(d - 1) < EPS && Math.abs(b) < EPS && Math.abs(c) < EPS) return null
442
+ return { a, b, c, d }
443
+ }
444
+
445
+ // ——— clipping ———
446
+
447
+ function intersect(a, b) {
448
+ if (a === NO_CLIP) return b
449
+ if (b === NO_CLIP) return a
450
+ return {
451
+ left: Math.max(a.left, b.left), top: Math.max(a.top, b.top),
452
+ right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom),
453
+ }
454
+ }
455
+
456
+ /**
457
+ * A run is selectable as its COMPLETE source string, so it is safe only when its
458
+ * complete painted rect is inside every overflow clip. Keeping a 60%-visible word
459
+ * would also keep the 40% used as a visual redaction. CLIP_EPS absorbs CSS/device
460
+ * rounding only; it is far smaller than a legible glyph stem.
461
+ */
462
+ function fullyInside(rect, clip) {
463
+ if (clip === NO_CLIP) return true
464
+ return rect.left >= clip.left - CLIP_EPS && rect.top >= clip.top - CLIP_EPS &&
465
+ rect.right <= clip.right + CLIP_EPS && rect.bottom <= clip.bottom + CLIP_EPS
466
+ }
467
+
468
+ /** Crop a link annotation to what is actually clickable after overflow clips. */
469
+ function clippedRect(rect, clip) {
470
+ if (clip === NO_CLIP) return {
471
+ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
472
+ width: rect.width, height: rect.height, cropped: false,
473
+ }
474
+ const left = Math.max(rect.left, clip.left)
475
+ const top = Math.max(rect.top, clip.top)
476
+ const right = Math.min(rect.right, clip.right)
477
+ const bottom = Math.min(rect.bottom, clip.bottom)
478
+ if (right <= left || bottom <= top) return null
479
+ return {
480
+ left, top, right, bottom, width: right - left, height: bottom - top,
481
+ cropped: left > rect.left + CLIP_EPS || top > rect.top + CLIP_EPS ||
482
+ right < rect.right - CLIP_EPS || bottom < rect.bottom - CLIP_EPS,
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Which axes clip, and how far past the padding box the clip edge sits on each.
488
+ * Per axis because `clip` next to `visible` is a legal COMPUTED pair — only
489
+ * `hidden`/`scroll`/`auto` force the other axis to `auto`. `overflow-x: clip;
490
+ * overflow-y: visible` cuts nothing off the top or the bottom, and taking one
491
+ * rect for both axes drops text the raster demonstrably paints.
492
+ */
493
+ function clipAxes(cs) {
494
+ const paint = CONTAIN_PAINT.test(cs.contain)
495
+ // The margin only exists where there IS an overflow clip EDGE to push outward.
496
+ // `overflow: hidden` clips at the padding box and ignores it — but Chromium
497
+ // computes the length there all the same, so taking it unconditionally WOULD
498
+ // keep text the raster cut off, which is what these two gates are for.
499
+ const m = parseFloat(cs.overflowClipMargin) || 0
500
+ return {
501
+ x: paint || cs.overflowX !== 'visible',
502
+ y: paint || cs.overflowY !== 'visible',
503
+ mx: paint || cs.overflowX === 'clip' ? m : 0,
504
+ my: paint || cs.overflowY === 'clip' ? m : 0,
505
+ }
506
+ }
507
+
508
+ /** A form control clips its own value at the padding box, whatever `overflow` says. */
509
+ const CONTROL_CLIP = { x: true, y: true, mx: 0, my: 0 }
510
+
511
+ function containsFixed(cs) {
512
+ // The individual transform properties are not everywhere: unguarded, `undefined
513
+ // !== 'none'` would make EVERY element a containing block and collapse clipAbs.
514
+ return cs.transform !== 'none' || cs.filter !== 'none' || cs.perspective !== 'none' ||
515
+ (cs.rotate || 'none') !== 'none' || (cs.scale || 'none') !== 'none' ||
516
+ (cs.translate || 'none') !== 'none' ||
517
+ (cs.backdropFilter || 'none') !== 'none' || cs.willChange.includes('transform') ||
518
+ CONTAIN_PAINT.test(cs.contain)
519
+ }
520
+
521
+ /**
522
+ * The clip rect is the PADDING box, and getBoundingClientRect gives the border
523
+ * box — insetting a transformed AABB by unscaled border widths would be wrong.
524
+ * offsetWidth/clientLeft are transform-free layout metrics, so the padding box is
525
+ * built in local px and mapped out through L: the AABB centre is the image of the
526
+ * local centre for any affine map, which pins the translation for free.
527
+ */
528
+ function paddingBox(el, L, ax) {
529
+ const r = el.getBoundingClientRect()
530
+ const { a, b, c, d } = L
531
+ const cw = el.clientWidth
532
+ const ch = el.clientHeight
533
+ let box
534
+ if (!cw || !el.offsetWidth) {
535
+ // No local box to expand before mapping, so the margins are mapped on their
536
+ // own — same units as the branch below, which is the whole point.
537
+ const mx = Math.abs(a) * ax.mx + Math.abs(c) * ax.my
538
+ const my = Math.abs(b) * ax.mx + Math.abs(d) * ax.my
539
+ box = { left: r.left - mx, top: r.top - my, right: r.right + mx, bottom: r.bottom + my }
540
+ } else {
541
+ const dx = el.clientLeft + cw / 2 - el.offsetWidth / 2
542
+ const dy = el.clientTop + ch / 2 - el.offsetHeight / 2
543
+ const cx = (r.left + r.right) / 2 + a * dx + c * dy
544
+ const cy = (r.top + r.bottom) / 2 + b * dx + d * dy
545
+ // overflow-clip-margin is a LOCAL length: expand the padding box before mapping
546
+ // it out, or a scale(4) box comes back clipped 3·margin too tight on each side.
547
+ const hx = (Math.abs(a) * (cw + 2 * ax.mx) + Math.abs(c) * (ch + 2 * ax.my)) / 2
548
+ const hy = (Math.abs(b) * (cw + 2 * ax.mx) + Math.abs(d) * (ch + 2 * ax.my)) / 2
549
+ box = { left: cx - hx, top: cy - hy, right: cx + hx, bottom: cy + hy }
550
+ }
551
+ if (ax.x && ax.y) return box
552
+ // One axis clipping alone bounds only the VIEWPORT axis its edge normal lands
553
+ // on — (d, −c) for local x, (−b, a) for local y, so a 90° rotation swaps them.
554
+ // Under a diagonal map it lands on neither: a slab is not an AABB, and leaving
555
+ // that axis open keeps text the raster paints instead of dropping it.
556
+ if (!((ax.x && Math.abs(c) < EPS) || (ax.y && Math.abs(a) < EPS))) { box.left = -Infinity; box.right = Infinity }
557
+ if (!((ax.x && Math.abs(d) < EPS) || (ax.y && Math.abs(b) < EPS))) { box.top = -Infinity; box.bottom = Infinity }
558
+ return box
559
+ }
560
+
561
+ // ——— accumulated ancestor context ———
562
+
563
+ const TOP = {
564
+ cs: null, L: IDENTITY, outer: IDENTITY, seg: IDENTITY,
565
+ preserve3d: false, persp: false, perspProp: false, tilt: false,
566
+ affine: true, bad: false, soft: false,
567
+ noCtm: false, alpha: 1, inside: false, unsafePaint: false,
568
+ clip: NO_CLIP, clipAbs: NO_CLIP, clipFixed: NO_CLIP,
569
+ }
570
+
571
+ /**
572
+ * Everything the ancestors do to an element, memoized per element so the walk
573
+ * costs one getComputedStyle each rather than one per text node.
574
+ *
575
+ * Two different stop points, on purpose. The transform accumulates to the top of
576
+ * the document because client rects already carry every transform up to the
577
+ * viewport — origin and matrix must describe the same space. Opacity and clipping
578
+ * stop at the capture root INCLUSIVE, because snapdom re-renders the element in
579
+ * isolation: an above-root `opacity: 0` does not blank the capture, but the root's
580
+ * own does, and with `overflow: visible` snapdom paints outside the root box.
581
+ */
582
+ function contextOf(el, S) {
583
+ const hit = S.ctxCache.get(el)
584
+ if (hit) return hit
585
+
586
+ const parent = renderParent(el)
587
+ const base = parent ? contextOf(parent, S) : TOP
588
+ const cs = getComputedStyle(el)
589
+ // An element that generates no box has its transform, opacity and filter ignored.
590
+ const boxless = cs.display === 'contents'
591
+
592
+ // Two parts, because a 3D rendering context is only flattened once it CLOSES:
593
+ // `outer` is every context above that already has, `seg` is the open one. `seg`
594
+ // stays IDENTITY while nothing 3D is in play, so the ordinary chain is one
595
+ // matrix and one multiply per transform, exactly as it was.
596
+ let outer = base.outer
597
+ let seg = base.seg
598
+ // A perspective that projects THIS element: the `perspective` property warps an
599
+ // element's descendants and never its own text, so it is inherited rather than
600
+ // applied here — but the perspective() transform FUNCTION is part of the
601
+ // element's own transform and projects the element itself.
602
+ //
603
+ // Both of these describe the OPEN 3D context only, and are cleared when one
604
+ // closes. `bad` is the one thing that cannot be: it records that `outer` itself
605
+ // is no longer a description of anything Blink paints.
606
+ let persp = base.persp
607
+ let tilt = base.tilt
608
+ let bad = base.bad
609
+ let soft = base.soft
610
+ let noCtm = base.noCtm
611
+ const ctm = !boxless && el.getScreenCTM ? el.getScreenCTM() : null
612
+ if (ctm) {
613
+ // viewBox scaling appears in no computed transform. getScreenCTM is the only
614
+ // place it surfaces, and it already carries every ancestor — stop here. Only
615
+ // its linear part is taken, so however it treats scroll offsets is moot.
616
+ outer = new DOMMatrix([ctm.a, ctm.b, ctm.c, ctm.d, 0, 0])
617
+ seg = IDENTITY
618
+ } else if (!boxless) {
619
+ if (el.getScreenCTM) noCtm = true
620
+ // A parent that does not preserve 3D closes the context above it: what it
621
+ // composites into its own plane is that context FLATTENED, and the flattened
622
+ // form is the only one the browser paints. Multiplying the whole chain naively
623
+ // instead lets two 3D transforms across such a boundary cancel terms that were
624
+ // already dropped — MEASURED: `rotateY(60deg)` over `rotateY(-60deg)
625
+ // rotate(25deg)` then places a run 60.7 px wide where Blink paints 15.2 px.
626
+ if (!base.preserve3d) {
627
+ // Whether this close changes anything — a 3D segment to flatten, a perspective
628
+ // or a tilt to drop. One that does is one a descendant can tell apart, which
629
+ // is the whole of what `soft` is for; one that does not is free.
630
+ if (seg !== IDENTITY || persp || tilt) soft = true
631
+ if (seg !== IDENTITY) { outer = flatten(outer, seg); seg = IDENTITY }
632
+ // Closing the context ends what it was, too. Below the boundary the geometry
633
+ // is a plane again, and every perspective above has been baked out of it —
634
+ // flattening zeroes m34, which is the whole of a perspective. MEASURED:
635
+ // `transform: perspective(500px)` on a flat parent leaves its rotateY(45deg)
636
+ // child painting [0.7071, 0, 0, 1], the bare orthographic squash, with no
637
+ // trace of the perspective in it.
638
+ //
639
+ // Unless the closing context was itself projected, in which case `outer` now
640
+ // holds an orthographic squash of something Blink painted as a trapezoid and
641
+ // nothing below it can be trusted again. MEASURED, `perspective: 500px` over
642
+ // a flat `rotateY(60deg)` over `rotateY(-60deg) rotate(25deg)`: the leaf's
643
+ // painted quad is not a parallelogram at all — its fourth corner misses by
644
+ // 50.5 px — while the matrix this would otherwise hand out claims it is.
645
+ if (!base.affine) bad = true
646
+ tilt = false
647
+ persp = false
648
+ }
649
+ // A perspective on the parent applies to this element whatever the parent's
650
+ // transform-style: flattening ends the parent's 3D CONTEXT, and the parent's
651
+ // perspective acts below that, on its children's own plane.
652
+ if (base.perspProp) persp = true
653
+ const own = ownLinear(cs)
654
+ if (own) {
655
+ // A 2D transform is its own flattening, so it can go straight into `outer`
656
+ // and leave the 3D bookkeeping — and its cost — to chains that are 3D.
657
+ if (seg === IDENTITY && own.is2D) outer = outer === IDENTITY ? own : outer.multiply(own)
658
+ else seg = seg === IDENTITY ? own : seg.multiply(own)
659
+ }
660
+ // `ownLinear` drops the `translate` longhand as a pure translation, but Blink
661
+ // builds a transform node for it all the same — and a transform node is what
662
+ // closes the context. Keying the reach-back guard on `own` alone let
663
+ // `perspective(300px)` over a bare `preserve-3d` div over `translate: 0 0 30px`
664
+ // ship a silent identity where Blink paints 1/(1 − 30/300).
665
+ if ((own && !own.isIdentity) || (cs.translate && cs.translate !== 'none')) {
666
+ // A context closed above at an element that carried no transform of its own
667
+ // is a close Blink may not have made: an element under a `preserve-3d`
668
+ // parent can reach back past it and compose against the UNFLATTENED chain.
669
+ // MEASURED, `rotate3d(0,1,1,45deg)` (flat) over an empty div over a
670
+ // `preserve-3d` div over `rotateY(25deg)`: Blink paints
671
+ // [0.4295, 0.3913, −0.5, 0.8536], the flattened PRODUCT, where closing at
672
+ // the empty div gives [0.6409, 0.4532, −0.5, 0.8536].
673
+ //
674
+ // Which way it goes is not decidable from anything this file reads. Also
675
+ // MEASURED on that same chain: giving the empty div `transform:
676
+ // translateX(1px)` or `margin-left: 37px` restores the close and the
677
+ // [0.6409, …] answer, while `transform: rotate(0deg)`, `width`, `height`,
678
+ // `padding-left` and `margin-top` all leave it reaching back. So refuse,
679
+ // rather than pick — and treat an identity transform as no transform, since
680
+ // `rotate(0deg)` measurably does not restore the close.
681
+ if (base.preserve3d && soft) bad = true
682
+ soft = false
683
+ }
684
+ // Per element, and deliberately so: an ancestor's perspective divides by
685
+ // 1 − z/d at every link, so a link that puts geometry off the plane is not
686
+ // undone by a later link that brings the ACCUMULATED 2×2 back. MEASURED under
687
+ // `perspective: 600px`, `rotateY(45deg)` over `rotateY(-45deg) rotate(25deg)`
688
+ // accumulates to a clean rotate(25deg) and Blink paints it 1.892× that, because
689
+ // the two rotations turn about origins 400 px apart and leave the plane off z.
690
+ // The same two rotations about the SAME origin do return to z = 0 and do paint
691
+ // rotate(25deg) exactly — and this rejects that one too, loudly rather than
692
+ // silently, because the difference is a layout offset this module never sees.
693
+ if (!zNeutral(cs, own)) tilt = true
694
+ if (own && own.m34) persp = true
695
+ }
696
+ // The map that paints this element's own text: its context, flattened into the
697
+ // plane it is composited onto.
698
+ const M = flatten(outer, seg)
699
+ // Text lives in the z = 0 plane, and a matrix3d maps that plane through its 2×2
700
+ // block alone as long as its projective row is trivial — which every
701
+ // GPU-promotion hack (translateZ(0), translate3d) is, and Chrome serializes ALL
702
+ // of them as matrix3d. That row is the only thing left to check here, because
703
+ // flattening has already taken care of the z column.
704
+ //
705
+ // Under a perspective the plane must also STAY at z = 0, or the map is a
706
+ // trapezoid — or a uniform scale by 1 / (1 − z/d) that this product has no term
707
+ // for — and the run falls back to axis-aligned. That is what keeps the card flip
708
+ // — `rotateY(180deg)`, 3D but z-neutral — exact instead of thrown away.
709
+ const affine = !bad && !M.m14 && !M.m24 && M.m44 === 1 && (!persp || !tilt)
710
+
711
+ const inside = base.inside || el === S.root
712
+ let alpha = base.alpha
713
+ let unsafePaint = inside && el !== S.root ? base.unsafePaint : false
714
+ if (inside && !boxless) {
715
+ alpha *= parseFloat(cs.opacity) || 0
716
+ const filtered = filterOpacity(cs.filter)
717
+ if (filtered !== null) alpha *= filtered
718
+ if (hasUnmodelledPaint(cs, filtered)) unsafePaint = true
719
+ }
720
+
721
+ // An absolutely positioned box escapes every clipper that is not in its
722
+ // containing-block chain, so it picks up the clip recorded at that point.
723
+ let clip = NO_CLIP
724
+ if (inside) clip = cs.position === 'fixed' ? base.clipFixed : cs.position === 'absolute' ? base.clipAbs : base.clip
725
+ if (inside && !boxless) {
726
+ const ax = clipAxes(cs)
727
+ if (ax.x || ax.y) clip = intersect(clip, paddingBox(el, M, ax))
728
+ }
729
+ const fixedCb = containsFixed(cs)
730
+
731
+ if (cs.zoom && cs.zoom !== '1') S.f.zoom = true
732
+ if (cs.contentVisibility && cs.contentVisibility !== 'visible') S.f.contentVisibility = true
733
+
734
+ const ctx = {
735
+ cs, L: M, outer, seg, affine, tilt, persp, bad, soft, noCtm,
736
+ alpha, inside, unsafePaint, clip,
737
+ // `display: contents` generates no box, so it neither preserves nor groups nor
738
+ // projects — whatever the parent decided still holds for its children.
739
+ preserve3d: boxless ? base.preserve3d : cs.transformStyle === 'preserve-3d' && !groups(cs),
740
+ perspProp: boxless ? base.perspProp : cs.perspective !== 'none',
741
+ clipAbs: cs.position !== 'static' || fixedCb ? clip : inside ? base.clipAbs : NO_CLIP,
742
+ clipFixed: fixedCb ? clip : inside ? base.clipFixed : NO_CLIP,
743
+ }
744
+ S.ctxCache.set(el, ctx)
745
+ return ctx
746
+ }
747
+
748
+ // ——— font metrics ———
749
+
750
+ /**
751
+ * Baseline metrics from the document that actually laid out the final artifact.
752
+ *
753
+ * Canvas metrics from the application document are not interchangeable here: an
754
+ * embedded @font-face belongs to the isolated artifact document, so the parent
755
+ * canvas can silently measure a fallback face. A zero-height inline block sits on
756
+ * the baseline; its top minus a neighbouring text Range's top is therefore the
757
+ * ascent Blink used for that inline box. The probe is off-screen, cached per font,
758
+ * and removed before collectTextLayer returns.
759
+ */
760
+ function layoutFontMetrics(el, S, css, fallbackAscent, fallbackDescent) {
761
+ const doc = el.ownerDocument || document
762
+ const key = css
763
+ const cached = S.layoutFontCache.get(key)
764
+ if (cached) return cached
765
+ if (!doc.body) return { ascent: fallbackAscent, descent: fallbackDescent }
766
+
767
+ let host = S.metricHost
768
+ if (!host || host.ownerDocument !== doc) {
769
+ host = doc.createElement('div')
770
+ host.setAttribute('data-snapdom-pdf-font-metrics', '')
771
+ host.style.cssText =
772
+ 'all:initial!important;position:fixed!important;left:0!important;top:0!important;' +
773
+ 'display:block!important;width:max-content!important;height:auto!important;' +
774
+ 'white-space:nowrap!important;line-height:normal!important;visibility:hidden!important;' +
775
+ 'pointer-events:none!important;z-index:-2147483648!important;'
776
+ doc.body.appendChild(host)
777
+ S.metricHost = host
778
+ }
779
+
780
+ const probe = doc.createElement('span')
781
+ probe.style.font = css
782
+ probe.style.lineHeight = 'normal'
783
+ probe.style.whiteSpace = 'nowrap'
784
+ const marker = doc.createElement('span')
785
+ marker.style.cssText = 'display:inline-block;width:0;height:0;padding:0;border:0;margin:0'
786
+ const sample = doc.createTextNode('Hxg')
787
+ probe.append(marker, sample)
788
+ host.appendChild(probe)
789
+
790
+ const range = doc.createRange()
791
+ range.selectNodeContents(sample)
792
+ const textRect = range.getClientRects()[0]
793
+ const baseline = marker.getBoundingClientRect().top
794
+ probe.remove()
795
+
796
+ const ascent = textRect ? baseline - textRect.top : NaN
797
+ const descent = textRect ? textRect.bottom - baseline : NaN
798
+ const measured = {
799
+ ascent: Number.isFinite(ascent) && ascent > 0 ? ascent : fallbackAscent,
800
+ descent: Number.isFinite(descent) && descent >= 0 ? descent : fallbackDescent,
801
+ }
802
+ S.layoutFontCache.set(key, measured)
803
+ return measured
804
+ }
805
+
806
+ function fontOf(el, S, cs) {
807
+ const hit = S.fontCache.get(el)
808
+ if (hit) return hit
809
+
810
+ const size = parseFloat(cs.fontSize) || 16
811
+ const key = base14Key(cs)
812
+ // The REAL identity of the face this style asks for, kept alongside the
813
+ // base-14 approximation: an export that can embed the actual binary needs the
814
+ // stack, and the stack is only readable while this document is alive.
815
+ const weight = parseInt(cs.fontWeight, 10) || (cs.fontWeight === 'bold' ? 700 : 400)
816
+ const italic = cs.fontStyle === 'italic' || cs.fontStyle.startsWith('oblique')
817
+ const fkKey = `${cs.fontFamily}|${weight}|${italic}`
818
+ let fk = S.fontKeyIndex.get(fkKey)
819
+ if (fk === undefined) {
820
+ fk = S.fontKeys.length
821
+ S.fontKeys.push({ stack: cs.fontFamily, weight, italic })
822
+ S.fontKeyIndex.set(fkKey, fk)
823
+ }
824
+ const css = `${cs.fontStyle === 'normal' ? '' : cs.fontStyle + ' '}${cs.fontWeight} ${size}px ${cs.fontFamily}`
825
+ const doc = el.ownerDocument || document
826
+ const ctx = measurer(doc)
827
+ ctx.font = css
828
+ const metrics = ctx.measureText('Hxg')
829
+ const fallbackAscent = metrics.fontBoundingBoxAscent || metrics.actualBoundingBoxAscent || size * 0.8
830
+ const fallbackDescent = metrics.fontBoundingBoxDescent || metrics.actualBoundingBoxDescent || size * 0.2
831
+ const { ascent, descent } = layoutFontMetrics(el, S, css, fallbackAscent, fallbackDescent)
832
+ const info = {
833
+ key, fk, size, ascent, descent, css, document: doc,
834
+ pdf: pdfFontShorthand(key, size),
835
+ transform: cs.textTransform,
836
+ // 'normal' is not a length: assigning it to the canvas leaves the previous value.
837
+ tracking: cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing,
838
+ spacing: cs.wordSpacing === 'normal' ? '0px' : cs.wordSpacing,
839
+ // 'normal' is ascent + descent + the font's line GAP, and no browser API
840
+ // exposes the gap — this under-reports it by a few percent of the size, which
841
+ // accumulates downward over a long textarea. Recovering it needs a mirror
842
+ // element laid out in the same style — the extra layout wrapLines below
843
+ // already declines to pay for, and paying for it here buys half an answer.
844
+ lineHeight: parseFloat(cs.lineHeight) || ascent + descent,
845
+ vertical: /^(vertical|sideways)/.test(cs.writingMode),
846
+ // sideways-lr turns glyphs COUNTER-clockwise; every other vertical mode turns
847
+ // them clockwise. text-orientation does not apply to sideways-* at all, so
848
+ // folding it in here keeps the upright test to one rule.
849
+ turn: cs.writingMode === 'sideways-lr' ? R270 : R90,
850
+ orientation: /^sideways/.test(cs.writingMode) ? 'sideways' : cs.textOrientation,
851
+ align: cs.textAlign,
852
+ rtl: cs.direction === 'rtl',
853
+ // The fill a vector-text export paints this run with, and null when the run
854
+ // must NOT be painted as vector: a colour that does not resolve, is
855
+ // translucent (a Type0 fill has no per-run alpha here), or a word-spacing a
856
+ // Type0 font cannot express — PDF `Tw` applies only to single-byte code 32,
857
+ // and Identity-H is two-byte. Letter-spacing IS expressible (via `Tc`) and
858
+ // travels on `tracking` instead of blocking the paint.
859
+ color: (colourAlpha(cs.color) >= 0.999 && (parseFloat(cs.wordSpacing) || 0) === 0)
860
+ ? colourTriple(cs.color) : null,
861
+ // Letter-spacing in CSS px, applied as character spacing (`Tc`) when
862
+ // painting. Distinct from `tracking` above, which is the canvas string the
863
+ // measurement path feeds to ctx.letterSpacing.
864
+ trackingPx: parseFloat(cs.letterSpacing) || 0,
865
+ }
866
+ S.fontCache.set(el, info)
867
+ return info
868
+ }
869
+
870
+ /** Advance in the REAL font — the only independent measurement a rotated run has. */
871
+ function measureReal(info, text) {
872
+ const ctx = measurer(info.document)
873
+ ctx.font = info.css
874
+ ctx.letterSpacing = info.tracking
875
+ ctx.wordSpacing = info.spacing
876
+ const w = ctx.measureText(text).width
877
+ ctx.letterSpacing = '0px'
878
+ ctx.wordSpacing = '0px'
879
+ return w
880
+ }
881
+
882
+ /**
883
+ * Advance the base-14 font will produce, which is what Tz has to correct for.
884
+ * Zero for a run the substitute maps to no advance at all (combining marks), and
885
+ * reported as such: the caller divides by it and has to guard, and inventing an
886
+ * epsilon here would silently turn that run into a 1000% stretch instead.
887
+ */
888
+ function measureNatural(info, text) {
889
+ const ctx = measurer(info.document)
890
+ ctx.font = info.pdf
891
+ return ctx.measureText(text).width
892
+ }
893
+
894
+ /**
895
+ * Advance of a string in a base-14 face, in the units `size` is given in.
896
+ *
897
+ * Exported because the page's own text — a number, a running head, a line of the
898
+ * generated index — has no DOM run to take a measurement from, and the standing
899
+ * `length × size × 0.5` estimate puts a right-aligned string visibly off its
900
+ * margin and makes dot leaders arrive at the wrong place.
901
+ *
902
+ * @param {string} text
903
+ * @param {string} [key='sans'] a base-14 key: `sans`, `serif-bold`, `mono`, …
904
+ * @param {number} [size=10]
905
+ */
906
+ export function measureBase14(text, key = 'sans', size = 10) {
907
+ const ctx = measurer()
908
+ ctx.font = pdfFontShorthand(key || 'sans', size)
909
+ return ctx.measureText(String(text)).width
910
+ }
911
+
912
+ // ——— placing a run ———
913
+
914
+ /**
915
+ * The run's box in ITS OWN space, recovered from the AABB the browser reported.
916
+ *
917
+ * Rotation makes this genuinely hard: the two AABB equations in (w, h) are
918
+ * singular at 45°, so one of the two must come from an independent measurement.
919
+ * We measure w (canvas advance) and recover h.
920
+ *
921
+ * h is the INLINE box — ascent + descent, what getClientRects reports for a text
922
+ * Range and what the fallback below uses. Not the line box: `line-height: 2`
923
+ * leaves its leading OUT of h, deliberately, because h is the run's painted
924
+ * extent and that is what a page-slice overlap test has to reason about.
925
+ */
926
+ function localBox(L, rect, info, text) {
927
+ if (!L) return { width: rect.width, height: rect.height }
928
+ const { a, b, c, d } = L
929
+ if (axisAligned(L)) {
930
+ // The AABB corners ARE the images of the local corners; h never enters.
931
+ return { width: rect.width / Math.abs(a), height: rect.height / Math.abs(d) }
932
+ }
933
+ const width = measureReal(info, text)
934
+ // Pick the better-conditioned axis: the larger divisor is the smaller error gain.
935
+ let height = Math.abs(d) >= Math.abs(c)
936
+ ? (rect.height - Math.abs(b) * width) / Math.abs(d)
937
+ : (rect.width - Math.abs(a) * width) / Math.abs(c)
938
+ // Blink's text fragment rects are inline boxes, not line boxes (getClientRects
939
+ // on an inline ignores line-height), so that is the shape to fall back to.
940
+ const inline = info.ascent + info.descent
941
+ if (!isFinite(height) || height < 0.5 * inline || height > 3 * inline) height = inline
942
+ return { width, height }
943
+ }
944
+
945
+ /**
946
+ * Baseline start point, in viewport px. The AABB centre is the image of the local
947
+ * centre under any affine map, so the origin is one displacement away along L —
948
+ * no inverse, and the unknown translation cancels.
949
+ *
950
+ * With L identity this collapses to `rect.left, rect.top + ascent`: the shipped,
951
+ * verified line, and h drops out. That is the untransformed path, unchanged.
952
+ */
953
+ function baselineOrigin(L, rect, box, A) {
954
+ if (!L) return { originX: rect.left, originY: rect.top + A }
955
+ const { a, b, c, d } = L
956
+ if (axisAligned(L)) {
957
+ return {
958
+ originX: a > 0 ? rect.left : rect.right,
959
+ originY: (d > 0 ? rect.top : rect.bottom) + d * A,
960
+ }
961
+ }
962
+ const dx = -box.width / 2
963
+ const dy = A - box.height / 2
964
+ return {
965
+ originX: rect.left + rect.width / 2 + a * dx + c * dy,
966
+ originY: rect.top + rect.height / 2 + b * dx + d * dy,
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Truthy only when the run really went out. Three paths drop it — a degenerate
972
+ * rect, a singular map, the clip — and all three COUNT what they dropped, or a
973
+ * `transform: scale(0)` would empty the whole layer without a word of warning.
974
+ */
975
+ function pushRun(S, text, rect, info, L, ctx, owner) {
976
+ // scale(0) and friends collapse an axis before the determinant below ever sees it.
977
+ if (rect.width <= 0 || rect.height <= 0) { S.n.invisible++; return }
978
+ // A singular map paints nothing and leaves w and h unrecoverable.
979
+ if (L && Math.abs(L.a * L.d - L.b * L.c) < SINGULAR) { S.n.invisible++; return }
980
+ if (!fullyInside(rect, ctx.clip)) { S.n.clipped++; return }
981
+ if (!ctx.affine) S.n.nonAffine++
982
+ // Only worth warning about once something was actually placed under it.
983
+ if (ctx.noCtm) S.f.svgNoCtm = true
984
+
985
+ const box = localBox(L, rect, info, text)
986
+ const { originX, originY } = baselineOrigin(L, rect, box, info.ascent)
987
+ S.runs.push({
988
+ text, originX, originY,
989
+ width: box.width, height: box.height, ascent: info.ascent,
990
+ size: info.size, font: info.key, fk: info.fk,
991
+ naturalWidth: measureNatural(info, text),
992
+ matrix: L, rtl: info.rtl, color: info.color, tracking: info.trackingPx,
993
+ })
994
+ // Parallel to `runs`, never inside a run: the run's key set is a pinned
995
+ // contract, and this is a node from the temporary layout DOM that must not
996
+ // travel with it.
997
+ S.owners.push(owner)
998
+ return true
999
+ }
1000
+
1001
+ // ——— text nodes ———
1002
+
1003
+ /** Glyphs that keep their horizontal orientation while the line advances downward. */
1004
+ function isUpright(info, text) {
1005
+ return info.orientation === 'upright' ||
1006
+ (info.orientation !== 'sideways' && UPRIGHT.test(text))
1007
+ }
1008
+
1009
+ function emitText(node, S) {
1010
+ if (!node.data || !/\S/.test(node.data)) return
1011
+ const parent = renderParent(node)
1012
+ if (!parent) return
1013
+
1014
+ const ctx = contextOf(parent, S)
1015
+ const preserve = /^(pre|pre-wrap|break-spaces)$/.test(ctx.cs.whiteSpace)
1016
+ const count = preserve
1017
+ ? (node.data.match(/[^\r\n]*\S[^\r\n]*/g) || []).length
1018
+ : (node.data.match(/\S+/g) || []).length
1019
+ const paints = textPaints(parent, ctx.cs)
1020
+ if (ctx.unsafePaint || textSecurity(ctx.cs) || (!paints && shadowPaint(ctx.cs))) {
1021
+ S.n.effects += count
1022
+ return
1023
+ }
1024
+ // visibility is inherited and a descendant may re-assert it, so the parent's
1025
+ // own computed value is already the whole answer.
1026
+ if (ctx.cs.visibility !== 'visible' || ctx.alpha < ALPHA_MIN || !paints) {
1027
+ S.n.invisible += count
1028
+ return
1029
+ }
1030
+
1031
+ const info = fontOf(parent, S, ctx.cs)
1032
+ const L = runMatrix(ctx)
1033
+ // Sideways glyphs are an exact affine image of a horizontal run, so a whole
1034
+ // word still places under one Tm — and per word is what keeps extraction from
1035
+ // reading H e l l o.
1036
+ const turn = info.vertical ? compose(L, info.turn) : L
1037
+
1038
+ const range = node.ownerDocument.createRange()
1039
+ // Collapsing white-space modes keep the compact one-run-per-word path. In
1040
+ // pre/pre-wrap/break-spaces the spaces are content, so keep each source line in
1041
+ // one logical run. PDF extractors routinely discard trailing-space-only runs;
1042
+ // an internal sequence inside `alpha beta` survives copy/paste reliably.
1043
+ const tokens = preserve ? /[^\r\n]*\S[^\r\n]*/g : /\S+/g
1044
+ for (const match of node.data.matchAll(tokens)) {
1045
+ const from = match.index
1046
+ const to = from + match[0].length
1047
+ // Per word in normal prose, per source line where white-space is preserved:
1048
+ // one CJK glyph anywhere else in a paragraph must not send every Latin word
1049
+ // around it down the per-glyph branch.
1050
+ if (info.vertical && isUpright(info, match[0])) {
1051
+ emitVertical(S, node, ctx, info, L, turn, range, from, to, preserve)
1052
+ continue
1053
+ }
1054
+ place(S, node, ctx, info, turn, range, from, to, true, preserve)
1055
+ }
1056
+ }
1057
+
1058
+ /**
1059
+ * Which of a wrapped word's rects each of its characters was really painted in.
1060
+ * Blink breaks where the LINE runs out, not where the character count divides, so
1061
+ * sharing the word out by proportion hands every fragment its neighbour's glyphs
1062
+ * and leaves Tz squashing them into a box they never occupied. One Range probe
1063
+ * per code point is the only thing that knows, and it is paid only by a word that
1064
+ * actually wrapped — which is rare, and always short.
1065
+ *
1066
+ * Returns the source text of each rect, and the non-empty rects in the order the
1067
+ * SOURCE reaches them: bidi hands back its rects in visual order, and the run that
1068
+ * goes out first is the one extraction reads first.
1069
+ *
1070
+ * `line-height: 0` is the case this LOSES, and not only for bidi. Every line box
1071
+ * is then painted in the same place, so no geometry separates the lines at all and
1072
+ * the horizontal gap below answers from whichever line it likes. MEASURED, 6 words
1073
+ * × 4 transforms at `line-height: 0`: the split is wrong in all 24. `Hyphenation`
1074
+ * is painted in four rects and comes back as ONE run carrying all eleven
1075
+ * characters, placed in the first rect's 28.45 px box — three of the four painted
1076
+ * fragments get no run at all. What survives is the two invariants extraction
1077
+ * actually needs: over 180 such cases nothing was lost or duplicated, and only the
1078
+ * one bidi word reordered.
1079
+ */
1080
+ function groupByRect(node, from, to, rects, range, L) {
1081
+ // A DOMRectList, not an array — it indexes and has a length, and that is all.
1082
+ const parts = new Array(rects.length).fill('')
1083
+ const at = new Array(rects.length).fill(0)
1084
+ const data = node.data
1085
+ // Which line a character is on is its position along the NORMAL to the mapped
1086
+ // baseline, (−b, a) — not along the mapped local +y, (c, d). Those two agree
1087
+ // only for an orthogonal map: under skewX(20deg) a step along the baseline has a
1088
+ // component on (c, d) worth several px, which hands characters to other lines.
1089
+ const vx = L ? -L.b : 0
1090
+ const vy = L ? L.a : 1
1091
+ // v / scale is the perpendicular gap to a line in painted px. Zero is not a
1092
+ // usable test for "same line": MEASURED, under rotate(30deg) the seven
1093
+ // characters of `xyאבגzw` sit 4.9e-8 … 1.25e-5 px from the three rects they all
1094
+ // share a LINE with, and never 0 — untransformed the same gaps are exactly 0.
1095
+ // Without a window the branch below, the only thing that knows which bidi run a
1096
+ // character is in, is dead. The window is sized per character: see TIE_REL.
1097
+ const scale = Math.hypot(vx, vy)
1098
+ // Within a line — bidi runs, the rect a hyphen gets to itself — the gap along
1099
+ // the viewport axis the baseline runs closest to decides instead. An AABB is not
1100
+ // the quad, but every fragment on one line shares that line's height, so each
1101
+ // one's AABB overshoots its quad by the same amount and the comparison between
1102
+ // them survives it.
1103
+ const alongX = !L || Math.abs(L.a) >= Math.abs(L.b)
1104
+ let last = 0
1105
+ for (let i = from; i < to;) {
1106
+ // Code POINTS, never UTF-16 units: half a surrogate pair is not a character,
1107
+ // and would reach /ToUnicode as an unpaired one.
1108
+ const ch = String.fromCodePoint(data.codePointAt(i))
1109
+ range.setStart(node, i)
1110
+ range.setEnd(node, i + ch.length)
1111
+ // A character that straddles a break has a rect on each side; the first one is
1112
+ // where it starts. Zero-width and combining characters still report a box at
1113
+ // their base's position, so only a character with NO box at all falls through
1114
+ // to its neighbour's fragment.
1115
+ const cr = range.getClientRects()[0]
1116
+ let best = last
1117
+ if (cr) {
1118
+ const cx = cr.left + cr.right
1119
+ const cy = cr.top + cr.bottom
1120
+ const tie = TIE_REL * scale * (Math.abs(cx) + Math.abs(cy)) / 2
1121
+ let bv = Infinity
1122
+ let bh = Infinity
1123
+ for (let n = 0; n < rects.length; n++) {
1124
+ // Start at the previous character's fragment, so a genuine tie resolves
1125
+ // forward and the split stays contiguous — which is also all that holds
1126
+ // the `line-height: 0` case together; see above.
1127
+ const k = (last + n) % rects.length
1128
+ const r = rects[k]
1129
+ const v = Math.abs((cx - r.left - r.right) * vx + (cy - r.top - r.bottom) * vy) / 2
1130
+ // Negative when the boxes overlap, and by how deeply.
1131
+ const h = alongX
1132
+ ? Math.max(r.left - cr.right, cr.left - r.right)
1133
+ : Math.max(r.top - cr.bottom, cr.top - r.bottom)
1134
+ if (v < bv - tie || (v < bv + tie && h < bh)) { best = k; bv = Math.min(bv, v); bh = h }
1135
+ }
1136
+ }
1137
+ if (!parts[best]) at[best] = i
1138
+ parts[best] += ch
1139
+ last = best
1140
+ i += ch.length
1141
+ }
1142
+ const order = []
1143
+ for (let k = 0; k < rects.length; k++) if (parts[k]) order.push(k)
1144
+ return { parts, order: order.sort((p, q) => at[p] - at[q]) }
1145
+ }
1146
+
1147
+ /**
1148
+ * One word/source line, or one same-orientation stretch of it. Offsets index the
1149
+ * SOURCE, and the painted form is derived here: `text-transform` never reaches
1150
+ * the text node, so the DOM string and the pixels disagree without it.
1151
+ */
1152
+ function place(S, node, ctx, info, L, range, from, to, first, preserveWords = false) {
1153
+ // The element the glyphs belong to STRUCTURALLY, which is the flattened parent
1154
+ // — the same one `ctx` was derived from. Read once per word, not per fragment.
1155
+ const owner = renderParent(node)
1156
+ range.setStart(node, from)
1157
+ range.setEnd(node, to)
1158
+ // A word that wraps yields several rects; each fragment is placed on its own.
1159
+ // Bidi does the same, which is why RTL needs no shaping here: every fragment
1160
+ // is placed from its own box and extraction stays in logical order.
1161
+ const rects = range.getClientRects()
1162
+ if (!rects.length) return
1163
+ const display = (text, startsWord) => preserveWords
1164
+ ? applyTransform(text, info.transform)
1165
+ : shown(info, text, startsWord)
1166
+ if (rects.length === 1) { pushRun(S, display(node.data.slice(from, to), first), rects[0], info, L, ctx, owner); return }
1167
+ const { parts, order } = groupByRect(node, from, to, rects, range, L)
1168
+ // Only the fragment the word STARTS in may take the capital.
1169
+ for (const i of order) pushRun(S, display(parts[i], first && i === order[0]), rects[i], info, L, ctx, owner)
1170
+ }
1171
+
1172
+ /**
1173
+ * Vertical writing never appears in any computed transform, so the accumulated L
1174
+ * is blind to it. This is the branch for a word that MIXES orientations under
1175
+ * `text-orientation: mixed`: an upright glyph's advance is vertical while the
1176
+ * glyph itself stays horizontal, which no single Tm expresses — it needs a Type0
1177
+ * font with a vertical CMap, so here it is simply placed square (and base-14 drops
1178
+ * it anyway, being CJK). The sideways stretches BETWEEN those glyphs still go out
1179
+ * whole: `日本語のtext` is one \S+ word, and its Latin tail must not shatter.
1180
+ */
1181
+ function emitVertical(S, node, ctx, info, L, turn, range, from, to, preserveWords = false) {
1182
+ const owner = renderParent(node)
1183
+ const data = node.data
1184
+ const chars = []
1185
+ // Code POINTS, not UTF-16 units: half a surrogate pair is not a character, and
1186
+ // pdf-writer would map it to an unpaired /ToUnicode destination.
1187
+ for (let i = from; i < to;) {
1188
+ const ch = String.fromCodePoint(data.codePointAt(i))
1189
+ chars.push({ i, ch, up: isUpright(info, ch) })
1190
+ i += ch.length
1191
+ }
1192
+ for (let k = 0; k < chars.length;) {
1193
+ const { i, ch, up } = chars[k]
1194
+ if (up) {
1195
+ range.setStart(node, i)
1196
+ range.setEnd(node, i + ch.length)
1197
+ const rect = range.getClientRects()[0]
1198
+ // The warning belongs to a glyph that was actually PLACED — pushRun still
1199
+ // drops it for a degenerate rect, a singular matrix or the clip.
1200
+ if (rect && pushRun(S, shown(info, ch, k === 0), rect, info, L, ctx, owner)) S.f.upright = true
1201
+ k++
1202
+ continue
1203
+ }
1204
+ let e = k + 1
1205
+ while (e < chars.length && !chars[e].up) e++
1206
+ const end = e < chars.length ? chars[e].i : to
1207
+ place(S, node, ctx, info, turn, range, i, end, k === 0, preserveWords)
1208
+ k = e
1209
+ }
1210
+ }
1211
+
1212
+ // ——— form controls ———
1213
+
1214
+ function controlText(el) {
1215
+ const tag = el.tagName
1216
+ if (tag === 'SELECT') {
1217
+ // A multiple/sized select paints a scrolling list of options; reconstructing
1218
+ // which ones are visible is guesswork, so it contributes nothing.
1219
+ if (el.multiple || el.size > 1) return ''
1220
+ const opt = el.selectedOptions[0]
1221
+ return opt ? opt.label || opt.text : ''
1222
+ }
1223
+ if (tag === 'TEXTAREA') return el.value || el.placeholder
1224
+ if (!TEXT_INPUTS.has(el.type)) return ''
1225
+ if (el.type === 'password') {
1226
+ // The value must never reach the file: a text layer is plain text on disk,
1227
+ // and an invisible one is still copy-pasteable. Bullets are what is painted.
1228
+ return el.value ? '•'.repeat(el.value.length) : el.placeholder
1229
+ }
1230
+ // Date-ish controls paint a locale-formatted value; the ISO value is what is
1231
+ // reachable, so those runs are searchable but may not match the pixels.
1232
+ return el.value || el.placeholder
1233
+ }
1234
+
1235
+ /**
1236
+ * True wrap geometry needs a mirror element laid out with the same style — a
1237
+ * whole extra layout per textarea. Greedy breaking on spaces at the content
1238
+ * width matches Chrome for ordinary prose and errs by one word for hyphenation,
1239
+ * CJK, and unbreakable strings (which Chrome breaks mid-word and this does not).
1240
+ */
1241
+ function wrapLines(text, info, width) {
1242
+ const out = []
1243
+ for (const para of text.split('\n')) {
1244
+ let line = ''
1245
+ for (const word of para.split(' ')) {
1246
+ const next = line ? `${line} ${word}` : word
1247
+ if (line && measureReal(info, next) > width) { out.push(line); line = word }
1248
+ else line = next
1249
+ }
1250
+ out.push(line)
1251
+ }
1252
+ return out
1253
+ }
1254
+
1255
+ function alignOffset(info, boxWidth, textWidth) {
1256
+ const a = info.align
1257
+ if (a === 'center') return (boxWidth - textWidth) / 2
1258
+ if (a === 'right' || a === 'end' || (info.rtl && (a === 'start' || a === 'match-parent'))) {
1259
+ return boxWidth - textWidth
1260
+ }
1261
+ return 0
1262
+ }
1263
+
1264
+ /**
1265
+ * Control values have no text node, so they get placed from layout metrics
1266
+ * instead of client rects: local point → viewport through the centring theorem,
1267
+ * exactly as paddingBox does.
1268
+ */
1269
+ function emitControl(el, S) {
1270
+ const ctx = contextOf(el, S)
1271
+ const cs = ctx.cs
1272
+ const raw = controlText(el)
1273
+ // Nothing to place is not the same as something dropped, so it is not counted.
1274
+ if (!raw || !/\S/.test(raw)) return
1275
+ const paints = textPaints(el, cs)
1276
+ // Passwords are deliberately converted to bullets by controlText(). For every
1277
+ // other use of -webkit-text-security, omission is safer than writing the source
1278
+ // value that the browser replaced with dots.
1279
+ const securedSource = textSecurity(cs) && !(el.tagName === 'INPUT' && el.type === 'password')
1280
+ if (ctx.unsafePaint || securedSource || (!paints && shadowPaint(cs))) {
1281
+ S.n.effects++
1282
+ return
1283
+ }
1284
+ if (cs.visibility !== 'visible' || ctx.alpha < ALPHA_MIN || !paints) {
1285
+ S.n.invisible++
1286
+ return
1287
+ }
1288
+
1289
+ const r = el.getBoundingClientRect()
1290
+ if (r.width <= 0 || r.height <= 0) { S.n.invisible++; return }
1291
+ const L = runMatrix(ctx)
1292
+ if (L && Math.abs(L.a * L.d - L.b * L.c) < SINGULAR) { S.n.invisible++; return }
1293
+
1294
+ const info = fontOf(el, S, cs)
1295
+ const text = applyTransform(raw, info.transform)
1296
+ const px = (v) => parseFloat(v) || 0
1297
+ let left = px(cs.borderLeftWidth) + px(cs.paddingLeft)
1298
+ const top = px(cs.borderTopWidth) + px(cs.paddingTop)
1299
+ // clientWidth/Height exclude the scrollbar, which is where the value is laid
1300
+ // out; their integer rounding is well under one glyph.
1301
+ let boxW = el.clientWidth - px(cs.paddingLeft) - px(cs.paddingRight)
1302
+ const boxH = el.clientHeight - px(cs.paddingTop) - px(cs.paddingBottom)
1303
+ if (el.tagName === 'SELECT' && THEMED.test(cs.appearance)) {
1304
+ // Both insets shrink the box the value is aligned in, and which one lands on
1305
+ // the left is what `direction` decides — under rtl the arrow is on the left.
1306
+ left += info.rtl ? MENULIST_END : MENULIST_START
1307
+ boxW -= MENULIST_START + MENULIST_END
1308
+ }
1309
+ if (boxW <= 0 || boxH <= 0) { S.n.invisible++; return }
1310
+
1311
+ // offsetWidth is integer-rounded, so prefer the exact AABB when nothing rotates.
1312
+ const frame = {
1313
+ cx: (r.left + r.right) / 2, cy: (r.top + r.bottom) / 2,
1314
+ w: L ? el.offsetWidth : r.width, h: L ? el.offsetHeight : r.height,
1315
+ }
1316
+ const clip = intersect(ctx.clip, paddingBox(el, ctx.L, CONTROL_CLIP))
1317
+
1318
+ const inline = info.ascent + info.descent
1319
+ const multiline = el.tagName === 'TEXTAREA'
1320
+ const lineH = multiline ? info.lineHeight : boxH
1321
+ const lines = multiline ? wrapLines(text, info, boxW) : [text]
1322
+ const scrollX = el.scrollLeft || 0
1323
+ const scrollY = el.scrollTop || 0
1324
+
1325
+ for (let i = 0; i < lines.length; i++) {
1326
+ const line = lines[i]
1327
+ if (!/\S/.test(line)) continue
1328
+ const width = measureReal(info, line)
1329
+ // Single-line controls centre their value in the content box; a textarea
1330
+ // stacks line boxes from the top, each with its half-leading.
1331
+ const ly = top + i * lineH + (lineH - inline) / 2 + info.ascent - scrollY
1332
+ const lx = left + alignOffset(info, boxW, width) - scrollX
1333
+ pushLocalRun(S, line, info, L, frame, lx, ly, width, inline, clip, el)
1334
+ }
1335
+ }
1336
+
1337
+ function pushLocalRun(S, text, info, L, frame, lx, ly, width, height, clip, owner) {
1338
+ const map = (x, y) => {
1339
+ const dx = x - frame.w / 2
1340
+ const dy = y - frame.h / 2
1341
+ return L
1342
+ ? [frame.cx + L.a * dx + L.c * dy, frame.cy + L.b * dx + L.d * dy]
1343
+ : [frame.cx + dx, frame.cy + dy]
1344
+ }
1345
+ const [ccx, ccy] = map(lx + width / 2, ly - info.ascent + height / 2)
1346
+ const hx = L ? (Math.abs(L.a) * width + Math.abs(L.c) * height) / 2 : width / 2
1347
+ const hy = L ? (Math.abs(L.b) * width + Math.abs(L.d) * height) / 2 : height / 2
1348
+ const aabb = {
1349
+ left: ccx - hx, top: ccy - hy, right: ccx + hx, bottom: ccy + hy,
1350
+ width: 2 * hx, height: 2 * hy,
1351
+ }
1352
+ if (!fullyInside(aabb, clip)) { S.n.clipped++; return }
1353
+
1354
+ const [originX, originY] = map(lx, ly)
1355
+ S.runs.push({
1356
+ text, originX, originY, width, height, ascent: info.ascent,
1357
+ size: info.size, font: info.key, fk: info.fk,
1358
+ naturalWidth: measureNatural(info, text),
1359
+ matrix: L, rtl: info.rtl, color: info.color, tracking: info.trackingPx,
1360
+ })
1361
+ // A control's value belongs to the control itself: there is no text node to
1362
+ // take a parent from, which is the whole reason this path exists.
1363
+ S.owners.push(owner)
1364
+ }
1365
+
1366
+ // ——— links ———
1367
+
1368
+ /**
1369
+ * Resolve HTML and SVG hrefs through the same URL parser. SVG exposes a raw
1370
+ * SVGAnimatedString (`#dest`), while HTMLAnchorElement.href is usually absolute;
1371
+ * reading the attribute first makes both paths identical and also lets the URL
1372
+ * parser canonicalize protocol case before the allowlist is applied.
1373
+ *
1374
+ * Same-document fragments are PDF destinations, not external actions, and are
1375
+ * allowed even on `file:`/`about:` pages. Other URLs must use an explicitly safe
1376
+ * protocol: active (`javascript:`), embedded (`data:`/`blob:`) and local-file
1377
+ * actions never reach the file.
1378
+ */
1379
+ function linkHref(el, documentURL) {
1380
+ const attr = el.getAttribute && el.getAttribute('href')
1381
+ const raw = attr != null ? attr : (typeof el.href === 'string' ? el.href : el.href?.baseVal)
1382
+ if (typeof raw !== 'string' || !raw.trim()) return null
1383
+
1384
+ let url
1385
+ try {
1386
+ url = new URL(raw, el.baseURI || el.ownerDocument?.baseURI || location.href)
1387
+ } catch {
1388
+ return null
1389
+ }
1390
+
1391
+ const href = url.href
1392
+ const hash = href.indexOf('#')
1393
+ // In production `el.ownerDocument.location` is `about:srcdoc`: layout runs in a
1394
+ // sandboxed iframe reconstructed from the final SVG. The source document URL is
1395
+ // therefore an explicit measurement input, not something this DOM can infer.
1396
+ const here = String(documentURL || el.ownerDocument?.location?.href || location.href).split('#')[0]
1397
+ if (hash >= 0 && href.slice(0, hash) === here) return href
1398
+ return LINK_PROTOCOLS.has(url.protocol.toLowerCase()) ? href : null
1399
+ }
1400
+
1401
+ function emitLink(el, S) {
1402
+ const href = linkHref(el, S.documentURL)
1403
+ if (!href) {
1404
+ if (String(el.getAttribute && el.getAttribute('href') || '').trim()) S.n.unsafeLinks++
1405
+ return
1406
+ }
1407
+ const ctx = contextOf(el, S)
1408
+ const paints = textPaints(el, ctx.cs)
1409
+ if (ctx.unsafePaint || textSecurity(ctx.cs) || (!paints && shadowPaint(ctx.cs))) {
1410
+ S.n.effects++
1411
+ return
1412
+ }
1413
+ const ownsText = /\S/.test(el.textContent || '')
1414
+ if (ctx.cs.visibility !== 'visible' || ctx.alpha < ALPHA_MIN ||
1415
+ (ownsText && !paints)) {
1416
+ S.n.invisible++
1417
+ return
1418
+ }
1419
+ for (const rect of el.getClientRects()) {
1420
+ if (rect.width <= 0 || rect.height <= 0) continue
1421
+ const shown = clippedRect(rect, ctx.clip)
1422
+ if (!shown) { S.n.clipped++; continue }
1423
+ if (shown.cropped) S.n.croppedLinks++
1424
+ // PDF 1.4 /Rect is axis-aligned and cannot be rotated, so a rotated link
1425
+ // ships its AABB: a superset of the real quad, generous rather than wrong.
1426
+ S.links.push({
1427
+ href, x: shown.left, y: shown.top,
1428
+ width: shown.width, height: shown.height, matrix: null,
1429
+ // The owner element, so the structure tree can hang a /Link node with an
1430
+ // /OBJR at the right place. index.js strips it before anything escapes the
1431
+ // measurement frame.
1432
+ el,
1433
+ })
1434
+ }
1435
+ }
1436
+
1437
+ // ——— fillable fields ———
1438
+
1439
+ /** Input types that become fillable text fields. Date-ish controls carry their
1440
+ * ISO value — the same value the text layer documents for its runs. */
1441
+ const FIELD_TEXT = new Set([
1442
+ 'text', 'search', 'email', 'url', 'tel', 'number',
1443
+ 'date', 'datetime-local', 'month', 'week', 'time',
1444
+ ])
1445
+
1446
+ /** A computed colour as an [r, g, b] triple in 0..1, or null when transparent.
1447
+ * The same 1px-canvas trick colourAlpha uses, reading colour instead of alpha. */
1448
+ function colourTriple(value) {
1449
+ if (!value || value === 'none') return null
1450
+ if (!paintContext) {
1451
+ // colourAlpha builds the shared context lazily; reuse its path.
1452
+ if (colourAlpha(value) === 0) return null
1453
+ }
1454
+ if (!paintContext) return null
1455
+ paintContext.clearRect(0, 0, 1, 1)
1456
+ paintContext.fillStyle = '#000'
1457
+ try { paintContext.fillStyle = value } catch { /* computed colours should parse */ }
1458
+ paintContext.fillRect(0, 0, 1, 1)
1459
+ const [r, g, b, a] = paintContext.getImageData(0, 0, 1, 1).data
1460
+ if (a < 128) return null
1461
+ return [r / 255, g / 255, b / 255]
1462
+ }
1463
+
1464
+ /** The label a viewer shows as the field's tooltip and a screen reader speaks —
1465
+ * the accessible-name order, shortened to what these controls actually use. */
1466
+ function controlLabel(el) {
1467
+ const aria = el.getAttribute && el.getAttribute('aria-label')
1468
+ if (aria && aria.trim()) return aria.trim()
1469
+ const label = el.labels && el.labels[0]
1470
+ if (label) {
1471
+ const text = (label.textContent || '').trim().replace(/\s+/g, ' ')
1472
+ if (text) return text
1473
+ }
1474
+ const placeholder = el.getAttribute && el.getAttribute('placeholder')
1475
+ if (placeholder && placeholder.trim()) return placeholder.trim()
1476
+ const title = el.getAttribute && el.getAttribute('title')
1477
+ return title && title.trim() ? title.trim() : null
1478
+ }
1479
+
1480
+ /**
1481
+ * One control record for the fillable-field planner: what the control IS, where
1482
+ * it is, what it holds, and how it is painted — measured under the same
1483
+ * visibility, clip and transform rules as every text run, in the same pass.
1484
+ *
1485
+ * The rules a record can fail, and what failing means:
1486
+ * - invisible / display-hidden controls produce NO record, exactly as a hidden
1487
+ * control cannot be filled on the page it was captured from;
1488
+ * - a clipped control is recorded with `skip: 'clipped'` — a widget reaching
1489
+ * outside the capture's clip would expose area the capture excluded;
1490
+ * - a transformed control is recorded with `skip: 'transformed'` — a widget's
1491
+ * /Rect is axis-aligned, and drawing one over a rotated control would be a
1492
+ * lie about where the field is.
1493
+ * Skipped records still reach the planner so the loss is REPORTED, not silent.
1494
+ */
1495
+ function recordControl(el, S) {
1496
+ const tag = el.tagName
1497
+ const type = tag === 'INPUT' ? String(el.type || 'text').toLowerCase() : null
1498
+
1499
+ let kind = null
1500
+ if (tag === 'SELECT') kind = 'select'
1501
+ else if (tag === 'TEXTAREA') kind = 'multiline'
1502
+ else if (type === 'checkbox') kind = 'checkbox'
1503
+ else if (type === 'radio') kind = 'radio'
1504
+ else if (type === 'password') kind = 'password'
1505
+ else if (FIELD_TEXT.has(type)) kind = 'text'
1506
+ // Buttons, file pickers, sliders, colour wells and hidden inputs stay pixels:
1507
+ // none of them holds a value a fillable field could round-trip.
1508
+ if (!kind) return
1509
+
1510
+ const ctx = contextOf(el, S)
1511
+ const cs = ctx.cs
1512
+ if (cs.visibility !== 'visible' || ctx.alpha < ALPHA_MIN) return
1513
+ const r = el.getBoundingClientRect()
1514
+ if (r.width <= 0 || r.height <= 0) return
1515
+
1516
+ let skip = null
1517
+ if (!ctx.affine || runMatrix(ctx)) skip = 'transformed'
1518
+ else {
1519
+ // ctx.clip includes the control's OWN clip — a text input carries a UA
1520
+ // `overflow: clip` — and no border box fits inside its own padding box. The
1521
+ // widget covers the whole control, so the question is whether an ANCESTOR
1522
+ // hides any of it: the same clip choice contextOf makes for the element,
1523
+ // one level up.
1524
+ const parent = renderParent(el)
1525
+ const base = parent ? contextOf(parent, S) : null
1526
+ const inherited = !base ? NO_CLIP
1527
+ : cs.position === 'fixed' ? base.clipFixed
1528
+ : cs.position === 'absolute' ? base.clipAbs
1529
+ : base.clip
1530
+ if (!fullyInside({ left: r.left, top: r.top, right: r.right, bottom: r.bottom }, inherited)) {
1531
+ skip = 'clipped'
1532
+ }
1533
+ }
1534
+
1535
+ const record = {
1536
+ kind,
1537
+ el,
1538
+ name: String(el.getAttribute('name') || '').trim() || null,
1539
+ id: String(el.id || '').trim() || null,
1540
+ label: controlLabel(el),
1541
+ // The password rule restated at the source: the VALUE never leaves the
1542
+ // measurement frame, only its length is even knowable to the planner.
1543
+ value: kind === 'password' ? '' :
1544
+ kind === 'checkbox' || kind === 'radio' ? String(el.value || '') :
1545
+ kind === 'select' ? '' : String(el.value || ''),
1546
+ checked: kind === 'checkbox' || kind === 'radio' ? !!el.checked : false,
1547
+ multiple: kind === 'select' ? !!el.multiple : false,
1548
+ listbox: kind === 'select' ? (!!el.multiple || (el.size || 0) > 1) : false,
1549
+ options: kind === 'select'
1550
+ ? [...el.options].map(o => ({
1551
+ value: String(o.value), label: String(o.label || o.text || ''), selected: !!o.selected,
1552
+ }))
1553
+ : [],
1554
+ readonly: !!(el.readOnly || el.disabled),
1555
+ required: !!el.required,
1556
+ maxLength: tag === 'TEXTAREA' || kind === 'text' || kind === 'password'
1557
+ ? (el.maxLength > 0 ? el.maxLength : 0) : 0,
1558
+ rect: { left: r.left, top: r.top, width: r.width, height: r.height },
1559
+ style: {
1560
+ bg: colourTriple(cs.backgroundColor),
1561
+ border: colourTriple(cs.borderTopColor),
1562
+ borderW: parseFloat(cs.borderTopWidth) || 0,
1563
+ color: colourTriple(cs.color) || [0, 0, 0],
1564
+ size: parseFloat(cs.fontSize) || 16,
1565
+ align: /right|end/.test(cs.textAlign) ? 'right' : cs.textAlign === 'center' ? 'center' : 'left',
1566
+ },
1567
+ skip,
1568
+ transformed: skip === 'transformed',
1569
+ runs: [],
1570
+ }
1571
+ S.controls.push(record)
1572
+ }
1573
+
1574
+ // ——— non-text semantic objects ———
1575
+
1576
+ function hiddenFromAT(el, root) {
1577
+ let node = el
1578
+ while (node) {
1579
+ if (node.getAttribute &&
1580
+ String(node.getAttribute('aria-hidden')).trim().toLowerCase() === 'true') return true
1581
+ if (node === root) break
1582
+ node = renderParent(node)
1583
+ }
1584
+ return false
1585
+ }
1586
+
1587
+ /**
1588
+ * A textless Figure has no run for the tagged-PDF ParentTree to associate with a
1589
+ * page. Give it one whitespace-only marked-content run: it paints no ink, adds no
1590
+ * searchable word, but lets a reader reach the Figure and announce its /Alt. The
1591
+ * whole-page raster remains an Artifact, so it is never announced twice.
1592
+ */
1593
+ function emitSemanticMarkers(root, S) {
1594
+ const seen = new Set()
1595
+ const occupied = new Set()
1596
+ for (const owner of new Set(S.owners)) {
1597
+ if (hiddenFromAT(owner, root)) continue
1598
+ let node = owner
1599
+ while (node) {
1600
+ if (isFigure(node)) occupied.add(node)
1601
+ if (node === root) break
1602
+ node = renderParent(node)
1603
+ }
1604
+ }
1605
+ const visit = (el) => {
1606
+ if (!el || seen.has(el)) return
1607
+ seen.add(el)
1608
+ if (hiddenFromAT(el, root)) return
1609
+
1610
+ // An image nobody named has no /Alt to announce, and the raster showing it is
1611
+ // an Artifact, so a painted one blocks the PDF/UA-1 claim. `alt=""` is the
1612
+ // author calling it decorative. A <figure> takes its name from what it holds.
1613
+ if (isFigure(el) && !semanticAlt(el) && !occupied.has(el) && !el.hasAttribute('alt') &&
1614
+ el.tagName.toUpperCase() !== 'FIGURE') {
1615
+ const ctx = contextOf(el, S)
1616
+ const r = el.getBoundingClientRect()
1617
+ if (ctx.cs.visibility === 'visible' && ctx.alpha >= ALPHA_MIN && r.width > 0 && r.height > 0) {
1618
+ S.n.unnamedImages++
1619
+ }
1620
+ }
1621
+
1622
+ if (isFigure(el) && semanticAlt(el) && !occupied.has(el)) {
1623
+ const ctx = contextOf(el, S)
1624
+ const r = el.getBoundingClientRect()
1625
+ if (ctx.unsafePaint) {
1626
+ S.n.effects++
1627
+ } else if (ctx.cs.visibility === 'visible' && ctx.alpha >= ALPHA_MIN &&
1628
+ r.width > 0 && r.height > 0 && fullyInside(r, ctx.clip)) {
1629
+ const size = 1
1630
+ const ascent = 0.8
1631
+ const width = Math.min(r.width, 0.278)
1632
+ S.runs.push({
1633
+ text: ' ', originX: r.left, originY: r.top + ascent,
1634
+ width, height: 1, ascent, size, font: 'sans', fk: -1, naturalWidth: 0.278,
1635
+ matrix: null, rtl: false, color: null, tracking: 0,
1636
+ })
1637
+ S.owners.push(el)
1638
+ } else if (r.width > 0 && r.height > 0 && !fullyInside(r, ctx.clip)) {
1639
+ S.n.clipped++
1640
+ } else {
1641
+ S.n.invisible++
1642
+ }
1643
+ }
1644
+
1645
+ const tag = el.tagName.toUpperCase()
1646
+ let kids
1647
+ if (el.shadowRoot) kids = el.shadowRoot.children
1648
+ else if (tag === 'SLOT') kids = el.assignedElements({ flatten: true })
1649
+ else kids = el.children
1650
+ for (const kid of kids) visit(kid)
1651
+ }
1652
+ visit(root)
1653
+ }
1654
+
1655
+ // ——— the walk ———
1656
+
1657
+ /**
1658
+ * Flattened-tree traversal, which is also the deduplication rule: a host's light
1659
+ * children are NOT visited as children of the host — they are reached exactly
1660
+ * once, through the slot they are assigned to. Walking both trees naively would
1661
+ * emit slotted text twice, at two different places.
1662
+ */
1663
+ function walk(node, S) {
1664
+ if (node.nodeType === Node.TEXT_NODE) { emitText(node, S); return }
1665
+ if (node.nodeType !== Node.ELEMENT_NODE) return
1666
+
1667
+ // SVG tag names are lower case, HTML's are upper — normalize once.
1668
+ const tag = node.tagName.toUpperCase()
1669
+ if (SKIP_TAGS.has(tag)) return
1670
+ if (tag === 'A' && node.hasAttribute('href')) emitLink(node, S)
1671
+ if (CONTROL_TAGS.has(tag)) {
1672
+ // Record before emit: the field planner needs the control even when its
1673
+ // value is empty, which is exactly when emitControl has nothing to place.
1674
+ if (S.forms) recordControl(node, S)
1675
+ if (S.formValues) emitControl(node, S)
1676
+ return
1677
+ }
1678
+
1679
+ let kids
1680
+ if (node.shadowRoot) {
1681
+ if (!S.shadow) return
1682
+ kids = node.shadowRoot.childNodes
1683
+ } else if (tag === 'SLOT') {
1684
+ // flatten:true also yields the fallback content when nothing is assigned.
1685
+ kids = node.assignedNodes({ flatten: true })
1686
+ } else {
1687
+ // A closed root is unreachable from here: the host looks like an empty leaf.
1688
+ if (tag.includes('-') && !node.firstChild && window.customElements?.get(tag.toLowerCase())) S.f.closed++
1689
+ kids = node.childNodes
1690
+ }
1691
+ for (const kid of kids) walk(kid, S)
1692
+ }
1693
+
1694
+ /**
1695
+ * @param {Element} root
1696
+ * @param {object} [options]
1697
+ * @param {boolean} [options.formValues=true] Emit input/textarea/select values.
1698
+ * @param {boolean} [options.shadow=true] Enter open shadow roots.
1699
+ * @param {string} [options.documentURL] Source URL; the measurement iframe
1700
+ * itself is `about:srcdoc`, so same-document fragments need this explicitly.
1701
+ * @returns {{runs: Array, links: Array, warnings: string[]}}
1702
+ *
1703
+ * A run is `{ text, originX, originY, width, height, ascent, size, font,
1704
+ * naturalWidth, matrix }`. `originX`/`originY` are the baseline start point in
1705
+ * VIEWPORT CSS px; everything else is a length in the run's OWN space, which
1706
+ * `matrix` — the 2×2 linear part, null when that map is the identity — carries
1707
+ * into the viewport.
1708
+ *
1709
+ * width the advance the run must occupy
1710
+ * height the INLINE box, ascent + descent. Not the line box: a
1711
+ * `line-height: 2` run's leading is deliberately not in it,
1712
+ * because this is the painted extent
1713
+ * ascent baseline to the top of that box, so `originY - ascent` is the
1714
+ * top edge and `+ height` the bottom — the span a page-slice
1715
+ * overlap test needs, and not derivable from `size`/`font`
1716
+ * (it comes from the CSS font, those name the substitute)
1717
+ * naturalWidth the advance the base-14 substitute produces, for
1718
+ * Tz = width / naturalWidth. MAY BE 0 for a run the substitute
1719
+ * gives no advance at all: the caller must guard the division.
1720
+ *
1721
+ * A link is `{ href, x, y, width, height, matrix }` and `matrix` is ALWAYS null,
1722
+ * by construction: PDF /Rect cannot be rotated, so x/y/width/height are the
1723
+ * viewport AABB of the link's quad even when it is rotated — a superset.
1724
+ */
1725
+ export function collectTextLayer(root, options = {}) {
1726
+ const { formValues = true, shadow = true, documentURL = null, forms = true } = options
1727
+ const S = {
1728
+ root, formValues, shadow, documentURL, forms,
1729
+ runs: [], owners: [], links: [], controls: [],
1730
+ fontKeys: [], fontKeyIndex: new Map(),
1731
+ ctxCache: new WeakMap(), fontCache: new WeakMap(), layoutFontCache: new Map(),
1732
+ metricHost: null,
1733
+ n: {
1734
+ invisible: 0, clipped: 0, croppedLinks: 0,
1735
+ effects: 0, nonAffine: 0, unsafeLinks: 0, unnamedImages: 0,
1736
+ },
1737
+ f: { upright: false, zoom: false, contentVisibility: false, svgNoCtm: false, closed: 0 },
1738
+ }
1739
+
1740
+ try {
1741
+ walk(root, S)
1742
+ emitSemanticMarkers(root, S)
1743
+ } finally {
1744
+ // The probe never belongs to the captured document and must not outlive the
1745
+ // synchronous measurement pass when this helper is used outside index.js.
1746
+ if (S.metricHost) S.metricHost.remove()
1747
+ }
1748
+
1749
+ const warnings = []
1750
+ if (S.n.nonAffine) warnings.push(`${S.n.nonAffine} run(s) under a 3D/perspective transform — placed axis-aligned`)
1751
+ if (S.n.invisible) warnings.push(`${S.n.invisible} run(s)/link(s) dropped because their paint is invisible or degenerate`)
1752
+ if (S.n.effects) warnings.push(`${S.n.effects} run(s)/link(s) omitted because a visual effect cannot be mapped safely ` +
1753
+ '(clip-path, mask, filter, text masking/shadow, blend mode, or text background clip)')
1754
+ if (S.n.clipped) warnings.push(`${S.n.clipped} run(s)/link(s) dropped because an overflow clip hides any part of them`)
1755
+ if (S.n.croppedLinks) warnings.push(`${S.n.croppedLinks} link annotation(s) cropped to their visible overflow area`)
1756
+ if (S.n.unsafeLinks) warnings.push(`${S.n.unsafeLinks} link(s) dropped because their URL is malformed or uses an unsafe protocol`)
1757
+ if (S.f.svgNoCtm) warnings.push('SVG content without a screen CTM — placed axis-aligned')
1758
+ if (S.f.upright) warnings.push('upright glyphs in vertical writing-mode — placed square, they need the Type0 vertical path')
1759
+ if (S.f.contentVisibility) warnings.push('content-visibility on an ancestor may have skipped content')
1760
+ if (S.f.zoom) warnings.push('zoom on an ancestor — geometry is taken as measured')
1761
+ if (S.f.closed) warnings.push(`${S.f.closed} custom element(s) may hold a closed shadow root — unreadable`)
1762
+ // index.js frames the raster on the root's AABB, which cannot describe a rotated root.
1763
+ if (runMatrix(contextOf(root, S))) {
1764
+ warnings.push('capture root has a non-identity transform — the raster frame assumes an axis-aligned root')
1765
+ }
1766
+
1767
+ return {
1768
+ runs: S.runs, owners: S.owners, links: S.links, controls: S.controls, fontKeys: S.fontKeys, warnings,
1769
+ unnamedImages: S.n.unnamedImages,
1770
+ }
1771
+ }