Skip to content

Commit 7abb29a

Browse files
committed
feat(plugins): expose capture geometry, exact export options and canvas cropping
Document exporters need to know where the content sits inside the serialized SVG and to rasterize it a region at a time. Three additions, all read-only surface: - result.meta / context.meta: frozen render geometry (viewBox size, logical capture box, contentX/contentY origin, resolved clip window). contentX/Y are the exact origin rather than a centred guess, so they stay correct under asymmetric shadows, transformed roots and clip windows. The property is non-writable so a hook cannot desynchronise it from the canonical SVG, but it stays configurable: options is caller-owned and toPng/toJpg/toWebp pass their raw bag straight to captureDOM, so a reused object must be able to take a second capture's geometry instead of throwing "Cannot redefine property". - export.requestedOptions: a frozen shallow copy of exactly what the caller passed, snapshotted when toXxx() is called rather than when the export reaches the session queue. Callers commonly reuse an options object, and a slow earlier export must not let later mutation rewrite an already-requested one. Key presence is preserved, so a plugin can tell an explicit value from an omitted one even when it equals the capture default. - toCanvas({ crop }): windows the SVG in viewBox coordinates by rewriting the header before decode, so a long capture can be rasterized page by page instead of allocating one bitmap that exceeds the browser decode limits. The crop clips to the intersection with the viewBox and never degrades silently: an empty, non-finite or fully outside window, or a non-SVG payload, rejects with a RangeError rather than returning the whole capture where one page was asked for. The decoded bitmap becomes the aspect reference, otherwise a width-only crop inherits the full document ratio and stretches.
1 parent fb7311a commit 7abb29a

10 files changed

Lines changed: 453 additions & 13 deletions

File tree

PLUGIN_SPEC.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,21 @@ Every hook receives a single context object (`ctx`):
129129
dataURL, // After afterRender
130130

