rapier-markdown-kit 1.1.104 → 1.2.3
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/LICENSE +5 -2
- package/README.md +34 -3
- package/dist/images/gif.mjs +47 -0
- package/dist/images/playback.mjs +101 -0
- package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
- package/dist/kit/conformance/04-wrap-box.md +1 -1
- package/dist/kit/conformance/05-behind.md +1 -1
- package/dist/kit/conformance/06-front.md +1 -1
- package/dist/kit/conformance/07-rotate-raster.md +1 -1
- package/dist/kit/conformance/08-drawing-rotation.md +1 -1
- package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
- package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
- package/dist/kit/conformance/10-both-sides.md +1 -1
- package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
- package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
- package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
- package/dist/kit/conformance/README.md +19 -28
- package/dist/kit/conformance/run.mjs +9 -7
- package/dist/kit/ledger/carried.mjs +10 -5
- package/dist/kit/ledger/format.mjs +12 -3
- package/dist/kit/ledger/hash.mjs +31 -15
- package/dist/kit/ledger/history.mjs +17 -5
- package/dist/kit/ledger/merge.mjs +4 -3
- package/dist/kit/pdf-vectors.mjs +4 -1
- package/dist/kit/rapier-ledger.mjs +2 -2
- package/dist/kit/render-ink.mjs +1 -1
- package/dist/kit/render-markdown.mjs +146 -31
- package/dist/kit/render-print.mjs +131 -55
- package/dist/kit/render-sanitize.mjs +6 -3
- package/dist/kit/render.mjs +34 -77
- package/dist/layout/model.mjs +10 -8
- package/dist/packages/rapier-diff/index.mjs +75 -32
- package/dist/spec/document-fonts.mjs +1 -1
- package/dist/spec/document-settings.mjs +11 -164
- package/dist/spec/html-blocks.mjs +618 -79
- package/dist/spec/md-assets.mjs +29 -6
- package/dist/spec/md-layout.mjs +8 -0
- package/dist/spec/md-marks.mjs +25 -147
- package/dist/spec/md-source.mjs +54 -19
- package/package.json +1 -1
- package/style.css +7 -51
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Fenced HTML stays source. Its preview has no document authority.
|
|
3
|
-
import {
|
|
3
|
+
import {inspectGIF} from '../images/gif.mjs';
|
|
4
|
+
import {createMediaPlayback} from '../images/playback.mjs';
|
|
5
|
+
import {sha256} from '../kit/ledger/hash.mjs';
|
|
6
|
+
import {formatLayout, parseLayout, validLayout, imageStyle, objectHeight} from './md-layout.mjs';
|
|
4
7
|
|
|
8
|
+
// The elements a preview keeps; every other element is removed with its content.
|
|
9
|
+
export const HTML_ELEMENTS = Object.freeze('a abbr address article aside b bdi bdo blockquote br button caption circle clipPath code col colgroup dd defs del details dfn div dl dt ellipse em fieldset figcaption figure filter feBlend feColorMatrix feComponentTransfer feComposite feFlood feFuncA feFuncB feFuncG feFuncR feGaussianBlur feMerge feMergeNode feOffset footer g h1 h2 h3 h4 h5 h6 header hr i image img input kbd label legend li line linearGradient main mark mask meter nav ol optgroup option p path pattern picture polygon polyline pre progress q radialGradient rect s samp section select small source span stop strong style sub summary sup svg table tbody td text textarea th thead time title tr tspan u ul use var animate animateTransform animateMotion set mpath'.toLowerCase().split(' '));
|
|
10
|
+
const VOID = new Set('area base br col embed hr img input link meta source track wbr'.split(' '));
|
|
11
|
+
|
|
12
|
+
// The element names a preview removes from this body, in first-seen order. Content inside a removed element goes with it.
|
|
13
|
+
export function htmlRemoved(body) {
|
|
14
|
+
const allowed = new Set(HTML_ELEMENTS), removed = new Set(), tag = /<(\/?)([A-Za-z][\w:-]*)[^>]*?(\/?)>/g;
|
|
15
|
+
for (let found; (found = tag.exec(body));) {
|
|
16
|
+
const name = found[2].toLowerCase();
|
|
17
|
+
if (found[1] || allowed.has(name)) continue;
|
|
18
|
+
removed.add(name);
|
|
19
|
+
if (VOID.has(name) || found[3]) continue;
|
|
20
|
+
// Skip to this element's own end tag; nested same-name elements stay counted.
|
|
21
|
+
const inner = new RegExp('<(/?)' + name + '\\b[^>]*>', 'gi'); inner.lastIndex = tag.lastIndex;
|
|
22
|
+
for (let depth = 1, next; depth && (next = inner.exec(body));) {depth += next[1] ? -1 : 1; tag.lastIndex = inner.lastIndex;}
|
|
23
|
+
if (!new RegExp('</' + name + '\\b', 'i').test(body.slice(found.index))) break;
|
|
24
|
+
}
|
|
25
|
+
return [...removed];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Placement belongs to the trailing md-layout comment alone. Unknown keys are kept and ignored.
|
|
5
29
|
export function htmlAttributes(info) {
|
|
6
30
|
const match = /^html(?:\s+\{([^{}]*)\})?\s*$/i.exec(String(info));
|
|
7
31
|
if (!match) return null;
|
|
@@ -16,9 +40,10 @@ export function htmlAttributes(info) {
|
|
|
16
40
|
}
|
|
17
41
|
if (attrs.id && !/^[A-Za-z][A-Za-z0-9_-]{0,127}$/.test(attrs.id)) return null;
|
|
18
42
|
if (attrs.height && attrs.height !== 'auto' && (!/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(attrs.height) || +attrs.height < .5 || +attrs.height > 512)) return null;
|
|
19
|
-
if (attrs.
|
|
20
|
-
if (attrs.
|
|
21
|
-
|
|
43
|
+
if (attrs.resize && !['scale', 'reflow'].includes(attrs.resize)) return null;
|
|
44
|
+
if (attrs.viewport && !/^[1-9]\d{0,3}x[1-9]\d{0,3}$/.test(attrs.viewport)) return null;
|
|
45
|
+
if (attrs.viewport && attrs.viewport.split('x').some(n => +n > 4096)) return null;
|
|
46
|
+
if (attrs.overflow && attrs.overflow !== 'clip') return null;
|
|
22
47
|
return attrs;
|
|
23
48
|
}
|
|
24
49
|
|
|
@@ -29,115 +54,629 @@ export function htmlFence(raw) {
|
|
|
29
54
|
const mark = opening[2], close = new RegExp('^ {0,3}' + mark[0] + '{' + mark.length + ',}[ \\t]*\\r?$', 'm');
|
|
30
55
|
const rest = raw.slice(opening[0].length), found = close.exec(rest);
|
|
31
56
|
if (!found) return null;
|
|
32
|
-
const end = opening[0].length + found.index + found[0].length;
|
|
33
|
-
|
|
57
|
+
const end = opening[0].length + found.index + found[0].replace(/\r$/, '').length;
|
|
58
|
+
const tail = raw.slice(end), attached = /^(\r?\n)(<!--md-layout:v1[^\r\n]*-->)(?:\r?\n)?$/.exec(tail);
|
|
59
|
+
const layout = attached && parseLayout(attached[2]);
|
|
60
|
+
if (tail.trim() && !layout) return null;
|
|
61
|
+
if (layout && ['align', 'first', 'indent', 'marker', 'hyphens', 'caps', 'hanging'].some(key => key in layout)) return null;
|
|
34
62
|
return {attrs, info: opening[3].trim(), body: rest.slice(0, found.index), infoStart: opening[1].length + mark.length,
|
|
35
|
-
infoEnd: opening[0].replace(/\r?\n$/, '').length, source: raw, start: 0, end: raw.length
|
|
63
|
+
infoEnd: opening[0].replace(/\r?\n$/, '').length, source: raw, start: 0, end: raw.length, fenceEnd: end,
|
|
64
|
+
layout: layout || HTML_FLOW, marker: attached ? {start: end + attached[1].length, end: end + attached[1].length + attached[2].length, text: attached[2]} : null};
|
|
36
65
|
}
|
|
37
66
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return {width, ...(attrs.float && attrs.float !== 'none' ? {wrap: 'box', x: attrs.x == null ? attrs.float === 'right' ? 100 - width / 2 : width / 2 : +attrs.x, y: +(attrs.y || 0)} : {})};
|
|
41
|
-
}
|
|
67
|
+
// Without a layout comment a block is in normal flow across the column.
|
|
68
|
+
const HTML_FLOW = Object.freeze({width: 100});
|
|
42
69
|
|
|
43
70
|
export function htmlImageRecord(raw) {
|
|
44
71
|
const fence = htmlFence(raw); if (!fence) return null;
|
|
45
72
|
return {...fence, htmlBlock: true, renderIndex: 0, alt: fence.attrs.id || 'HTML', altSource: '',
|
|
46
|
-
tokenEnd: raw.length, source: raw,
|
|
73
|
+
tokenEnd: raw.length, source: raw, placement: fence.layout, standalone: true, imageOnly: true};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The editor's bounded worker splits before layout annotation. Join only the
|
|
77
|
+
// exact adjacent marker proven by this same source grammar, preserving offsets.
|
|
78
|
+
export function mergeHtmlObjectBlocks(blocks, source) {
|
|
79
|
+
for (let index = 0; index + 1 < blocks.length; index++) {
|
|
80
|
+
const block = blocks[index], next = blocks[index + 1];
|
|
81
|
+
if (block.type !== 'fence' || next.type !== 'html_block') continue;
|
|
82
|
+
const raw = source.slice(block._sourceStart, next._sourceEnd);
|
|
83
|
+
if (!htmlFence(raw)?.marker) continue;
|
|
84
|
+
block.raw = raw; block._sourceEnd = next._sourceEnd; block.rendered = null;
|
|
85
|
+
if (next._rapierFacts) block._rapierFacts = [...(block._rapierFacts || []), ...next._rapierFacts];
|
|
86
|
+
blocks.splice(index + 1, 1);
|
|
87
|
+
}
|
|
88
|
+
return blocks;
|
|
47
89
|
}
|
|
48
90
|
|
|
49
91
|
export function htmlLayoutEdit(record, value) {
|
|
50
92
|
const fence = htmlFence(record.block.raw); if (!fence) return null;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
93
|
+
const layout = Object.fromEntries(Object.entries(value).filter(([,v]) => v != null));
|
|
94
|
+
// HTML height is its clipping viewport in document lines, not drop-cap sizing.
|
|
95
|
+
const height = layout.lines; delete layout.lines;
|
|
96
|
+
if (!validLayout(layout)) return null;
|
|
97
|
+
const resizedHeight = height ?? (fence.attrs.resize === 'scale' && fence.attrs.height && fence.attrs.height !== 'auto' && layout.width && fence.layout.width
|
|
98
|
+
? Math.max(.5, Math.min(512, Math.round(+fence.attrs.height * layout.width / fence.layout.width * 10) / 10)) : null);
|
|
99
|
+
const info = resizedHeight != null ? htmlInfoEdit(fence.info, {height: resizedHeight}) : fence.info;
|
|
100
|
+
const line = fence.source.includes('\r\n') ? '\r\n' : '\n', marker = formatLayout(layout);
|
|
101
|
+
const tail = fence.source.slice(fence.marker?.end ?? fence.fenceEnd);
|
|
102
|
+
return {start: fence.infoStart, end: fence.source.length,
|
|
103
|
+
text: info + fence.source.slice(fence.infoEnd, fence.fenceEnd) + (marker ? line + marker : '') + tail};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Change only owned header tokens. Body, fence spelling, class/id and unrelated attributes stay exact.
|
|
107
|
+
export function htmlInfoEdit(info, changes) {
|
|
108
|
+
let text = info;
|
|
109
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
110
|
+
const pattern = new RegExp('(^|[\\s{])\\s*' + key + '=(?:"[^"]*"|\'[^\']*\'|[^\\s}]+)', 'g');
|
|
111
|
+
text = text.replace(pattern, (_, lead) => lead.trim()).replace(/{\s+/, '{');
|
|
112
|
+
if (value != null) text = text.includes('{') ? text.replace(/\s*}$/, ' ' + key + '=' + value + '}') : text + ' {' + key + '=' + value + '}';
|
|
113
|
+
}
|
|
114
|
+
return text.replace(/\s*{\s*}$/, '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function htmlModeEdit(record, resize, viewport) {
|
|
118
|
+
const fence = htmlFence(record.block.raw); if (!fence || !['scale', 'reflow'].includes(resize)) return null;
|
|
119
|
+
const info = htmlInfoEdit(fence.info, {resize, ...(resize === 'scale' && !fence.attrs.viewport ? {viewport: viewport || '640x360'} : {})});
|
|
120
|
+
return htmlAttributes(info) ? {start: fence.infoStart, end: fence.infoEnd, text: info} : null;
|
|
60
121
|
}
|
|
61
122
|
|
|
62
123
|
export function htmlPlaceholder(token, escape) {
|
|
63
124
|
const attrs = htmlAttributes(token.info); if (!attrs || token.content.length > 1048576 || token.meta?.rapierFenceClosed === false) return null;
|
|
64
|
-
const layout =
|
|
125
|
+
const layout = token.meta?.mdLayout?.layout || HTML_FLOW, marker = encodeURIComponent(formatLayout(layout));
|
|
65
126
|
const height = attrs.height && attrs.height !== 'auto' ? attrs.height : 8;
|
|
66
|
-
const source = encodeURIComponent(JSON.stringify({info: token.info, body: token.content, attrs}));
|
|
127
|
+
const source = encodeURIComponent(JSON.stringify({info: token.info, body: token.content, attrs, layout}));
|
|
67
128
|
// The image is the existing placement owner's measurable box. Preview content is mounted separately.
|
|
68
129
|
const pixel = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=';
|
|
69
130
|
return '<p class="rapier-html-block" data-md-layout="' + escape(marker) + '"><img data-rapier-markdown-image="" data-rapier-image-index="0" data-rapier-html="' + escape(source) + '" data-rapier-image-layout="' + escape(marker) + '" src="' + pixel + '" alt="' + escape(attrs.id || 'HTML block') + '" style="' + imageStyle(layout) + ';height:calc(' + height + ' * var(--md-line, 1.6em));object-fit:fill" /></p>\n';
|
|
70
131
|
}
|
|
71
132
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
133
|
+
// The frame owns CSS layout and pixels only. All commands are scoped to this
|
|
134
|
+
// opaque window instance; no measurement or playback reply can edit source.
|
|
135
|
+
function htmlFrameRuntime(config, media, win) {
|
|
136
|
+
const send = value => win.parent.postMessage({type: 'rapier-object-state', id: config.id, ...value}, '*');
|
|
137
|
+
let paused = true, disposed = false, generation = 0, timer = 0, motion = 0, signature = '', busy = false;
|
|
138
|
+
const rasters = [], root = win.document.body;
|
|
139
|
+
const animations = () => win.document.getAnimations();
|
|
140
|
+
const vectors = () => [...root.querySelectorAll('svg')];
|
|
141
|
+
function transport(stop) {
|
|
142
|
+
paused = stop;
|
|
143
|
+
win.cancelAnimationFrame(motion); motion = 0;
|
|
144
|
+
for (const animation of animations()) {if (stop) {const time = animation.currentTime; animation.pause(); if (time != null) animation.currentTime = time;} else animation.play();}
|
|
145
|
+
for (const svg of vectors()) {if (stop) svg.pauseAnimations?.(); else svg.unpauseAnimations?.();}
|
|
146
|
+
for (const row of rasters) {if (stop) row.player.pause(); else row.player.play();}
|
|
147
|
+
for (const row of rasters) rasterPosition(row);
|
|
148
|
+
if (!stop && rasters.length) motion = win.requestAnimationFrame(positionRasters);
|
|
149
|
+
}
|
|
150
|
+
function positionRasters() {
|
|
151
|
+
motion = 0; if (disposed || paused) return;
|
|
152
|
+
for (const row of rasters) rasterPosition(row);
|
|
153
|
+
motion = win.requestAnimationFrame(positionRasters);
|
|
154
|
+
}
|
|
155
|
+
function rasterPosition(row) {
|
|
156
|
+
const image = row.image, style = win.getComputedStyle(image);
|
|
157
|
+
row.canvas.style.cssText = 'position:absolute;pointer-events:none;margin:0;left:' + image.offsetLeft + 'px;top:' + image.offsetTop + 'px;width:' + image.offsetWidth + 'px;height:' + image.offsetHeight + 'px';
|
|
158
|
+
for (const property of ['transform','transform-origin','opacity','filter','mix-blend-mode','border-radius','clip-path','object-fit','object-position','z-index'])
|
|
159
|
+
row.canvas.style.setProperty(property, style.getPropertyValue(property));
|
|
160
|
+
row.canvas.style.visibility = row.visibility;
|
|
161
|
+
const rect = image.getBoundingClientRect(), clip = {left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom};
|
|
162
|
+
for (let parent = image.parentElement; parent && parent !== root; parent = parent.parentElement) {
|
|
163
|
+
const look = win.getComputedStyle(parent);
|
|
164
|
+
if (look.overflowX === 'visible' && look.overflowY === 'visible') continue;
|
|
165
|
+
if (look.transform !== 'none' || style.transform !== 'none') {row.player?.pause(); row.canvas.hidden = true; image.style.visibility = ''; send({mediaStatus: 'clip-playback-unavailable'}); return;}
|
|
166
|
+
const box = parent.getBoundingClientRect();
|
|
167
|
+
if (look.overflowX !== 'visible') {clip.left = Math.max(clip.left, box.left + parent.clientLeft); clip.right = Math.min(clip.right, box.left + parent.clientLeft + parent.clientWidth);}
|
|
168
|
+
if (look.overflowY !== 'visible') {clip.top = Math.max(clip.top, box.top + parent.clientTop); clip.bottom = Math.min(clip.bottom, box.top + parent.clientTop + parent.clientHeight);}
|
|
169
|
+
}
|
|
170
|
+
if (style.clipPath === 'none') row.canvas.style.clipPath = 'inset(' + [clip.top-rect.top, rect.right-clip.right, rect.bottom-clip.bottom, clip.left-rect.left].map(n => Math.max(0,n)+'px').join(' ') + ')';
|
|
171
|
+
}
|
|
172
|
+
for (const image of root.querySelectorAll('img[data-rapier-animation]')) {
|
|
173
|
+
const canvas = win.document.createElement('canvas'); canvas.hidden = true; canvas.dataset.rapierRuntime = '';
|
|
174
|
+
image.after(canvas);
|
|
175
|
+
const row = {image, canvas, visibility: win.getComputedStyle(image).visibility};
|
|
176
|
+
try {
|
|
177
|
+
row.player = media.raster(image.dataset.rapierAnimation, canvas, state => {
|
|
178
|
+
canvas.hidden = state.status !== 'ready'; rasterPosition(row);
|
|
179
|
+
image.style.visibility = state.status === 'ready' && !canvas.hidden ? 'hidden' : '';
|
|
180
|
+
if (state.first) image.dataset.rapierFirstFrame = state.first;
|
|
181
|
+
send({mediaStatus: canvas.hidden && state.status === 'ready' ? 'clip-playback-unavailable' : state.status});
|
|
182
|
+
}, {width: +image.dataset.rapierMediaWidth, height: +image.dataset.rapierMediaHeight}); rasters.push(row);
|
|
183
|
+
} catch {canvas.remove(); send({mediaStatus: 'admission-failed'});}
|
|
184
|
+
}
|
|
185
|
+
const settle = promise => Promise.race([promise, new Promise((_, reject) => win.setTimeout(() => reject(new Error('Resource readiness timeout')), 2000))]);
|
|
186
|
+
async function snapshot() {
|
|
187
|
+
timer = 0;
|
|
188
|
+
if (disposed) return;
|
|
189
|
+
if (busy) {schedule(); return;}
|
|
190
|
+
const width = Math.ceil(win.innerWidth), height = Math.ceil(win.innerHeight), key = width + 'x' + height;
|
|
191
|
+
const measured = Math.min(4096, Math.max(1, Math.ceil(root.getBoundingClientRect().height), root.scrollHeight));
|
|
192
|
+
send({height: measured});
|
|
193
|
+
if (key === signature) return;
|
|
194
|
+
signature = key; busy = true; const stamp = ++generation;
|
|
195
|
+
let saved = [], svgTimes = [];
|
|
196
|
+
try {
|
|
197
|
+
await settle(Promise.all([win.document.fonts.ready, ...[...win.document.images].filter(image => image.hasAttribute('src')).map(image => image.decode())]));
|
|
198
|
+
for (const row of rasters) {
|
|
199
|
+
const first = await row.player.first();
|
|
200
|
+
if (!first) throw new Error('First-frame decoder unavailable');
|
|
201
|
+
row.image.dataset.rapierFirstFrame = first;
|
|
202
|
+
}
|
|
203
|
+
if (disposed || stamp !== generation) return;
|
|
204
|
+
if (width !== Math.ceil(win.innerWidth) || height !== Math.ceil(win.innerHeight)) {signature = ''; return;}
|
|
205
|
+
const live = [root, ...root.querySelectorAll('*')].filter(el => !el.matches('script,[data-rapier-runtime]'));
|
|
206
|
+
if (live.length > 512 || !width || !height || width > 4096 || height > 4096) throw new Error('Snapshot work budget');
|
|
207
|
+
const active = animations();
|
|
208
|
+
if (active.length > 512) {active.forEach(animation => animation.cancel()); throw new Error('Animation count budget');}
|
|
209
|
+
const animated = active.length > 0 || !!root.querySelector('animate,animateTransform,animateMotion,set') || rasters.some(row => row.player.state.animated);
|
|
210
|
+
saved = active.map(animation => ({animation, time: animation.currentTime, state: animation.playState}));
|
|
211
|
+
for (const animation of active) {animation.pause(); animation.currentTime = 0;}
|
|
212
|
+
svgTimes = vectors().map(svg => ({svg, time: svg.getCurrentTime?.() || 0}));
|
|
213
|
+
for (const {svg} of svgTimes) {svg.pauseAnimations?.(); svg.setCurrentTime?.(0);}
|
|
214
|
+
const clone = root.cloneNode(true); clone.querySelectorAll('script,[data-rapier-runtime]').forEach(el => el.remove());
|
|
215
|
+
const copies = [clone, ...clone.querySelectorAll('*')];
|
|
216
|
+
for (let i = 0; i < live.length; i++) {
|
|
217
|
+
const element = live[i], copy = copies[i]; if (!copy) throw new Error('Snapshot tree changed');
|
|
218
|
+
const style = win.getComputedStyle(element);
|
|
219
|
+
for (const property of style) copy.style.setProperty(property, style.getPropertyValue(property), 'important');
|
|
220
|
+
if (element.tagName === 'IMG' && element.dataset.rapierAnimation) copy.style.setProperty('visibility', rasters.find(row => row.image === element)?.visibility || 'visible', 'important');
|
|
221
|
+
copy.style.setProperty('animation', 'none', 'important'); copy.style.setProperty('transition', 'none', 'important');
|
|
222
|
+
// SVG animated geometry is not represented by serialized timeline state.
|
|
223
|
+
for (const attr of element.attributes) {
|
|
224
|
+
const value = element[attr.name]?.animVal;
|
|
225
|
+
if (value && typeof value.value === 'number') copy.setAttribute(attr.name, String(value.value));
|
|
226
|
+
else if (typeof value === 'number') copy.setAttribute(attr.name, String(value));
|
|
227
|
+
else if (typeof value === 'string') copy.setAttribute(attr.name, value);
|
|
228
|
+
else if (value && ['x','y','width','height'].every(key => typeof value[key] === 'number')) copy.setAttribute(attr.name, [value.x, value.y, value.width, value.height].join(' '));
|
|
229
|
+
else if (value && typeof value.numberOfItems === 'number' && value.numberOfItems <= 4096) {
|
|
230
|
+
const items = Array.from({length: value.numberOfItems}, (_, index) => value.getItem(index));
|
|
231
|
+
if (items.every(item => typeof item.value === 'number')) copy.setAttribute(attr.name, items.map(item => item.value).join(' '));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (element.animatedPoints?.numberOfItems > 4096) throw new Error('SVG point budget');
|
|
235
|
+
if (element.animatedPoints) copy.setAttribute('points', Array.from({length: element.animatedPoints.numberOfItems}, (_, index) => {
|
|
236
|
+
const p = element.animatedPoints.getItem(index); return p.x + ',' + p.y;
|
|
237
|
+
}).join(' '));
|
|
238
|
+
if (element.tagName === 'IMG' && element.dataset.rapierFirstFrame) copy.src = element.dataset.rapierFirstFrame;
|
|
239
|
+
// Pseudo content is a separate painted box; materialize it before removing
|
|
240
|
+
// stylesheets so media queries cannot run again in the smaller raster SVG.
|
|
241
|
+
for (const pseudo of ['::before', '::after']) {
|
|
242
|
+
const computed = win.getComputedStyle(element, pseudo), content = computed.content;
|
|
243
|
+
if (!content || content === 'none' || content === 'normal') continue;
|
|
244
|
+
if (!/^"(?:[^"\\]|\\.)*"$/.test(content)) throw new Error('Unsupported generated content');
|
|
245
|
+
const span = win.document.createElement('span'); span.textContent = content.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
246
|
+
for (const property of computed) span.style.setProperty(property, computed.getPropertyValue(property), 'important');
|
|
247
|
+
span.style.setProperty('animation', 'none', 'important');
|
|
248
|
+
if (pseudo === '::before') copy.prepend(span); else copy.append(span);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const fonts = [];
|
|
252
|
+
const fontRules = rules => {for (const rule of rules) {if (rule.type === 5) fonts.push(rule.cssText); else if (rule.cssRules) fontRules(rule.cssRules);}};
|
|
253
|
+
for (const sheet of win.document.styleSheets) fontRules(sheet.cssRules);
|
|
254
|
+
clone.querySelectorAll('animate,animateTransform,animateMotion,set,style').forEach(el => el.remove());
|
|
255
|
+
if (fonts.length) {const style = win.document.createElement('style'); style.textContent = fonts.join('\n'); clone.prepend(style);}
|
|
256
|
+
const markup = new win.XMLSerializer().serializeToString(clone);
|
|
257
|
+
if (markup.length > 2097152) throw new Error('Snapshot byte budget');
|
|
258
|
+
send({height: measured, animated, width, viewportHeight: height, markup,
|
|
259
|
+
contour: animated ? 'animation-box' : config.overflow ? 'overflow-box' : 'sampled',
|
|
260
|
+
snapshotStatus: root.querySelector('animateMotion') ? 'unsupported-motion' : 'sampled'});
|
|
261
|
+
} catch (error) {send({snapshotStatus: 'unavailable', contour: 'unavailable-box', reason: String(error.message).slice(0, 120)});}
|
|
262
|
+
finally {
|
|
263
|
+
for (const {animation, time, state} of saved) {animation.currentTime = time; if (!paused && state !== 'finished') animation.play();}
|
|
264
|
+
for (const {svg, time} of svgTimes) {svg.setCurrentTime?.(time); if (!paused) svg.unpauseAnimations?.();}
|
|
265
|
+
busy = false;
|
|
266
|
+
if (!disposed && (width !== Math.ceil(win.innerWidth) || height !== Math.ceil(win.innerHeight))) {signature = ''; schedule();}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// A pending snapshot is never pushed back: a steady stream of resize notices (Firefox sends one per frame) must not starve it.
|
|
270
|
+
const schedule = () => {if (!timer) timer = win.setTimeout(snapshot, 80); for (const row of rasters) rasterPosition(row);};
|
|
271
|
+
const observer = new win.ResizeObserver(schedule); observer.observe(root);
|
|
272
|
+
win.addEventListener('resize', schedule);
|
|
273
|
+
win.addEventListener('message', event => {
|
|
274
|
+
if (event.source !== win.parent || event.data?.type !== 'rapier-object-command' || event.data.id !== config.id) return;
|
|
275
|
+
if (event.data.action === 'pause' || event.data.action === 'play') transport(event.data.action === 'pause');
|
|
276
|
+
if (event.data.action === 'suspend') {transport(true); for (const row of rasters) row.player.suspend();}
|
|
277
|
+
if (event.data.action === 'snapshot') {signature = ''; schedule();}
|
|
278
|
+
});
|
|
279
|
+
win.addEventListener('pagehide', event => {if (event.persisted) {transport(true); return;}
|
|
280
|
+
disposed = true; generation++; win.clearTimeout(timer); win.cancelAnimationFrame(motion); observer.disconnect(); for (const row of rasters) row.player.close();});
|
|
281
|
+
win.addEventListener('pageshow', schedule);
|
|
282
|
+
transport(true); win.addEventListener('load', schedule); schedule();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function htmlRuntimeSource() {
|
|
286
|
+
return '{const HTML_ELEMENTS=' + JSON.stringify(HTML_ELEMENTS) + ';(' + mountHtmlBlocks.toString() + ')(document,{media:(' + createMediaPlayback.toString() + ')(window,' + inspectGIF.toString() + '),frameRuntime:' + htmlFrameRuntime.toString() + ',heightOf:' + objectHeight.toString() + '});}';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// A poster is an ordinary existing Markdown image definition, bound to source
|
|
290
|
+
// and the canonical export viewport. Placement still belongs only to md-layout.
|
|
291
|
+
export function objectPosterLabel(image, width = 640) {
|
|
292
|
+
if (!image.hasAttribute('data-rapier-html')) return posterLabel(image.getAttribute('data-rapier-animation'));
|
|
293
|
+
const source = JSON.parse(decodeURIComponent(image.dataset.rapierHtml));
|
|
294
|
+
return posterLabel(source, parseLayout(decodeURIComponent(image.dataset.rapierImageLayout || '')) || source.layout, width);
|
|
295
|
+
}
|
|
296
|
+
const posterLabel = (object, layout, width = 640) => {
|
|
297
|
+
let value = object;
|
|
298
|
+
if (typeof object !== 'string') {
|
|
299
|
+
const attrs = {...object.attrs}; delete attrs.id;
|
|
300
|
+
value = JSON.stringify({body: object.body.replace(/\r\n?/g, '\n'), attrs, width: Math.round(width * (layout?.width || 100) / 100)});
|
|
301
|
+
}
|
|
302
|
+
return 'rapier-poster-v1-' + sha256('rapier-object-poster-v1\nlight\n' + value);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// An act that removes or changes an object removes the still that belonged to it, in the same act.
|
|
306
|
+
// Only objects the act touched are hashed; an identical untouched object keeps the shared still.
|
|
307
|
+
// Picture definitions the same act retires (`retired`, offsets in after) take their stills with them.
|
|
308
|
+
export function retiredPosterSplices(before, after, splices, retired = []) {
|
|
309
|
+
if (!splices.length || !after.includes('rapier-poster-v1-')) return [];
|
|
310
|
+
// The changed window: the one splice itself, or the common prefix and suffix of a several-splice act.
|
|
311
|
+
let start = 0, endBefore = before.length, endAfter = after.length;
|
|
312
|
+
if (splices.length === 1) {start = splices[0].pos; endBefore = start + splices[0].removed.length; endAfter = start + splices[0].inserted.length;}
|
|
313
|
+
else {
|
|
314
|
+
while (start < endBefore && start < endAfter && before.charCodeAt(start) === after.charCodeAt(start)) start++;
|
|
315
|
+
while (endBefore > start && endAfter > start && before.charCodeAt(endBefore - 1) === after.charCodeAt(endAfter - 1)) {endBefore--; endAfter--;}
|
|
316
|
+
}
|
|
317
|
+
const touched = (text, from, to) => {
|
|
318
|
+
const rows = [], open = /^ {0,3}(`{3,}|~{3,})html(?=[\s{]|$)[^\r\n]*\r?\n/gm;
|
|
319
|
+
for (let found; (found = open.exec(text)) && found.index <= to;) {
|
|
320
|
+
const close = new RegExp('^ {0,3}' + found[1][0] + '{' + found[1].length + ',}[ \\t]*(?:\\r?\\n|$)', 'gm'); close.lastIndex = open.lastIndex;
|
|
321
|
+
const closing = close.exec(text); if (!closing) break;
|
|
322
|
+
let end = closing.index + closing[0].length;
|
|
323
|
+
const marker = /^<!--md-layout:v1[^\r\n]*-->(?:\r?\n)?/.exec(text.slice(end, end + 1024)); if (marker) end += marker[0].length;
|
|
324
|
+
open.lastIndex = end;
|
|
325
|
+
const fence = end >= from && found.index <= to && htmlFence(text.slice(found.index, end));
|
|
326
|
+
if (fence) rows.push({label: posterLabel(fence, fence.layout), raw: text.slice(found.index, end)});
|
|
86
327
|
}
|
|
328
|
+
const media = /data:image\/(?:gif|jxl);base64,[A-Za-z0-9+/]+={0,2}/gi;
|
|
329
|
+
for (let found; (found = media.exec(text)) && found.index <= to;) if (media.lastIndex >= from && found.index <= to) rows.push({label: posterLabel(found[0]), raw: found[0]});
|
|
330
|
+
return rows;
|
|
87
331
|
};
|
|
332
|
+
let rest = after;
|
|
333
|
+
for (const row of [...retired].sort((a, b) => b.pos - a.pos)) rest = rest.slice(0, row.pos) + row.inserted + rest.slice(row.pos + row.removed.length);
|
|
334
|
+
const definitions = retired.flatMap(row => [...row.removed.matchAll(/data:image\/(?:gif|jxl);base64,[A-Za-z0-9+/]+={0,2}/gi)].map(found => ({label: posterLabel(found[0]), raw: found[0]})));
|
|
335
|
+
const kept = new Set(touched(after, start, endAfter).map(row => row.label));
|
|
336
|
+
const gone = new Set([...touched(before, start, endBefore), ...definitions].filter(row => !kept.has(row.label) && !rest.includes(row.raw)).map(row => row.label));
|
|
337
|
+
const rows = [];
|
|
338
|
+
for (const found of after.matchAll(/^\[(rapier-poster-v1-[0-9a-f]{64})\]:[^\r\n]*(?:\r?\n)?/gm))
|
|
339
|
+
if (gone.has(found[1])) rows.push({pos: found.index, removed: found[0], inserted: ''});
|
|
340
|
+
return rows.reverse();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// One host presentation adapter: existing image geometry remains the authority.
|
|
344
|
+
export function mountHtmlBlocks(doc, options = {}) {
|
|
345
|
+
if (!options.root && doc.rapierObjects) return doc.rapierObjects;
|
|
346
|
+
const root = options.root || doc.body;
|
|
347
|
+
const win = doc.defaultView, media = options.media || createMediaPlayback(win, inspectGIF), frameRuntime = options.frameRuntime || htmlFrameRuntime;
|
|
348
|
+
const imageInfo = options.imageInfo || (url => {const assets = win.RapierImageAssets; if (!assets?.decodeDataImageBytes) return null;
|
|
349
|
+
const {bytes, codec} = assets.decodeDataImageBytes(url); return {...(codec === 'image/jxl' ? assets.inspectJPEGXL(bytes) : {}), ...assets.imageDimensions(bytes, codec)};});
|
|
350
|
+
const heightOf = options.heightOf || objectHeight;
|
|
351
|
+
const owned = new Map(), local = new Map(), reduced = win.matchMedia('(prefers-reduced-motion: reduce)');
|
|
352
|
+
let pending = 0, dead = false, sequence = 0, noticed = false, identity = options.identity?.();
|
|
353
|
+
const pixel = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=';
|
|
354
|
+
// Each frame gets its own nonce; the page's script nonce never enters a preview.
|
|
355
|
+
const random = () => [...win.crypto.getRandomValues(new Uint8Array(16))].map(n => n.toString(16).padStart(2, '0')).join(''), prefix = random();
|
|
356
|
+
const sheet = doc.createElement('style');
|
|
357
|
+
sheet.textContent = 'img[data-rapier-live-surface]{filter:opacity(0)!important}@media print{[data-rapier-html-preview],[data-rapier-object-preview],[data-rapier-playback-control]{display:none!important}img[data-rapier-live-surface]{filter:none!important}}';
|
|
358
|
+
doc.head.append(sheet);
|
|
359
|
+
const schedule = () => {if (!dead && !pending) pending = win.requestAnimationFrame(update);};
|
|
360
|
+
const lineOf = image => {for (let p = image.parentElement; p; p = p.parentElement) {const n = parseFloat(win.getComputedStyle(p).lineHeight); if (n > 0) return n;} return 24;};
|
|
361
|
+
const keyOf = image => image.getAttribute('data-rapier-html') || image.getAttribute('data-rapier-animation') || '';
|
|
362
|
+
const message = (row, action) => row.frame?.contentWindow?.postMessage({type: 'rapier-object-command', id: row.id, action}, '*');
|
|
363
|
+
function applyPlayback(row) {
|
|
364
|
+
const stopped = row.paused || !row.visible || doc.hidden;
|
|
365
|
+
if (!row.visible || doc.hidden) {message(row, 'suspend'); row.player?.suspend();}
|
|
366
|
+
else if (stopped) {message(row, 'pause'); row.player?.pause();} else {message(row, 'play'); row.player?.play();}
|
|
367
|
+
if (row.button) {
|
|
368
|
+
row.button.setAttribute('aria-label', row.paused ? 'Play animation' : 'Pause animation');
|
|
369
|
+
row.button.innerHTML = '<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:.5">' +
|
|
370
|
+
(row.paused ? '<polygon points="5 3 19 12 5 21 5 3"/>' : '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>') + '</svg>';
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function setPlayback(row, playing) {
|
|
374
|
+
if (!row?.animated || !row.image.isConnected || owned.get(row.image) !== row) return {ok: false, reason: 'preview_unavailable'};
|
|
375
|
+
row.paused = !playing; local.set(row.key, row.paused);
|
|
376
|
+
if (local.size > 64) local.delete(local.keys().next().value);
|
|
377
|
+
applyPlayback(row); doc.dispatchEvent(new win.Event('rapier-playback')); return {ok: true, playing: !row.paused, visible: row.visible, status: row.image.dataset.rapierPlayback || 'ready'};
|
|
378
|
+
}
|
|
379
|
+
function control(row) {
|
|
380
|
+
if (row.button || !row.animated) return;
|
|
381
|
+
const button = doc.createElement('button'); button.type = 'button'; button.dataset.rapierPlaybackControl = '';
|
|
382
|
+
button.style.cssText = 'position:absolute;width:44px;height:44px;display:grid;place-items:center;padding:0;border:0;border-radius:0;background:transparent;color:inherit;cursor:pointer';
|
|
383
|
+
button.addEventListener('click', event => {event.preventDefault(); event.stopPropagation(); if (event.isTrusted) setPlayback(row, row.paused);});
|
|
384
|
+
row.button = button; row.parent.append(button); applyPlayback(row); schedule(); doc.dispatchEvent(new win.Event('rapier-playback'));
|
|
385
|
+
}
|
|
386
|
+
function release(row) {
|
|
387
|
+
row.generation++; row.player?.close(); row.frame?.remove(); row.surface?.remove(); row.button?.remove();
|
|
388
|
+
row.image.removeAttribute('data-rapier-live-surface');
|
|
389
|
+
resize.unobserve(row.image); visibility?.unobserve(row.image); owned.delete(row.image);
|
|
390
|
+
}
|
|
391
|
+
function update() {
|
|
392
|
+
pending = 0;
|
|
393
|
+
for (const row of owned.values()) {
|
|
394
|
+
const image = row.image;
|
|
395
|
+
if (!image.isConnected || keyOf(image) !== row.key) {release(row); continue;}
|
|
396
|
+
const box = image.getBoundingClientRect(), parent = row.surface.offsetParent || row.parent, origin = parent.getBoundingClientRect();
|
|
397
|
+
const css = win.getComputedStyle(image), width = image.offsetWidth, height = image.offsetHeight;
|
|
398
|
+
const left = box.left + box.width / 2 - width / 2 - origin.left + parent.scrollLeft - parent.clientLeft;
|
|
399
|
+
const top = box.top + box.height / 2 - height / 2 - origin.top + parent.scrollTop - parent.clientTop;
|
|
400
|
+
Object.assign(row.surface.style, {left: left + 'px', top: top + 'px', width: width + 'px', height: height + 'px',
|
|
401
|
+
transform: css.transform, transformOrigin: '50% 50%', opacity: css.opacity, zIndex: String((parseInt(css.zIndex) || 0) + 1)});
|
|
402
|
+
row.surface.hidden = !width || !height || row.player && row.player.state.status !== 'ready';
|
|
403
|
+
row.surface.style.opacity = row.frame && !row.snapshot ? '0' : css.opacity;
|
|
404
|
+
if (row.frame) {
|
|
405
|
+
const view = (row.attrs.viewport || '640x360').split('x').map(Number), scale = row.attrs.resize === 'scale';
|
|
406
|
+
const iw = scale ? view[0] : Math.max(1, width), ih = scale ? view[1] : Math.max(1, height);
|
|
407
|
+
const viewport = iw + 'x' + ih;
|
|
408
|
+
Object.assign(row.frame.style, {width: iw + 'px', height: ih + 'px', transform: scale ? 'scale(' + width / iw + ')' : 'none'});
|
|
409
|
+
if (row.viewport !== viewport) {row.viewport = viewport; row.generation++; message(row, 'snapshot');}
|
|
410
|
+
const auto = !row.attrs.height || row.attrs.height === 'auto';
|
|
411
|
+
const desired = heightOf(width, row.measured, row.attrs, lineOf(image));
|
|
412
|
+
if (desired > 0 && Math.abs(height - desired) > 1) {
|
|
413
|
+
image.style.height = Math.min(4096, desired) + 'px'; image.dataset.rapierNaturalHeight = String(desired);
|
|
414
|
+
image.dispatchEvent(new win.Event('rapier-html-resize', {bubbles: true}));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// The control stacks just above its own object, never over the editor's toolbars, menus or notices.
|
|
418
|
+
if (row.button) {row.button.hidden = !width || !height; row.button.style.zIndex = String((parseInt(css.zIndex) || 0) + 2); row.button.style.left = box.right - origin.left + parent.scrollLeft - parent.clientLeft - 44 + 'px'; row.button.style.top = box.top - origin.top + parent.scrollTop - parent.clientTop + 'px';}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
88
421
|
const resize = new win.ResizeObserver(schedule);
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
422
|
+
const visibility = win.IntersectionObserver ? new win.IntersectionObserver(entries => {for (const entry of entries) {const row = owned.get(entry.target); if (row) {row.visible = entry.isIntersecting; applyPlayback(row);}}}, {rootMargin: '0px'}) : null;
|
|
423
|
+
function sanitize(body, admitted = {}) {
|
|
424
|
+
const template = doc.createElement('template'); template.innerHTML = body;
|
|
425
|
+
const allowed = new Set(HTML_ELEMENTS);
|
|
426
|
+
let count = 0, pixels = 0;
|
|
427
|
+
const css = value => {
|
|
428
|
+
if (value.length > 262144) throw new Error('CSS byte budget');
|
|
429
|
+
const decoded = value.replace(/\\([0-9a-f]{1,6})\s?|\\([^\r\n])/gi, (_, hex, char) => hex ? String.fromCodePoint(Math.min(0x10ffff, parseInt(hex,16))) : char);
|
|
430
|
+
if (value.includes('\\') && /(?:url|image-set|image|src)\s*\(|@import/i.test(decoded)) throw new Error('Escaped CSS resource is unsupported');
|
|
431
|
+
if (/(?:image-set|image|src)\s*\(/i.test(decoded)) throw new Error('CSS image resources require an image element');
|
|
432
|
+
// A space keeps the token boundary: removing an import must not join image-@import x;set( into a new function.
|
|
433
|
+
return value.replace(/@import(?![\w-])[^;{}]*;?/gi, ' ').replace(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi, (_, a,b,c) => {
|
|
434
|
+
const url = (a || b || c || '').trim();
|
|
435
|
+
if (/^#[A-Za-z][\w.-]*$/.test(url)) return 'url("' + url + '")';
|
|
436
|
+
if (/^data:(?:font\/[\w-]+|application\/(?:font-woff|vnd.ms-fontobject|octet-stream));base64,[A-Za-z0-9+/=]+$/i.test(url)) {
|
|
437
|
+
if (url.length > 1400000 || !['\u0000\u0001\u0000\u0000','OTTO','wOFF','wOF2'].includes(win.atob(url.slice(url.indexOf(',') + 1)).slice(0,4))) throw new Error('Invalid embedded font container');
|
|
438
|
+
return 'url("' + url + '")';
|
|
439
|
+
}
|
|
440
|
+
if (/^data:/i.test(url)) throw new Error('CSS image resources require an image element');
|
|
441
|
+
return 'url()';
|
|
442
|
+
});
|
|
443
|
+
};
|
|
95
444
|
const walk = root => {
|
|
96
445
|
for (const el of [...root.children]) {
|
|
446
|
+
if (++count > 512) throw new Error('HTML node budget exceeded');
|
|
97
447
|
const tag = el.localName.toLowerCase();
|
|
98
448
|
if (!allowed.has(tag)) {el.remove(); continue;}
|
|
449
|
+
if (tag === 'style') el.textContent = css(el.textContent);
|
|
450
|
+
if (el.hasAttribute('style')) el.setAttribute('style', css(el.getAttribute('style')));
|
|
99
451
|
for (const attr of [...el.attributes]) {
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
|
|
452
|
+
const name = attr.name.toLowerCase(), value = attr.value;
|
|
453
|
+
if (name.startsWith('on') || name.startsWith('data-rapier-') || ['nonce','srcdoc','is','autofocus','form','formaction','action','target','download','ping','xmlns:xlink'].includes(name)) el.removeAttribute(attr.name);
|
|
454
|
+
// The local name: an SVG parsed as XML may bind XLink to any prefix (x:href).
|
|
455
|
+
if (['src','srcset','href'].includes(attr.localName.toLowerCase()) && !(name.endsWith('href') && /^#[A-Za-z][\w.-]*$/.test(value) ||
|
|
456
|
+
(name === 'src' && tag === 'img' || name.endsWith('href') && tag === 'image') && /^data:image\/(?:png|jpeg|webp|gif|jxl|svg\+xml);/i.test(value))) el.removeAttribute(attr.name);
|
|
457
|
+
}
|
|
458
|
+
if (tag.startsWith('animate') || tag === 'set') {
|
|
459
|
+
const attribute = (el.getAttribute('attributeName') || '').trim().toLowerCase().split(':').pop();
|
|
460
|
+
if (/^(?:href|base|on.*|style|src)$/.test(attribute)) {el.remove(); continue;}
|
|
461
|
+
// Event-triggered timelines have no interactive authority in this preview.
|
|
462
|
+
if (/[a-z_]\w*\./i.test(el.getAttribute('begin') || '')) el.setAttribute('begin', '0s');
|
|
103
463
|
}
|
|
104
464
|
if (tag === 'button') el.setAttribute('type', 'button');
|
|
465
|
+
if (tag === 'img' || tag === 'image') {
|
|
466
|
+
const name = tag === 'img' ? 'src' : el.hasAttribute('href') ? 'href' : 'xlink:href', original = el.getAttribute(name);
|
|
467
|
+
if (original) {
|
|
468
|
+
const match = /^data:(image\/(?:png|jpeg|webp|gif|jxl|svg\+xml));(?:charset=[^;,]+;)?base64,([A-Za-z0-9+/]+={0,2})$/i.exec(original);
|
|
469
|
+
if (!match || original.length > 24 * 1024 * 1024) throw new Error('Image requires a bounded base64 data URL');
|
|
470
|
+
const url = 'data:' + match[1].toLowerCase() + ';base64,' + match[2];
|
|
471
|
+
const bytes = Uint8Array.from(win.atob(match[2]), c => c.charCodeAt(0));
|
|
472
|
+
const info = match[1].toLowerCase() === 'image/gif' ? media.inspectGIF(bytes) : imageInfo(url) || admitted[url];
|
|
473
|
+
if (!info && match[1].toLowerCase() !== 'image/svg+xml') {el.removeAttribute(name); continue;}
|
|
474
|
+
if (info) {pixels += info.width * info.height; if (pixels > 24000000) throw new Error('HTML image pixel budget'); admitted[url] = {width: info.width, height: info.height, animated: !!info.animated};}
|
|
475
|
+
if (match[1].toLowerCase() === 'image/svg+xml') {
|
|
476
|
+
const svgText = new win.TextDecoder('utf-8', {fatal: true}).decode(bytes);
|
|
477
|
+
const parsed = new win.DOMParser().parseFromString(svgText, 'image/svg+xml');
|
|
478
|
+
if (parsed.querySelector('parsererror') || parsed.documentElement.localName !== 'svg') throw new Error('Invalid SVG image');
|
|
479
|
+
const svg = doc.importNode(parsed.documentElement, true);
|
|
480
|
+
if (el.getAttribute('style')) svg.setAttribute('style', el.getAttribute('style'));
|
|
481
|
+
for (const key of ['width','height','class','id']) if (el.hasAttribute(key)) svg.setAttribute(key, el.getAttribute(key));
|
|
482
|
+
const holder = doc.createElement('div'); holder.append(svg); walk(holder); el.replaceWith(svg); continue;
|
|
483
|
+
}
|
|
484
|
+
el.setAttribute(name, info.first ? 'data:image/gif;base64,' + media.encode(info.first) : url);
|
|
485
|
+
if (info.animated) {
|
|
486
|
+
if (tag !== 'img') throw new Error('Animated SVG image element is unsupported');
|
|
487
|
+
el.dataset.rapierAnimation = url; el.dataset.rapierMediaWidth = info.width; el.dataset.rapierMediaHeight = info.height;
|
|
488
|
+
if (info.first) el.dataset.rapierFirstFrame = el.src; else el.src = pixel;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
105
492
|
walk(el);
|
|
106
493
|
}
|
|
107
494
|
};
|
|
108
|
-
walk(template.content);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
495
|
+
walk(template.content); return template.innerHTML;
|
|
496
|
+
}
|
|
497
|
+
function install(image) {
|
|
498
|
+
const key = keyOf(image); if (!key || key.length > 24 * 1024 * 1024) return;
|
|
499
|
+
const existing = owned.get(image); if (existing?.key === key) return; if (existing) release(existing);
|
|
500
|
+
if (owned.size >= 64 || [...owned.values()].reduce((sum, row) => sum + row.key.length, key.length) > 32 * 1024 * 1024) {
|
|
501
|
+
if (!noticed) {noticed = true; options.notice?.(owned.size >= 64 ? 'Only 64 objects play at once; the rest show their box' : 'Objects past 32 MB show their box');}
|
|
502
|
+
image.dataset.rapierContour = 'budget-box'; return;
|
|
503
|
+
}
|
|
504
|
+
const parent = image.closest('.block-read') || image.parentElement?.parentElement; if (!parent) return;
|
|
505
|
+
const row = {image, key, parent, id: prefix + '-' + ++sequence, generation: 0, attrs: {}, visible: !visibility, paused: local.get(key) ?? reduced.matches};
|
|
506
|
+
try {
|
|
507
|
+
if (image.hasAttribute('data-rapier-html') || /^data:image\/svg\+xml;/i.test(key)) {
|
|
508
|
+
// A body over 1 MiB is refused below; refusing its base64 first keeps every synchronous decode that small.
|
|
509
|
+
if (!image.hasAttribute('data-rapier-html') && key.length > 1398200) return;
|
|
510
|
+
const value = image.hasAttribute('data-rapier-html') ? JSON.parse(decodeURIComponent(key)) : {
|
|
511
|
+
body: new win.TextDecoder('utf-8', {fatal: true}).decode(Uint8Array.from(win.atob(key.slice(key.indexOf(',') + 1)), c => c.charCodeAt(0))),
|
|
512
|
+
attrs: {resize: 'scale', viewport: (image.dataset.rapierNaturalWidth || image.naturalWidth || 640) + 'x' + (image.dataset.rapierNaturalHeight || image.naturalHeight || 360)}};
|
|
513
|
+
if (typeof value.body !== 'string' || value.body.length > 1048576) return;
|
|
514
|
+
row.attrs = value.attrs || {}; const admitted = value.media || {}; const body = sanitize(value.body, admitted); row.media = admitted;
|
|
515
|
+
const viewport = (row.attrs.viewport || '640x360').split('x').map(Number);
|
|
516
|
+
row.pixels = viewport[0] * viewport[1] + Object.values(admitted).reduce((sum, size) => sum + size.width * size.height, 0);
|
|
517
|
+
if (!Number.isFinite(row.pixels) || [...owned.values()].reduce((sum, item) => sum + (item.pixels || 0), row.pixels) > 24000000) throw new Error('Object surface pixel budget');
|
|
518
|
+
const surface = doc.createElement('div'), frame = doc.createElement('iframe'); row.surface = surface; row.frame = frame;
|
|
519
|
+
frame.dataset.rapierHtmlPreview = '';
|
|
520
|
+
surface.dataset.rapierHtmlPreview = ''; surface.style.cssText = 'position:absolute;pointer-events:none;overflow:hidden;margin:0;padding:0;background:transparent';
|
|
521
|
+
frame.title = value.attrs?.id || 'HTML block'; frame.tabIndex = -1; frame.setAttribute('sandbox', 'allow-scripts'); frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
522
|
+
frame.setAttribute('allow', "camera 'none'; microphone 'none'; geolocation 'none'; clipboard-read 'none'; clipboard-write 'none'");
|
|
523
|
+
frame.style.cssText = 'display:block;border:0;margin:0;padding:0;pointer-events:none;background:transparent;max-width:none;transform-origin:0 0';
|
|
524
|
+
const frameNonce = random(), policy = "default-src 'none'; connect-src 'none'; img-src data:; font-src data:; media-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-" + frameNonce + "'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; worker-src 'none'";
|
|
525
|
+
const script = '(' + frameRuntime.toString() + ')(' + JSON.stringify({id: row.id}) + ',' + media.source + ',window);';
|
|
526
|
+
frame.srcdoc = '<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="' + policy + '"><meta name="referrer" content="no-referrer"><style>html{color-scheme:light dark;overflow:hidden}body{margin:0;display:flow-root;overflow-wrap:anywhere}*{box-sizing:border-box}img,svg{max-width:100%}</style></head><body>' + body + '<script nonce="' + frameNonce + '">' + script.replace(/<\/script/gi, '<\\/script') + '</script></body></html>';
|
|
527
|
+
surface.append(frame); parent.append(surface);
|
|
528
|
+
image.dataset.rapierContour = 'pending-box';
|
|
529
|
+
} else {
|
|
530
|
+
const canvas = doc.createElement('canvas'); row.surface = canvas; canvas.dataset.rapierObjectPreview = '';
|
|
531
|
+
canvas.style.cssText = 'position:absolute;pointer-events:none;margin:0;padding:0;max-width:none'; parent.append(canvas);
|
|
532
|
+
row.player = media.raster(key, canvas, state => {
|
|
533
|
+
if (owned.get(image) !== row) return;
|
|
534
|
+
image.dataset.rapierPlayback = state.status;
|
|
535
|
+
if (state.status === 'ready') image.dataset.rapierLiveSurface = ''; else image.removeAttribute('data-rapier-live-surface');
|
|
536
|
+
if (state.first) {image.dataset.rapierFirstFrame = state.first; if (image.src !== state.first) image.src = state.first;}
|
|
537
|
+
row.animated = state.animated; control(row); schedule();
|
|
538
|
+
}, {width: +(image.dataset.rapierNaturalWidth || image.naturalWidth), height: +(image.dataset.rapierNaturalHeight || image.naturalHeight)});
|
|
539
|
+
row.animated = row.player.state.animated; image.dataset.rapierContour = row.animated ? 'animation-box' : 'sampled';
|
|
137
540
|
}
|
|
541
|
+
owned.set(image, row); resize.observe(image); visibility?.observe(image); control(row); applyPlayback(row); schedule();
|
|
542
|
+
if (options.source) void (async () => {
|
|
543
|
+
const source = options.source(), label = await objectPosterLabel(image), assets = win.RapierImageAssets;
|
|
544
|
+
const index = assets.documentAssets(source), poster = index.assets.get(assets.normalizeLabel(label));
|
|
545
|
+
const match = /^object-poster-v1 (sampled|animation-box|unavailable-box) ([1-9]\d*)x([1-9]\d*)$/.exec(poster?.title || '');
|
|
546
|
+
if (!poster || poster.codec !== 'image/png' || !match || index.duplicateLabels.has?.(assets.normalizeLabel(label))) return;
|
|
547
|
+
const {bytes, codec} = assets.decodeDataImageBytes(poster.url), size = assets.imageDimensions(bytes, codec);
|
|
548
|
+
if (size.width !== +match[2] || size.height !== +match[3] || size.width * size.height > 1048576 || owned.get(image) !== row || keyOf(image) !== key || options.source() !== source || row.snapshot) return;
|
|
549
|
+
image.src = poster.url; image.dataset.rapierFirstFrame = poster.url;
|
|
550
|
+
if (image.hasAttribute('data-rapier-html')) {row.measured = image.offsetWidth * size.height / size.width; schedule();}
|
|
551
|
+
})().catch(() => {});
|
|
552
|
+
} catch (error) {row.player?.close(); row.surface?.remove(); image.dataset.rapierContour = 'unavailable-box'; image.dataset.rapierPlayback = 'admission-failed';}
|
|
553
|
+
}
|
|
554
|
+
const scan = () => {if (!dead) {
|
|
555
|
+
if (options.identity && options.identity() !== identity) {identity = options.identity(); local.clear(); for (const row of [...owned.values()]) release(row);}
|
|
556
|
+
for (const row of [...owned.values()]) if (!row.image.isConnected || keyOf(row.image) !== row.key) release(row);
|
|
557
|
+
for (const image of root.querySelectorAll('img[data-rapier-html],img[data-rapier-animation]')) install(image); schedule();}};
|
|
558
|
+
async function receive(event) {
|
|
559
|
+
const value = event.data;
|
|
560
|
+
if (value?.type !== 'rapier-object-state' || typeof value.id !== 'string') return;
|
|
561
|
+
const row = [...owned.values()].find(item => item.id === value.id && item.frame?.contentWindow === event.source);
|
|
562
|
+
if (!row || keyOf(row.image) !== row.key || !row.image.isConnected) return;
|
|
563
|
+
if (Number.isFinite(value.height) && value.height > 0 && value.height <= 4096) {
|
|
564
|
+
// A box whose own media queries answer its height turns back and forth forever: after a few turns it only grows.
|
|
565
|
+
const turn = value.height > row.measured ? 1 : value.height < row.measured ? -1 : 0;
|
|
566
|
+
if (turn && row.turn && turn !== row.turn) row.turns = (row.turns || 0) + 1;
|
|
567
|
+
if (turn) row.turn = turn;
|
|
568
|
+
if (turn >= 0 || !(row.turns > 4)) {row.measured = value.height; schedule();}
|
|
138
569
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
570
|
+
if (typeof value.animated === 'boolean') {row.animated = value.animated; control(row); applyPlayback(row);}
|
|
571
|
+
if (typeof value.mediaStatus === 'string') row.image.dataset.rapierPlayback = value.mediaStatus.slice(0, 48);
|
|
572
|
+
if (typeof value.snapshotStatus === 'string') row.image.dataset.rapierSnapshot = value.snapshotStatus.slice(0, 48);
|
|
573
|
+
if (value.snapshotStatus === 'unavailable') row.image.dataset.rapierContour = 'unavailable-box';
|
|
574
|
+
if (typeof value.markup !== 'string' || value.markup.length > 2097152 || !Number.isFinite(value.width) || !Number.isFinite(value.viewportHeight) ||
|
|
575
|
+
value.width < 1 || value.viewportHeight < 1 || value.width > 4096 || value.viewportHeight > 4096) return;
|
|
576
|
+
if (Math.abs(row.frame.clientWidth - value.width) > 1 || Math.abs(row.frame.clientHeight - value.viewportHeight) > 1) {message(row, 'snapshot'); return;}
|
|
577
|
+
const stamp = ++row.generation, scale = Math.min(1, 1024 / Math.max(value.width, value.viewportHeight));
|
|
578
|
+
const canvas = doc.createElement('canvas'); canvas.width = Math.max(1, Math.ceil(value.width * scale)); canvas.height = Math.max(1, Math.ceil(value.viewportHeight * scale));
|
|
579
|
+
const snapshot = new win.Image();
|
|
580
|
+
try {
|
|
581
|
+
snapshot.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="' + canvas.width + '" height="' + canvas.height + '" viewBox="0 0 ' + value.width + ' ' + value.viewportHeight + '"><foreignObject width="100%" height="100%">' + value.markup + '</foreignObject></svg>');
|
|
582
|
+
await Promise.race([snapshot.decode(), new Promise((_, reject) => win.setTimeout(() => reject(new Error('Snapshot decode timeout')), 2000))]);
|
|
583
|
+
if (dead || owned.get(row.image) !== row || stamp !== row.generation || keyOf(row.image) !== row.key) return;
|
|
584
|
+
if (Math.abs(row.frame.clientWidth - value.width) > 1 || Math.abs(row.frame.clientHeight - value.viewportHeight) > 1) {message(row, 'snapshot'); return;}
|
|
585
|
+
const ctx = canvas.getContext('2d'); ctx.drawImage(snapshot, 0, 0); ctx.getImageData(0, 0, 1, 1);
|
|
586
|
+
const first = canvas.toDataURL('image/png'); if (first.length > 4 * 1024 * 1024) throw new Error('Snapshot byte budget');
|
|
587
|
+
row.image.dataset.rapierFirstFrame = first; row.image.src = first;
|
|
588
|
+
row.snapshot = true;
|
|
589
|
+
row.image.dataset.rapierLiveSurface = '';
|
|
590
|
+
row.image.dataset.rapierContour = row.animated ? 'animation-box' : value.snapshotStatus === 'sampled' ? 'sampled' : 'unavailable-box';
|
|
591
|
+
row.image.dataset.rapierNaturalWidth = String(value.width); row.image.dataset.rapierNaturalHeight = String(value.viewportHeight);
|
|
592
|
+
row.image.dispatchEvent(new win.Event('rapier-html-resize', {bubbles: true})); schedule();
|
|
593
|
+
} catch {if (owned.get(row.image) === row) {row.image.dataset.rapierContour = 'unavailable-box'; row.image.dataset.rapierSnapshot = 'unavailable';}}
|
|
594
|
+
finally {snapshot.removeAttribute('src'); canvas.width = canvas.height = 0;}
|
|
595
|
+
}
|
|
596
|
+
const changedVisibility = () => {for (const row of owned.values()) applyPlayback(row);};
|
|
597
|
+
const hidden = event => {if (!event.persisted) {api.close(); return;} for (const row of owned.values()) {row.visible = false; applyPlayback(row);}};
|
|
598
|
+
const shown = () => {scan(); for (const row of owned.values()) {const box = row.image.getBoundingClientRect();
|
|
599
|
+
row.visible = box.bottom > 0 && box.top < win.innerHeight && box.right > 0 && box.left < win.innerWidth; applyPlayback(row);}};
|
|
600
|
+
const changedMotion = () => {if (reduced.matches) for (const row of owned.values()) {row.paused = true; applyPlayback(row);}};
|
|
601
|
+
const observer = new win.MutationObserver(records => {if (records.some(record => record.type === 'childList' || record.target.matches?.('img[data-rapier-html],img[data-rapier-animation]'))) scan();});
|
|
602
|
+
observer.observe(root, {subtree: true, childList: true, attributes: true, attributeFilter: ['data-rapier-html','data-rapier-animation','style']});
|
|
603
|
+
win.addEventListener('message', receive); win.addEventListener('resize', schedule); doc.addEventListener('scroll', schedule, true);
|
|
604
|
+
doc.addEventListener('visibilitychange', changedVisibility); reduced.addEventListener('change', changedMotion);
|
|
605
|
+
const api = {playback(image, playing) {return setPlayback(owned.get(image), playing);},
|
|
606
|
+
async first(image) {const row = owned.get(image); if (row?.player) {const first = await row.player.first(); if (first) image.dataset.rapierFirstFrame = first;} return image.dataset.rapierFirstFrame || '';},
|
|
607
|
+
media(image) {return owned.get(image)?.media || {};},
|
|
608
|
+
reset() {local.clear(); for (const row of [...owned.values()]) release(row); scan();},
|
|
609
|
+
inspect(image) {const row = owned.get(image); return row ? {animated: !!row.animated, paused: row.paused, visible: row.visible,
|
|
610
|
+
snapshot: image.dataset.rapierSnapshot || null, contour: image.dataset.rapierContour || null, status: image.dataset.rapierPlayback || 'ready'} : null;},
|
|
611
|
+
close() {dead = true; win.cancelAnimationFrame(pending); observer.disconnect(); resize.disconnect(); visibility?.disconnect();
|
|
612
|
+
win.removeEventListener('message', receive); win.removeEventListener('resize', schedule); doc.removeEventListener('scroll', schedule, true);
|
|
613
|
+
doc.removeEventListener('visibilitychange', changedVisibility); reduced.removeEventListener('change', changedMotion);
|
|
614
|
+
for (const row of [...owned.values()]) release(row); sheet.remove(); local.clear();
|
|
615
|
+
win.removeEventListener('pagehide', hidden); win.removeEventListener('pageshow', shown); if (doc.rapierObjects === api) delete doc.rapierObjects;}};
|
|
616
|
+
win.addEventListener('pagehide', hidden); win.addEventListener('pageshow', shown); if (!options.root) doc.rapierObjects = api; scan(); return api;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Cold export uses the same opaque renderer, independently of the visible editor
|
|
620
|
+
// and its caches. The caller owns original bytes; only detached display nodes change.
|
|
621
|
+
export async function materializeObjects(root, {signal, width = 640, imageInfo, poster, bestEffort = false, issues = []} = {}) {
|
|
622
|
+
const doc = root.ownerDocument, win = doc.defaultView;
|
|
623
|
+
const images = [...root.querySelectorAll('img[data-rapier-html],img[data-rapier-animation]')];
|
|
624
|
+
if (images.length > 64) throw new Error('Object export count budget');
|
|
625
|
+
for (const image of images) {
|
|
626
|
+
signal?.throwIfAborted();
|
|
627
|
+
const held = await poster?.(image);
|
|
628
|
+
const host = doc.createElement('div'); host.style.cssText = 'position:fixed;left:-10000px;top:0;width:' + Math.max(1, Math.min(4096, width)) + 'px;line-height:24px;font-size:16px;pointer-events:none;color-scheme:light';
|
|
629
|
+
const paragraph = doc.createElement('p'), clone = image.cloneNode(true); paragraph.style.margin = '0';
|
|
630
|
+
clone.removeAttribute('data-rapier-live-surface'); clone.style.transform = 'none'; clone.style.opacity = '1';
|
|
631
|
+
paragraph.append(clone); host.append(paragraph); doc.body.append(host);
|
|
632
|
+
const api = mountHtmlBlocks(doc, {root: host, imageInfo});
|
|
633
|
+
for (const frame of host.querySelectorAll('iframe')) frame.style.colorScheme = 'light';
|
|
634
|
+
try {
|
|
635
|
+
if (image.hasAttribute('data-rapier-html')) {
|
|
636
|
+
const value = JSON.parse(decodeURIComponent(image.dataset.rapierHtml)); value.media = api.media(clone);
|
|
637
|
+
image.dataset.rapierHtml = encodeURIComponent(JSON.stringify(value));
|
|
638
|
+
}
|
|
639
|
+
if (held) {
|
|
640
|
+
image.src = held.url; image.dataset.rapierFirstFrame = held.url; image.dataset.rapierContour = held.contour || 'unverified-box';
|
|
641
|
+
if (image.hasAttribute('data-rapier-html')) {
|
|
642
|
+
const attrs = JSON.parse(decodeURIComponent(image.dataset.rapierHtml)).attrs;
|
|
643
|
+
image.style.height = objectHeight(clone.offsetWidth, clone.offsetWidth * held.height / held.width, attrs, 24) + 'px';
|
|
644
|
+
}
|
|
645
|
+
image.dataset.rapierSnapshot = 'sampled'; continue;
|
|
646
|
+
}
|
|
647
|
+
await api.first(clone);
|
|
648
|
+
const started = win.performance.now();
|
|
649
|
+
// A block that takes its content's height is captured at that height: a frame taken before it settled shows only its top.
|
|
650
|
+
const settles = image.hasAttribute('data-rapier-html') && (() => {const attrs = JSON.parse(decodeURIComponent(image.dataset.rapierHtml)).attrs || {};
|
|
651
|
+
return (!attrs.height || attrs.height === 'auto') && attrs.resize !== 'scale';})();
|
|
652
|
+
const first = new win.Image();
|
|
653
|
+
try {
|
|
654
|
+
for (;;) {
|
|
655
|
+
while (!clone.dataset.rapierFirstFrame) {
|
|
656
|
+
signal?.throwIfAborted();
|
|
657
|
+
if (clone.dataset.rapierSnapshot === 'unavailable' || clone.dataset.rapierPlayback === 'admission-failed' || win.performance.now() - started > 5000)
|
|
658
|
+
throw Object.assign(new Error('A required first-frame image is unavailable; original source is unchanged.'), {code: 'OBJECT_SNAPSHOT_UNAVAILABLE'});
|
|
659
|
+
await new Promise(resolve => win.setTimeout(resolve, 20));
|
|
660
|
+
}
|
|
661
|
+
if (clone.dataset.rapierSnapshot === 'unsupported-motion') throw Object.assign(new Error('This SVG motion cannot be frozen by this renderer.'), {code: 'OBJECT_SNAPSHOT_UNSUPPORTED'});
|
|
662
|
+
const frame = clone.dataset.rapierFirstFrame; first.src = frame;
|
|
663
|
+
await first.decode(); signal?.throwIfAborted();
|
|
664
|
+
if (!settles || !first.naturalWidth || Math.abs(clone.offsetWidth * first.naturalHeight / first.naturalWidth - clone.offsetHeight) <= 2) break;
|
|
665
|
+
// The next snapshot follows the settled viewport; wait for it.
|
|
666
|
+
if (clone.dataset.rapierFirstFrame === frame) delete clone.dataset.rapierFirstFrame;
|
|
667
|
+
}
|
|
668
|
+
if (!first.naturalWidth || !first.naturalHeight || first.naturalWidth * first.naturalHeight > 24000000) throw new Error('First-frame image budget');
|
|
669
|
+
const scale = Math.min(1, 1024 / Math.max(first.naturalWidth, first.naturalHeight)), canvas = doc.createElement('canvas');
|
|
670
|
+
canvas.width = Math.ceil(first.naturalWidth * scale); canvas.height = Math.ceil(first.naturalHeight * scale);
|
|
671
|
+
try {canvas.getContext('2d').drawImage(first, 0, 0, canvas.width, canvas.height); image.src = canvas.toDataURL('image/png');}
|
|
672
|
+
finally {canvas.width = canvas.height = 0;}
|
|
673
|
+
} finally {first.removeAttribute('src');}
|
|
674
|
+
image.dataset.rapierFirstFrame = image.src;
|
|
675
|
+
if (image.hasAttribute('data-rapier-html')) image.style.height = clone.style.height;
|
|
676
|
+
image.dataset.rapierContour = clone.dataset.rapierContour || 'animation-box';
|
|
677
|
+
image.dataset.rapierSnapshot = clone.dataset.rapierSnapshot || 'sampled';
|
|
678
|
+
} catch (error) {signal?.throwIfAborted(); if (!bestEffort) throw error; issues.push({code: error.code || 'OBJECT_SNAPSHOT_UNAVAILABLE', message: error.message});}
|
|
679
|
+
finally {api.close(); host.remove();}
|
|
680
|
+
}
|
|
681
|
+
return root;
|
|
143
682
|
}
|