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.
Files changed (89) hide show
  1. package/README.md +181 -10
  2. package/README.zh-CN.md +181 -10
  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 +22 -10
  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 +22 -10
  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 +88 -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 +88 -7
  15. package/dist/commands/clipboard.cjs +136 -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 +135 -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/engine/create-document-engine.cjs +85 -14
  24. package/dist/engine/create-document-engine.d.cts +20 -1
  25. package/dist/engine/create-document-engine.d.ts +20 -1
  26. package/dist/engine/create-document-engine.js +86 -15
  27. package/dist/engine/errors.d.cts +1 -1
  28. package/dist/engine/errors.d.ts +1 -1
  29. package/dist/export/batch-render.cjs +172 -0
  30. package/dist/export/batch-render.d.cts +43 -0
  31. package/dist/export/batch-render.d.ts +43 -0
  32. package/dist/export/batch-render.js +171 -0
  33. package/dist/export/export-options.cjs +18 -1
  34. package/dist/export/export-options.d.cts +23 -0
  35. package/dist/export/export-options.d.ts +23 -0
  36. package/dist/export/export-options.js +18 -2
  37. package/dist/export/preflight-export.cjs +11 -2
  38. package/dist/export/preflight-export.d.cts +1 -1
  39. package/dist/export/preflight-export.d.ts +1 -1
  40. package/dist/export/preflight-export.js +11 -2
  41. package/dist/export/render-export.cjs +18 -3
  42. package/dist/export/render-export.js +18 -4
  43. package/dist/export/svg/embed-assets.cjs +249 -0
  44. package/dist/export/svg/embed-assets.js +249 -0
  45. package/dist/export/svg/overrides.cjs +32 -0
  46. package/dist/export/svg/overrides.js +32 -0
  47. package/dist/export/svg/text-on-path.cjs +252 -0
  48. package/dist/export/svg/text-on-path.js +249 -0
  49. package/dist/fabric/fabric-adapter.cjs +46 -5
  50. package/dist/fabric/fabric-adapter.js +46 -5
  51. package/dist/fabric/object-registry.cjs +1 -1
  52. package/dist/fabric/object-registry.js +1 -1
  53. package/dist/import/svg-import.cjs +256 -0
  54. package/dist/import/svg-import.d.cts +50 -0
  55. package/dist/import/svg-import.d.ts +50 -0
  56. package/dist/import/svg-import.js +256 -0
  57. package/dist/index.cjs +12 -0
  58. package/dist/index.d.cts +9 -4
  59. package/dist/index.d.ts +9 -4
  60. package/dist/index.js +4 -1
  61. package/dist/pdf/export-pdf.cjs +440 -0
  62. package/dist/pdf/export-pdf.d.cts +73 -0
  63. package/dist/pdf/export-pdf.d.ts +73 -0
  64. package/dist/pdf/export-pdf.js +440 -0
  65. package/dist/pdf/fonts.cjs +115 -0
  66. package/dist/pdf/fonts.d.cts +12 -0
  67. package/dist/pdf/fonts.d.ts +12 -0
  68. package/dist/pdf/fonts.js +112 -0
  69. package/dist/pdf/page-layout.cjs +79 -0
  70. package/dist/pdf/page-layout.d.cts +51 -0
  71. package/dist/pdf/page-layout.d.ts +51 -0
  72. package/dist/pdf/page-layout.js +76 -0
  73. package/dist/pdf/text-decorations.cjs +108 -0
  74. package/dist/pdf/text-decorations.js +107 -0
  75. package/dist/pdf.cjs +7 -0
  76. package/dist/pdf.d.cts +4 -0
  77. package/dist/pdf.d.ts +4 -0
  78. package/dist/pdf.js +3 -0
  79. package/dist/react/use-layers.cjs +53 -0
  80. package/dist/react/use-layers.d.cts +10 -0
  81. package/dist/react/use-layers.d.ts +10 -0
  82. package/dist/react/use-layers.js +53 -0
  83. package/dist/react.cjs +2 -0
  84. package/dist/react.d.cts +3 -1
  85. package/dist/react.d.ts +3 -1
  86. package/dist/react.js +2 -1
  87. package/dist/util/concurrency.cjs +28 -0
  88. package/dist/util/concurrency.js +27 -0
  89. 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 [missingUrls, unavailableFonts] = await Promise.all([options.checkImages === false ? Promise.resolve([]) : require_image_check.findMissingImages(uniqueImages, signal), require_font_check.findUnavailableFonts(manifest.fonts, options.loadFont)]);
64
- const missing = new Set(missingUrls);
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) => missing.has(image.url)),
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 urls = stillMissing.map((image) => image.url).join(", ");
122
- throw new require_errors.DocumentEngineError("MISSING_ASSETS", `These images could not be loaded: ${urls}`, { missingAssets: stillMissing });
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 [missingUrls, unavailableFonts] = await Promise.all([options.checkImages === false ? Promise.resolve([]) : findMissingImages(uniqueImages, signal), findUnavailableFonts(manifest.fonts, options.loadFont)]);
64
- const missing = new Set(missingUrls);
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) => missing.has(image.url)),
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 urls = stillMissing.map((image) => image.url).join(", ");
122
- throw new DocumentEngineError("MISSING_ASSETS", `These images could not be loaded: ${urls}`, { missingAssets: stillMissing });
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
- return document.createElement("canvas").getContext("2d");
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
- return document.createElement("canvas").getContext("2d");
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
- async function findMissingImages(images, signal) {
26
- const results = await Promise.allSettled(images.map((image) => fabric.util.loadImage(image.url, {
27
- signal,
28
- crossOrigin: image.crossOrigin ?? null
29
- })));
30
- return images.filter((_, index) => results[index].status === "rejected").map((image) => image.url);
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
- async function findMissingImages(images, signal) {
26
- const results = await Promise.allSettled(images.map((image) => util.loadImage(image.url, {
27
- signal,
28
- crossOrigin: image.crossOrigin ?? null
29
- })));
30
- return images.filter((_, index) => results[index].status === "rejected").map((image) => image.url);
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;