@zumer/snapdom-plugins 3.0.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 +50 -16
- 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 +16 -5
- 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,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)
|