131131
// During export hooks
132-
export: { type, options, url }
132+
export: { type, options, requestedOptions, url }
133133
}
134134
```
135135
136+
During an export, `export.options` is the normalized merge of capture defaults and
137+
the export call. `export.requestedOptions` is a frozen shallow copy of exactly what
138+
the caller passed to `toXxx(...)`: key presence is preserved, including explicit
139+
values equal to a capture default. It is snapped synchronously when `toXxx()` is
140+
called, before that export waits behind any earlier job in the capture's queue.
141+
Plugin exporters should use it when applying their own defaults. In
142+
`defineExports`, only the final canonical `export.url` is guaranteed because no
143+
export call is active yet. `element` remains the original source element in both
144+
`defineExports` and export-hook contexts, including when it belongs to a
145+
same-origin iframe.
146+
136147
### Hook Rules
137148
138149
1. Hooks can be sync or async. SnapDOM awaits all hooks.
@@ -165,6 +176,21 @@ const blob = await result.toPdf({ width: 800 });
165176
166177
**Priority.** When multiple sources define the same export key, resolution is **local plugin > global plugin > core**. So a plugin passed via `snapdom(el, { plugins: [...] })` can override `toPng`, `toJpg`, `toCanvas`, etc., and a per-capture plugin beats a globally-registered one with the same key. Use this to swap a core exporter for a plugin implementation (e.g. a plugin-provided `png` that reuses the existing SVG via `ctx.export.url`).
167178
179+
`defineExports(ctx)` also receives `ctx.exports`, a silent facade over the core
180+
exporters. It reuses this capture without recursively firing export hooks. Its
181+
`canvas()` method accepts `crop: { x, y, width, height }` in SVG viewBox
182+
coordinates; SnapDOM windows the SVG before image decode, allowing document
183+
plugins to rasterize page-sized regions instead of one browser-limited bitmap.
184+
A crop is clipped to the intersection with the viewBox, and it never degrades
185+
silently: a non-finite or empty window, a window fully outside the viewBox, or a
186+
payload that is not a serialized SVG capture all reject with a `RangeError`
187+
rather than returning the whole capture where one page was requested.
188+
The returned capture object exposes the same immutable render geometry as
189+
`result.meta`; both the metadata value and the result property that holds it are
190+
non-writable/non-configurable. Auxiliary element captures can therefore measure
191+
their own final SVG artifact without consulting the live source tree or risking
192+
URL/geometry drift.
193+
168194
## Distribution
169195
170196
### Official plugins

__tests__/core.clip.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ describe('clip option (region capture)', () => {
5555
expect(svg).toMatch(new RegExp(`height="${clip.height}"`))
5656
})
5757

58+
it('publishes immutable clip and content-origin geometry on the capture result', async () => {
59+
const wrap = mount(buildBlocks(4, 200))
60+
const target = wrap.children[2]
61+
const box = target.getBoundingClientRect()
62+
const clip = {
63+
x: box.left + window.scrollX,
64+
y: box.top + window.scrollY,
65+
width: box.width,
66+
height: box.height,
67+
}
68+
const result = await snapdom(document.body, { clip })
69+
70+
expect(Object.isFrozen(result.meta)).toBe(true)
71+
expect(Object.isFrozen(result.meta.clip)).toBe(true)
72+
expect(Object.getOwnPropertyDescriptor(result, 'meta')).toMatchObject({
73+
writable: false, configurable: false, enumerable: true,
74+
})
75+
expect(() => { result.meta = null }).toThrow(TypeError)
76+
expect(result.meta.w0).toBeCloseTo(clip.width, 3)
77+
expect(result.meta.h0).toBeCloseTo(clip.height, 3)
78+
expect(result.meta.contentX).toBeCloseTo(0, 3)
79+
expect(result.meta.contentY).toBeCloseTo(0, 3)
80+
expect(result.meta.clip.width).toBeCloseTo(clip.width, 3)
81+
expect(result.meta.clip.height).toBeCloseTo(clip.height, 3)
82+
})
83+
5884
it('culled siblings keep their layout slot: clipped region pixels match the live DOM', async () => {
5985
const wrap = mount(buildBlocks(8, 400))
6086
const target = wrap.children[5]

__tests__/core.meta.test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Capture geometry contract (`context.meta` / `result.meta`): the render pass publishes
2+
// an immutable geometry record that document exporters translate page windows against.
3+
// The clip-specific shape is covered in core.clip.test.js; this file covers the plain
4+
// capture shape, the write protection and the caller-owned-options reuse case.
5+
import { describe, it, expect, afterEach } from 'vitest'
6+
import { snapdom } from '../src/index'
7+
import { captureDOM } from '../src/core/capture.js'
8+
9+
const added = []
10+
function mount(css = 'width:120px;height:60px;background:#fff') {
11+
const el = document.createElement('div')
12+
el.style.cssText = css
13+
el.textContent = 'META'
14+
document.body.appendChild(el)
15+
added.push(el)
16+
return el
17+
}
18+
19+
afterEach(() => {
20+
while (added.length) added.pop().remove()
21+
})
22+
23+
describe('capture geometry meta', () => {
24+
it('describes a plain (unclipped) capture', async () => {
25+
const el = mount()
26+
const result = await snapdom(el)
27+
28+
expect(Object.isFrozen(result.meta)).toBe(true)
29+
expect(result.meta.clip).toBe(null)
30+
expect(result.meta.w0).toBeCloseTo(120, 3)
31+
expect(result.meta.h0).toBeCloseTo(60, 3)
32+
// The viewBox is the content box plus symmetric padding, and contentX/contentY
33+
// must land the content box back inside it.
34+
expect(result.meta.vbW).toBeGreaterThanOrEqual(result.meta.w0)
35+
expect(result.meta.vbH).toBeGreaterThanOrEqual(result.meta.h0)
36+
expect(result.meta.contentX).toBeGreaterThanOrEqual(0)
37+
expect(result.meta.contentY).toBeGreaterThanOrEqual(0)
38+
expect(result.meta.contentX + result.meta.w0).toBeLessThanOrEqual(result.meta.vbW)
39+
expect(result.meta.contentY + result.meta.h0).toBeLessThanOrEqual(result.meta.vbH)
40+
})
41+
42+
it('rejects plain assignment on the capture context', async () => {
43+
const el = mount()
44+
const options = {}
45+
await captureDOM(el, options)
46+
47+
expect(Object.isFrozen(options.meta)).toBe(true)
48+
// ESM is strict mode: a non-writable property rejects assignment loudly instead
49+
// of letting a hook silently desynchronise geometry from the serialized SVG.
50+
expect(() => { options.meta = { w0: 1 } }).toThrow(TypeError)
51+
expect(() => { options.meta.w0 = 999 }).toThrow(TypeError)
52+
})
53+
54+
it('lets a reused options bag take a second capture geometry', async () => {
55+
// toPng/toJpg/toWebp forward their raw caller-owned opts straight to captureDOM,
56+
// so the same object legitimately reaches two captures. A non-configurable `meta`
57+
// would turn the second one into "Cannot redefine property: meta".
58+
const small = mount('width:120px;height:60px;background:#fff')
59+
const large = mount('width:240px;height:180px;background:#fff')
60+
const options = { scale: 1, dpr: 1 }
61+
62+
await captureDOM(small, options)
63+
const first = options.meta
64+
expect(first.w0).toBeCloseTo(120, 3)
65+
66+
await expect(captureDOM(large, options)).resolves.toMatch(/^data:image\/svg\+xml/)
67+
expect(options.meta).not.toBe(first)
68+
expect(options.meta.w0).toBeCloseTo(240, 3)
69+
expect(options.meta.h0).toBeCloseTo(180, 3)
70+
// Still protected after the redefinition.
71+
expect(Object.isFrozen(options.meta)).toBe(true)
72+
expect(() => { options.meta = null }).toThrow(TypeError)
73+
// ...and the first record is untouched, so anything holding it keeps valid geometry.
74+
expect(first.w0).toBeCloseTo(120, 3)
75+
})
76+
77+
it('reaches plugin export hooks with the same values as result.meta', async () => {
78+
const el = mount()
79+
let seen = null
80+
const plugin = {
81+
name: 'meta-reader',
82+
defineExports: () => ({
83+
geometry: async (ctx) => { seen = ctx.meta; return 'ok' },
84+
}),
85+
}
86+
const result = await snapdom(el, { plugins: [plugin] })
87+
await result.toGeometry()
88+
89+
expect(seen).toEqual(result.meta)
90+
})
91+
})

__tests__/exporter.toCanvas.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ const ONE_BY_ONE_PNG =
1212
'data:image/png;base64,' +
1313
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=='
1414

15+
const SQUARE_SVG = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
16+
'<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">' +
17+
'<rect width="400" height="400" fill="red"/></svg>'
18+
)
19+
1520
beforeEach(() => {
1621
// clean up DOM between tests
1722
document.body.innerHTML = ''
@@ -71,4 +76,77 @@ describe('toCanvas (Browser Mode)', () => {
7176
stoSpy.mockRestore()
7277
rmSpy.mockRestore()
7378
})
79+
80+
it('uses a crop aspect ratio for width-only and height-only sizing', async () => {
81+
vi.mocked(browser.isSafari).mockReturnValue(false)
82+
const crop = { x: 20, y: 30, width: 100, height: 50 }
83+
const meta = { vbW: 400, vbH: 400, w0: 400, h0: 400 }
84+
85+
const byWidth = await toCanvas(SQUARE_SVG, {
86+
crop, meta, width: 200, scale: 1, dpr: 1,
87+
})
88+
expect(byWidth.width).toBe(200)
89+
expect(byWidth.height).toBe(100)
90+
91+
const byHeight = await toCanvas(SQUARE_SVG, {
92+
crop, meta, height: 120, scale: 1, dpr: 1,
93+
})
94+
expect(byHeight.width).toBe(240)
95+
expect(byHeight.height).toBe(120)
96+
})
97+
98+
it('sizes from the effective crop intersection and rejects invalid windows', async () => {
99+
vi.mocked(browser.isSafari).mockReturnValue(false)
100+
const intersected = await toCanvas(SQUARE_SVG, {
101+
crop: { x: 350, y: 300, width: 100, height: 50 },
102+
meta: { vbW: 400, vbH: 400 }, width: 100, scale: 1, dpr: 1,
103+
})
104+
expect(intersected.width).toBe(100)
105+
expect(intersected.height).toBe(100)
106+
107+
await expect(toCanvas(SQUARE_SVG, {
108+
crop: { x: 500, y: 0, width: 10, height: 10 }, scale: 1, dpr: 1,
109+
})).rejects.toThrow(/does not intersect/)
110+
await expect(toCanvas(SQUARE_SVG, {
111+
crop: { x: 0, y: 0, width: 0, height: 10 }, scale: 1, dpr: 1,
112+
})).rejects.toThrow(/positive width\/height/)
113+
})
114+
115+
it('refuses to crop a payload that is not a serialized SVG capture', async () => {
116+
vi.mocked(browser.isSafari).mockReturnValue(false)
117+
// Cropping rewrites the viewBox, so it cannot apply to a raster payload. Silently
118+
// rasterizing whole would hand a document exporter a full bitmap where it asked
119+
// for one page slice, which is a worse failure than not exporting at all.
120+
await expect(toCanvas(ONE_BY_ONE_PNG, {
121+
crop: { x: 0, y: 0, width: 1, height: 1 }, scale: 1, dpr: 1,
122+
})).rejects.toThrow(/requires an SVG capture payload/)
123+
124+
// Without a crop the same payload still rasterizes normally.
125+
const plain = await toCanvas(ONE_BY_ONE_PNG, { scale: 1, dpr: 1 })
126+
expect(plain.width).toBe(1)
127+
expect(plain.height).toBe(1)
128+
})
129+
130+
it('paginates a tall capture into non-overlapping slices', async () => {
131+
vi.mocked(browser.isSafari).mockReturnValue(false)
132+
// The document-exporter use case: three page windows out of one capture, each
133+
// decoded at page size instead of allocating the full-height bitmap once.
134+
const tall = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
135+
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="300" viewBox="0 0 100 300">' +
136+
'<rect y="0" width="100" height="100" fill="rgb(255,0,0)"/>' +
137+
'<rect y="100" width="100" height="100" fill="rgb(0,255,0)"/>' +
138+
'<rect y="200" width="100" height="100" fill="rgb(0,0,255)"/></svg>'
139+
)
140+
const expected = [[255, 0, 0], [0, 255, 0], [0, 0, 255]]
141+
for (let page = 0; page < 3; page++) {
142+
const canvas = await toCanvas(tall, {
143+
crop: { x: 0, y: page * 100, width: 100, height: 100 }, scale: 1, dpr: 1,
144+
})
145+
expect(canvas.width).toBe(100)
146+
expect(canvas.height).toBe(100)
147+
const [r, g, b] = canvas.getContext('2d', { willReadFrequently: true })
148+
.getImageData(50, 50, 1, 1).data
149+
expect([r, g, b]).toEqual(expected[page])
150+
}
151+
})
74152
})

__tests__/plugin.priority.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,59 @@ describe('plugin export priority', () => {
7070
expect((await result.to('pdf')).kind).toBe('pdf')
7171
document.body.removeChild(el)
7272
})
73+
74+
it('gives plugin exports the exact frozen raw option bag', async () => {
75+
const el = makeEl()
76+
const plugin = {
77+
name: 'raw-export-options',
78+
defineExports: () => ({
79+
contract: async (ctx) => ({
80+
requested: ctx.export.requestedOptions,
81+
normalized: ctx.export.options,
82+
element: ctx.element,
83+
}),
84+
}),
85+
}
86+
const result = await snapdom(el, { plugins: [plugin] })
87+
88+
// null is also the capture default. Presence must survive even when comparing
89+
// merged values could not distinguish this call from an omitted option.
90+
const explicit = await result.toContract({ backgroundColor: null })
91+
expect(Object.hasOwn(explicit.requested, 'backgroundColor')).toBe(true)
92+
expect(explicit.requested.backgroundColor).toBe(null)
93+
expect(Object.isFrozen(explicit.requested)).toBe(true)
94+
expect(explicit.normalized.backgroundColor).toBe(null)
95+
expect(explicit.element).toBe(el)
96+
97+
const omitted = await result.toContract()
98+
expect(Object.hasOwn(omitted.requested, 'backgroundColor')).toBe(false)
99+
expect(Object.isFrozen(omitted.requested)).toBe(true)
100+
document.body.removeChild(el)
101+
})
102+
103+
it('snapshots raw options before a queued export can observe later mutation', async () => {
104+
const el = makeEl()
105+
let release
106+
const gate = new Promise(resolve => { release = resolve })
107+
let calls = 0
108+
const plugin = {
109+
name: 'queued-raw-options',
110+
defineExports: () => ({
111+
contract: async (ctx) => {
112+
if (++calls === 1) await gate
113+
return ctx.export.requestedOptions.label
114+
},
115+
}),
116+
}
117+
const result = await snapdom(el, { plugins: [plugin] })
118+
const first = result.toContract({ label: 'first' })
119+
const laterOptions = { label: 'queued' }
120+
const second = result.toContract(laterOptions)
121+
laterOptions.label = 'mutated-after-call'
122+
release()
123+
124+
expect(await first).toBe('first')
125+
expect(await second).toBe('queued')
126+
document.body.removeChild(el)
127+
})
73128
})

__tests__/visual.demos.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ if (Object.keys(demos).length === 0) {
7878
wait: 2500,
7979
setup: async (win) => { try { await win.document.fonts.ready } catch { } }
8080
},
81+
// Paginates one capture into three crop'd canvases at load. The baseline holds the
82+
// live document next to the rasterized pages, so a wrong crop origin or a stretched
83+
// page shows up as the tiles no longer reconstructing the source.
84+
'd-crop-pages': {
85+
setup: async (win) => { try { await win.__ready } catch { /* error is rendered into the demo */ } },
86+
},
8187
// Issue #474 formulas rendered live at load (captures only run on click) — snapshotting
8288
// the table is a KaTeX layout fidelity check. Same CDN font wait as d454.
8389
'd474-katex-formulas': {

src/api/snapdom.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ async function main(element, userOptions) {
9999
snapdom.capture = async (el, context, _token) => {
100100
if (_token !== INTERNAL_TOKEN) throw new Error('[snapdom.capture] is internal. Use snapdom(...) instead.')
101101

102+
// Export/defineExports contexts are the same capture context promised to every
103+
// other hook. Keep the source element available there too (not only in
104+
// captureDOM's transient state wrapper), which is required for ownerDocument
105+
// URL/language semantics in document exporters.
106+
context.element = el
107+
102108
const url = await captureDOM(el, context)
103109

104110
// ——— 1) Core exports por defecto (carga lazy en cada tipo) ———
@@ -185,11 +191,20 @@ snapdom.capture = async (el, context, _token) => {
185191
let afterSnapFired = false
186192
let _exportQueue = Promise.resolve()
187193
async function runExport(type, opts) {
194+
// Snapshot at CALL time, not when this export eventually reaches the session
195+
// queue. Callers commonly reuse an options object; a slow earlier export must
196+
// not let later mutation rewrite the meaning of an already-requested export.
197+
const requestedOptions = Object.freeze(
198+
opts && typeof opts === 'object' ? { ...opts } : {}
199+
)
188200
const job = async () => {
189201
const work = exportsMap[type]
190202
if (!work) throw new Error(`[snapdom] Unknown export type: ${type}`)
191-
const nextOpts = normalizeExportOptions(type, opts)
192-
const ctx = { ...context, export: { type, options: nextOpts, url } }
203+
// Preserve key presence as well as values. A plugin default and a normalized
204+
// capture default may legitimately have the same value; comparing merged
205+
// values cannot tell whether the caller explicitly overrode the plugin.
206+
const nextOpts = normalizeExportOptions(type, requestedOptions)
207+
const ctx = { ...context, export: { type, options: nextOpts, requestedOptions, url } }
193208
// Payload shape per the plugin spec: beforeExport(ctx, {format, options}),
194209
// afterExport(ctx, {format, options, result}). `type` is the export name (png/blob/…).
195210
await runHook('beforeExport', ctx, { format: type, options: nextOpts })
@@ -226,6 +241,11 @@ snapdom.capture = async (el, context, _token) => {
226241
toWebp: (opts) => runExport('webp', opts),
227242
download: (opts) => runExport('download', opts)
228243
}
244+
// Read-only render geometry for document exporters and diagnostics. Pin both
245+
// the frozen value and the result property so URL/meta cannot diverge later.
246+
Object.defineProperty(result, 'meta', {
247+
value: context.meta, enumerable: true, writable: false, configurable: false,
248+
})
229249

230250
// Azúcar dinámico por cada export registrado (plugins incluidos)
231251
for (const key of Object.keys(exportsMap)) {

0 commit comments

Comments
 (0)