fabricjs-document-engine 1.0.2 → 1.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 (118) hide show
  1. package/README.md +220 -11
  2. package/README.zh-CN.md +220 -11
  3. package/dist/assets/asset-manifest.d.cts +3 -0
  4. package/dist/assets/asset-manifest.d.ts +3 -0
  5. package/dist/assets/asset-pipeline.cjs +31 -17
  6. package/dist/assets/asset-pipeline.d.cts +5 -1
  7. package/dist/assets/asset-pipeline.d.ts +5 -1
  8. package/dist/assets/asset-pipeline.js +31 -17
  9. package/dist/assets/font-check.cjs +9 -1
  10. package/dist/assets/font-check.js +9 -1
  11. package/dist/assets/image-check.cjs +89 -6
  12. package/dist/assets/image-check.d.cts +17 -0
  13. package/dist/assets/image-check.d.ts +17 -0
  14. package/dist/assets/image-check.js +89 -7
  15. package/dist/commands/clipboard.cjs +141 -0
  16. package/dist/commands/clipboard.d.cts +47 -0
  17. package/dist/commands/clipboard.d.ts +47 -0
  18. package/dist/commands/clipboard.js +140 -0
  19. package/dist/commands/layers.cjs +97 -0
  20. package/dist/commands/layers.d.cts +39 -0
  21. package/dist/commands/layers.d.ts +39 -0
  22. package/dist/commands/layers.js +92 -0
  23. package/dist/document/document-format.d.cts +15 -0
  24. package/dist/document/document-format.d.ts +15 -0
  25. package/dist/document/validate-document.cjs +8 -0
  26. package/dist/document/validate-document.js +8 -0
  27. package/dist/engine/create-document-engine.cjs +196 -31
  28. package/dist/engine/create-document-engine.d.cts +44 -7
  29. package/dist/engine/create-document-engine.d.ts +44 -7
  30. package/dist/engine/create-document-engine.js +197 -32
  31. package/dist/engine/errors.d.cts +1 -1
  32. package/dist/engine/errors.d.ts +1 -1
  33. package/dist/export/batch-render.cjs +172 -0
  34. package/dist/export/batch-render.d.cts +43 -0
  35. package/dist/export/batch-render.d.ts +43 -0
  36. package/dist/export/batch-render.js +171 -0
  37. package/dist/export/export-options.cjs +21 -1
  38. package/dist/export/export-options.d.cts +29 -0
  39. package/dist/export/export-options.d.ts +29 -0
  40. package/dist/export/export-options.js +21 -2
  41. package/dist/export/preflight-export.cjs +11 -2
  42. package/dist/export/preflight-export.d.cts +1 -1
  43. package/dist/export/preflight-export.d.ts +1 -1
  44. package/dist/export/preflight-export.js +11 -2
  45. package/dist/export/render-export.cjs +57 -4
  46. package/dist/export/render-export.js +57 -5
  47. package/dist/export/svg/clip-masks.cjs +39 -0
  48. package/dist/export/svg/clip-masks.js +37 -0
  49. package/dist/export/svg/embed-assets.cjs +250 -0
  50. package/dist/export/svg/embed-assets.js +250 -0
  51. package/dist/export/svg/overrides.cjs +32 -0
  52. package/dist/export/svg/overrides.js +32 -0
  53. package/dist/export/svg/raster-object.cjs +82 -0
  54. package/dist/export/svg/raster-object.js +81 -0
  55. package/dist/export/svg/text-decorations.cjs +108 -0
  56. package/dist/export/svg/text-decorations.js +107 -0
  57. package/dist/export/svg/text-on-path.cjs +242 -0
  58. package/dist/export/svg/text-on-path.js +240 -0
  59. package/dist/fabric/fabric-adapter.cjs +49 -6
  60. package/dist/fabric/fabric-adapter.js +49 -6
  61. package/dist/fabric/object-registry.cjs +1 -1
  62. package/dist/fabric/object-registry.js +1 -1
  63. package/dist/fabric/page-state.cjs +69 -0
  64. package/dist/fabric/page-state.js +64 -0
  65. package/dist/history/apply-state.cjs +19 -2
  66. package/dist/history/apply-state.js +19 -2
  67. package/dist/history/create-history.cjs +53 -13
  68. package/dist/history/create-history.d.cts +8 -0
  69. package/dist/history/create-history.d.ts +8 -0
  70. package/dist/history/create-history.js +53 -13
  71. package/dist/history/snapshot.cjs +10 -1
  72. package/dist/history/snapshot.js +10 -2
  73. package/dist/import/svg-import.cjs +256 -0
  74. package/dist/import/svg-import.d.cts +50 -0
  75. package/dist/import/svg-import.d.ts +50 -0
  76. package/dist/import/svg-import.js +256 -0
  77. package/dist/index.cjs +12 -0
  78. package/dist/index.d.cts +10 -5
  79. package/dist/index.d.ts +10 -5
  80. package/dist/index.js +4 -1
  81. package/dist/migrations/migrate-document.cjs +2 -1
  82. package/dist/migrations/migrate-document.js +2 -1
  83. package/dist/pdf/export-pdf.cjs +381 -0
  84. package/dist/pdf/export-pdf.d.cts +73 -0
  85. package/dist/pdf/export-pdf.d.ts +73 -0
  86. package/dist/pdf/export-pdf.js +381 -0
  87. package/dist/pdf/fonts.cjs +115 -0
  88. package/dist/pdf/fonts.d.cts +12 -0
  89. package/dist/pdf/fonts.d.ts +12 -0
  90. package/dist/pdf/fonts.js +112 -0
  91. package/dist/pdf/page-layout.cjs +79 -0
  92. package/dist/pdf/page-layout.d.cts +51 -0
  93. package/dist/pdf/page-layout.d.ts +51 -0
  94. package/dist/pdf/page-layout.js +76 -0
  95. package/dist/pdf.cjs +7 -0
  96. package/dist/pdf.d.cts +4 -0
  97. package/dist/pdf.d.ts +4 -0
  98. package/dist/pdf.js +3 -0
  99. package/dist/react/use-layers.cjs +53 -0
  100. package/dist/react/use-layers.d.cts +10 -0
  101. package/dist/react/use-layers.d.ts +10 -0
  102. package/dist/react/use-layers.js +53 -0
  103. package/dist/react.cjs +2 -0
  104. package/dist/react.d.cts +3 -1
  105. package/dist/react.d.ts +3 -1
  106. package/dist/react.js +2 -1
  107. package/dist/recovery/recovery-controller.cjs +148 -29
  108. package/dist/recovery/recovery-controller.d.cts +8 -0
  109. package/dist/recovery/recovery-controller.d.ts +8 -0
  110. package/dist/recovery/recovery-controller.js +148 -29
  111. package/dist/security/content-limits.cjs +43 -3
  112. package/dist/security/content-limits.d.cts +8 -0
  113. package/dist/security/content-limits.d.ts +8 -0
  114. package/dist/security/content-limits.js +39 -4
  115. package/dist/util/concurrency.cjs +41 -0
  116. package/dist/util/concurrency.js +40 -0
  117. package/package.json +30 -3
  118. package/schema/document-v1.schema.json +5 -1
