@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.
Files changed (106) hide show
  1. package/API.md +15 -5
  2. package/CHANGELOG.md +39 -1
  3. package/README.md +140 -20
  4. package/RELEASING.md +29 -21
  5. package/dist/changes/service.d.ts +5 -2
  6. package/dist/changes/service.d.ts.map +1 -1
  7. package/dist/changes/service.js +29 -4
  8. package/dist/changes/service.js.map +1 -1
  9. package/dist/changes/types.d.ts +9 -0
  10. package/dist/changes/types.d.ts.map +1 -1
  11. package/dist/changes/types.js.map +1 -1
  12. package/dist/changes.d.ts +5 -3
  13. package/dist/changes.d.ts.map +1 -1
  14. package/dist/changes.js +103 -2
  15. package/dist/changes.js.map +1 -1
  16. package/dist/dom/images.d.ts.map +1 -1
  17. package/dist/dom/images.js +3 -1
  18. package/dist/dom/images.js.map +1 -1
  19. package/dist/elements/image.d.ts.map +1 -1
  20. package/dist/elements/image.js +4 -1
  21. package/dist/elements/image.js.map +1 -1
  22. package/dist/interchange/clone.js +1 -1
  23. package/dist/interchange/clone.js.map +1 -1
  24. package/dist/interchange/dom.d.ts +3 -1
  25. package/dist/interchange/dom.d.ts.map +1 -1
  26. package/dist/interchange/dom.js +29 -5
  27. package/dist/interchange/dom.js.map +1 -1
  28. package/dist/interchange/markup.d.ts.map +1 -1
  29. package/dist/interchange/markup.js +71 -3
  30. package/dist/interchange/markup.js.map +1 -1
  31. package/dist/interchange/policy.d.ts.map +1 -1
  32. package/dist/interchange/policy.js +4 -0
  33. package/dist/interchange/policy.js.map +1 -1
  34. package/dist/interchange/types.d.ts +10 -1
  35. package/dist/interchange/types.d.ts.map +1 -1
  36. package/dist/mixins/transformable.d.ts.map +1 -1
  37. package/dist/mixins/transformable.js +25 -27
  38. package/dist/mixins/transformable.js.map +1 -1
  39. package/dist/raster/frame.d.ts +12 -0
  40. package/dist/raster/frame.d.ts.map +1 -0
  41. package/dist/raster/frame.js +43 -0
  42. package/dist/raster/frame.js.map +1 -0
  43. package/dist/raster/images.d.ts +2 -0
  44. package/dist/raster/images.d.ts.map +1 -0
  45. package/dist/raster/images.js +60 -0
  46. package/dist/raster/images.js.map +1 -0
  47. package/dist/raster/render.d.ts +3 -0
  48. package/dist/raster/render.d.ts.map +1 -0
  49. package/dist/raster/render.js +40 -0
  50. package/dist/raster/render.js.map +1 -0
  51. package/dist/raster/types.d.ts +10 -0
  52. package/dist/raster/types.d.ts.map +1 -0
  53. package/dist/raster/types.js +2 -0
  54. package/dist/raster/types.js.map +1 -0
  55. package/dist/raster.d.ts +6 -0
  56. package/dist/raster.d.ts.map +1 -0
  57. package/dist/raster.js +10 -0
  58. package/dist/raster.js.map +1 -0
  59. package/dist/resources/references.d.ts.map +1 -1
  60. package/dist/resources/references.js +3 -1
  61. package/dist/resources/references.js.map +1 -1
  62. package/dist/services/viewport.d.ts +2 -0
  63. package/dist/services/viewport.d.ts.map +1 -1
  64. package/dist/services/viewport.js +4 -3
  65. package/dist/services/viewport.js.map +1 -1
  66. package/dist/text/content.d.ts +0 -2
  67. package/dist/text/content.d.ts.map +1 -1
  68. package/dist/text/content.js +4 -10
  69. package/dist/text/content.js.map +1 -1
  70. package/dist/text/policies.d.ts +7 -0
  71. package/dist/text/policies.d.ts.map +1 -0
  72. package/dist/text/policies.js +13 -0
  73. package/dist/text/policies.js.map +1 -0
  74. package/package.json +6 -1
  75. package/reports/adoption.json +20 -20
  76. package/reports/animation-benchmark.json +617 -617
  77. package/reports/benchmark.json +44 -44
  78. package/reports/creation.json +26 -26
  79. package/reports/interchange-benchmark.json +85 -85
  80. package/reports/public-api-contract.json +20 -7
  81. package/reports/public-api-review.md +23 -0
  82. package/reports/size-parity.json +54 -19
  83. package/reports/size.json +2 -2
  84. package/reports/svgjs-comparison.json +3464 -3464
  85. package/reports/svgjs-comparison.md +19 -19
  86. package/reports/svgjs-parity-current.json +58 -34
  87. package/src/changes/service.ts +39 -5
  88. package/src/changes/types.ts +9 -0
  89. package/src/changes.ts +93 -4
  90. package/src/dom/images.ts +2 -1
  91. package/src/elements/image.ts +4 -1
  92. package/src/interchange/clone.ts +1 -1
  93. package/src/interchange/dom.ts +34 -5
  94. package/src/interchange/markup.ts +77 -3
  95. package/src/interchange/policy.ts +6 -0
  96. package/src/interchange/types.ts +10 -1
  97. package/src/mixins/transformable.ts +23 -25
  98. package/src/raster/frame.ts +53 -0
  99. package/src/raster/images.ts +73 -0
  100. package/src/raster/render.ts +45 -0
  101. package/src/raster/types.ts +9 -0
  102. package/src/raster.ts +13 -0
  103. package/src/resources/references.ts +5 -1
  104. package/src/services/viewport.ts +5 -4
  105. package/src/text/content.ts +4 -14
  106. 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' ? element : document.createElementNS(SVG_NAMESPACE, '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
- envelope.append(element);
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
- const roots = InterchangeDom.parse(parent.el, markup);
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) &&
@@ -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
- /** External URLs are rejected by default. Opt in to absolute HTTP(S) image/link/Use URLs. */
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
- return mutation(this, () => {
114
- this.assertLive();
115
- if (state !== undefined) return this.restoreTransformState(state);
116
- const { base, geometry } = this.mappingState();
117
- const transform = this.getAttributeValue('transform');
118
- const common = {
119
- tag: this.context.dom.tag(this.el),
120
- transform: transform === undefined ? null : String(transform),
121
- geometry: Object.freeze(geometry.toArray()),
122
- attributes: geometrySnapshot(this),
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: 'components',
134
- position: components.position,
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 (attribute === 'href') {
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
  }
@@ -17,7 +17,8 @@ export function unresolved(error: unknown): Bounds {
17
17
  throw error;
18
18
  }
19
19
 
20
- function length(node: AnyNode, name: string, axis: Axis, fallback: unknown): number {
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 = length(node, axis, axis, '100%');
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 = length(node, 'x', 'width', 0),
41
- y = length(node, 'y', 'height', 0),
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)
@@ -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 = contents.get(host);
280
+ let content = textContents.get(host);
281
281
  if (!content) {
282
282
  content = new TextContent(host);
283
- contents.set(host, content);
283
+ textContents.set(host, content);
284
284
  }
285
285
  return content;
286
286
  }
287
287
  export function refreshLines(host: AnyNode): void {
288
- contents.get(host)?.refreshLines();
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
+ }