@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.
- package/CHANGELOG.md +13 -0
- package/README.md +54 -20
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +17 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- 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
|
+
}
|