@@ -1,3 +1,9 @@
1
+ import { readObjectId } from "../fabric/object-ids.js";
2
+ import { withSvgOverrides } from "./svg/overrides.js";
3
+ import { isTextOnPath, textOnPathToSVG } from "./svg/text-on-path.js";
4
+ import { hasNestedClipPath, ownClipPathIsInverted, writeWithInvertedMask } from "./svg/clip-masks.js";
5
+ import { rasterMarkup } from "./svg/raster-object.js";
6
+ import { decoratedTextToSVG, hasStraightDecorations } from "./svg/text-decorations.js";
1
7
  //#region src/export/render-export.ts
2
8
  const identity = [
3
9
  1,
@@ -34,12 +40,54 @@ function withExportView(canvas, background, format, work) {
34
40
  }
35
41
  function renderRaster(canvas, area, format, scale, quality) {
36
42
  const rendered = canvas.toCanvasElement(scale, area);
43
+ const release = () => {
44
+ rendered.width = 0;
45
+ rendered.height = 0;
46
+ };
37
47
  return new Promise((resolve, reject) => {
38
48
  rendered.toBlob((blob) => blob ? resolve(blob) : reject(/* @__PURE__ */ new Error(`The browser could not encode ${format}`)), mimeTypes[format], quality);
39
- });
49
+ }).finally(release);
40
50
  }
