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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +54 -20
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +17 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
package/pdf/src/nav.js ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Navigation: internal destinations and the outline (what a viewer shows as
3
+ * "bookmarks").
4
+ *
5
+ * Both are read from the laid-out DOM mounted in an isolated iframe from
6
+ * SnapDOM's FINAL SVG artifact, in the same pass that measures the text layer. A
7
+ * destination is a point on the page, and a point is only knowable while that
8
+ * artifact's layout still exists. Nothing here writes PDF — it returns viewport
9
+ * CSS px and lets `index.js`, which is the only thing that knows where the page
10
+ * breaks fell, turn them into pages.
11
+ *
12
+ * The split with `text-layer.js` is deliberate. That file answers "what does the
13
+ * raster paint, and where" and has to be exact about glyphs. This one answers
14
+ * "what can the reader jump to", which is a question about DOCUMENT STRUCTURE and
15
+ * has no pixels in it at all: a destination is still useful pointing at a heading
16
+ * the reader can see, even if that heading's own runs were dropped as clipped.
17
+ */
18
+
19
+ import { inspectTextPaint } from './text-layer.js'
20
+
21
+ /** Headings that become outline entries. ARIA's is handled separately — it carries its level. */
22
+ const HEADING_TAGS = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 }
23
+
24
+ /** Never walked into, for the same reason the text layer skips them. */
25
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'TITLE'])
26
+
27
+ /** An outline entry nobody can read is worse than none — a runaway heading is cut. */
28
+ const TITLE_MAX = 240
29
+
30
+ /** Keep outline visibility in step with the text layer's own paint threshold. */
31
+ const ALPHA_MIN = 0.01
32
+
33
+ /** Flattened-tree parent: slots and shadow roots follow what the browser paints. */
34
+ function renderParent(node) {
35
+ if (node.assignedSlot) return node.assignedSlot
36
+ if (node.parentElement) return node.parentElement
37
+ const tree = node.getRootNode && node.getRootNode()
38
+ return tree instanceof ShadowRoot ? tree.host : null
39
+ }
40
+
41
+ function ariaHidden(el) {
42
+ return el.getAttribute && String(el.getAttribute('aria-hidden')).trim().toLowerCase() === 'true'
43
+ }
44
+
45
+ /**
46
+ * Whether a subtree is structurally suppressed. `ownText` additionally asks if
47
+ * THIS element's glyph paint is safe/visible; titleOf uses that only at each text
48
+ * node's actual parent, so a transparent wrapper does not hide a child that
49
+ * explicitly reasserts an opaque colour or `visibility:visible`.
50
+ */
51
+ function hiddenHeading(el, root, ownText = false) {
52
+ let node = el
53
+ let alpha = 1
54
+ while (node) {
55
+ if (ariaHidden(node)) return true
56
+ const cs = getComputedStyle(node)
57
+ // `visibility` is inherited but a descendant may reassert it, so the computed
58
+ // value on the heading itself is authoritative. display:none is different: an
59
+ // ancestor suppresses the entire subtree and cannot be reasserted below it.
60
+ if (ownText && node === el && cs.visibility !== 'visible') return true
61
+ if (cs.display === 'none') return true
62
+ if (cs.display !== 'contents') {
63
+ alpha *= parseFloat(cs.opacity) || 0
64
+ const paint = inspectTextPaint(node, cs)
65
+ if (paint.unmodelled || (ownText && node === el &&
66
+ (paint.secured || paint.shadowOnly || !paint.paints))) return true
67
+ alpha *= paint.filterAlpha
68
+ }
69
+ if (node === root) break
70
+ node = renderParent(node)
71
+ }
72
+ return alpha < ALPHA_MIN
73
+ }
74
+
75
+ /**
76
+ * The fragment a link points at within THIS document, or null.
77
+ *
78
+ * `el.href` is always absolute, so `#intro` arrives as `https://host/page#intro`
79
+ * and telling an internal link from an external one means comparing everything
80
+ * left of the hash. A bare `#` and `#top` are the document top by HTML's own
81
+ * rule, and both come back as the empty string so a caller can tell "the top"
82
+ * from "no fragment at all" (null).
83
+ *
84
+ * @param {string} href an ABSOLUTE url, as `HTMLAnchorElement.href` gives it
85
+ * @param {string} [base=location.href]
86
+ * @returns {string|null}
87
+ */
88
+ export function fragmentOf(href, base) {
89
+ if (typeof href !== 'string') return null
90
+ const here = String(base ?? location.href).split('#')[0]
91
+ const hash = href.indexOf('#')
92
+ if (hash < 0) return null
93
+ if (href.slice(0, hash) !== here) return null
94
+ const raw = href.slice(hash + 1)
95
+ if (!raw || raw === 'top') return ''
96
+ try { return decodeURIComponent(raw) } catch { return raw }
97
+ }
98
+
99
+ /**
100
+ * `root.contains` stops at a shadow boundary, so a link in the light DOM pointing
101
+ * at a captured heading would read as "outside the capture" and be dropped. This
102
+ * climbs through hosts the way the flattened tree does.
103
+ */
104
+ function withinRoot(el, root) {
105
+ let node = el
106
+ while (node) {
107
+ if (node === root) return true
108
+ node = node.parentNode || node.host || null
109
+ }
110
+ return false
111
+ }
112
+
113
+ /**
114
+ * Where a destination points: the top-left of the target's border box, in
115
+ * viewport CSS px. Null when the element has no box at all — `display: none`, or
116
+ * a named anchor in a collapsed subtree — because a point that is nowhere would
117
+ * silently become the page origin.
118
+ */
119
+ function anchorPoint(el) {
120
+ const rects = el.getClientRects()
121
+ const rect = rects.length ? rects[0] : el.getBoundingClientRect()
122
+ if (!rect.width && !rect.height && !rect.top && !rect.left) return null
123
+ // The whole box, not just the corner: a generated index that cannot paint a
124
+ // heading's script has to be able to CROP that heading out of the raster
125
+ // instead, and a point is not a crop.
126
+ return { x: rect.left, y: rect.top, w: rect.width, h: rect.height }
127
+ }
128
+
129
+ /**
130
+ * The visible/accessibility title, not raw textContent: otherwise an aria-hidden
131
+ * span (or an opacity-zero one) becomes public bookmark/TOC text.
132
+ */
133
+ function titleOf(el) {
134
+ const parts = []
135
+ const read = (node) => {
136
+ if (node.nodeType === Node.TEXT_NODE) {
137
+ const parent = renderParent(node)
138
+ if (parent && !hiddenHeading(parent, el, true)) parts.push(node.data)
139
+ return
140
+ }
141
+ if (node.nodeType !== Node.ELEMENT_NODE) return
142
+ const tag = node.tagName.toUpperCase()
143
+ if (SKIP_TAGS.has(tag) || (node !== el && hiddenHeading(node, el, false))) return
144
+
145
+ let kids
146
+ if (node.shadowRoot) kids = node.shadowRoot.childNodes
147
+ else if (tag === 'SLOT') kids = node.assignedNodes({ flatten: true })
148
+ else kids = node.childNodes
149
+ for (const kid of kids) read(kid)
150
+ }
151
+ read(el)
152
+ const text = parts.join('').replace(/\s+/g, ' ').trim()
153
+ return text.length > TITLE_MAX ? text.slice(0, TITLE_MAX - 1) + '…' : text
154
+ }
155
+
156
+ /**
157
+ * The heading's own level. An `aria-level` on a `role="heading"` is authoritative
158
+ * where it exists, because that is the level assistive tech reads and the outline
159
+ * is the same promise made to a sighted reader.
160
+ */
161
+ function levelOf(el, tag) {
162
+ if (el.getAttribute && el.getAttribute('role') === 'heading') {
163
+ const n = parseInt(el.getAttribute('aria-level'), 10)
164
+ if (n >= 1 && n <= 6) return n
165
+ }
166
+ return HEADING_TAGS[tag] || 0
167
+ }
168
+
169
+ /**
170
+ * Flattened-tree walk, with the same dedup rule the text layer relies on: a
171
+ * host's light children are reached through their slot and never as children of
172
+ * the host, so a slotted heading produces ONE outline entry.
173
+ */
174
+ function walk(node, S) {
175
+ if (node.nodeType !== Node.ELEMENT_NODE) return
176
+ const tag = node.tagName.toUpperCase()
177
+ if (SKIP_TAGS.has(tag) || ariaHidden(node)) return
178
+
179
+ const level = levelOf(node, tag)
180
+ if (level && !hiddenHeading(node, S.root, false)) {
181
+ const at = anchorPoint(node)
182
+ const title = titleOf(node)
183
+ if (at && title) S.headings.push({ level, title, box: at, el: node })
184
+ }
185
+
186
+ let kids
187
+ if (node.shadowRoot) {
188
+ if (!S.shadow) return
189
+ kids = node.shadowRoot.childNodes
190
+ } else if (tag === 'SLOT') {
191
+ kids = node.assignedNodes({ flatten: true })
192
+ } else {
193
+ kids = node.childNodes
194
+ }
195
+ for (const kid of kids) walk(kid, S)
196
+ }
197
+
198
+ /**
199
+ * Read everything a reader can jump to.
200
+ *
201
+ * @param {Element} root
202
+ * @param {object} [options]
203
+ * @param {Array<{href: string}>} [options.links=[]] the text layer's links, which
204
+ * is where the set of destinations worth resolving comes from: resolving every
205
+ * `id` on the page would cost a `getClientRects` per element for destinations
206
+ * nothing points at.
207
+ * @param {boolean} [options.shadow=true] enter open shadow roots
208
+ * @param {boolean} [options.outline=true] collect headings
209
+ * @param {string} [options.headings] selector overriding `h1`–`h6`. Levels
210
+ * then come from the tag or `aria-level`; an element the selector matches that
211
+ * is neither gets level 1.
212
+ * @returns {{dests: Map<string, {x:number, y:number}>,
213
+ * outline: Array<{level:number, title:string, dest:string}>,
214
+ * warnings: string[]}}
215
+ * `dests` is keyed by the NAME a link's fragment resolves to; the empty string
216
+ * is the document top and is present whenever something points at it. Every
217
+ * `outline[i].dest` is a key of `dests`.
218
+ */
219
+ export function collectNav(root, options = {}) {
220
+ const { links = [], shadow = true, outline = true, headings = null } = options
221
+ const doc = root.ownerDocument || document
222
+ const dests = new Map()
223
+ const warnings = []
224
+ let unresolved = 0
225
+ let offRoot = 0
226
+
227
+ // The document top is a destination without an element: page 1, top edge. It is
228
+ // added only when something points at it, so a file with no `#top` link carries
229
+ // no name tree entry for it.
230
+ const wanted = new Set()
231
+ for (const link of links) {
232
+ const name = fragmentOf(link.href)
233
+ if (name !== null) wanted.add(name)
234
+ }
235
+
236
+ for (const name of wanted) {
237
+ if (name === '') { dests.set('', { x: 0, y: 0, top: true }); continue }
238
+ // getElementById first, then the legacy `<a name>` HTML still honours.
239
+ const el = doc.getElementById(name) ||
240
+ (doc.getElementsByName ? doc.getElementsByName(name)[0] : null)
241
+ if (!el) { unresolved++; continue }
242
+ if (!withinRoot(el, root)) { offRoot++; continue }
243
+ const at = anchorPoint(el)
244
+ if (!at) { unresolved++; continue }
245
+ // A destination is a POINT — `/XYZ` takes nothing else. The rest of the rect
246
+ // is what an outline entry needs for its crop, and it travels on the entry.
247
+ dests.set(name, { x: at.x, y: at.y })
248
+ }
249
+
250
+ const S = { headings: [], shadow, root }
251
+ if (outline) {
252
+ if (headings) {
253
+ // An explicit selector is the caller's tree, in document order. It cannot
254
+ // see into shadow roots — `querySelectorAll` never does — and says so.
255
+ let matches = null
256
+ try {
257
+ matches = root.querySelectorAll(headings)
258
+ } catch (error) {
259
+ warnings.push(`invalid outline selector \`${headings}\` — no outline written` +
260
+ (error && error.message ? `: ${error.message}` : '.'))
261
+ }
262
+ for (const el of matches || []) {
263
+ if (hiddenHeading(el, root, false)) continue
264
+ const at = anchorPoint(el)
265
+ const title = titleOf(el)
266
+ if (!at || !title) continue
267
+ S.headings.push({ level: levelOf(el, el.tagName.toUpperCase()) || 1, title, box: at, el })
268
+ }
269
+ // Only when something IS actually out of reach. `querySelectorAll` never
270
+ // enters a shadow root, but a slotted heading lives in the light DOM and is
271
+ // found perfectly well — warning on the mere presence of a shadow root
272
+ // would fire on most design systems and lose nothing, which is how a
273
+ // warning stops being read.
274
+ const missed = matches && shadow ? shadowMatches(root, headings) : 0
275
+ if (missed) {
276
+ warnings.push(`${missed} heading(s) matching \`${headings}\` live inside a shadow root, ` +
277
+ 'which a selector cannot enter — they are not in the outline. The default h1–h6 walk ' +
278
+ 'goes through the flattened tree and does reach them.')
279
+ }
280
+ } else {
281
+ walk(root, S)
282
+ }
283
+ }
284
+
285
+ // A heading needs a name to be jumped to. Its own `id` is the one a reader may
286
+ // already be linking to from outside the file, so it wins; the generated
287
+ // fallback is namespaced and checked, because colliding with a real id would
288
+ // silently retarget somebody's cross-reference.
289
+ const tree = []
290
+ for (let i = 0; i < S.headings.length; i++) {
291
+ const h = S.headings[i]
292
+ let name = h.el.id || ''
293
+ if (!name || (dests.has(name) && dests.get(name).y !== h.box.y)) name = ''
294
+ if (!name) {
295
+ name = `sd-h${i}`
296
+ while (dests.has(name) || doc.getElementById(name)) name += '_'
297
+ }
298
+ dests.set(name, { x: h.box.x, y: h.box.y })
299
+ // The box travels with the entry: it is what lets a generated index show a
300
+ // heading whose script the plugin's base-14 text cannot paint.
301
+ tree.push({ level: h.level, title: h.title, dest: name, box: h.box })
302
+ }
303
+
304
+ if (unresolved) {
305
+ warnings.push(`${unresolved} internal link(s) point at a fragment this document has no element for — ` +
306
+ 'written as no annotation at all rather than a link that goes nowhere')
307
+ }
308
+ if (offRoot) {
309
+ warnings.push(`${offRoot} internal link(s) point outside the captured element, which is not in the ` +
310
+ 'file — the annotation is dropped')
311
+ }
312
+ return { dests, outline: tree, warnings }
313
+ }
314
+
315
+ /**
316
+ * How many elements matching `selector` are inside an open shadow root under
317
+ * `root` — the headings a `querySelectorAll` on the light tree cannot reach.
318
+ * Counts only what is genuinely lost, so the warning it feeds means something.
319
+ */
320
+ function shadowMatches(root, selector) {
321
+ let n = 0
322
+ const stack = [root]
323
+ while (stack.length) {
324
+ const el = stack.pop()
325
+ if (el.shadowRoot) {
326
+ n += el.shadowRoot.querySelectorAll(selector).length
327
+ for (const kid of el.shadowRoot.children) stack.push(kid)
328
+ }
329
+ for (const kid of el.children) stack.push(kid)
330
+ }
331
+ return n
332
+ }
333
+
334
+ /**
335
+ * Turn the flat, level-tagged list into the nested shape PDF outlines are.
336
+ *
337
+ * A document that starts at `h2`, or jumps `h1` → `h3`, is normal HTML and must
338
+ * not produce an empty top level: a level is nested under the closest PREVIOUS
339
+ * entry with a strictly smaller level, whatever the numeric gap is. That is also
340
+ * what a screen reader does with the same markup.
341
+ *
342
+ * @param {Array<{level:number, title:string, dest:string}>} flat
343
+ * @returns {Array<{title:string, dest:string, children:Array}>}
344
+ */
345
+ export function outlineTree(flat) {
346
+ const roots = []
347
+ /** @type {Array<{level:number, node:{children:Array}}>} */
348
+ const stack = []
349
+ for (const item of flat) {
350
+ const node = { title: item.title, dest: item.dest, box: item.box || null, children: [] }
351
+ while (stack.length && stack[stack.length - 1].level >= item.level) stack.pop()
352
+ if (stack.length) stack[stack.length - 1].node.children.push(node)
353
+ else roots.push(node)
354
+ stack.push({ level: item.level, node })
355
+ }
356
+ return roots
357
+ }