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
|
@@ -5,6 +5,12 @@ const require_font_check = require("./font-check.cjs");
|
|
|
5
5
|
const require_content_limits = require("../security/content-limits.cjs");
|
|
6
6
|
const require_image_check = require("./image-check.cjs");
|
|
7
7
|
//#region src/assets/asset-pipeline.ts
|
|
8
|
+
function imageCheckOptions(options) {
|
|
9
|
+
return {
|
|
10
|
+
timeoutMs: options.imageTimeout,
|
|
11
|
+
concurrency: options.maxConcurrentImages
|
|
12
|
+
};
|
|
13
|
+
}
|
|
8
14
|
function cloneDocument(document) {
|
|
9
15
|
return JSON.parse(JSON.stringify(document));
|
|
10
16
|
}
|
|
@@ -54,17 +60,23 @@ function fontWarnings(fonts) {
|
|
|
54
60
|
objectIds: font.objectIds
|
|
55
61
|
}));
|
|
56
62
|
}
|
|
57
|
-
async function inspectAssets(document, options, signal) {
|
|
63
|
+
async function inspectAssets(document, options, signal, onImageProgress) {
|
|
58
64
|
const manifest = require_asset_manifest.buildAssetManifest(document.objects);
|
|
59
65
|
const uniqueImages = [...groupByUrl(require_asset_references.findImageReferences(document.objects).filter((reference) => !require_asset_manifest.isEmbeddedUrl(reference.url)))].map(([url, references]) => ({
|
|
60
66
|
url,
|
|
61
67
|
crossOrigin: references[0].crossOrigin
|
|
62
68
|
}));
|
|
63
|
-
const [
|
|
64
|
-
|
|
69
|
+
const [failures, unavailableFonts] = await Promise.all([options.checkImages === false ? Promise.resolve([]) : require_image_check.findMissingImages(uniqueImages, signal, {
|
|
70
|
+
...imageCheckOptions(options),
|
|
71
|
+
onProgress: onImageProgress
|
|
72
|
+
}), require_font_check.findUnavailableFonts(manifest.fonts, options.loadFont)]);
|
|
73
|
+
const failuresByUrl = new Map(failures.map((failure) => [failure.url, failure]));
|
|
65
74
|
return {
|
|
66
75
|
manifest,
|
|
67
|
-
missingImages: manifest.images.filter((image) =>
|
|
76
|
+
missingImages: manifest.images.filter((image) => failuresByUrl.has(image.url)).map((image) => ({
|
|
77
|
+
...image,
|
|
78
|
+
failure: failuresByUrl.get(image.url)
|
|
79
|
+
})),
|
|
68
80
|
unavailableFonts,
|
|
69
81
|
warnings: [...crossOriginWarnings(document), ...fontWarnings(unavailableFonts)]
|
|
70
82
|
};
|
|
@@ -80,10 +92,10 @@ async function replaceMissingImages(document, missingImages, options, signal) {
|
|
|
80
92
|
const replacement = await replace(image);
|
|
81
93
|
if (typeof replacement === "string" && replacement.length > 0) replacements.set(image.url, replacement);
|
|
82
94
|
}
|
|
83
|
-
const brokenReplacements = new Set(await require_image_check.findMissingImages([...replacements.values()].map((url) => ({
|
|
95
|
+
const brokenReplacements = new Set((await require_image_check.findMissingImages([...replacements.values()].map((url) => ({
|
|
84
96
|
url,
|
|
85
97
|
crossOrigin: null
|
|
86
|
-
})), signal));
|
|
98
|
+
})), signal, imageCheckOptions(options))).map((failure) => failure.url));
|
|
87
99
|
const stillMissing = [];
|
|
88
100
|
const warnings = [];
|
|
89
101
|
const groups = groupByUrl(require_asset_references.findImageReferences(document.objects));
|
|
@@ -106,20 +118,20 @@ async function replaceMissingImages(document, missingImages, options, signal) {
|
|
|
106
118
|
warnings
|
|
107
119
|
};
|
|
108
120
|
}
|
|
109
|
-
async function prepareAssetsForLoad(input, options, signal, isAllowedUrl) {
|
|
121
|
+
async function prepareAssetsForLoad(input, options, signal, isAllowedUrl, onImageProgress) {
|
|
110
122
|
const document = cloneDocument(input);
|
|
111
123
|
const { resolveUrl } = options;
|
|
112
124
|
if (resolveUrl) await rewriteUrls(document, async (url) => resolveUrl(url));
|
|
113
125
|
require_content_limits.refuseUnsafeImageUrls(document.objects, isAllowedUrl);
|
|
114
|
-
const report = await inspectAssets(document, options, signal);
|
|
126
|
+
const report = await inspectAssets(document, options, signal, onImageProgress);
|
|
115
127
|
if (options.requireFonts && report.unavailableFonts.length > 0) {
|
|
116
128
|
const families = report.unavailableFonts.map((font) => font.family).join(", ");
|
|
117
129
|
throw new require_errors.DocumentEngineError("MISSING_FONTS", `These fonts are not available: ${families}`, { missingFonts: report.unavailableFonts });
|
|
118
130
|
}
|
|
119
131
|
const { stillMissing, warnings } = await replaceMissingImages(document, report.missingImages, options, signal);
|
|
120
132
|
if (stillMissing.length > 0) {
|
|
121
|
-
const
|
|
122
|
-
throw new require_errors.DocumentEngineError("MISSING_ASSETS", `These images could not be loaded: ${
|
|
133
|
+
const reasons = stillMissing.map((image) => image.failure ? `${image.url} (${image.failure.reason})` : image.url).join(", ");
|
|
134
|
+
throw new require_errors.DocumentEngineError("MISSING_ASSETS", `These images could not be loaded: ${reasons}`, { missingAssets: stillMissing });
|
|
123
135
|
}
|
|
124
136
|
return {
|
|
125
137
|
document,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AssetManifest, FontAsset, ImageAsset } from "./asset-manifest.cjs";
|
|
2
2
|
import { FontLoader } from "./font-check.cjs";
|
|
3
3
|
//#region src/assets/asset-pipeline.d.ts
|
|
4
|
-
export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED";
|
|
4
|
+
export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED" | "IMAGE_NOT_EMBEDDED" | "FONT_NOT_EMBEDDED" | "TEXT_ON_PATH_APPROXIMATED";
|
|
5
5
|
export interface AssetWarning {
|
|
6
6
|
code: AssetWarningCode;
|
|
7
7
|
message: string;
|
|
@@ -21,6 +21,10 @@ export interface AssetOptions {
|
|
|
21
21
|
loadFont?: FontLoader;
|
|
22
22
|
checkImages?: boolean;
|
|
23
23
|
requireFonts?: boolean;
|
|
24
|
+
/** Milliseconds to wait for each image before it counts as missing. Default 30000. `0` waits forever. */
|
|
25
|
+
imageTimeout?: number;
|
|
26
|
+
/** How many images load at the same time while checking. Default 6. */
|
|
27
|
+
maxConcurrentImages?: number;
|
|
24
28
|
}
|
|
25
29
|
export interface AssetReport {
|
|
26
30
|
manifest: AssetManifest;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AssetManifest, FontAsset, ImageAsset } from "./asset-manifest.js";
|
|
2
2
|
import { FontLoader } from "./font-check.js";
|
|
3
3
|
//#region src/assets/asset-pipeline.d.ts
|
|
4
|
-
export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED";
|
|
4
|
+
export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED" | "IMAGE_NOT_EMBEDDED" | "FONT_NOT_EMBEDDED" | "TEXT_ON_PATH_APPROXIMATED";
|
|
5
5
|
export interface AssetWarning {
|
|
6
6
|
code: AssetWarningCode;
|
|
7
7
|
message: string;
|
|
@@ -21,6 +21,10 @@ export interface AssetOptions {
|
|
|
21
21
|
loadFont?: FontLoader;
|
|
22
22
|
checkImages?: boolean;
|
|
23
23
|
requireFonts?: boolean;
|
|
24
|
+
/** Milliseconds to wait for each image before it counts as missing. Default 30000. `0` waits forever. */
|
|
25
|
+
imageTimeout?: number;
|
|
26
|
+
/** How many images load at the same time while checking. Default 6. */
|
|
27
|
+
maxConcurrentImages?: number;
|
|
24
28
|
}
|
|
25
29
|
export interface AssetReport {
|
|
26
30
|
manifest: AssetManifest;
|
|
@@ -5,6 +5,12 @@ import { findUnavailableFonts } from "./font-check.js";
|
|
|
5
5
|
import { refuseUnsafeImageUrls } from "../security/content-limits.js";
|
|
6
6
|
import { findMissingImages, isCrossOriginUrl, isPortableUrl } from "./image-check.js";
|
|
7
7
|
//#region src/assets/asset-pipeline.ts
|
|
8
|
+
function imageCheckOptions(options) {
|
|
9
|
+
return {
|
|
10
|
+
timeoutMs: options.imageTimeout,
|
|
11
|
+
concurrency: options.maxConcurrentImages
|
|
12
|
+
};
|
|
13
|
+
}
|
|
8
14
|
function cloneDocument(document) {
|
|
9
15
|
return JSON.parse(JSON.stringify(document));
|
|
10
16
|
}
|
|
@@ -54,17 +60,23 @@ function fontWarnings(fonts) {
|
|
|
54
60
|
objectIds: font.objectIds
|
|
55
61
|
}));
|
|
56
62
|
}
|
|
57
|
-
async function inspectAssets(document, options, signal) {
|
|
63
|
+
async function inspectAssets(document, options, signal, onImageProgress) {
|
|
58
64
|
const manifest = buildAssetManifest(document.objects);
|
|
59
65
|
const uniqueImages = [...groupByUrl(findImageReferences(document.objects).filter((reference) => !isEmbeddedUrl(reference.url)))].map(([url, references]) => ({
|
|
60
66
|
url,
|
|
61
67
|
crossOrigin: references[0].crossOrigin
|
|
62
68
|
}));
|
|
63
|
-
const [
|
|
64
|
-
|
|
69
|
+
const [failures, unavailableFonts] = await Promise.all([options.checkImages === false ? Promise.resolve([]) : findMissingImages(uniqueImages, signal, {
|
|
70
|
+
...imageCheckOptions(options),
|
|
71
|
+
onProgress: onImageProgress
|
|
72
|
+
}), findUnavailableFonts(manifest.fonts, options.loadFont)]);
|
|
73
|
+
const failuresByUrl = new Map(failures.map((failure) => [failure.url, failure]));
|
|
65
74
|
return {
|
|
66
75
|
manifest,
|
|
67
|
-
missingImages: manifest.images.filter((image) =>
|
|
76
|
+
missingImages: manifest.images.filter((image) => failuresByUrl.has(image.url)).map((image) => ({
|
|
77
|
+
...image,
|
|
78
|
+
failure: failuresByUrl.get(image.url)
|
|
79
|
+
})),
|
|
68
80
|
unavailableFonts,
|
|
69
81
|
warnings: [...crossOriginWarnings(document), ...fontWarnings(unavailableFonts)]
|
|
70
82
|
};
|
|
@@ -80,10 +92,10 @@ async function replaceMissingImages(document, missingImages, options, signal) {
|
|
|
80
92
|
const replacement = await replace(image);
|
|
81
93
|
if (typeof replacement === "string" && replacement.length > 0) replacements.set(image.url, replacement);
|
|
82
94
|
}
|
|
83
|
-
const brokenReplacements = new Set(await findMissingImages([...replacements.values()].map((url) => ({
|
|
95
|
+
const brokenReplacements = new Set((await findMissingImages([...replacements.values()].map((url) => ({
|
|
84
96
|
url,
|
|
85
97
|
crossOrigin: null
|
|
86
|
-
})), signal));
|
|
98
|
+
})), signal, imageCheckOptions(options))).map((failure) => failure.url));
|
|
87
99
|
const stillMissing = [];
|
|
88
100
|
const warnings = [];
|
|
89
101
|
const groups = groupByUrl(findImageReferences(document.objects));
|
|
@@ -106,20 +118,20 @@ async function replaceMissingImages(document, missingImages, options, signal) {
|
|
|
106
118
|
warnings
|
|
107
119
|
};
|
|
108
120
|
}
|
|
109
|
-
async function prepareAssetsForLoad(input, options, signal, isAllowedUrl) {
|
|
121
|
+
async function prepareAssetsForLoad(input, options, signal, isAllowedUrl, onImageProgress) {
|
|
110
122
|
const document = cloneDocument(input);
|
|
111
123
|
const { resolveUrl } = options;
|
|
112
124
|
if (resolveUrl) await rewriteUrls(document, async (url) => resolveUrl(url));
|
|
113
125
|
refuseUnsafeImageUrls(document.objects, isAllowedUrl);
|
|
114
|
-
const report = await inspectAssets(document, options, signal);
|
|
126
|
+
const report = await inspectAssets(document, options, signal, onImageProgress);
|
|
115
127
|
if (options.requireFonts && report.unavailableFonts.length > 0) {
|
|
116
128
|
const families = report.unavailableFonts.map((font) => font.family).join(", ");
|
|
117
129
|
throw new DocumentEngineError("MISSING_FONTS", `These fonts are not available: ${families}`, { missingFonts: report.unavailableFonts });
|
|
118
130
|
}
|
|
119
131
|
const { stillMissing, warnings } = await replaceMissingImages(document, report.missingImages, options, signal);
|
|
120
132
|
if (stillMissing.length > 0) {
|
|
121
|
-
const
|
|
122
|
-
throw new DocumentEngineError("MISSING_ASSETS", `These images could not be loaded: ${
|
|
133
|
+
const reasons = stillMissing.map((image) => image.failure ? `${image.url} (${image.failure.reason})` : image.url).join(", ");
|
|
134
|
+
throw new DocumentEngineError("MISSING_ASSETS", `These images could not be loaded: ${reasons}`, { missingAssets: stillMissing });
|
|
123
135
|
}
|
|
124
136
|
return {
|
|
125
137
|
document,
|
|
@@ -29,9 +29,17 @@ function cssFamily(family) {
|
|
|
29
29
|
function fontDescriptor(font, size = 32) {
|
|
30
30
|
return `${font.style} ${font.weight} ${size}px ${cssFamily(firstFamily(font.family))}`;
|
|
31
31
|
}
|
|
32
|
+
let measuringContext;
|
|
33
|
+
/** One small canvas for every check, rather than a new one each time. */
|
|
32
34
|
function createMeasuringContext() {
|
|
33
35
|
if (typeof document === "undefined") return null;
|
|
34
|
-
|
|
36
|
+
if (measuringContext === void 0) {
|
|
37
|
+
const canvas = document.createElement("canvas");
|
|
38
|
+
canvas.width = 1;
|
|
39
|
+
canvas.height = 1;
|
|
40
|
+
measuringContext = canvas.getContext("2d");
|
|
41
|
+
}
|
|
42
|
+
return measuringContext;
|
|
35
43
|
}
|
|
36
44
|
function isFontRenderable(font, context = createMeasuringContext()) {
|
|
37
45
|
const family = firstFamily(font.family);
|
|
@@ -29,9 +29,17 @@ function cssFamily(family) {
|
|
|
29
29
|
function fontDescriptor(font, size = 32) {
|
|
30
30
|
return `${font.style} ${font.weight} ${size}px ${cssFamily(firstFamily(font.family))}`;
|
|
31
31
|
}
|
|
32
|
+
let measuringContext;
|
|
33
|
+
/** One small canvas for every check, rather than a new one each time. */
|
|
32
34
|
function createMeasuringContext() {
|
|
33
35
|
if (typeof document === "undefined") return null;
|
|
34
|
-
|
|
36
|
+
if (measuringContext === void 0) {
|
|
37
|
+
const canvas = document.createElement("canvas");
|
|
38
|
+
canvas.width = 1;
|
|
39
|
+
canvas.height = 1;
|
|
40
|
+
measuringContext = canvas.getContext("2d");
|
|
41
|
+
}
|
|
42
|
+
return measuringContext;
|
|
35
43
|
}
|
|
36
44
|
function isFontRenderable(font, context = createMeasuringContext()) {
|
|
37
45
|
const family = firstFamily(font.family);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
const require_concurrency = require("../util/concurrency.cjs");
|
|
1
2
|
let fabric = require("fabric");
|
|
2
3
|
//#region src/assets/image-check.ts
|
|
4
|
+
const DEFAULT_IMAGE_TIMEOUT_MS = 3e4;
|
|
3
5
|
function isCrossOriginUrl(url) {
|
|
4
6
|
const location = globalThis.location;
|
|
5
7
|
if (!location || url.startsWith("data:") || url.startsWith("blob:")) return false;
|
|
@@ -22,14 +24,94 @@ function isSameUrl(first, second) {
|
|
|
22
24
|
return false;
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
function shortUrl(url) {
|
|
28
|
+
return url.length > 80 ? `${url.slice(0, 77)}...` : url;
|
|
29
|
+
}
|
|
30
|
+
function failure(url, reason, message, extra = {}) {
|
|
31
|
+
return {
|
|
32
|
+
url,
|
|
33
|
+
reason,
|
|
34
|
+
message: `The image ${shortUrl(url)} ${message}`,
|
|
35
|
+
...extra
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function fetchHeaders(url, init) {
|
|
39
|
+
const response = await fetch(url, {
|
|
40
|
+
cache: "no-store",
|
|
41
|
+
...init
|
|
42
|
+
});
|
|
43
|
+
response.body?.cancel().catch(() => void 0);
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
46
|
+
/** Works out why an image failed to load, after it already failed. */
|
|
47
|
+
async function explainFailure(image, signal) {
|
|
48
|
+
const { url } = image;
|
|
49
|
+
if (url.startsWith("data:")) return failure(url, "DECODE", "is embedded data the browser cannot decode as a picture");
|
|
50
|
+
if (typeof fetch !== "function") return failure(url, "NETWORK", "could not be loaded");
|
|
51
|
+
const credentials = image.crossOrigin === "use-credentials" ? "include" : "same-origin";
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetchHeaders(url, {
|
|
54
|
+
mode: "cors",
|
|
55
|
+
credentials,
|
|
56
|
+
signal
|
|
57
|
+
});
|
|
58
|
+
if (response.ok) return failure(url, "DECODE", "downloaded, but the browser could not decode it as a picture", { status: response.status });
|
|
59
|
+
if (response.status === 404 || response.status === 410) return failure(url, "NOT_FOUND", `was not found (HTTP ${response.status})`, { status: response.status });
|
|
60
|
+
return failure(url, "HTTP_ERROR", `could not be downloaded (HTTP ${response.status})`, { status: response.status });
|
|
61
|
+
} catch {
|
|
62
|
+
if (signal.aborted) return failure(url, "ABORTED", "stopped loading because the work was cancelled");
|
|
63
|
+
}
|
|
64
|
+
if (isCrossOriginUrl(url)) try {
|
|
65
|
+
await fetchHeaders(url, {
|
|
66
|
+
mode: "no-cors",
|
|
67
|
+
signal
|
|
68
|
+
});
|
|
69
|
+
if (image.crossOrigin) return failure(url, "CORS", "comes from another site that does not send CORS headers (Access-Control-Allow-Origin) for this page");
|
|
70
|
+
return failure(url, "NETWORK", "comes from another site that answered, but the browser hides the reason it failed. Check the URL and the server.");
|
|
71
|
+
} catch {
|
|
72
|
+
if (signal.aborted) return failure(url, "ABORTED", "stopped loading because the work was cancelled");
|
|
73
|
+
}
|
|
74
|
+
return failure(url, "NETWORK", "could not be reached. Check the URL and the network connection.");
|
|
75
|
+
}
|
|
76
|
+
async function checkImage(image, signal, timeoutMs) {
|
|
77
|
+
const attempt = new AbortController();
|
|
78
|
+
let timedOut = false;
|
|
79
|
+
const stop = () => attempt.abort();
|
|
80
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
81
|
+
const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => {
|
|
82
|
+
timedOut = true;
|
|
83
|
+
attempt.abort();
|
|
84
|
+
}, timeoutMs) : void 0;
|
|
85
|
+
try {
|
|
86
|
+
if (signal.aborted) attempt.abort();
|
|
87
|
+
await fabric.util.loadImage(image.url, {
|
|
88
|
+
signal: attempt.signal,
|
|
89
|
+
crossOrigin: image.crossOrigin ?? null
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
} catch {
|
|
93
|
+
if (timedOut) return failure(image.url, "TIMEOUT", `did not finish loading within ${timeoutMs} ms`, { timeoutMs });
|
|
94
|
+
if (signal.aborted) return failure(image.url, "ABORTED", "stopped loading because the work was cancelled");
|
|
95
|
+
return explainFailure(image, signal);
|
|
96
|
+
} finally {
|
|
97
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
98
|
+
signal.removeEventListener("abort", stop);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Loads every image, a few at a time, and returns the ones that failed. */
|
|
102
|
+
async function findMissingImages(images, signal, options = {}) {
|
|
103
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
104
|
+
let done = 0;
|
|
105
|
+
options.onProgress?.(0, images.length);
|
|
106
|
+
return (await require_concurrency.mapWithConcurrency(images, options.concurrency ?? 6, async (image) => {
|
|
107
|
+
const result = await checkImage(image, signal, timeoutMs);
|
|
108
|
+
done += 1;
|
|
109
|
+
options.onProgress?.(done, images.length);
|
|
110
|
+
return result;
|
|
111
|
+
})).filter((result) => result !== void 0);
|
|
31
112
|
}
|
|
32
113
|
//#endregion
|
|
114
|
+
exports.DEFAULT_IMAGE_TIMEOUT_MS = DEFAULT_IMAGE_TIMEOUT_MS;
|
|
33
115
|
exports.findMissingImages = findMissingImages;
|
|
34
116
|
exports.isCrossOriginUrl = isCrossOriginUrl;
|
|
35
117
|
exports.isPortableUrl = isPortableUrl;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/assets/image-check.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Why an image could not be loaded. Browsers hide some details on purpose:
|
|
4
|
+
* a server on another site that sends no CORS headers looks the same as a
|
|
5
|
+
* network failure unless the image asked for CORS.
|
|
6
|
+
*/
|
|
7
|
+
export type ImageFailureReason = "NOT_FOUND" | "HTTP_ERROR" | "CORS" | "NETWORK" | "TIMEOUT" | "DECODE" | "ABORTED";
|
|
8
|
+
export interface ImageLoadFailure {
|
|
9
|
+
url: string;
|
|
10
|
+
reason: ImageFailureReason;
|
|
11
|
+
message: string;
|
|
12
|
+
/** The HTTP status, when the browser lets the page read it. */
|
|
13
|
+
status?: number;
|
|
14
|
+
/** The time limit that ran out, for `TIMEOUT`. */
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/assets/image-check.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Why an image could not be loaded. Browsers hide some details on purpose:
|
|
4
|
+
* a server on another site that sends no CORS headers looks the same as a
|
|
5
|
+
* network failure unless the image asked for CORS.
|
|
6
|
+
*/
|
|
7
|
+
export type ImageFailureReason = "NOT_FOUND" | "HTTP_ERROR" | "CORS" | "NETWORK" | "TIMEOUT" | "DECODE" | "ABORTED";
|
|
8
|
+
export interface ImageLoadFailure {
|
|
9
|
+
url: string;
|
|
10
|
+
reason: ImageFailureReason;
|
|
11
|
+
message: string;
|
|
12
|
+
/** The HTTP status, when the browser lets the page read it. */
|
|
13
|
+
status?: number;
|
|
14
|
+
/** The time limit that ran out, for `TIMEOUT`. */
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { mapWithConcurrency } from "../util/concurrency.js";
|
|
1
2
|
import { util } from "fabric";
|
|
2
3
|
//#region src/assets/image-check.ts
|
|
4
|
+
const DEFAULT_IMAGE_TIMEOUT_MS = 3e4;
|
|
3
5
|
function isCrossOriginUrl(url) {
|
|
4
6
|
const location = globalThis.location;
|
|
5
7
|
if (!location || url.startsWith("data:") || url.startsWith("blob:")) return false;
|
|
@@ -22,12 +24,91 @@ function isSameUrl(first, second) {
|
|
|
22
24
|
return false;
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
function shortUrl(url) {
|
|
28
|
+
return url.length > 80 ? `${url.slice(0, 77)}...` : url;
|
|
29
|
+
}
|
|
30
|
+
function failure(url, reason, message, extra = {}) {
|
|
31
|
+
return {
|
|
32
|
+
url,
|
|
33
|
+
reason,
|
|
34
|
+
message: `The image ${shortUrl(url)} ${message}`,
|
|
35
|
+
...extra
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function fetchHeaders(url, init) {
|
|
39
|
+
const response = await fetch(url, {
|
|
40
|
+
cache: "no-store",
|
|
41
|
+
...init
|
|
42
|
+
});
|
|
43
|
+
response.body?.cancel().catch(() => void 0);
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
46
|
+
/** Works out why an image failed to load, after it already failed. */
|
|
47
|
+
async function explainFailure(image, signal) {
|
|
48
|
+
const { url } = image;
|
|
49
|
+
if (url.startsWith("data:")) return failure(url, "DECODE", "is embedded data the browser cannot decode as a picture");
|
|
50
|
+
if (typeof fetch !== "function") return failure(url, "NETWORK", "could not be loaded");
|
|
51
|
+
const credentials = image.crossOrigin === "use-credentials" ? "include" : "same-origin";
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetchHeaders(url, {
|
|
54
|
+
mode: "cors",
|
|
55
|
+
credentials,
|
|
56
|
+
signal
|
|
57
|
+
});
|
|
58
|
+
if (response.ok) return failure(url, "DECODE", "downloaded, but the browser could not decode it as a picture", { status: response.status });
|
|
59
|
+
if (response.status === 404 || response.status === 410) return failure(url, "NOT_FOUND", `was not found (HTTP ${response.status})`, { status: response.status });
|
|
60
|
+
return failure(url, "HTTP_ERROR", `could not be downloaded (HTTP ${response.status})`, { status: response.status });
|
|
61
|
+
} catch {
|
|
62
|
+
if (signal.aborted) return failure(url, "ABORTED", "stopped loading because the work was cancelled");
|
|
63
|
+
}
|
|
64
|
+
if (isCrossOriginUrl(url)) try {
|
|
65
|
+
await fetchHeaders(url, {
|
|
66
|
+
mode: "no-cors",
|
|
67
|
+
signal
|
|
68
|
+
});
|
|
69
|
+
if (image.crossOrigin) return failure(url, "CORS", "comes from another site that does not send CORS headers (Access-Control-Allow-Origin) for this page");
|
|
70
|
+
return failure(url, "NETWORK", "comes from another site that answered, but the browser hides the reason it failed. Check the URL and the server.");
|
|
71
|
+
} catch {
|
|
72
|
+
if (signal.aborted) return failure(url, "ABORTED", "stopped loading because the work was cancelled");
|
|
73
|
+
}
|
|
74
|
+
return failure(url, "NETWORK", "could not be reached. Check the URL and the network connection.");
|
|
75
|
+
}
|
|
76
|
+
async function checkImage(image, signal, timeoutMs) {
|
|
77
|
+
const attempt = new AbortController();
|
|
78
|
+
let timedOut = false;
|
|
79
|
+
const stop = () => attempt.abort();
|
|
80
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
81
|
+
const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => {
|
|
82
|
+
timedOut = true;
|
|
83
|
+
attempt.abort();
|
|
84
|
+
}, timeoutMs) : void 0;
|
|
85
|
+
try {
|
|
86
|
+
if (signal.aborted) attempt.abort();
|
|
87
|
+
await util.loadImage(image.url, {
|
|
88
|
+
signal: attempt.signal,
|
|
89
|
+
crossOrigin: image.crossOrigin ?? null
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
} catch {
|
|
93
|
+
if (timedOut) return failure(image.url, "TIMEOUT", `did not finish loading within ${timeoutMs} ms`, { timeoutMs });
|
|
94
|
+
if (signal.aborted) return failure(image.url, "ABORTED", "stopped loading because the work was cancelled");
|
|
95
|
+
return explainFailure(image, signal);
|
|
96
|
+
} finally {
|
|
97
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
98
|
+
signal.removeEventListener("abort", stop);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Loads every image, a few at a time, and returns the ones that failed. */
|
|
102
|
+
async function findMissingImages(images, signal, options = {}) {
|
|
103
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
104
|
+
let done = 0;
|
|
105
|
+
options.onProgress?.(0, images.length);
|
|
106
|
+
return (await mapWithConcurrency(images, options.concurrency ?? 6, async (image) => {
|
|
107
|
+
const result = await checkImage(image, signal, timeoutMs);
|
|
108
|
+
done += 1;
|
|
109
|
+
options.onProgress?.(done, images.length);
|
|
110
|
+
return result;
|
|
111
|
+
})).filter((result) => result !== void 0);
|
|
31
112
|
}
|
|
32
113
|
//#endregion
|
|
33
|
-
export { findMissingImages, isCrossOriginUrl, isPortableUrl, isSameUrl };
|
|
114
|
+
export { DEFAULT_IMAGE_TIMEOUT_MS, findMissingImages, isCrossOriginUrl, isPortableUrl, isSameUrl };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const require_errors = require("../engine/errors.cjs");
|
|
2
|
+
const require_content_limits = require("../security/content-limits.cjs");
|
|
3
|
+
const require_object_ids = require("../fabric/object-ids.cjs");
|
|
4
|
+
const require_walk_objects = require("../fabric/walk-objects.cjs");
|
|
5
|
+
const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
|
|
6
|
+
let fabric = require("fabric");
|
|
7
|
+
//#region src/commands/clipboard.ts
|
|
8
|
+
const CLIPBOARD_FORMAT = "fabricjs-document-engine/objects";
|
|
9
|
+
function clone(value) {
|
|
10
|
+
return JSON.parse(JSON.stringify(value));
|
|
11
|
+
}
|
|
12
|
+
/** Removes every id, so the engine gives each pasted object, child and clip path a new one. */
|
|
13
|
+
function withoutIds(object) {
|
|
14
|
+
const copy = { ...object };
|
|
15
|
+
delete copy.id;
|
|
16
|
+
if (Array.isArray(copy.objects)) copy.objects = copy.objects.map(withoutIds);
|
|
17
|
+
if (copy.clipPath && typeof copy.clipPath === "object") copy.clipPath = withoutIds(copy.clipPath);
|
|
18
|
+
return copy;
|
|
19
|
+
}
|
|
20
|
+
function moved(object, distance) {
|
|
21
|
+
const left = typeof object.left === "number" ? object.left : 0;
|
|
22
|
+
const top = typeof object.top === "number" ? object.top : 0;
|
|
23
|
+
return {
|
|
24
|
+
...object,
|
|
25
|
+
left: left + distance,
|
|
26
|
+
top: top + distance
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function refuseInvalidContent(message) {
|
|
30
|
+
throw new require_errors.DocumentEngineError("INVALID_DOCUMENT", `The clipboard content is not valid: ${message}`);
|
|
31
|
+
}
|
|
32
|
+
function checkContent(content, limits) {
|
|
33
|
+
if (typeof content !== "object" || content === null) refuseInvalidContent("it is not an object");
|
|
34
|
+
const candidate = content;
|
|
35
|
+
if (candidate.format !== "fabricjs-document-engine/objects") refuseInvalidContent(`its format is not "${CLIPBOARD_FORMAT}"`);
|
|
36
|
+
if (candidate.version !== 1) refuseInvalidContent("its version is not 1");
|
|
37
|
+
if (!Array.isArray(candidate.objects)) refuseInvalidContent("it has no objects list");
|
|
38
|
+
const secured = require_content_limits.secureDocument({ objects: candidate.objects }, limits);
|
|
39
|
+
const objects = secured.objects.filter((object) => typeof object === "object" && object !== null && typeof object.type === "string");
|
|
40
|
+
if (objects.length !== secured.objects.length) refuseInvalidContent("every object needs a type");
|
|
41
|
+
require_content_limits.refuseUnsafeImageUrls(objects, limits?.isAllowedUrl);
|
|
42
|
+
return {
|
|
43
|
+
format: CLIPBOARD_FORMAT,
|
|
44
|
+
version: 1,
|
|
45
|
+
objects
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function topLevelInOrder(engine, objects) {
|
|
49
|
+
const stack = engine.canvas.getObjects();
|
|
50
|
+
const wanted = new Set(objects);
|
|
51
|
+
return stack.filter((object) => wanted.has(object));
|
|
52
|
+
}
|
|
53
|
+
function selectionOf(engine) {
|
|
54
|
+
return engine.canvas.getActiveObjects?.() ?? [];
|
|
55
|
+
}
|
|
56
|
+
function refuseUnknownTypes(objects) {
|
|
57
|
+
const unknownTypes = [...require_walk_objects.collectSerializedTypes(objects)].filter((type) => !fabric.classRegistry.has(type));
|
|
58
|
+
if (unknownTypes.length === 0) return;
|
|
59
|
+
throw new require_errors.DocumentEngineError("UNKNOWN_OBJECT_TYPE", `The clipboard holds object types that are not registered: ${unknownTypes.join(", ")}`, { unknownTypes });
|
|
60
|
+
}
|
|
61
|
+
function select(engine, objects) {
|
|
62
|
+
const canvas = engine.canvas;
|
|
63
|
+
if (typeof canvas.setActiveObject !== "function" || objects.length === 0) return;
|
|
64
|
+
canvas.discardActiveObject?.();
|
|
65
|
+
canvas.setActiveObject(objects.length === 1 ? objects[0] : new fabric.ActiveSelection(objects, { canvas: engine.canvas }));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A clipboard for one engine. Copies keep custom properties and the exact
|
|
69
|
+
* position of objects in groups and selections; pastes get fresh ids for
|
|
70
|
+
* every object and child and count as one undo step.
|
|
71
|
+
*/
|
|
72
|
+
function createClipboard(engine, options = {}) {
|
|
73
|
+
const defaultOffset = options.offset ?? 10;
|
|
74
|
+
let content;
|
|
75
|
+
let pastes = 0;
|
|
76
|
+
function copyObjects(objects) {
|
|
77
|
+
const chosen = topLevelInOrder(engine, objects);
|
|
78
|
+
if (chosen.length === 0) return 0;
|
|
79
|
+
const document = engine.toDocument();
|
|
80
|
+
const serialized = chosen.map((object) => require_object_ids.readObjectId(object)).map((id) => document.objects.find((object) => object.id === id)).filter((object) => object !== void 0);
|
|
81
|
+
content = {
|
|
82
|
+
format: CLIPBOARD_FORMAT,
|
|
83
|
+
version: 1,
|
|
84
|
+
objects: clone(serialized)
|
|
85
|
+
};
|
|
86
|
+
return serialized.length;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
copy(objects) {
|
|
90
|
+
const count = copyObjects(objects ?? selectionOf(engine));
|
|
91
|
+
if (count > 0) pastes = 0;
|
|
92
|
+
return count;
|
|
93
|
+
},
|
|
94
|
+
cut(objects) {
|
|
95
|
+
const chosen = topLevelInOrder(engine, objects ?? selectionOf(engine));
|
|
96
|
+
const count = copyObjects(chosen);
|
|
97
|
+
if (count === 0) return 0;
|
|
98
|
+
pastes = -1;
|
|
99
|
+
engine.transaction(count === 1 ? "Cut" : `Cut ${count} objects`, () => {
|
|
100
|
+
engine.canvas.discardActiveObject?.();
|
|
101
|
+
engine.canvas.remove(...chosen);
|
|
102
|
+
});
|
|
103
|
+
engine.canvas.requestRenderAll();
|
|
104
|
+
return count;
|
|
105
|
+
},
|
|
106
|
+
async paste(pasteOptions = {}) {
|
|
107
|
+
if (!content) return [];
|
|
108
|
+
const target = pasteOptions.target ?? engine;
|
|
109
|
+
const offset = pasteOptions.offset ?? defaultOffset;
|
|
110
|
+
pastes += 1;
|
|
111
|
+
const distance = offset * pastes;
|
|
112
|
+
const serialized = content.objects.map((object) => moved(withoutIds(object), distance));
|
|
113
|
+
refuseUnknownTypes(serialized);
|
|
114
|
+
const created = await require_fabric_adapter.createObjects(clone(serialized));
|
|
115
|
+
target.transaction(created.length === 1 ? "Paste" : `Paste ${created.length} objects`, () => {
|
|
116
|
+
target.canvas.add(...created);
|
|
117
|
+
});
|
|
118
|
+
if (pasteOptions.select ?? true) select(target, created);
|
|
119
|
+
target.canvas.requestRenderAll();
|
|
120
|
+
return created;
|
|
121
|
+
},
|
|
122
|
+
hasContent: () => content !== void 0,
|
|
123
|
+
read: () => content ? clone(content) : void 0,
|
|
124
|
+
write(next) {
|
|
125
|
+
content = checkContent(next, options.limits);
|
|
126
|
+
pastes = 0;
|
|
127
|
+
},
|
|
128
|
+
clear() {
|
|
129
|
+
content = void 0;
|
|
130
|
+
pastes = 0;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
exports.CLIPBOARD_FORMAT = CLIPBOARD_FORMAT;
|
|
136
|
+
exports.createClipboard = createClipboard;
|