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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +54 -20
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +17 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,215 @@
1
+ /**
2
+ * snapdom Pro — the hybrid: the paint that Figma's own clipboard channel cannot
3
+ * describe, generated as vector by this engine's SVD backend and carried inside the
4
+ * same h2d payload.
5
+ *
6
+ * **Why this exists is a measurement, not a preference** (FIGMA_FINDINGS.md, «El
7
+ * híbrido», 2026-08-07). The same page was pasted into Figma twice, once with our
8
+ * payload and once with Figma's own extension, and three of the six hard cases —
9
+ * `conic-gradient`, `filter`/`mix-blend-mode`, `clip-path` — came out broken BOTH
10
+ * times, identically. They are not gaps in this emitter: Figma's importer does not
11
+ * have them. No amount of work on the CSS side of the h2d channel can ever fix them,
12
+ * and their own extension is proof, because it fails the same way.
13
+ *
14
+ * What CAN be fixed is how the paint arrives. In the same experiment, an
15
+ * `image/svg+xml` asset of ours went through the channel and Figma imported it **as
16
+ * paths inside a group** — vectors, not an image fill. Verified in Figma's own panel.
17
+ * So a node whose paint the channel cannot spell is sent as a node whose paint is an
18
+ * SVG this engine drew, and the layer that lands is still editable.
19
+ *
20
+ * **Roles, because they are the opposite of what they sound like.** Figma picks ONE
21
+ * clipboard flavour: writing the SVG and writing the h2d envelope are mutually
22
+ * exclusive, there is no paste that reads both. So the envelope has to be h2d, and
23
+ * `emit/svg-flat.js` stops being a competing channel and becomes the paint generator
24
+ * for the nodes h2d cannot describe. It is not retired — it stays the destination
25
+ * independent output, the one Illustrator and Penpot can open — it just also feeds
26
+ * this one now. And wrapping the WHOLE capture in one SVG asset is not the shortcut
27
+ * it looks like: that is the SVG channel with extra steps, and it loses the
28
+ * `letter-spacing` that this channel was adopted for.
29
+ *
30
+ * Nothing here modifies the SVD or `svg-flat.js`. A slice is a plain object built out
31
+ * of an already-assembled document, and the window is moved by rewriting the emitted
32
+ * root's `viewBox` — the coordinates inside stay exactly as that backend wrote them.
33
+ */
34
+
35
+ import { svdToSvg } from './svg-flat.js'
36
+
37
+ /**
38
+ * What the h2d channel cannot say, and therefore what gets drawn instead.
39
+ *
40
+ * Every entry was checked against `H2D_PROPS` — the property list transcribed from two
41
+ * captures of their extension — so this is the complement of what the channel carries,
42
+ * not a wish list. `filter`, `mix-blend-mode`, `clip-path`, `mask-image`,
43
+ * `backdrop-filter` and `border-image` are absent from their capture entirely; a conic
44
+ * gradient DOES travel (it is a `background-image` value) and still arrives as an
45
+ * unfilled box, which is the one entry here that is a failure of the IMPORTER rather
46
+ * than of the wire format.
47
+ *
48
+ * `-webkit-text-stroke` is deliberately NOT here. It is text, and replacing a text
49
+ * block with a drawing costs the editable layer this whole channel exists to keep —
50
+ * `h2d.text-stroke-dropped` declares it instead.
51
+ */
52
+ export const FALLBACK_PROBES = [
53
+ { code: 'conic', test: (cs) => /conic-gradient/.test(cs.backgroundImage || '') },
54
+ { code: 'filter', test: (cs) => notNone(cs.filter) },
55
+ { code: 'clip-path', test: (cs) => notNone(cs.clipPath || cs.webkitClipPath) },
56
+ { code: 'mask', test: (cs) => notNone(cs.maskImage || cs.webkitMaskImage) },
57
+ { code: 'border-image', test: (cs) => notNone(cs.borderImageSource) },
58
+ ]
59
+
60
+ /**
61
+ * The two the channel cannot say and this module cannot draw either.
62
+ *
63
+ * `mix-blend-mode` and `backdrop-filter` are both functions of what is UNDERNEATH the
64
+ * element, and an asset per node has no underneath: the slice is its own document, its
65
+ * backdrop is transparent, and multiplying against nothing is the same picture. Sent
66
+ * through the fallback they would arrive looking exactly as they do now, having paid
67
+ * for it with the editable layer.
68
+ *
69
+ * They are separated rather than quietly dropped because the first version of this
70
+ * file DID list them, and the probe rendered `mix-blend-mode: multiply` as a flat cyan
71
+ * square — identical to describing it as a box, which is what made the cost visible.
72
+ * Figma has no blend on import either, so nothing is being conceded to their tool.
73
+ */
74
+ export const BACKDROP_PROBES = [
75
+ { code: 'blend', test: (cs) => cs.mixBlendMode && cs.mixBlendMode !== 'normal' },
76
+ { code: 'backdrop-filter', test: (cs) => notNone(cs.backdropFilter || cs.webkitBackdropFilter) },
77
+ ]
78
+
79
+ const notNone = (v) => !!v && v !== 'none' && v !== 'normal'
80
+
81
+ /**
82
+ * Does this subtree paint any glyph?
83
+ *
84
+ * The whole policy turns on it. Replacing a node with a drawing replaces its children
85
+ * too, and a paragraph that arrives as vector is a paragraph nobody can retype — which
86
+ * is precisely what the SVG channel was abandoned for. Whitespace does not count: a
87
+ * `\n` between two `<div>`s is not text anyone will edit.
88
+ */
89
+ function paintsText (el) {
90
+ const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT)
91
+ for (let t = walker.nextNode(); t; t = walker.nextNode()) {
92
+ if (t.nodeValue && t.nodeValue.trim()) return true
93
+ }
94
+ return false
95
+ }
96
+
97
+ /**
98
+ * Every node on the clone whose paint has to be drawn rather than described.
99
+ *
100
+ * @param {Element} clone the MOUNTED clone — computed styles are the whole input
101
+ * @param {object} [opts]
102
+ * @param {'design'|'replica'} [opts.mode='design'] what to do when the node has text
103
+ * inside. `design` keeps the text editable and lets the effect go, and says so;
104
+ * `replica` buys the effect with the text. One engine, a policy per node — which is
105
+ * the shape the modes were specified with from the start.
106
+ * @param {(el: Element) => boolean} [opts.isExcluded]
107
+ * @returns {{targets: Map<Element, {codes: string[]}>, withheld: Array<{codes: string[], tag: string}>,
108
+ * backdrop: string[]}}
109
+ */
110
+ export function findFallbackTargets (clone, opts = {}) {
111
+ const mode = opts.mode === 'replica' ? 'replica' : 'design'
112
+ const isExcluded = typeof opts.isExcluded === 'function' ? opts.isExcluded : () => false
113
+ const targets = new Map()
114
+ const withheld = []
115
+ const backdrop = []
116
+ const view = clone.ownerDocument && clone.ownerDocument.defaultView
117
+ if (!view) return { targets, withheld, backdrop }
118
+
119
+ const visit = (el) => {
120
+ if (isExcluded(el)) return
121
+ // A node already inside a target is inside the drawing that replaces it: probing it
122
+ // would produce a second asset for pixels the first one already carries.
123
+ let cs = null
124
+ try { cs = view.getComputedStyle(el) } catch { cs = null }
125
+ if (cs) {
126
+ for (const probe of BACKDROP_PROBES) {
127
+ try { if (probe.test(cs)) backdrop.push(probe.code) } catch { /* unreadable is not a finding */ }
128
+ }
129
+ const codes = []
130
+ for (const probe of FALLBACK_PROBES) {
131
+ try { if (probe.test(cs)) codes.push(probe.code) } catch { /* a property this engine cannot read is not a finding */ }
132
+ }
133
+ if (codes.length) {
134
+ const r = el.getBoundingClientRect()
135
+ if (r.width >= 1 && r.height >= 1) {
136
+ if (mode === 'replica' || !paintsText(el)) { targets.set(el, { codes }); return }
137
+ withheld.push({ codes, tag: el.tagName })
138
+ }
139
+ }
140
+ }
141
+ // Not into an inline <svg>. Its whole subtree travels as one asset built from the
142
+ // markup (`inlineSvgDataUri`), so a `clip-path` on a `<rect>` in there is already
143
+ // carried — and those elements have no SVD node of their own, which showed up as
144
+ // `h2d.vector-paint-unresolved` on fx-svg: the engine accusing itself of a bug that
145
+ // was really this walk going somewhere it had no business being.
146
+ if (el.localName === 'svg') return
147
+ for (const child of el.children) visit(child)
148
+ }
149
+ visit(clone)
150
+ return { targets, withheld, backdrop }
151
+ }
152
+
153
+ /**
154
+ * One node of an assembled SVD, on its own, as an `image/svg+xml` data URI.
155
+ *
156
+ * The slice is a shallow document over the same node objects — nothing is copied and
157
+ * nothing is mutated, so the caller's document is still the one it assembled. Two
158
+ * things make the window right without touching the emitter:
159
+ *
160
+ * 1. `capture.root` is set to the node's own box, which is what `svdToSvg` reads for
161
+ * the root `width`/`height`;
162
+ * 2. the emitted `viewBox` is rewritten from `0 0 w h` to the node's FRAME.
163
+ *
164
+ * That second point is the one that had to be measured rather than reasoned about. A
165
+ * node is written as `<g transform="translate(fx fy)">` with its geometry at the local
166
+ * origin, and `frame` is that offset — relative to the PARENT, not to the capture. The
167
+ * first version of this used `abs` and every asset came out blank: `.sp-clip` was
168
+ * drawn at (98,10) while the window had been put at (504,536). `abs` is what the
169
+ * schema carries for the deltas between nodes; `frame` is where the emitter actually
170
+ * puts the pen. Moving the window is one attribute; translating the geometry would be
171
+ * a rewrite of coordinates this module has no business knowing the shape of.
172
+ *
173
+ * @param {object} doc an assembled SVD document
174
+ * @param {string} id the node to isolate
175
+ * @returns {{uri: string, bytes: number, diagnostics: object[]}|null}
176
+ */
177
+ export function svdSliceToDataUri (doc, id) {
178
+ const node = doc && doc.nodes && doc.nodes[id]
179
+ if (!node || !node.abs) return null
180
+
181
+ const nodes = {}
182
+ const take = (nid) => {
183
+ const n = doc.nodes[nid]
184
+ if (!n || nodes[nid]) return
185
+ nodes[nid] = n
186
+ for (const kid of n.children || []) take(kid)
187
+ // A mask lives outside the child list and is emitted by reference; dropping it
188
+ // turns a masked node into an unmasked one, which is a silent change of paint.
189
+ if (n.clip && n.clip.maskNode) take(n.clip.maskNode)
190
+ }
191
+ take(id)
192
+
193
+ const frame = (node.frame && Number.isFinite(node.frame.w) ? node.frame : node.abs)
194
+ const sliced = {
195
+ ...doc,
196
+ root: id,
197
+ nodes,
198
+ capture: { ...(doc.capture || {}), root: { w: frame.w, h: frame.h } },
199
+ // The slice is not the capture, and a report that describes 60 nodes on a document
200
+ // that has 3 is worse than no report.
201
+ report: undefined,
202
+ diagnostics: [],
203
+ }
204
+
205
+ let out
206
+ try { out = svdToSvg(sliced) } catch { return null }
207
+ if (!out || !out.svg) return null
208
+
209
+ const svg = out.svg.replace(/^(<svg\b[^>]*\bviewBox=")[^"]*(")/,
210
+ `$1${round(frame.x)} ${round(frame.y)} ${round(frame.w)} ${round(frame.h)}$2`)
211
+ const uri = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
212
+ return { uri, bytes: svg.length, diagnostics: out.diagnostics || [] }
213
+ }
214
+
215
+ const round = (n) => (Number.isFinite(n) ? Math.round(n * 100) / 100 : 0)