41
- function renderSvg(canvas, area, scale) {
42
- return canvas.toSVG({
51
+ /**
52
+ * Fabric 6 and 7 write `rotate="..."style="..."` with no space for the
53
+ * spaces in text on a path, which is not valid XML: browsers, Illustrator and
54
+ * Inkscape refuse to open the file. Put the missing space back.
55
+ */
56
+ function repairFabricSvg(svg) {
57
+ return svg.replace(/(rotate="[^"]*")(style=)/g, "$1 $2");
58
+ }
59
+ /**
60
+ * Writes the SVG with the engine's corrections on top of Fabric's output:
61
+ * curved text and decorations as the canvas draws them, inverted clip paths
62
+ * as masks, and objects with nested clip paths as pictures. Every other
63
+ * object is written by Fabric unchanged.
64
+ */
65
+ function renderSvg(canvas, area, scale, options, limits) {
66
+ const textOnPath = (options?.textOnPath ?? "vector") === "vector";
67
+ const decorations = (options?.textDecorations ?? "shapes") === "shapes";
68
+ const rasterizedObjectIds = [];
69
+ const pictures = /* @__PURE__ */ new Map();
70
+ for (const object of canvas.getObjects()) {
71
+ if (!hasNestedClipPath(object)) continue;
72
+ pictures.set(object, rasterMarkup(canvas, object, Math.max(2, scale * 2), limits));
73
+ const id = readObjectId(object);
74
+ if (id !== void 0) rasterizedObjectIds.push(id);
75
+ }
76
+ const baseWriter = (object) => {
77
+ if (textOnPath && isTextOnPath(object)) return (reviver) => textOnPathToSVG(object, reviver);
78
+ if (decorations && hasStraightDecorations(object)) return (reviver) => decoratedTextToSVG(object, reviver);
79
+ };
80
+ const writerFor = (object) => {
81
+ const picture = pictures.get(object);
82
+ if (picture !== void 0) return () => picture;
83
+ const base = baseWriter(object);
84
+ if (ownClipPathIsInverted(object)) {
85
+ const prototypeWriter = (reviver) => Object.getPrototypeOf(object).toSVG.call(object, reviver);
86
+ return (reviver) => writeWithInvertedMask(object, base ?? prototypeWriter, reviver);
87
+ }
88
+ return base;
89
+ };
90
+ const svg = withSvgOverrides(canvas, writerFor, () => canvas.toSVG({
43
91
  viewBox: {
44
92
  x: area.left,
45
93
  y: area.top,
@@ -48,7 +96,11 @@ function renderSvg(canvas, area, scale) {
48
96
  },
49
97
  width: String(area.width * scale),
50
98
  height: String(area.height * scale)
51
- });
99
+ })).result;
100
+ return {
101
+ svg: repairFabricSvg(svg),
102
+ rasterizedObjectIds
103
+ };
52
104
  }
53
105
  //#endregion
54
- export { mimeTypes, renderRaster, renderSvg, withExportView };
106
+ export { mimeTypes, renderRaster, renderSvg, repairFabricSvg, withExportView };
@@ -0,0 +1,39 @@
1
+ const require_walk_objects = require("../../fabric/walk-objects.cjs");
2
+ //#region src/export/svg/clip-masks.ts
3
+ /** True when a clip path anywhere in the object's tree has a clip path of its own. */
4
+ function hasNestedClipPath(object) {
5
+ let nested = false;
6
+ require_walk_objects.walkObjects([object], (child) => {
7
+ if (child.clipPath?.clipPath) nested = true;
8
+ });
9
+ return nested;
10
+ }
11
+ /** True when the object's own clip path is inverted (and simple). */
12
+ function ownClipPathIsInverted(object) {
13
+ const clipPath = object.clipPath;
14
+ return Boolean(clipPath?.inverted) && !clipPath?.clipPath;
15
+ }
16
+ const FAR = 1e6;
17
+ /**
18
+ * Turns the object's own `<clipPath>` in Fabric's markup into an inverting
19
+ * `<mask>`. `write` produces the markup with the clip path not inverted; the
20
+ * id Fabric gave the clip path picks out exactly this object's definition.
21
+ */
22
+ function writeWithInvertedMask(object, write, reviver) {
23
+ const clipPath = object.clipPath;
24
+ clipPath.inverted = false;
25
+ let markup;
26
+ try {
27
+ markup = write(reviver);
28
+ } finally {
29
+ clipPath.inverted = true;
30
+ }
31
+ const id = clipPath.clipPathId;
32
+ if (!id) return markup;
33
+ const definition = new RegExp(`<clipPath id="${id}"\\s*>([\\s\\S]*?)</clipPath>`);
34
+ return markup.replace(definition, (_, shape) => `<mask id="${id}" maskUnits="userSpaceOnUse" x="-1000000" y="-1000000" width="${2 * FAR}" height="${2 * FAR}">\n<rect x="-1000000" y="-1000000" width="${2 * FAR}" height="${2 * FAR}" fill="white" />\n<g fill="black" stroke="none">${shape}</g>\n</mask>`).replace(`clip-path="url(#${id})"`, `mask="url(#${id})"`);
35
+ }
36
+ //#endregion
37
+ exports.hasNestedClipPath = hasNestedClipPath;
38
+ exports.ownClipPathIsInverted = ownClipPathIsInverted;
39
+ exports.writeWithInvertedMask = writeWithInvertedMask;
@@ -0,0 +1,37 @@
1
+ import { walkObjects } from "../../fabric/walk-objects.js";
2
+ //#region src/export/svg/clip-masks.ts
3
+ /** True when a clip path anywhere in the object's tree has a clip path of its own. */
4
+ function hasNestedClipPath(object) {
5
+ let nested = false;
6
+ walkObjects([object], (child) => {
7
+ if (child.clipPath?.clipPath) nested = true;
8
+ });
9
+ return nested;
10
+ }
11
+ /** True when the object's own clip path is inverted (and simple). */
12
+ function ownClipPathIsInverted(object) {
13
+ const clipPath = object.clipPath;
14
+ return Boolean(clipPath?.inverted) && !clipPath?.clipPath;
15
+ }
16
+ const FAR = 1e6;
17
+ /**
18
+ * Turns the object's own `<clipPath>` in Fabric's markup into an inverting
19
+ * `<mask>`. `write` produces the markup with the clip path not inverted; the
20
+ * id Fabric gave the clip path picks out exactly this object's definition.
21
+ */
22
+ function writeWithInvertedMask(object, write, reviver) {
23
+ const clipPath = object.clipPath;
24
+ clipPath.inverted = false;
25
+ let markup;
26
+ try {
27
+ markup = write(reviver);
28
+ } finally {
29
+ clipPath.inverted = true;
30
+ }
31
+ const id = clipPath.clipPathId;
32
+ if (!id) return markup;
33
+ const definition = new RegExp(`<clipPath id="${id}"\\s*>([\\s\\S]*?)</clipPath>`);
34
+ return markup.replace(definition, (_, shape) => `<mask id="${id}" maskUnits="userSpaceOnUse" x="-1000000" y="-1000000" width="${2 * FAR}" height="${2 * FAR}">\n<rect x="-1000000" y="-1000000" width="${2 * FAR}" height="${2 * FAR}" fill="white" />\n<g fill="black" stroke="none">${shape}</g>\n</mask>`).replace(`clip-path="url(#${id})"`, `mask="url(#${id})"`);
35
+ }
36
+ //#endregion
37
+ export { hasNestedClipPath, ownClipPathIsInverted, writeWithInvertedMask };
@@ -0,0 +1,250 @@
1
+ const require_content_limits = require("../../security/content-limits.cjs");
2
+ const require_object_ids = require("../../fabric/object-ids.cjs");
3
+ const require_walk_objects = require("../../fabric/walk-objects.cjs");
4
+ //#region src/export/svg/embed-assets.ts
5
+ const GENERIC_FAMILIES = /* @__PURE__ */ new Set([
6
+ "serif",
7
+ "sans-serif",
8
+ "monospace",
9
+ "cursive",
10
+ "fantasy",
11
+ "system-ui",
12
+ "ui-serif",
13
+ "ui-sans-serif",
14
+ "ui-monospace",
15
+ "ui-rounded",
16
+ "emoji",
17
+ "math",
18
+ "fangsong"
19
+ ]);
20
+ function unescapeXml(value) {
21
+ return value.replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
22
+ }
23
+ function escapeAttribute(value) {
24
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
25
+ }
26
+ function shortUrl(url) {
27
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
28
+ }
29
+ function bytesToBase64(bytes) {
30
+ let binary = "";
31
+ const chunk = 32768;
32
+ for (let index = 0; index < bytes.length; index += chunk) binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
33
+ return btoa(binary);
34
+ }
35
+ async function blobToDataUrl(blob, type) {
36
+ const bytes = new Uint8Array(await blob.arrayBuffer());
37
+ return `data:${type ?? (blob.type || "application/octet-stream")};base64,${bytesToBase64(bytes)}`;
38
+ }
39
+ /** Images on the canvas by URL, with the ids of the objects that show them. */
40
+ function imagesOnCanvas(canvas) {
41
+ const images = /* @__PURE__ */ new Map();
42
+ const pending = canvas.getObjects().map((object) => ({
43
+ object,
44
+ ownerId: require_object_ids.readObjectId(object)
45
+ }));
46
+ const note = (element, id) => {
47
+ if (typeof HTMLImageElement === "undefined" || !(element instanceof HTMLImageElement)) return;
48
+ for (const url of /* @__PURE__ */ new Set([
49
+ element.currentSrc,
50
+ element.src,
51
+ element.getAttribute("src") ?? ""
52
+ ])) {
53
+ if (!url) continue;
54
+ const entry = images.get(url) ?? {
55
+ element,
56
+ objectIds: /* @__PURE__ */ new Set()
57
+ };
58
+ if (id !== void 0) entry.objectIds.add(id);
59
+ images.set(url, entry);
60
+ }
61
+ };
62
+ while (pending.length > 0) {
63
+ const { object, ownerId } = pending.pop();
64
+ const id = require_object_ids.readObjectId(object) ?? ownerId;
65
+ const withElement = object;
66
+ if (typeof withElement.getElement === "function") note(withElement.getElement(), id);
67
+ for (const paintValue of [object.fill, object.stroke]) note(paintValue?.source, id);
68
+ require_walk_objects.childrenOf(object).forEach((child) => pending.push({
69
+ object: child,
70
+ ownerId: id
71
+ }));
72
+ if (object.clipPath) pending.push({
73
+ object: object.clipPath,
74
+ ownerId: id
75
+ });
76
+ }
77
+ return images;
78
+ }
79
+ function drawToDataUrl(element) {
80
+ if (element.naturalWidth * element.naturalHeight > 67108864) throw new Error("the picture is too large to copy");
81
+ const canvas = document.createElement("canvas");
82
+ canvas.width = element.naturalWidth;
83
+ canvas.height = element.naturalHeight;
84
+ try {
85
+ canvas.getContext("2d").drawImage(element, 0, 0);
86
+ return canvas.toDataURL("image/png");
87
+ } finally {
88
+ canvas.width = 0;
89
+ canvas.height = 0;
90
+ }
91
+ }
92
+ async function embedImage(url, element, context) {
93
+ if (!context.isAllowedUrl(url)) return { reason: "its address is not allowed" };
94
+ let fetchError = "";
95
+ try {
96
+ const response = await fetch(url, {
97
+ signal: context.signal,
98
+ mode: "cors",
99
+ credentials: "same-origin"
100
+ });
101
+ if (!response.ok) fetchError = `the server answered HTTP ${response.status}`;
102
+ else {
103
+ if (Number(response.headers.get("content-length") ?? "0") > context.options.maxEmbeddedImageBytes) {
104
+ response.body?.cancel().catch(() => void 0);
105
+ return { reason: `it is larger than ${context.options.maxEmbeddedImageBytes} bytes` };
106
+ }
107
+ const blob = await response.blob();
108
+ if (blob.size > context.options.maxEmbeddedImageBytes) return { reason: `it is larger than ${context.options.maxEmbeddedImageBytes} bytes` };
109
+ return { dataUrl: await blobToDataUrl(blob, blob.type.startsWith("image/") ? blob.type : void 0) };
110
+ }
111
+ } catch (error) {
112
+ if (context.signal?.aborted) throw error;
113
+ fetchError = "it could not be downloaded, usually because the other site does not allow it with CORS";
114
+ }
115
+ if (element && element.complete && element.naturalWidth > 0) try {
116
+ return { dataUrl: drawToDataUrl(element) };
117
+ } catch {
118
+ return { reason: `${fetchError}, and the loaded picture is from another site without CORS` };
119
+ }
120
+ return { reason: fetchError };
121
+ }
122
+ async function embedImages(svg, context, warnings, problems) {
123
+ const attribute = /(\s(?:xlink:href|href))="([^"]*)"/g;
124
+ const urls = /* @__PURE__ */ new Set();
125
+ for (const match of svg.matchAll(attribute)) {
126
+ const url = unescapeXml(match[2]);
127
+ if (url && !url.startsWith("data:") && !url.startsWith("#")) urls.add(url);
128
+ }
129
+ if (urls.size === 0) return svg;
130
+ const images = imagesOnCanvas(context.canvas);
131
+ const replacements = /* @__PURE__ */ new Map();
132
+ await Promise.all([...urls].map(async (url) => {
133
+ const known = images.get(url) ?? images.get(new URL(url, globalThis.location?.href).href);
134
+ const outcome = await embedImage(url, known?.element, context);
135
+ if ("dataUrl" in outcome) {
136
+ replacements.set(url, outcome.dataUrl);
137
+ return;
138
+ }
139
+ const message = `The image ${shortUrl(url)} was not embedded in the SVG because ${outcome.reason}`;
140
+ problems.push(message);
141
+ warnings.push({
142
+ code: "IMAGE_NOT_EMBEDDED",
143
+ message,
144
+ url,
145
+ objectIds: [...known?.objectIds ?? []]
146
+ });
147
+ }));
148
+ return svg.replace(attribute, (whole, name, value) => {
149
+ const replacement = replacements.get(unescapeXml(value));
150
+ return replacement ? `${name}="${escapeAttribute(replacement)}"` : whole;
151
+ });
152
+ }
153
+ function fontMimeType(bytes) {
154
+ const tag = String.fromCharCode(...bytes.subarray(0, 4));
155
+ if (tag === "wOF2") return "font/woff2";
156
+ if (tag === "wOFF") return "font/woff";
157
+ if (tag === "OTTO") return "font/otf";
158
+ return "font/ttf";
159
+ }
160
+ async function fontBytes(source, signal) {
161
+ if (typeof source === "string") {
162
+ const response = await fetch(source, { signal });
163
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
164
+ return new Uint8Array(await response.arrayBuffer());
165
+ }
166
+ if (source instanceof Uint8Array) return source;
167
+ if (source instanceof ArrayBuffer) return new Uint8Array(source);
168
+ return new Uint8Array(await source.arrayBuffer());
169
+ }
170
+ /** Font families the canvas uses, including per-character styles. */
171
+ function familiesInUse(canvas) {
172
+ const families = /* @__PURE__ */ new Set();
173
+ const add = (family) => {
174
+ if (typeof family !== "string") return;
175
+ for (const part of family.split(",")) {
176
+ const name = part.trim().replace(/^['"]|['"]$/g, "");
177
+ if (name && !GENERIC_FAMILIES.has(name.toLowerCase())) families.add(name);
178
+ }
179
+ };
180
+ const pending = [...canvas.getObjects()];
181
+ while (pending.length > 0) {
182
+ const object = pending.pop();
183
+ const text = object;
184
+ add(text.fontFamily);
185
+ for (const line of Object.values(text.styles ?? {})) for (const style of Object.values(line ?? {})) add(style?.fontFamily);
186
+ pending.push(...require_walk_objects.childrenOf(object));
187
+ }
188
+ return families;
189
+ }
190
+ async function embedFonts(svg, context, warnings, problems) {
191
+ const sources = context.options.embedFonts;
192
+ if (Object.keys(sources).length === 0) return svg;
193
+ const rules = [];
194
+ for (const family of familiesInUse(context.canvas)) {
195
+ const source = sources[family];
196
+ if (source === void 0) {
197
+ const message = `The font "${family}" was not embedded in the SVG because embedFonts has no file for it`;
198
+ warnings.push({
199
+ code: "FONT_NOT_EMBEDDED",
200
+ message,
201
+ family,
202
+ objectIds: []
203
+ });
204
+ continue;
205
+ }
206
+ try {
207
+ const bytes = await fontBytes(source, context.signal);
208
+ const name = family.replace(/["\\<>]/g, "");
209
+ rules.push(`@font-face { font-family: "${name}"; src: url("data:${fontMimeType(bytes)};base64,${bytesToBase64(bytes)}"); }`);
210
+ } catch (error) {
211
+ if (context.signal?.aborted) throw error;
212
+ const message = `The font "${family}" was not embedded in the SVG because its file could not be read: ${error instanceof Error ? error.message : String(error)}`;
213
+ problems.push(message);
214
+ warnings.push({
215
+ code: "FONT_NOT_EMBEDDED",
216
+ message,
217
+ family,
218
+ objectIds: []
219
+ });
220
+ }
221
+ }
222
+ if (rules.length === 0) return svg;
223
+ const style = `<defs>\n<style type="text/css"><![CDATA[\n${rules.join("\n")}\n]]></style>\n</defs>\n`;
224
+ return svg.replace(/(<svg\b[^>]*>\s*)/, `$1${style}`);
225
+ }
226
+ /**
227
+ * Embeds images and fonts as the options ask. With `embedImages: 'require'`,
228
+ * `problems` lists what could not be embedded so the caller can block the
229
+ * export.
230
+ */
231
+ async function embedSvgAssets(svg, canvas, options, signal, isAllowedUrl = require_content_limits.isSafeImageUrl) {
232
+ const warnings = [];
233
+ const problems = [];
234
+ const context = {
235
+ canvas,
236
+ options,
237
+ signal,
238
+ isAllowedUrl
239
+ };
240
+ let result = svg;
241
+ if (options.embedImages) result = await embedImages(result, context, warnings, problems);
242
+ result = await embedFonts(result, context, warnings, []);
243
+ return {
244
+ svg: result,
245
+ warnings,
246
+ problems
247
+ };
248
+ }
249
+ //#endregion
250
+ exports.embedSvgAssets = embedSvgAssets;
@@ -0,0 +1,250 @@
1
+ import { isSafeImageUrl } from "../../security/content-limits.js";
2
+ import { readObjectId } from "../../fabric/object-ids.js";
3
+ import { childrenOf } from "../../fabric/walk-objects.js";
4
+ //#region src/export/svg/embed-assets.ts
5
+ const GENERIC_FAMILIES = /* @__PURE__ */ new Set([
6
+ "serif",
7
+ "sans-serif",
8
+ "monospace",
9
+ "cursive",
10
+ "fantasy",
11
+ "system-ui",
12
+ "ui-serif",
13
+ "ui-sans-serif",
14
+ "ui-monospace",
15
+ "ui-rounded",
16
+ "emoji",
17
+ "math",
18
+ "fangsong"
19
+ ]);
20
+ function unescapeXml(value) {
21
+ return value.replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
22
+ }
23
+ function escapeAttribute(value) {
24
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
25
+ }
26
+ function shortUrl(url) {
27
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
28
+ }
29
+ function bytesToBase64(bytes) {
30
+ let binary = "";
31
+ const chunk = 32768;
32
+ for (let index = 0; index < bytes.length; index += chunk) binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
33
+ return btoa(binary);
34
+ }
35
+ async function blobToDataUrl(blob, type) {
36
+ const bytes = new Uint8Array(await blob.arrayBuffer());
37
+ return `data:${type ?? (blob.type || "application/octet-stream")};base64,${bytesToBase64(bytes)}`;
38
+ }
39
+ /** Images on the canvas by URL, with the ids of the objects that show them. */
40
+ function imagesOnCanvas(canvas) {
41
+ const images = /* @__PURE__ */ new Map();
42
+ const pending = canvas.getObjects().map((object) => ({
43
+ object,
44
+ ownerId: readObjectId(object)
45
+ }));
46
+ const note = (element, id) => {
47
+ if (typeof HTMLImageElement === "undefined" || !(element instanceof HTMLImageElement)) return;
48
+ for (const url of /* @__PURE__ */ new Set([
49
+ element.currentSrc,
50
+ element.src,
51
+ element.getAttribute("src") ?? ""
52
+ ])) {
53
+ if (!url) continue;
54
+ const entry = images.get(url) ?? {
55
+ element,
56
+ objectIds: /* @__PURE__ */ new Set()
57
+ };
58
+ if (id !== void 0) entry.objectIds.add(id);
59
+ images.set(url, entry);
60
+ }
61
+ };
62
+ while (pending.length > 0) {
63
+ const { object, ownerId } = pending.pop();
64
+ const id = readObjectId(object) ?? ownerId;
65
+ const withElement = object;
66
+ if (typeof withElement.getElement === "function") note(withElement.getElement(), id);
67
+ for (const paintValue of [object.fill, object.stroke]) note(paintValue?.source, id);
68
+ childrenOf(object).forEach((child) => pending.push({
69
+ object: child,
70
+ ownerId: id
71
+ }));
72
+ if (object.clipPath) pending.push({
73
+ object: object.clipPath,
74
+ ownerId: id
75
+ });
76
+ }
77
+ return images;
78
+ }
79
+ function drawToDataUrl(element) {
80
+ if (element.naturalWidth * element.naturalHeight > 67108864) throw new Error("the picture is too large to copy");
81
+ const canvas = document.createElement("canvas");
82
+ canvas.width = element.naturalWidth;
83
+ canvas.height = element.naturalHeight;
84
+ try {
85
+ canvas.getContext("2d").drawImage(element, 0, 0);
86
+ return canvas.toDataURL("image/png");
87
+ } finally {
88
+ canvas.width = 0;
89
+ canvas.height = 0;
90
+ }
91
+ }
92
+ async function embedImage(url, element, context) {
93
+ if (!context.isAllowedUrl(url)) return { reason: "its address is not allowed" };
94
+ let fetchError = "";
95
+ try {
96
+ const response = await fetch(url, {
97
+ signal: context.signal,
98
+ mode: "cors",
99
+ credentials: "same-origin"
100
+ });
101
+ if (!response.ok) fetchError = `the server answered HTTP ${response.status}`;
102
+ else {
103
+ if (Number(response.headers.get("content-length") ?? "0") > context.options.maxEmbeddedImageBytes) {
104
+ response.body?.cancel().catch(() => void 0);
105
+ return { reason: `it is larger than ${context.options.maxEmbeddedImageBytes} bytes` };
106
+ }
107
+ const blob = await response.blob();
108
+ if (blob.size > context.options.maxEmbeddedImageBytes) return { reason: `it is larger than ${context.options.maxEmbeddedImageBytes} bytes` };
109
+ return { dataUrl: await blobToDataUrl(blob, blob.type.startsWith("image/") ? blob.type : void 0) };
110
+ }
111
+ } catch (error) {
112
+ if (context.signal?.aborted) throw error;
113
+ fetchError = "it could not be downloaded, usually because the other site does not allow it with CORS";
114
+ }
115
+ if (element && element.complete && element.naturalWidth > 0) try {
116
+ return { dataUrl: drawToDataUrl(element) };
117
+ } catch {
118
+ return { reason: `${fetchError}, and the loaded picture is from another site without CORS` };
119
+ }
120
+ return { reason: fetchError };
121
+ }
122
+ async function embedImages(svg, context, warnings, problems) {
123
+ const attribute = /(\s(?:xlink:href|href))="([^"]*)"/g;
124
+ const urls = /* @__PURE__ */ new Set();
125
+ for (const match of svg.matchAll(attribute)) {
126
+ const url = unescapeXml(match[2]);
127
+ if (url && !url.startsWith("data:") && !url.startsWith("#")) urls.add(url);
128
+ }
129
+ if (urls.size === 0) return svg;
130
+ const images = imagesOnCanvas(context.canvas);
131
+ const replacements = /* @__PURE__ */ new Map();
132
+ await Promise.all([...urls].map(async (url) => {
133
+ const known = images.get(url) ?? images.get(new URL(url, globalThis.location?.href).href);
134
+ const outcome = await embedImage(url, known?.element, context);
135
+ if ("dataUrl" in outcome) {
136
+ replacements.set(url, outcome.dataUrl);
137
+ return;
138
+ }
139
+ const message = `The image ${shortUrl(url)} was not embedded in the SVG because ${outcome.reason}`;
140
+ problems.push(message);
141
+ warnings.push({
142
+ code: "IMAGE_NOT_EMBEDDED",
143
+ message,
144
+ url,
145
+ objectIds: [...known?.objectIds ?? []]
146
+ });
147
+ }));
148
+ return svg.replace(attribute, (whole, name, value) => {
149
+ const replacement = replacements.get(unescapeXml(value));
150
+ return replacement ? `${name}="${escapeAttribute(replacement)}"` : whole;
151
+ });
152
+ }
153
+ function fontMimeType(bytes) {
154
+ const tag = String.fromCharCode(...bytes.subarray(0, 4));
155
+ if (tag === "wOF2") return "font/woff2";
156
+ if (tag === "wOFF") return "font/woff";
157
+ if (tag === "OTTO") return "font/otf";
158
+ return "font/ttf";
159
+ }
160
+ async function fontBytes(source, signal) {
161
+ if (typeof source === "string") {
162
+ const response = await fetch(source, { signal });
163
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
164
+ return new Uint8Array(await response.arrayBuffer());
165
+ }
166
+ if (source instanceof Uint8Array) return source;
167
+ if (source instanceof ArrayBuffer) return new Uint8Array(source);
168
+ return new Uint8Array(await source.arrayBuffer());
169
+ }
170
+ /** Font families the canvas uses, including per-character styles. */
171
+ function familiesInUse(canvas) {
172
+ const families = /* @__PURE__ */ new Set();
173
+ const add = (family) => {
174
+ if (typeof family !== "string") return;
175
+ for (const part of family.split(",")) {
176
+ const name = part.trim().replace(/^['"]|['"]$/g, "");
177
+ if (name && !GENERIC_FAMILIES.has(name.toLowerCase())) families.add(name);
178
+ }
179
+ };
180
+ const pending = [...canvas.getObjects()];
181
+ while (pending.length > 0) {
182
+ const object = pending.pop();
183
+ const text = object;
184
+ add(text.fontFamily);
185
+ for (const line of Object.values(text.styles ?? {})) for (const style of Object.values(line ?? {})) add(style?.fontFamily);
186
+ pending.push(...childrenOf(object));
187
+ }
188
+ return families;
189
+ }
190
+ async function embedFonts(svg, context, warnings, problems) {
191
+ const sources = context.options.embedFonts;
192
+ if (Object.keys(sources).length === 0) return svg;
193
+ const rules = [];
194
+ for (const family of familiesInUse(context.canvas)) {
195
+ const source = sources[family];
196
+ if (source === void 0) {
197
+ const message = `The font "${family}" was not embedded in the SVG because embedFonts has no file for it`;
198
+ warnings.push({
199
+ code: "FONT_NOT_EMBEDDED",
200
+ message,
201
+ family,
202
+ objectIds: []
203
+ });
204
+ continue;
205
+ }
206
+ try {
207
+ const bytes = await fontBytes(source, context.signal);
208
+ const name = family.replace(/["\\<>]/g, "");
209
+ rules.push(`@font-face { font-family: "${name}"; src: url("data:${fontMimeType(bytes)};base64,${bytesToBase64(bytes)}"); }`);
210
+ } catch (error) {
211
+ if (context.signal?.aborted) throw error;
212
+ const message = `The font "${family}" was not embedded in the SVG because its file could not be read: ${error instanceof Error ? error.message : String(error)}`;
213
+ problems.push(message);
214
+ warnings.push({
215
+ code: "FONT_NOT_EMBEDDED",
216
+ message,
217
+ family,
218
+ objectIds: []
219
+ });
220
+ }
221
+ }
222
+ if (rules.length === 0) return svg;
223
+ const style = `<defs>\n<style type="text/css"><![CDATA[\n${rules.join("\n")}\n]]></style>\n</defs>\n`;
224
+ return svg.replace(/(<svg\b[^>]*>\s*)/, `$1${style}`);
225
+ }
226
+ /**
227
+ * Embeds images and fonts as the options ask. With `embedImages: 'require'`,
228
+ * `problems` lists what could not be embedded so the caller can block the
229
+ * export.
230
+ */
231
+ async function embedSvgAssets(svg, canvas, options, signal, isAllowedUrl = isSafeImageUrl) {
232
+ const warnings = [];
233
+ const problems = [];
234
+ const context = {
235
+ canvas,
236
+ options,
237
+ signal,
238
+ isAllowedUrl
239
+ };
240
+ let result = svg;
241
+ if (options.embedImages) result = await embedImages(result, context, warnings, problems);
242
+ result = await embedFonts(result, context, warnings, []);
243
+ return {
244
+ svg: result,
245
+ warnings,
246
+ problems
247
+ };
248
+ }
249
+ //#endregion
250
+ export { embedSvgAssets };
@@ -0,0 +1,32 @@
1
+ const require_walk_objects = require("../../fabric/walk-objects.cjs");
2
+ //#region src/export/svg/overrides.ts
3
+ /**
4
+ * Runs `work` while chosen objects on the canvas, including objects in
5
+ * groups, write their SVG with `writerFor`. Any toSVG an app set on an
6
+ * object itself is put back afterwards. Returns how many objects were
7
+ * handled.
8
+ */
9
+ function withSvgOverrides(canvas, writerFor, work) {
10
+ const patched = [];
11
+ require_walk_objects.walkObjects(canvas.getObjects(), (object) => {
12
+ const writer = writerFor(object);
13
+ if (!writer) return;
14
+ patched.push([object, Object.getOwnPropertyDescriptor(object, "toSVG")]);
15
+ Object.defineProperty(object, "toSVG", {
16
+ configurable: true,
17
+ writable: true,
18
+ value: writer
19
+ });
20
+ });
21
+ try {
22
+ return {
23
+ result: work(),
24
+ count: patched.length
25
+ };
26
+ } finally {
27
+ for (const [object, own] of patched) if (own) Object.defineProperty(object, "toSVG", own);
28
+ else delete object.toSVG;
29
+ }
30
+ }
31
+ //#endregion
32
+ exports.withSvgOverrides = withSvgOverrides;