@rasamjs/core 0.1.2 → 0.2.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/API.md +15 -5
- package/CHANGELOG.md +39 -1
- package/README.md +140 -20
- package/RELEASING.md +29 -21
- package/dist/changes/service.d.ts +5 -2
- package/dist/changes/service.d.ts.map +1 -1
- package/dist/changes/service.js +29 -4
- package/dist/changes/service.js.map +1 -1
- package/dist/changes/types.d.ts +9 -0
- package/dist/changes/types.d.ts.map +1 -1
- package/dist/changes/types.js.map +1 -1
- package/dist/changes.d.ts +5 -3
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +103 -2
- package/dist/changes.js.map +1 -1
- package/dist/dom/images.d.ts.map +1 -1
- package/dist/dom/images.js +3 -1
- package/dist/dom/images.js.map +1 -1
- package/dist/elements/image.d.ts.map +1 -1
- package/dist/elements/image.js +4 -1
- package/dist/elements/image.js.map +1 -1
- package/dist/interchange/clone.js +1 -1
- package/dist/interchange/clone.js.map +1 -1
- package/dist/interchange/dom.d.ts +3 -1
- package/dist/interchange/dom.d.ts.map +1 -1
- package/dist/interchange/dom.js +29 -5
- package/dist/interchange/dom.js.map +1 -1
- package/dist/interchange/markup.d.ts.map +1 -1
- package/dist/interchange/markup.js +71 -3
- package/dist/interchange/markup.js.map +1 -1
- package/dist/interchange/policy.d.ts.map +1 -1
- package/dist/interchange/policy.js +4 -0
- package/dist/interchange/policy.js.map +1 -1
- package/dist/interchange/types.d.ts +10 -1
- package/dist/interchange/types.d.ts.map +1 -1
- package/dist/mixins/transformable.d.ts.map +1 -1
- package/dist/mixins/transformable.js +25 -27
- package/dist/mixins/transformable.js.map +1 -1
- package/dist/raster/frame.d.ts +12 -0
- package/dist/raster/frame.d.ts.map +1 -0
- package/dist/raster/frame.js +43 -0
- package/dist/raster/frame.js.map +1 -0
- package/dist/raster/images.d.ts +2 -0
- package/dist/raster/images.d.ts.map +1 -0
- package/dist/raster/images.js +60 -0
- package/dist/raster/images.js.map +1 -0
- package/dist/raster/render.d.ts +3 -0
- package/dist/raster/render.d.ts.map +1 -0
- package/dist/raster/render.js +40 -0
- package/dist/raster/render.js.map +1 -0
- package/dist/raster/types.d.ts +10 -0
- package/dist/raster/types.d.ts.map +1 -0
- package/dist/raster/types.js +2 -0
- package/dist/raster/types.js.map +1 -0
- package/dist/raster.d.ts +6 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js +10 -0
- package/dist/raster.js.map +1 -0
- package/dist/resources/references.d.ts.map +1 -1
- package/dist/resources/references.js +3 -1
- package/dist/resources/references.js.map +1 -1
- package/dist/services/viewport.d.ts +2 -0
- package/dist/services/viewport.d.ts.map +1 -1
- package/dist/services/viewport.js +4 -3
- package/dist/services/viewport.js.map +1 -1
- package/dist/text/content.d.ts +0 -2
- package/dist/text/content.d.ts.map +1 -1
- package/dist/text/content.js +4 -10
- package/dist/text/content.js.map +1 -1
- package/dist/text/policies.d.ts +7 -0
- package/dist/text/policies.d.ts.map +1 -0
- package/dist/text/policies.js +13 -0
- package/dist/text/policies.js.map +1 -0
- package/package.json +6 -1
- package/reports/adoption.json +20 -20
- package/reports/animation-benchmark.json +617 -617
- package/reports/benchmark.json +44 -44
- package/reports/creation.json +26 -26
- package/reports/interchange-benchmark.json +85 -85
- package/reports/public-api-contract.json +20 -7
- package/reports/public-api-review.md +23 -0
- package/reports/size-parity.json +54 -19
- package/reports/size.json +2 -2
- package/reports/svgjs-comparison.json +3464 -3464
- package/reports/svgjs-comparison.md +19 -19
- package/reports/svgjs-parity-current.json +58 -34
- package/src/changes/service.ts +39 -5
- package/src/changes/types.ts +9 -0
- package/src/changes.ts +93 -4
- package/src/dom/images.ts +2 -1
- package/src/elements/image.ts +4 -1
- package/src/interchange/clone.ts +1 -1
- package/src/interchange/dom.ts +34 -5
- package/src/interchange/markup.ts +77 -3
- package/src/interchange/policy.ts +6 -0
- package/src/interchange/types.ts +10 -1
- package/src/mixins/transformable.ts +23 -25
- package/src/raster/frame.ts +53 -0
- package/src/raster/images.ts +73 -0
- package/src/raster/render.ts +45 -0
- package/src/raster/types.ts +9 -0
- package/src/raster.ts +13 -0
- package/src/resources/references.ts +5 -1
- package/src/services/viewport.ts +5 -4
- package/src/text/content.ts +4 -14
- package/src/text/policies.ts +18 -0
|
@@ -8,6 +8,31 @@ import { InterchangeDom } from './dom.js';
|
|
|
8
8
|
import { preflight, reconstruct } from './reconstruct.js';
|
|
9
9
|
import { validateImportContent } from './policy.js';
|
|
10
10
|
import type { ExportOptions, ImportOptions } from './types.js';
|
|
11
|
+
import { BBox } from '../math/bbox.js';
|
|
12
|
+
import { isNestedViewport, viewportLength } from '../services/viewport.js';
|
|
13
|
+
import { Attributes } from '../dom/attributes.js';
|
|
14
|
+
|
|
15
|
+
function frameContent(source: AnyNode, envelope: SVGElement, padding: number): void {
|
|
16
|
+
const bounds = source.bounds('root');
|
|
17
|
+
if (bounds.status === 'unavailable')
|
|
18
|
+
throw new Error(`Cannot frame SVG content: ${bounds.reason}`);
|
|
19
|
+
const box =
|
|
20
|
+
'box' in bounds
|
|
21
|
+
? bounds.box
|
|
22
|
+
: {
|
|
23
|
+
x: 0,
|
|
24
|
+
y: 0,
|
|
25
|
+
width: BBox.numeric(source.context.root?.getAttributeValue('width'), 800) ?? 800,
|
|
26
|
+
height: BBox.numeric(source.context.root?.getAttributeValue('height'), 600) ?? 600,
|
|
27
|
+
};
|
|
28
|
+
const width = box.width + padding * 2,
|
|
29
|
+
height = box.height + padding * 2;
|
|
30
|
+
if (![box.x - padding, box.y - padding, width, height].every(Number.isFinite))
|
|
31
|
+
throw new RangeError('SVG content frame must be finite');
|
|
32
|
+
envelope.setAttribute('viewBox', `${box.x - padding} ${box.y - padding} ${width} ${height}`);
|
|
33
|
+
envelope.setAttribute('width', String(width));
|
|
34
|
+
envelope.setAttribute('height', String(height));
|
|
35
|
+
}
|
|
11
36
|
|
|
12
37
|
export function exportSvg(source: AnyNode, options: ExportOptions = {}): string {
|
|
13
38
|
source.assertLive();
|
|
@@ -15,6 +40,13 @@ export function exportSvg(source: AnyNode, options: ExportOptions = {}): string
|
|
|
15
40
|
throw new TypeError('Unknown SVG export format');
|
|
16
41
|
if (options.resources !== undefined && !['referenced', 'none'].includes(options.resources))
|
|
17
42
|
throw new TypeError('Unknown export resource policy');
|
|
43
|
+
if (options.frame !== undefined && options.frame !== 'content')
|
|
44
|
+
throw new TypeError('Unknown SVG frame');
|
|
45
|
+
if (options.frame && options.format === 'subtree')
|
|
46
|
+
throw new TypeError('Content framing requires a document export');
|
|
47
|
+
const padding = options.padding ?? 0;
|
|
48
|
+
if (!Number.isFinite(padding) || padding < 0)
|
|
49
|
+
throw new RangeError('SVG padding must be nonnegative and finite');
|
|
18
50
|
source.flush();
|
|
19
51
|
const dependencies = options.resources === 'none' ? [] : dependencyRoots(source, 'local');
|
|
20
52
|
const copies = new Map<AnyNode, SVGElement>();
|
|
@@ -25,20 +57,45 @@ export function exportSvg(source: AnyNode, options: ExportOptions = {}): string
|
|
|
25
57
|
return InterchangeDom.serialize(element);
|
|
26
58
|
}
|
|
27
59
|
const document = element.ownerDocument;
|
|
60
|
+
const framedSubtree = options.frame === 'content' && source !== source.context.root;
|
|
28
61
|
const envelope =
|
|
29
|
-
element.localName === 'svg'
|
|
62
|
+
element.localName === 'svg' && !framedSubtree
|
|
63
|
+
? element
|
|
64
|
+
: document.createElementNS(SVG_NAMESPACE, 'svg');
|
|
30
65
|
if (envelope !== element) {
|
|
31
66
|
for (const name of ['width', 'height', 'viewBox', 'preserveAspectRatio']) {
|
|
32
67
|
const value = source.context.root?.getAttributeValue(name);
|
|
33
68
|
if (value !== undefined) envelope.setAttribute(name, String(value));
|
|
34
69
|
}
|
|
35
|
-
|
|
70
|
+
if (framedSubtree && source.parent) {
|
|
71
|
+
// The snapshot retains its own transform and any nested SVG viewport. Only
|
|
72
|
+
// the omitted ancestors need mapping into the document-space content frame.
|
|
73
|
+
const mapping = document.createElementNS(SVG_NAMESPACE, 'g');
|
|
74
|
+
mapping.setAttribute('transform', source.context.bounds.matrix(source.parent).toString());
|
|
75
|
+
mapping.append(element);
|
|
76
|
+
envelope.append(mapping);
|
|
77
|
+
} else envelope.append(element);
|
|
36
78
|
}
|
|
37
79
|
if (dependencies.length) {
|
|
38
80
|
const defs = document.createElementNS(SVG_NAMESPACE, 'defs');
|
|
39
81
|
for (const dependency of dependencies) defs.append(snapshot(dependency, copies));
|
|
40
82
|
envelope.prepend(defs);
|
|
41
83
|
}
|
|
84
|
+
if (options.frame === 'content') {
|
|
85
|
+
frameContent(source, envelope, padding);
|
|
86
|
+
// Reframing changes percentage bases. Preserve each copied nested viewport's
|
|
87
|
+
// original placement and dimensions, including omitted 100% sizes.
|
|
88
|
+
for (const [node, copy] of copies) {
|
|
89
|
+
if (!isNestedViewport(node)) continue;
|
|
90
|
+
for (const name of ['x', 'y', 'width', 'height'] as const) {
|
|
91
|
+
const axis = name === 'x' || name === 'width' ? 'width' : 'height';
|
|
92
|
+
copy.setAttribute(
|
|
93
|
+
name,
|
|
94
|
+
String(viewportLength(node, name, axis, name === axis ? '100%' : 0)),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
42
99
|
return InterchangeDom.serialize(envelope);
|
|
43
100
|
}
|
|
44
101
|
|
|
@@ -51,8 +108,22 @@ export function importSvg(
|
|
|
51
108
|
assertCanAddChildren(parent);
|
|
52
109
|
const index = options.index ?? parent.children.length;
|
|
53
110
|
validateIndex(index, parent.children.length);
|
|
54
|
-
|
|
111
|
+
if (
|
|
112
|
+
options.document !== undefined &&
|
|
113
|
+
typeof options.document !== 'boolean' &&
|
|
114
|
+
!['presentation', 'replace'].includes(options.document)
|
|
115
|
+
)
|
|
116
|
+
throw new TypeError('Unknown document import mode');
|
|
117
|
+
if (
|
|
118
|
+
options.document === 'replace' &&
|
|
119
|
+
(parent !== parent.context.root || parent.children.length)
|
|
120
|
+
)
|
|
121
|
+
throw new TypeError('Document replacement requires an empty owning drawing');
|
|
122
|
+
let roots = InterchangeDom.parse(parent.el, markup, !!options.document);
|
|
55
123
|
validateImportContent(roots, options);
|
|
124
|
+
const replace =
|
|
125
|
+
options.document === 'replace' && roots.length === 1 && roots[0].localName === 'svg';
|
|
126
|
+
if (options.document) roots = InterchangeDom.unwrap(roots, options.document !== true);
|
|
56
127
|
preflight(parent.context, roots);
|
|
57
128
|
remapIds(parent, roots);
|
|
58
129
|
return reconstruct(parent, roots, (nodes) => {
|
|
@@ -60,6 +131,9 @@ export function importSvg(
|
|
|
60
131
|
for (const node of nodes) parent[validateChild](node);
|
|
61
132
|
for (let position = 0; position < nodes.length; position++)
|
|
62
133
|
parent.add(nodes[position], index + position);
|
|
134
|
+
if (replace)
|
|
135
|
+
for (const name of Attributes.common)
|
|
136
|
+
if (!['id', 'class'].includes(name)) parent.setAttributeValue(name, undefined);
|
|
63
137
|
return Object.freeze(nodes);
|
|
64
138
|
});
|
|
65
139
|
});
|
|
@@ -89,6 +89,12 @@ function validateUrl(tag: string, name: string, value: string, options: ImportOp
|
|
|
89
89
|
if (reference) return;
|
|
90
90
|
if (name === 'href') {
|
|
91
91
|
if (value === '') return;
|
|
92
|
+
// Image documents are passive resources; keep embedded image files restorable.
|
|
93
|
+
if (
|
|
94
|
+
tag === 'image' &&
|
|
95
|
+
/^data:image\/(?:png|jpe?g|gif|webp|avif|svg\+xml)(?:;[^,]*)?,/i.test(value)
|
|
96
|
+
)
|
|
97
|
+
return;
|
|
92
98
|
if (
|
|
93
99
|
options.externalUrls === 'http' &&
|
|
94
100
|
['image', 'a', 'use'].includes(tag) &&
|
package/src/interchange/types.ts
CHANGED
|
@@ -10,9 +10,18 @@ export interface ExportOptions {
|
|
|
10
10
|
format?: 'document' | 'subtree';
|
|
11
11
|
/** Include reachable typed and unambiguous local references. CSS URLs are not traversed. */
|
|
12
12
|
resources?: 'referenced' | 'none';
|
|
13
|
+
/** Frame geometric content in document coordinates rather than the current camera. */
|
|
14
|
+
frame?: 'content';
|
|
15
|
+
/** Nonnegative drawing units around a content frame; defaults to zero. */
|
|
16
|
+
padding?: number;
|
|
13
17
|
}
|
|
14
18
|
export interface ImportOptions {
|
|
15
19
|
index?: number;
|
|
16
|
-
/**
|
|
20
|
+
/** true unwraps children; 'presentation' retains scoped root attributes without
|
|
21
|
+
* the viewport. 'replace' additionally clears configured root presentation for a
|
|
22
|
+
* full document, and requires an empty owning drawing. Fragments keep defaults. */
|
|
23
|
+
document?: boolean | 'presentation' | 'replace';
|
|
24
|
+
/** External URLs are rejected by default; embedded data images are allowed on image elements.
|
|
25
|
+
* Opt in to absolute HTTP(S) image/link/Use URLs. */
|
|
17
26
|
externalUrls?: 'reject' | 'http';
|
|
18
27
|
}
|
|
@@ -53,8 +53,8 @@ export const Transformable: MixinTransformable = (BaseClass) => {
|
|
|
53
53
|
matrix(): Matrix;
|
|
54
54
|
matrix(value: Matrix | MatrixValues): this;
|
|
55
55
|
matrix(value?: Matrix | MatrixValues): Matrix | this {
|
|
56
|
+
if (value === undefined) return this.localMatrix();
|
|
56
57
|
return mutation(this, () => {
|
|
57
|
-
if (value === undefined) return this.localMatrix();
|
|
58
58
|
this.assertLive();
|
|
59
59
|
const matrix = Matrix.from(value);
|
|
60
60
|
this.#matrixMode = true;
|
|
@@ -110,33 +110,31 @@ export const Transformable: MixinTransformable = (BaseClass) => {
|
|
|
110
110
|
transformState(): TransformState;
|
|
111
111
|
transformState(state: TransformState): this;
|
|
112
112
|
transformState(state?: TransformState): TransformState | this {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (this.#matrixMode || transform !== this.#lastTransform)
|
|
125
|
-
return Object.freeze({
|
|
126
|
-
...common,
|
|
127
|
-
mode: 'matrix',
|
|
128
|
-
matrix: Object.freeze(base.toArray()),
|
|
129
|
-
});
|
|
130
|
-
const components = this.components;
|
|
113
|
+
this.assertLive();
|
|
114
|
+
if (state !== undefined) return mutation(this, () => this.restoreTransformState(state));
|
|
115
|
+
const { base, geometry } = this.mappingState();
|
|
116
|
+
const transform = this.getAttributeValue('transform');
|
|
117
|
+
const common = {
|
|
118
|
+
tag: this.context.dom.tag(this.el),
|
|
119
|
+
transform: transform === undefined ? null : String(transform),
|
|
120
|
+
geometry: Object.freeze(geometry.toArray()),
|
|
121
|
+
attributes: geometrySnapshot(this),
|
|
122
|
+
};
|
|
123
|
+
if (this.#matrixMode || transform !== this.#lastTransform)
|
|
131
124
|
return Object.freeze({
|
|
132
125
|
...common,
|
|
133
|
-
mode: '
|
|
134
|
-
|
|
135
|
-
rotation: components.rotation,
|
|
136
|
-
scale: components.scale,
|
|
137
|
-
origin: components.origin,
|
|
138
|
-
automaticOrigin: !components.explicitOrigin,
|
|
126
|
+
mode: 'matrix',
|
|
127
|
+
matrix: Object.freeze(base.toArray()),
|
|
139
128
|
});
|
|
129
|
+
const components = this.components;
|
|
130
|
+
return Object.freeze({
|
|
131
|
+
...common,
|
|
132
|
+
mode: 'components',
|
|
133
|
+
position: components.position,
|
|
134
|
+
rotation: components.rotation,
|
|
135
|
+
scale: components.scale,
|
|
136
|
+
origin: components.origin,
|
|
137
|
+
automaticOrigin: !components.explicitOrigin,
|
|
140
138
|
});
|
|
141
139
|
}
|
|
142
140
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { AnyNode } from '../core/types.js';
|
|
2
|
+
import { exportSvg } from '../interchange/markup.js';
|
|
3
|
+
import type { RasterOptions } from './types.js';
|
|
4
|
+
export function prepareRaster(root: AnyNode, options: RasterOptions) {
|
|
5
|
+
root.assertLive();
|
|
6
|
+
const document = root.el.ownerDocument,
|
|
7
|
+
view = document.defaultView;
|
|
8
|
+
if (!view) throw new Error('Raster export needs a browser document');
|
|
9
|
+
const type = options.type ?? 'image/png',
|
|
10
|
+
scale = options.scale ?? 1,
|
|
11
|
+
frame = options.frame ?? 'content',
|
|
12
|
+
padding = options.padding ?? 0;
|
|
13
|
+
if (!['image/png', 'image/webp', 'image/jpeg'].includes(type))
|
|
14
|
+
throw new TypeError('Unknown raster image type');
|
|
15
|
+
if (!Number.isFinite(scale) || scale <= 0)
|
|
16
|
+
throw new RangeError('Raster scale must be positive and finite');
|
|
17
|
+
if (!Number.isFinite(padding) || padding < 0)
|
|
18
|
+
throw new RangeError('Raster padding must be nonnegative and finite');
|
|
19
|
+
if (!['content', 'viewBox'].includes(frame)) throw new TypeError('Unknown raster frame');
|
|
20
|
+
const background = options.background ?? (type === 'image/jpeg' ? '#ffffff' : undefined);
|
|
21
|
+
if (
|
|
22
|
+
background !== undefined &&
|
|
23
|
+
(typeof background !== 'string' || !view.CSS.supports('color', background))
|
|
24
|
+
)
|
|
25
|
+
throw new TypeError('Raster background must be a CSS color');
|
|
26
|
+
const markup = exportSvg(root, { frame: frame === 'content' ? 'content' : undefined, padding });
|
|
27
|
+
const svg = new view.DOMParser().parseFromString(markup, 'image/svg+xml').documentElement;
|
|
28
|
+
const box = svg
|
|
29
|
+
.getAttribute('viewBox')
|
|
30
|
+
?.trim()
|
|
31
|
+
.split(/[\s,]+/)
|
|
32
|
+
.map(Number);
|
|
33
|
+
const extent = root.context.root!.el.getBoundingClientRect();
|
|
34
|
+
const length = (name: 'width' | 'height', fallback: number) => {
|
|
35
|
+
const value = svg.getAttribute(name) ?? '';
|
|
36
|
+
return /^\d+(?:\.\d+)?(?:px)?$/.test(value) ? parseFloat(value) : fallback;
|
|
37
|
+
};
|
|
38
|
+
const width = box?.length === 4 ? box[2] : length('width', extent.width),
|
|
39
|
+
height = box?.length === 4 ? box[3] : length('height', extent.height);
|
|
40
|
+
const pixels = [Math.ceil(width * scale), Math.ceil(height * scale)];
|
|
41
|
+
if (
|
|
42
|
+
!pixels.every((value) => Number.isSafeInteger(value) && value > 0 && value <= 32767) ||
|
|
43
|
+
pixels[0] * pixels[1] > 16777216
|
|
44
|
+
)
|
|
45
|
+
throw new RangeError(
|
|
46
|
+
'Raster export needs positive dimensions, at most 32767 per axis and 16777216 pixels; reduce scale or add padding to zero-size content',
|
|
47
|
+
);
|
|
48
|
+
svg.setAttribute('width', String(pixels[0]));
|
|
49
|
+
svg.setAttribute('height', String(pixels[1]));
|
|
50
|
+
if (!box) svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
51
|
+
return { document, svg, type, background, width: pixels[0], height: pixels[1] };
|
|
52
|
+
}
|
|
53
|
+
export type RasterSnapshot = ReturnType<typeof prepareRaster>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Inline supported image resources; SVG-as-image rendering cannot load external files. */
|
|
2
|
+
const xlink = 'http://www.w3.org/1999/xlink';
|
|
3
|
+
export async function embedImages(
|
|
4
|
+
svg: Element,
|
|
5
|
+
document: Document,
|
|
6
|
+
base: string,
|
|
7
|
+
ancestors: readonly string[] = [],
|
|
8
|
+
): Promise<void> {
|
|
9
|
+
const view = document.defaultView!;
|
|
10
|
+
for (const image of svg.querySelectorAll('image,feImage')) {
|
|
11
|
+
const href = image.getAttribute('href') ?? image.getAttributeNS(xlink, 'href');
|
|
12
|
+
if (!href || href.startsWith('#')) continue;
|
|
13
|
+
const url = new URL(href, base);
|
|
14
|
+
if (
|
|
15
|
+
url.protocol !== 'data:' &&
|
|
16
|
+
(!['http:', 'https:'].includes(url.protocol) || url.origin !== new URL(document.URL).origin)
|
|
17
|
+
)
|
|
18
|
+
throw new Error('Raster export supports only same-origin and data-URL images');
|
|
19
|
+
if (ancestors.length >= 8 || ancestors.includes(url.href))
|
|
20
|
+
throw new Error('Raster image resources are cyclic or too deeply nested');
|
|
21
|
+
let blob: Blob;
|
|
22
|
+
try {
|
|
23
|
+
const response = await view.fetch(
|
|
24
|
+
url.href,
|
|
25
|
+
url.protocol === 'data:'
|
|
26
|
+
? {}
|
|
27
|
+
: { mode: 'same-origin', credentials: 'same-origin', redirect: 'error' },
|
|
28
|
+
);
|
|
29
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
30
|
+
blob = await response.blob();
|
|
31
|
+
} catch (cause) {
|
|
32
|
+
throw new Error('Cannot load a raster image resource', { cause });
|
|
33
|
+
}
|
|
34
|
+
const mime = blob.type.split(';')[0].toLowerCase();
|
|
35
|
+
if (
|
|
36
|
+
![
|
|
37
|
+
'image/png',
|
|
38
|
+
'image/jpeg',
|
|
39
|
+
'image/webp',
|
|
40
|
+
'image/gif',
|
|
41
|
+
'image/avif',
|
|
42
|
+
'image/svg+xml',
|
|
43
|
+
].includes(mime)
|
|
44
|
+
)
|
|
45
|
+
throw new Error('Unsupported raster image resource type');
|
|
46
|
+
if (mime === 'image/svg+xml') {
|
|
47
|
+
const nested = new view.DOMParser().parseFromString(await blob.text(), 'image/svg+xml');
|
|
48
|
+
if (nested.querySelector('parsererror') || nested.documentElement.localName !== 'svg')
|
|
49
|
+
throw new Error('Invalid SVG image resource');
|
|
50
|
+
await embedImages(
|
|
51
|
+
nested.documentElement,
|
|
52
|
+
document,
|
|
53
|
+
url.protocol === 'data:' ? base : url.href,
|
|
54
|
+
[...ancestors, url.href],
|
|
55
|
+
);
|
|
56
|
+
blob = new view.Blob([new view.XMLSerializer().serializeToString(nested)], { type: mime });
|
|
57
|
+
}
|
|
58
|
+
const data = await new Promise<string>((resolve, reject) => {
|
|
59
|
+
const reader = new view.FileReader();
|
|
60
|
+
reader.onload = () => resolve(String(reader.result));
|
|
61
|
+
reader.onerror = () => reject(new Error('Cannot encode a raster image resource'));
|
|
62
|
+
reader.readAsDataURL(blob);
|
|
63
|
+
});
|
|
64
|
+
await new Promise<void>((resolve, reject) => {
|
|
65
|
+
const decoded = new view.Image();
|
|
66
|
+
decoded.onload = () => resolve();
|
|
67
|
+
decoded.onerror = () => reject(new Error('Cannot decode a raster image resource'));
|
|
68
|
+
decoded.src = data;
|
|
69
|
+
});
|
|
70
|
+
image.removeAttributeNS(xlink, 'href');
|
|
71
|
+
image.setAttribute('href', data);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RasterSnapshot } from './frame.js';
|
|
2
|
+
export async function renderRaster({
|
|
3
|
+
document,
|
|
4
|
+
svg,
|
|
5
|
+
type,
|
|
6
|
+
background,
|
|
7
|
+
width,
|
|
8
|
+
height,
|
|
9
|
+
}: RasterSnapshot): Promise<Blob> {
|
|
10
|
+
const view = document.defaultView!;
|
|
11
|
+
const url = view.URL.createObjectURL(
|
|
12
|
+
new view.Blob([new view.XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' }),
|
|
13
|
+
);
|
|
14
|
+
try {
|
|
15
|
+
const image = new view.Image();
|
|
16
|
+
await new Promise<void>((resolve, reject) => {
|
|
17
|
+
image.onload = () => resolve();
|
|
18
|
+
image.onerror = () => reject(new Error('Cannot decode SVG for raster export'));
|
|
19
|
+
image.src = url;
|
|
20
|
+
});
|
|
21
|
+
const canvas = document.createElement('canvas');
|
|
22
|
+
canvas.width = width;
|
|
23
|
+
canvas.height = height;
|
|
24
|
+
const context = canvas.getContext('2d');
|
|
25
|
+
if (!context) throw new Error('Raster export needs a 2D canvas context');
|
|
26
|
+
if (background !== undefined) {
|
|
27
|
+
context.fillStyle = background;
|
|
28
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
29
|
+
}
|
|
30
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
31
|
+
return await new Promise<Blob>((resolve, reject) =>
|
|
32
|
+
canvas.toBlob((blob) => {
|
|
33
|
+
if (!blob) reject(new Error('Browser could not encode the raster image'));
|
|
34
|
+
else if (blob.type !== type) reject(new Error(`Browser cannot encode ${type}`));
|
|
35
|
+
else resolve(blob);
|
|
36
|
+
}, type),
|
|
37
|
+
);
|
|
38
|
+
} catch (cause) {
|
|
39
|
+
if ((cause as { name?: string })?.name === 'SecurityError')
|
|
40
|
+
throw new Error('Cannot export image: canvas is tainted by cross-origin content', { cause });
|
|
41
|
+
throw cause;
|
|
42
|
+
} finally {
|
|
43
|
+
view.URL.revokeObjectURL(url);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface RasterOptions {
|
|
2
|
+
readonly type?: 'image/png' | 'image/webp' | 'image/jpeg';
|
|
3
|
+
readonly scale?: number;
|
|
4
|
+
readonly background?: string;
|
|
5
|
+
/** Content bounds by default; viewBox exports the current document camera. */
|
|
6
|
+
readonly frame?: 'content' | 'viewBox';
|
|
7
|
+
/** Drawing units around content bounds, default zero. */
|
|
8
|
+
readonly padding?: number;
|
|
9
|
+
}
|
package/src/raster.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AnyNode } from './core/types.js';
|
|
2
|
+
import type { RasterOptions } from './raster/types.js';
|
|
3
|
+
import { prepareRaster } from './raster/frame.js';
|
|
4
|
+
import { embedImages } from './raster/images.js';
|
|
5
|
+
import { renderRaster } from './raster/render.js';
|
|
6
|
+
export type { RasterOptions };
|
|
7
|
+
|
|
8
|
+
/** Snapshot and render a passive SVG document without changing its camera or model. */
|
|
9
|
+
export async function exportImage(root: AnyNode, options: RasterOptions = {}): Promise<Blob> {
|
|
10
|
+
const snapshot = prepareRaster(root, options);
|
|
11
|
+
await embedImages(snapshot.svg, snapshot.document, root.el.baseURI);
|
|
12
|
+
return renderRaster(snapshot);
|
|
13
|
+
}
|
|
@@ -35,7 +35,11 @@ export function classifyReference(attribute: string, value: unknown): LocalRefer
|
|
|
35
35
|
attribute = Attributes.normalizeName(attribute);
|
|
36
36
|
if (typeof value !== 'string') return null;
|
|
37
37
|
const source = value.replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g, '');
|
|
38
|
-
if (
|
|
38
|
+
if (
|
|
39
|
+
attribute === 'href' ||
|
|
40
|
+
attribute === 'data-rasam-connector-start' ||
|
|
41
|
+
attribute === 'data-rasam-connector-end'
|
|
42
|
+
) {
|
|
39
43
|
const match = /^#([^\s#<>"'()\\\u0000-\u001f\u007f]+)$/.exec(source);
|
|
40
44
|
return localReference('href', match?.[1]);
|
|
41
45
|
}
|
package/src/services/viewport.ts
CHANGED
|
@@ -17,7 +17,8 @@ export function unresolved(error: unknown): Bounds {
|
|
|
17
17
|
throw error;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** Resolve an authored viewport length against its original ancestor viewport. */
|
|
21
|
+
export function viewportLength(node: AnyNode, name: string, axis: Axis, fallback: unknown): number {
|
|
21
22
|
return userLength(node.getAttributeValue(name) ?? fallback, () => {
|
|
22
23
|
// Percentages use the nearest ancestor viewport's viewBox, or its size without one.
|
|
23
24
|
let viewport = node === node.context.root ? null : node.parent;
|
|
@@ -30,15 +31,15 @@ function length(node: AnyNode, name: string, axis: Axis, fallback: unknown): num
|
|
|
30
31
|
|
|
31
32
|
/** A viewport's width or height in its parent's user units; omitted sizes are 100%. */
|
|
32
33
|
export function viewportSize(node: AnyNode, axis: Axis): number {
|
|
33
|
-
const size =
|
|
34
|
+
const size = viewportLength(node, axis, axis, '100%');
|
|
34
35
|
if (size < 0) throw new ViewportError('Negative viewport size');
|
|
35
36
|
return size;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/** Placement and viewBox transform from a nested viewport's user space to its parent's. */
|
|
39
40
|
export function viewportMapping(node: AnyNode): Matrix {
|
|
40
|
-
const x =
|
|
41
|
-
y =
|
|
41
|
+
const x = viewportLength(node, 'x', 'width', 0),
|
|
42
|
+
y = viewportLength(node, 'y', 'height', 0),
|
|
42
43
|
viewBox = node.getAttributeValue('viewBox');
|
|
43
44
|
return viewBox === undefined
|
|
44
45
|
? Matrix.translation(x, y)
|
package/src/text/content.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { assertCanAddChildren, validateChild } from '../mixins/parent-internals.
|
|
|
7
7
|
import { destroySubtrees } from '../core/destruction.js';
|
|
8
8
|
import { TextDom } from '../dom/text.js';
|
|
9
9
|
import type { TextPolicies } from '../changes/types.js';
|
|
10
|
+
import { textContents } from './policies.js';
|
|
10
11
|
|
|
11
12
|
class TextSlot implements TextBinding {
|
|
12
13
|
active = true;
|
|
@@ -275,25 +276,14 @@ export class TextContent {
|
|
|
275
276
|
}
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
const contents = new WeakMap<AnyNode, TextContent>();
|
|
279
279
|
export function contentFor(host: ParentNode): TextContent {
|
|
280
|
-
let content =
|
|
280
|
+
let content = textContents.get(host);
|
|
281
281
|
if (!content) {
|
|
282
282
|
content = new TextContent(host);
|
|
283
|
-
|
|
283
|
+
textContents.set(host, content);
|
|
284
284
|
}
|
|
285
285
|
return content;
|
|
286
286
|
}
|
|
287
287
|
export function refreshLines(host: AnyNode): void {
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Internal reconstruction bridge; copies policies onto fresh children, never callback closures. */
|
|
292
|
-
export function copyTextPolicies(
|
|
293
|
-
source: AnyNode,
|
|
294
|
-
target: ParentNode,
|
|
295
|
-
copies: ReadonlyMap<AnyNode, AnyNode>,
|
|
296
|
-
): void {
|
|
297
|
-
const content = contents.get(source);
|
|
298
|
-
if (content) content.copyLinesTo(contentFor(target), copies);
|
|
288
|
+
textContents.get(host)?.refreshLines();
|
|
299
289
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AnyNode } from '../core/types.js';
|
|
2
|
+
import type { TextContent } from './content.js';
|
|
3
|
+
|
|
4
|
+
/** Shared state without retaining the rich-text implementation in interchange bundles. */
|
|
5
|
+
export const textContents = new WeakMap<AnyNode, TextContent>();
|
|
6
|
+
|
|
7
|
+
/** Copies policies onto reconstructed text children, never callback closures. */
|
|
8
|
+
export function copyTextPolicies(
|
|
9
|
+
source: AnyNode,
|
|
10
|
+
target: AnyNode,
|
|
11
|
+
copies: ReadonlyMap<AnyNode, AnyNode>,
|
|
12
|
+
): void {
|
|
13
|
+
const content = textContents.get(source);
|
|
14
|
+
if (!content) return;
|
|
15
|
+
const destination = textContents.get(target);
|
|
16
|
+
if (!destination) throw new TypeError('Clone target must support the source text content');
|
|
17
|
+
content.copyLinesTo(destination, copies);
|
|
18
|
+
}
|