fabricjs-document-engine 1.0.2 → 1.1.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/README.md +181 -10
- package/README.zh-CN.md +181 -10
- package/dist/assets/asset-manifest.d.cts +3 -0
- package/dist/assets/asset-manifest.d.ts +3 -0
- package/dist/assets/asset-pipeline.cjs +22 -10
- package/dist/assets/asset-pipeline.d.cts +5 -1
- package/dist/assets/asset-pipeline.d.ts +5 -1
- package/dist/assets/asset-pipeline.js +22 -10
- package/dist/assets/font-check.cjs +9 -1
- package/dist/assets/font-check.js +9 -1
- package/dist/assets/image-check.cjs +88 -6
- package/dist/assets/image-check.d.cts +17 -0
- package/dist/assets/image-check.d.ts +17 -0
- package/dist/assets/image-check.js +88 -7
- package/dist/commands/clipboard.cjs +136 -0
- package/dist/commands/clipboard.d.cts +47 -0
- package/dist/commands/clipboard.d.ts +47 -0
- package/dist/commands/clipboard.js +135 -0
- package/dist/commands/layers.cjs +97 -0
- package/dist/commands/layers.d.cts +39 -0
- package/dist/commands/layers.d.ts +39 -0
- package/dist/commands/layers.js +92 -0
- package/dist/engine/create-document-engine.cjs +85 -14
- package/dist/engine/create-document-engine.d.cts +20 -1
- package/dist/engine/create-document-engine.d.ts +20 -1
- package/dist/engine/create-document-engine.js +86 -15
- package/dist/engine/errors.d.cts +1 -1
- package/dist/engine/errors.d.ts +1 -1
- package/dist/export/batch-render.cjs +172 -0
- package/dist/export/batch-render.d.cts +43 -0
- package/dist/export/batch-render.d.ts +43 -0
- package/dist/export/batch-render.js +171 -0
- package/dist/export/export-options.cjs +18 -1
- package/dist/export/export-options.d.cts +23 -0
- package/dist/export/export-options.d.ts +23 -0
- package/dist/export/export-options.js +18 -2
- package/dist/export/preflight-export.cjs +11 -2
- package/dist/export/preflight-export.d.cts +1 -1
- package/dist/export/preflight-export.d.ts +1 -1
- package/dist/export/preflight-export.js +11 -2
- package/dist/export/render-export.cjs +18 -3
- package/dist/export/render-export.js +18 -4
- package/dist/export/svg/embed-assets.cjs +249 -0
- package/dist/export/svg/embed-assets.js +249 -0
- package/dist/export/svg/overrides.cjs +32 -0
- package/dist/export/svg/overrides.js +32 -0
- package/dist/export/svg/text-on-path.cjs +252 -0
- package/dist/export/svg/text-on-path.js +249 -0
- package/dist/fabric/fabric-adapter.cjs +46 -5
- package/dist/fabric/fabric-adapter.js +46 -5
- package/dist/fabric/object-registry.cjs +1 -1
- package/dist/fabric/object-registry.js +1 -1
- package/dist/import/svg-import.cjs +256 -0
- package/dist/import/svg-import.d.cts +50 -0
- package/dist/import/svg-import.d.ts +50 -0
- package/dist/import/svg-import.js +256 -0
- package/dist/index.cjs +12 -0
- package/dist/index.d.cts +9 -4
- package/dist/index.d.ts +9 -4
- package/dist/index.js +4 -1
- package/dist/pdf/export-pdf.cjs +440 -0
- package/dist/pdf/export-pdf.d.cts +73 -0
- package/dist/pdf/export-pdf.d.ts +73 -0
- package/dist/pdf/export-pdf.js +440 -0
- package/dist/pdf/fonts.cjs +115 -0
- package/dist/pdf/fonts.d.cts +12 -0
- package/dist/pdf/fonts.d.ts +12 -0
- package/dist/pdf/fonts.js +112 -0
- package/dist/pdf/page-layout.cjs +79 -0
- package/dist/pdf/page-layout.d.cts +51 -0
- package/dist/pdf/page-layout.d.ts +51 -0
- package/dist/pdf/page-layout.js +76 -0
- package/dist/pdf/text-decorations.cjs +108 -0
- package/dist/pdf/text-decorations.js +107 -0
- package/dist/pdf.cjs +7 -0
- package/dist/pdf.d.cts +4 -0
- package/dist/pdf.d.ts +4 -0
- package/dist/pdf.js +3 -0
- package/dist/react/use-layers.cjs +53 -0
- package/dist/react/use-layers.d.cts +10 -0
- package/dist/react/use-layers.d.ts +10 -0
- package/dist/react/use-layers.js +53 -0
- package/dist/react.cjs +2 -0
- package/dist/react.d.cts +3 -1
- package/dist/react.d.ts +3 -1
- package/dist/react.js +2 -1
- package/dist/util/concurrency.cjs +28 -0
- package/dist/util/concurrency.js +27 -0
- package/package.json +30 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const require_text_on_path = require("./svg/text-on-path.cjs");
|
|
1
2
|
//#region src/export/render-export.ts
|
|
2
3
|
const identity = [
|
|
3
4
|
1,
|
|
@@ -34,12 +35,24 @@ function withExportView(canvas, background, format, work) {
|
|
|
34
35
|
}
|
|
35
36
|
function renderRaster(canvas, area, format, scale, quality) {
|
|
36
37
|
const rendered = canvas.toCanvasElement(scale, area);
|
|
38
|
+
const release = () => {
|
|
39
|
+
rendered.width = 0;
|
|
40
|
+
rendered.height = 0;
|
|
41
|
+
};
|
|
37
42
|
return new Promise((resolve, reject) => {
|
|
38
43
|
rendered.toBlob((blob) => blob ? resolve(blob) : reject(/* @__PURE__ */ new Error(`The browser could not encode ${format}`)), mimeTypes[format], quality);
|
|
39
|
-
});
|
|
44
|
+
}).finally(release);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fabric 6 and 7 write `rotate="..."style="..."` with no space for the
|
|
48
|
+
* spaces in text on a path, which is not valid XML: browsers, Illustrator and
|
|
49
|
+
* Inkscape refuse to open the file. Put the missing space back.
|
|
50
|
+
*/
|
|
51
|
+
function repairFabricSvg(svg) {
|
|
52
|
+
return svg.replace(/(rotate="[^"]*")(style=)/g, "$1 $2");
|
|
40
53
|
}
|
|
41
|
-
function renderSvg(canvas, area, scale) {
|
|
42
|
-
|
|
54
|
+
function renderSvg(canvas, area, scale, options) {
|
|
55
|
+
const write = () => canvas.toSVG({
|
|
43
56
|
viewBox: {
|
|
44
57
|
x: area.left,
|
|
45
58
|
y: area.top,
|
|
@@ -49,9 +62,11 @@ function renderSvg(canvas, area, scale) {
|
|
|
49
62
|
width: String(area.width * scale),
|
|
50
63
|
height: String(area.height * scale)
|
|
51
64
|
});
|
|
65
|
+
return repairFabricSvg((options?.textOnPath ?? "vector") === "vector" ? require_text_on_path.withTextOnPathSVG(canvas, write).result : write());
|
|
52
66
|
}
|
|
53
67
|
//#endregion
|
|
54
68
|
exports.mimeTypes = mimeTypes;
|
|
55
69
|
exports.renderRaster = renderRaster;
|
|
56
70
|
exports.renderSvg = renderSvg;
|
|
71
|
+
exports.repairFabricSvg = repairFabricSvg;
|
|
57
72
|
exports.withExportView = withExportView;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withTextOnPathSVG } from "./svg/text-on-path.js";
|
|
1
2
|
//#region src/export/render-export.ts
|
|
2
3
|
const identity = [
|
|
3
4
|
1,
|
|
@@ -34,12 +35,24 @@ function withExportView(canvas, background, format, work) {
|
|
|
34
35
|
}
|
|
35
36
|
function renderRaster(canvas, area, format, scale, quality) {
|
|
36
37
|
const rendered = canvas.toCanvasElement(scale, area);
|
|
38
|
+
const release = () => {
|
|
39
|
+
rendered.width = 0;
|
|
40
|
+
rendered.height = 0;
|
|
41
|
+
};
|
|
37
42
|
return new Promise((resolve, reject) => {
|
|
38
43
|
rendered.toBlob((blob) => blob ? resolve(blob) : reject(/* @__PURE__ */ new Error(`The browser could not encode ${format}`)), mimeTypes[format], quality);
|
|
39
|
-
});
|
|
44
|
+
}).finally(release);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fabric 6 and 7 write `rotate="..."style="..."` with no space for the
|
|
48
|
+
* spaces in text on a path, which is not valid XML: browsers, Illustrator and
|
|
49
|
+
* Inkscape refuse to open the file. Put the missing space back.
|
|
50
|
+
*/
|
|
51
|
+
function repairFabricSvg(svg) {
|
|
52
|
+
return svg.replace(/(rotate="[^"]*")(style=)/g, "$1 $2");
|
|
40
53
|
}
|
|
41
|
-
function renderSvg(canvas, area, scale) {
|
|
42
|
-
|
|
54
|
+
function renderSvg(canvas, area, scale, options) {
|
|
55
|
+
const write = () => canvas.toSVG({
|
|
43
56
|
viewBox: {
|
|
44
57
|
x: area.left,
|
|
45
58
|
y: area.top,
|
|
@@ -49,6 +62,7 @@ function renderSvg(canvas, area, scale) {
|
|
|
49
62
|
width: String(area.width * scale),
|
|
50
63
|
height: String(area.height * scale)
|
|
51
64
|
});
|
|
65
|
+
return repairFabricSvg((options?.textOnPath ?? "vector") === "vector" ? withTextOnPathSVG(canvas, write).result : write());
|
|
52
66
|
}
|
|
53
67
|
//#endregion
|
|
54
|
-
export { mimeTypes, renderRaster, renderSvg, withExportView };
|
|
68
|
+
export { mimeTypes, renderRaster, renderSvg, repairFabricSvg, withExportView };
|
|
@@ -0,0 +1,249 @@
|
|
|
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(/"/g, "\"").replace(/'/g, "'").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
22
|
+
}
|
|
23
|
+
function escapeAttribute(value) {
|
|
24
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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
|
+
const canvas = document.createElement("canvas");
|
|
81
|
+
canvas.width = element.naturalWidth;
|
|
82
|
+
canvas.height = element.naturalHeight;
|
|
83
|
+
try {
|
|
84
|
+
canvas.getContext("2d").drawImage(element, 0, 0);
|
|
85
|
+
return canvas.toDataURL("image/png");
|
|
86
|
+
} finally {
|
|
87
|
+
canvas.width = 0;
|
|
88
|
+
canvas.height = 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function embedImage(url, element, context) {
|
|
92
|
+
if (!context.isAllowedUrl(url)) return { reason: "its address is not allowed" };
|
|
93
|
+
let fetchError = "";
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(url, {
|
|
96
|
+
signal: context.signal,
|
|
97
|
+
mode: "cors",
|
|
98
|
+
credentials: "same-origin"
|
|
99
|
+
});
|
|
100
|
+
if (!response.ok) fetchError = `the server answered HTTP ${response.status}`;
|
|
101
|
+
else {
|
|
102
|
+
if (Number(response.headers.get("content-length") ?? "0") > context.options.maxEmbeddedImageBytes) {
|
|
103
|
+
response.body?.cancel().catch(() => void 0);
|
|
104
|
+
return { reason: `it is larger than ${context.options.maxEmbeddedImageBytes} bytes` };
|
|
105
|
+
}
|
|
106
|
+
const blob = await response.blob();
|
|
107
|
+
if (blob.size > context.options.maxEmbeddedImageBytes) return { reason: `it is larger than ${context.options.maxEmbeddedImageBytes} bytes` };
|
|
108
|
+
return { dataUrl: await blobToDataUrl(blob, blob.type.startsWith("image/") ? blob.type : void 0) };
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (context.signal?.aborted) throw error;
|
|
112
|
+
fetchError = "it could not be downloaded, usually because the other site does not allow it with CORS";
|
|
113
|
+
}
|
|
114
|
+
if (element && element.complete && element.naturalWidth > 0) try {
|
|
115
|
+
return { dataUrl: drawToDataUrl(element) };
|
|
116
|
+
} catch {
|
|
117
|
+
return { reason: `${fetchError}, and the loaded picture is from another site without CORS` };
|
|
118
|
+
}
|
|
119
|
+
return { reason: fetchError };
|
|
120
|
+
}
|
|
121
|
+
async function embedImages(svg, context, warnings, problems) {
|
|
122
|
+
const attribute = /(\s(?:xlink:href|href))="([^"]*)"/g;
|
|
123
|
+
const urls = /* @__PURE__ */ new Set();
|
|
124
|
+
for (const match of svg.matchAll(attribute)) {
|
|
125
|
+
const url = unescapeXml(match[2]);
|
|
126
|
+
if (url && !url.startsWith("data:") && !url.startsWith("#")) urls.add(url);
|
|
127
|
+
}
|
|
128
|
+
if (urls.size === 0) return svg;
|
|
129
|
+
const images = imagesOnCanvas(context.canvas);
|
|
130
|
+
const replacements = /* @__PURE__ */ new Map();
|
|
131
|
+
await Promise.all([...urls].map(async (url) => {
|
|
132
|
+
const known = images.get(url) ?? images.get(new URL(url, globalThis.location?.href).href);
|
|
133
|
+
const outcome = await embedImage(url, known?.element, context);
|
|
134
|
+
if ("dataUrl" in outcome) {
|
|
135
|
+
replacements.set(url, outcome.dataUrl);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const message = `The image ${shortUrl(url)} was not embedded in the SVG because ${outcome.reason}`;
|
|
139
|
+
problems.push(message);
|
|
140
|
+
warnings.push({
|
|
141
|
+
code: "IMAGE_NOT_EMBEDDED",
|
|
142
|
+
message,
|
|
143
|
+
url,
|
|
144
|
+
objectIds: [...known?.objectIds ?? []]
|
|
145
|
+
});
|
|
146
|
+
}));
|
|
147
|
+
return svg.replace(attribute, (whole, name, value) => {
|
|
148
|
+
const replacement = replacements.get(unescapeXml(value));
|
|
149
|
+
return replacement ? `${name}="${escapeAttribute(replacement)}"` : whole;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function fontMimeType(bytes) {
|
|
153
|
+
const tag = String.fromCharCode(...bytes.subarray(0, 4));
|
|
154
|
+
if (tag === "wOF2") return "font/woff2";
|
|
155
|
+
if (tag === "wOFF") return "font/woff";
|
|
156
|
+
if (tag === "OTTO") return "font/otf";
|
|
157
|
+
return "font/ttf";
|
|
158
|
+
}
|
|
159
|
+
async function fontBytes(source, signal) {
|
|
160
|
+
if (typeof source === "string") {
|
|
161
|
+
const response = await fetch(source, { signal });
|
|
162
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
163
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
164
|
+
}
|
|
165
|
+
if (source instanceof Uint8Array) return source;
|
|
166
|
+
if (source instanceof ArrayBuffer) return new Uint8Array(source);
|
|
167
|
+
return new Uint8Array(await source.arrayBuffer());
|
|
168
|
+
}
|
|
169
|
+
/** Font families the canvas uses, including per-character styles. */
|
|
170
|
+
function familiesInUse(canvas) {
|
|
171
|
+
const families = /* @__PURE__ */ new Set();
|
|
172
|
+
const add = (family) => {
|
|
173
|
+
if (typeof family !== "string") return;
|
|
174
|
+
for (const part of family.split(",")) {
|
|
175
|
+
const name = part.trim().replace(/^['"]|['"]$/g, "");
|
|
176
|
+
if (name && !GENERIC_FAMILIES.has(name.toLowerCase())) families.add(name);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const pending = [...canvas.getObjects()];
|
|
180
|
+
while (pending.length > 0) {
|
|
181
|
+
const object = pending.pop();
|
|
182
|
+
const text = object;
|
|
183
|
+
add(text.fontFamily);
|
|
184
|
+
for (const line of Object.values(text.styles ?? {})) for (const style of Object.values(line ?? {})) add(style?.fontFamily);
|
|
185
|
+
pending.push(...require_walk_objects.childrenOf(object));
|
|
186
|
+
}
|
|
187
|
+
return families;
|
|
188
|
+
}
|
|
189
|
+
async function embedFonts(svg, context, warnings, problems) {
|
|
190
|
+
const sources = context.options.embedFonts;
|
|
191
|
+
if (Object.keys(sources).length === 0) return svg;
|
|
192
|
+
const rules = [];
|
|
193
|
+
for (const family of familiesInUse(context.canvas)) {
|
|
194
|
+
const source = sources[family];
|
|
195
|
+
if (source === void 0) {
|
|
196
|
+
const message = `The font "${family}" was not embedded in the SVG because embedFonts has no file for it`;
|
|
197
|
+
warnings.push({
|
|
198
|
+
code: "FONT_NOT_EMBEDDED",
|
|
199
|
+
message,
|
|
200
|
+
family,
|
|
201
|
+
objectIds: []
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const bytes = await fontBytes(source, context.signal);
|
|
207
|
+
const name = family.replace(/["\\<>]/g, "");
|
|
208
|
+
rules.push(`@font-face { font-family: "${name}"; src: url("data:${fontMimeType(bytes)};base64,${bytesToBase64(bytes)}"); }`);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (context.signal?.aborted) throw error;
|
|
211
|
+
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)}`;
|
|
212
|
+
problems.push(message);
|
|
213
|
+
warnings.push({
|
|
214
|
+
code: "FONT_NOT_EMBEDDED",
|
|
215
|
+
message,
|
|
216
|
+
family,
|
|
217
|
+
objectIds: []
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (rules.length === 0) return svg;
|
|
222
|
+
const style = `<defs>\n<style type="text/css"><![CDATA[\n${rules.join("\n")}\n]]></style>\n</defs>\n`;
|
|
223
|
+
return svg.replace(/(<svg\b[^>]*>\s*)/, `$1${style}`);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Embeds images and fonts as the options ask. With `embedImages: 'require'`,
|
|
227
|
+
* `problems` lists what could not be embedded so the caller can block the
|
|
228
|
+
* export.
|
|
229
|
+
*/
|
|
230
|
+
async function embedSvgAssets(svg, canvas, options, signal, isAllowedUrl = require_content_limits.isSafeImageUrl) {
|
|
231
|
+
const warnings = [];
|
|
232
|
+
const problems = [];
|
|
233
|
+
const context = {
|
|
234
|
+
canvas,
|
|
235
|
+
options,
|
|
236
|
+
signal,
|
|
237
|
+
isAllowedUrl
|
|
238
|
+
};
|
|
239
|
+
let result = svg;
|
|
240
|
+
if (options.embedImages) result = await embedImages(result, context, warnings, problems);
|
|
241
|
+
result = await embedFonts(result, context, warnings, []);
|
|
242
|
+
return {
|
|
243
|
+
svg: result,
|
|
244
|
+
warnings,
|
|
245
|
+
problems
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
//#endregion
|
|
249
|
+
exports.embedSvgAssets = embedSvgAssets;
|
|
@@ -0,0 +1,249 @@
|
|
|
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(/"/g, "\"").replace(/'/g, "'").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
22
|
+
}
|
|
23
|
+
function escapeAttribute(value) {
|
|
24
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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
|
+
const canvas = document.createElement("canvas");
|
|
81
|
+
canvas.width = element.naturalWidth;
|
|
82
|
+
canvas.height = element.naturalHeight;
|
|
83
|
+
try {
|
|
84
|
+
canvas.getContext("2d").drawImage(element, 0, 0);
|
|
85
|
+
return canvas.toDataURL("image/png");
|
|
86
|
+
} finally {
|
|
87
|
+
canvas.width = 0;
|
|
88
|
+
canvas.height = 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function embedImage(url, element, context) {
|
|
92
|
+
if (!context.isAllowedUrl(url)) return { reason: "its address is not allowed" };
|
|
93
|
+
let fetchError = "";
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(url, {
|
|
96
|
+
signal: context.signal,
|
|
97
|
+
mode: "cors",
|
|
98
|
+
credentials: "same-origin"
|
|
99
|
+
});
|
|
100
|
+
if (!response.ok) fetchError = `the server answered HTTP ${response.status}`;
|
|
101
|
+
else {
|
|
102
|
+
if (Number(response.headers.get("content-length") ?? "0") > context.options.maxEmbeddedImageBytes) {
|
|
103
|
+
response.body?.cancel().catch(() => void 0);
|
|
104
|
+
return { reason: `it is larger than ${context.options.maxEmbeddedImageBytes} bytes` };
|
|
105
|
+
}
|
|
106
|
+
const blob = await response.blob();
|
|
107
|
+
if (blob.size > context.options.maxEmbeddedImageBytes) return { reason: `it is larger than ${context.options.maxEmbeddedImageBytes} bytes` };
|
|
108
|
+
return { dataUrl: await blobToDataUrl(blob, blob.type.startsWith("image/") ? blob.type : void 0) };
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (context.signal?.aborted) throw error;
|
|
112
|
+
fetchError = "it could not be downloaded, usually because the other site does not allow it with CORS";
|
|
113
|
+
}
|
|
114
|
+
if (element && element.complete && element.naturalWidth > 0) try {
|
|
115
|
+
return { dataUrl: drawToDataUrl(element) };
|
|
116
|
+
} catch {
|
|
117
|
+
return { reason: `${fetchError}, and the loaded picture is from another site without CORS` };
|
|
118
|
+
}
|
|
119
|
+
return { reason: fetchError };
|
|
120
|
+
}
|
|
121
|
+
async function embedImages(svg, context, warnings, problems) {
|
|
122
|
+
const attribute = /(\s(?:xlink:href|href))="([^"]*)"/g;
|
|
123
|
+
const urls = /* @__PURE__ */ new Set();
|
|
124
|
+
for (const match of svg.matchAll(attribute)) {
|
|
125
|
+
const url = unescapeXml(match[2]);
|
|
126
|
+
if (url && !url.startsWith("data:") && !url.startsWith("#")) urls.add(url);
|
|
127
|
+
}
|
|
128
|
+
if (urls.size === 0) return svg;
|
|
129
|
+
const images = imagesOnCanvas(context.canvas);
|
|
130
|
+
const replacements = /* @__PURE__ */ new Map();
|
|
131
|
+
await Promise.all([...urls].map(async (url) => {
|
|
132
|
+
const known = images.get(url) ?? images.get(new URL(url, globalThis.location?.href).href);
|
|
133
|
+
const outcome = await embedImage(url, known?.element, context);
|
|
134
|
+
if ("dataUrl" in outcome) {
|
|
135
|
+
replacements.set(url, outcome.dataUrl);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const message = `The image ${shortUrl(url)} was not embedded in the SVG because ${outcome.reason}`;
|
|
139
|
+
problems.push(message);
|
|
140
|
+
warnings.push({
|
|
141
|
+
code: "IMAGE_NOT_EMBEDDED",
|
|
142
|
+
message,
|
|
143
|
+
url,
|
|
144
|
+
objectIds: [...known?.objectIds ?? []]
|
|
145
|
+
});
|
|
146
|
+
}));
|
|
147
|
+
return svg.replace(attribute, (whole, name, value) => {
|
|
148
|
+
const replacement = replacements.get(unescapeXml(value));
|
|
149
|
+
return replacement ? `${name}="${escapeAttribute(replacement)}"` : whole;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function fontMimeType(bytes) {
|
|
153
|
+
const tag = String.fromCharCode(...bytes.subarray(0, 4));
|
|
154
|
+
if (tag === "wOF2") return "font/woff2";
|
|
155
|
+
if (tag === "wOFF") return "font/woff";
|
|
156
|
+
if (tag === "OTTO") return "font/otf";
|
|
157
|
+
return "font/ttf";
|
|
158
|
+
}
|
|
159
|
+
async function fontBytes(source, signal) {
|
|
160
|
+
if (typeof source === "string") {
|
|
161
|
+
const response = await fetch(source, { signal });
|
|
162
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
163
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
164
|
+
}
|
|
165
|
+
if (source instanceof Uint8Array) return source;
|
|
166
|
+
if (source instanceof ArrayBuffer) return new Uint8Array(source);
|
|
167
|
+
return new Uint8Array(await source.arrayBuffer());
|
|
168
|
+
}
|
|
169
|
+
/** Font families the canvas uses, including per-character styles. */
|
|
170
|
+
function familiesInUse(canvas) {
|
|
171
|
+
const families = /* @__PURE__ */ new Set();
|
|
172
|
+
const add = (family) => {
|
|
173
|
+
if (typeof family !== "string") return;
|
|
174
|
+
for (const part of family.split(",")) {
|
|
175
|
+
const name = part.trim().replace(/^['"]|['"]$/g, "");
|
|
176
|
+
if (name && !GENERIC_FAMILIES.has(name.toLowerCase())) families.add(name);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const pending = [...canvas.getObjects()];
|
|
180
|
+
while (pending.length > 0) {
|
|
181
|
+
const object = pending.pop();
|
|
182
|
+
const text = object;
|
|
183
|
+
add(text.fontFamily);
|
|
184
|
+
for (const line of Object.values(text.styles ?? {})) for (const style of Object.values(line ?? {})) add(style?.fontFamily);
|
|
185
|
+
pending.push(...childrenOf(object));
|
|
186
|
+
}
|
|
187
|
+
return families;
|
|
188
|
+
}
|
|
189
|
+
async function embedFonts(svg, context, warnings, problems) {
|
|
190
|
+
const sources = context.options.embedFonts;
|
|
191
|
+
if (Object.keys(sources).length === 0) return svg;
|
|
192
|
+
const rules = [];
|
|
193
|
+
for (const family of familiesInUse(context.canvas)) {
|
|
194
|
+
const source = sources[family];
|
|
195
|
+
if (source === void 0) {
|
|
196
|
+
const message = `The font "${family}" was not embedded in the SVG because embedFonts has no file for it`;
|
|
197
|
+
warnings.push({
|
|
198
|
+
code: "FONT_NOT_EMBEDDED",
|
|
199
|
+
message,
|
|
200
|
+
family,
|
|
201
|
+
objectIds: []
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const bytes = await fontBytes(source, context.signal);
|
|
207
|
+
const name = family.replace(/["\\<>]/g, "");
|
|
208
|
+
rules.push(`@font-face { font-family: "${name}"; src: url("data:${fontMimeType(bytes)};base64,${bytesToBase64(bytes)}"); }`);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (context.signal?.aborted) throw error;
|
|
211
|
+
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)}`;
|
|
212
|
+
problems.push(message);
|
|
213
|
+
warnings.push({
|
|
214
|
+
code: "FONT_NOT_EMBEDDED",
|
|
215
|
+
message,
|
|
216
|
+
family,
|
|
217
|
+
objectIds: []
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (rules.length === 0) return svg;
|
|
222
|
+
const style = `<defs>\n<style type="text/css"><![CDATA[\n${rules.join("\n")}\n]]></style>\n</defs>\n`;
|
|
223
|
+
return svg.replace(/(<svg\b[^>]*>\s*)/, `$1${style}`);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Embeds images and fonts as the options ask. With `embedImages: 'require'`,
|
|
227
|
+
* `problems` lists what could not be embedded so the caller can block the
|
|
228
|
+
* export.
|
|
229
|
+
*/
|
|
230
|
+
async function embedSvgAssets(svg, canvas, options, signal, isAllowedUrl = isSafeImageUrl) {
|
|
231
|
+
const warnings = [];
|
|
232
|
+
const problems = [];
|
|
233
|
+
const context = {
|
|
234
|
+
canvas,
|
|
235
|
+
options,
|
|
236
|
+
signal,
|
|
237
|
+
isAllowedUrl
|
|
238
|
+
};
|
|
239
|
+
let result = svg;
|
|
240
|
+
if (options.embedImages) result = await embedImages(result, context, warnings, problems);
|
|
241
|
+
result = await embedFonts(result, context, warnings, []);
|
|
242
|
+
return {
|
|
243
|
+
svg: result,
|
|
244
|
+
warnings,
|
|
245
|
+
problems
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
//#endregion
|
|
249
|
+
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;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { walkObjects } from "../../fabric/walk-objects.js";
|
|
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
|
+
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
|
+
export { withSvgOverrides };
|