fabricjs-document-engine 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +220 -11
  2. package/README.zh-CN.md +220 -11
  3. package/dist/assets/asset-manifest.d.cts +3 -0
  4. package/dist/assets/asset-manifest.d.ts +3 -0
  5. package/dist/assets/asset-pipeline.cjs +31 -17
  6. package/dist/assets/asset-pipeline.d.cts +5 -1
  7. package/dist/assets/asset-pipeline.d.ts +5 -1
  8. package/dist/assets/asset-pipeline.js +31 -17
  9. package/dist/assets/font-check.cjs +9 -1
  10. package/dist/assets/font-check.js +9 -1
  11. package/dist/assets/image-check.cjs +89 -6
  12. package/dist/assets/image-check.d.cts +17 -0
  13. package/dist/assets/image-check.d.ts +17 -0
  14. package/dist/assets/image-check.js +89 -7
  15. package/dist/commands/clipboard.cjs +141 -0
  16. package/dist/commands/clipboard.d.cts +47 -0
  17. package/dist/commands/clipboard.d.ts +47 -0
  18. package/dist/commands/clipboard.js +140 -0
  19. package/dist/commands/layers.cjs +97 -0
  20. package/dist/commands/layers.d.cts +39 -0
  21. package/dist/commands/layers.d.ts +39 -0
  22. package/dist/commands/layers.js +92 -0
  23. package/dist/document/document-format.d.cts +15 -0
  24. package/dist/document/document-format.d.ts +15 -0
  25. package/dist/document/validate-document.cjs +8 -0
  26. package/dist/document/validate-document.js +8 -0
  27. package/dist/engine/create-document-engine.cjs +196 -31
  28. package/dist/engine/create-document-engine.d.cts +44 -7
  29. package/dist/engine/create-document-engine.d.ts +44 -7
  30. package/dist/engine/create-document-engine.js +197 -32
  31. package/dist/engine/errors.d.cts +1 -1
  32. package/dist/engine/errors.d.ts +1 -1
  33. package/dist/export/batch-render.cjs +172 -0
  34. package/dist/export/batch-render.d.cts +43 -0
  35. package/dist/export/batch-render.d.ts +43 -0
  36. package/dist/export/batch-render.js +171 -0
  37. package/dist/export/export-options.cjs +21 -1
  38. package/dist/export/export-options.d.cts +29 -0
  39. package/dist/export/export-options.d.ts +29 -0
  40. package/dist/export/export-options.js +21 -2
  41. package/dist/export/preflight-export.cjs +11 -2
  42. package/dist/export/preflight-export.d.cts +1 -1
  43. package/dist/export/preflight-export.d.ts +1 -1
  44. package/dist/export/preflight-export.js +11 -2
  45. package/dist/export/render-export.cjs +57 -4
  46. package/dist/export/render-export.js +57 -5
  47. package/dist/export/svg/clip-masks.cjs +39 -0
  48. package/dist/export/svg/clip-masks.js +37 -0
  49. package/dist/export/svg/embed-assets.cjs +250 -0
  50. package/dist/export/svg/embed-assets.js +250 -0
  51. package/dist/export/svg/overrides.cjs +32 -0
  52. package/dist/export/svg/overrides.js +32 -0
  53. package/dist/export/svg/raster-object.cjs +82 -0
  54. package/dist/export/svg/raster-object.js +81 -0
  55. package/dist/export/svg/text-decorations.cjs +108 -0
  56. package/dist/export/svg/text-decorations.js +107 -0
  57. package/dist/export/svg/text-on-path.cjs +242 -0
  58. package/dist/export/svg/text-on-path.js +240 -0
  59. package/dist/fabric/fabric-adapter.cjs +49 -6
  60. package/dist/fabric/fabric-adapter.js +49 -6
  61. package/dist/fabric/object-registry.cjs +1 -1
  62. package/dist/fabric/object-registry.js +1 -1
  63. package/dist/fabric/page-state.cjs +69 -0
  64. package/dist/fabric/page-state.js +64 -0
  65. package/dist/history/apply-state.cjs +19 -2
  66. package/dist/history/apply-state.js +19 -2
  67. package/dist/history/create-history.cjs +53 -13
  68. package/dist/history/create-history.d.cts +8 -0
  69. package/dist/history/create-history.d.ts +8 -0
  70. package/dist/history/create-history.js +53 -13
  71. package/dist/history/snapshot.cjs +10 -1
  72. package/dist/history/snapshot.js +10 -2
  73. package/dist/import/svg-import.cjs +256 -0
  74. package/dist/import/svg-import.d.cts +50 -0
  75. package/dist/import/svg-import.d.ts +50 -0
  76. package/dist/import/svg-import.js +256 -0
  77. package/dist/index.cjs +12 -0
  78. package/dist/index.d.cts +10 -5
  79. package/dist/index.d.ts +10 -5
  80. package/dist/index.js +4 -1
  81. package/dist/migrations/migrate-document.cjs +2 -1
  82. package/dist/migrations/migrate-document.js +2 -1
  83. package/dist/pdf/export-pdf.cjs +381 -0
  84. package/dist/pdf/export-pdf.d.cts +73 -0
  85. package/dist/pdf/export-pdf.d.ts +73 -0
  86. package/dist/pdf/export-pdf.js +381 -0
  87. package/dist/pdf/fonts.cjs +115 -0
  88. package/dist/pdf/fonts.d.cts +12 -0
  89. package/dist/pdf/fonts.d.ts +12 -0
  90. package/dist/pdf/fonts.js +112 -0
  91. package/dist/pdf/page-layout.cjs +79 -0
  92. package/dist/pdf/page-layout.d.cts +51 -0
  93. package/dist/pdf/page-layout.d.ts +51 -0
  94. package/dist/pdf/page-layout.js +76 -0
  95. package/dist/pdf.cjs +7 -0
  96. package/dist/pdf.d.cts +4 -0
  97. package/dist/pdf.d.ts +4 -0
  98. package/dist/pdf.js +3 -0
  99. package/dist/react/use-layers.cjs +53 -0
  100. package/dist/react/use-layers.d.cts +10 -0
  101. package/dist/react/use-layers.d.ts +10 -0
  102. package/dist/react/use-layers.js +53 -0
  103. package/dist/react.cjs +2 -0
  104. package/dist/react.d.cts +3 -1
  105. package/dist/react.d.ts +3 -1
  106. package/dist/react.js +2 -1
  107. package/dist/recovery/recovery-controller.cjs +148 -29
  108. package/dist/recovery/recovery-controller.d.cts +8 -0
  109. package/dist/recovery/recovery-controller.d.ts +8 -0
  110. package/dist/recovery/recovery-controller.js +148 -29
  111. package/dist/security/content-limits.cjs +43 -3
  112. package/dist/security/content-limits.d.cts +8 -0
  113. package/dist/security/content-limits.d.ts +8 -0
  114. package/dist/security/content-limits.js +39 -4
  115. package/dist/util/concurrency.cjs +41 -0
  116. package/dist/util/concurrency.js +40 -0
  117. package/package.json +30 -3
  118. package/schema/document-v1.schema.json +5 -1
@@ -1,6 +1,7 @@
1
1
  import { buildAssetManifest } from "../assets/asset-manifest.js";
2
+ import { documentObjects, pickPageFields } from "../fabric/page-state.js";
2
3
  import { DocumentEngineError, isDocumentEngineError } from "./errors.js";
3
- import { secureDocument } from "../security/content-limits.js";
4
+ import { canvasSizeProblem, isSafeImageUrl, secureDocument } from "../security/content-limits.js";
4
5
  import { isSameUrl } from "../assets/image-check.js";
5
6
  import { inspectAssets, prepareAssetsForLoad, prepareAssetsForSave } from "../assets/asset-pipeline.js";
6
7
  import { resolveExportArea } from "../export/export-area.js";
@@ -9,6 +10,8 @@ import { readObjectId, writeObjectId } from "../fabric/object-ids.js";
9
10
  import { collectSerializedTypes, walkObjects } from "../fabric/walk-objects.js";
10
11
  import { preflightExport } from "../export/preflight-export.js";
11
12
  import { mimeTypes, renderRaster, renderSvg, withExportView } from "../export/render-export.js";
13
+ import { embedSvgAssets } from "../export/svg/embed-assets.js";
14
+ import { readSvg } from "../import/svg-import.js";
12
15
  import "../document/document-format.js";
13
16
  import { createId } from "../document/ids.js";
14
17
  import { createDocumentInfo } from "../document/create-document.js";
@@ -36,11 +39,21 @@ function createDocumentEngine(options) {
36
39
  const registry = createObjectRegistry(options.customObjects);
37
40
  const events = createEventEmitter();
38
41
  const objectsById = /* @__PURE__ */ new Map();
39
- const assetOptions = options.assets ?? {};
42
+ const assetOptions = {
43
+ ...options.assets,
44
+ maxImagePixels: options.limits?.maxImagePixels ?? 67108864
45
+ };
40
46
  const uploadedUrls = /* @__PURE__ */ new Map();
41
47
  let documentInfo = createDocumentInfo(options.document);
42
48
  let activeLoad = null;
43
49
  let destroyed = false;
50
+ let mutations = 0;
51
+ let session = 0;
52
+ /** Throws when async work started under `startedIn` would now change another document. */
53
+ function ensureSameSession(startedIn, what) {
54
+ ensureUsable();
55
+ if (session !== startedIn) throw new DocumentEngineError("DOCUMENT_CHANGED", `${what} was dropped because another document was opened while it ran`);
56
+ }
44
57
  function ensureUsable() {
45
58
  if (destroyed) throw new DocumentEngineError("ENGINE_DESTROYED", "This document engine was destroyed");
46
59
  }
@@ -65,21 +78,41 @@ function createDocumentEngine(options) {
65
78
  objectsById.clear();
66
79
  canvas.getObjects().forEach(indexObjectTree);
67
80
  }
68
- const handleObjectAdded = ({ target }) => indexObjectTree(target);
69
- const handleObjectRemoved = ({ target }) => removeObjectTreeFromIndex(target);
81
+ const handleObjectAdded = ({ target }) => {
82
+ mutations += 1;
83
+ indexObjectTree(target);
84
+ };
85
+ const handleObjectRemoved = ({ target }) => {
86
+ mutations += 1;
87
+ removeObjectTreeFromIndex(target);
88
+ };
89
+ const countMutation = () => {
90
+ mutations += 1;
91
+ };
70
92
  canvas.on("object:added", handleObjectAdded);
71
93
  canvas.on("object:removed", handleObjectRemoved);
94
+ canvas.on("object:modified", countMutation);
95
+ canvas.on("text:changed", countMutation);
72
96
  rebuildIndex();
73
97
  const history = createHistory({
74
98
  canvas,
75
99
  limit: options.history?.limit,
76
100
  maxBytes: options.history?.maxBytes,
77
- serializeObjects: () => {
101
+ serializeState: () => {
78
102
  rebuildIndex();
79
- return serializeCanvas(canvas, registry.propertiesToInclude()).objects;
103
+ const serialized = serializeCanvas(canvas, registry.propertiesToInclude());
104
+ return {
105
+ objects: serialized.objects,
106
+ page: {
107
+ width: canvas.getWidth(),
108
+ height: canvas.getHeight(),
109
+ ...pickPageFields(serialized)
110
+ }
111
+ };
80
112
  },
81
113
  onChange: (state) => events.emit("history:change", state),
82
- onContentChange: () => noteContentChange()
114
+ onContentChange: () => noteContentChange(),
115
+ onLiveChange: () => noteContentChange()
83
116
  });
84
117
  const saving = createSaveController({
85
118
  getStorage: () => requireStorage(),
@@ -114,6 +147,7 @@ function createDocumentEngine(options) {
114
147
  }
115
148
  }) : void 0;
116
149
  function noteContentChange() {
150
+ mutations += 1;
117
151
  saving.noteContentChange();
118
152
  recovery?.schedule();
119
153
  }
@@ -136,15 +170,21 @@ function createDocumentEngine(options) {
136
170
  height: canvas.getHeight()
137
171
  },
138
172
  objects: serialized.objects,
139
- assets: buildAssetManifest(serialized.objects),
173
+ assets: buildAssetManifest(documentObjects({
174
+ objects: serialized.objects,
175
+ canvas: serialized
176
+ })),
140
177
  metadata: { ...documentInfo.metadata }
141
178
  };
142
179
  if (serialized.version !== void 0) document.fabricVersion = serialized.version;
143
- if (serialized.background !== void 0) document.canvas.background = serialized.background;
180
+ Object.assign(document.canvas, pickPageFields(serialized));
144
181
  return document;
145
182
  }
146
- function toLoadError(error, signal) {
147
- if (signal.aborted) return new DocumentEngineError("LOAD_ABORTED", "Loading stopped because a newer load started", { cause: error });
183
+ function toLoadError(error, controller) {
184
+ if (controller.signal.aborted) {
185
+ const reason = cancelledByCaller.has(controller) ? "Loading was cancelled" : destroyed ? "Loading stopped because the engine was destroyed" : "Loading stopped because a newer load started";
186
+ return new DocumentEngineError("LOAD_ABORTED", reason, { cause: error });
187
+ }
148
188
  if (isDocumentEngineError(error)) return error;
149
189
  const reason = error instanceof Error ? error.message : String(error);
150
190
  return new DocumentEngineError("LOAD_FAILED", `Fabric could not load the document: ${reason}`, { cause: error });
@@ -156,7 +196,7 @@ function createDocumentEngine(options) {
156
196
  throw new DocumentEngineError(issues[0].code, `The document is not valid: ${summary}`, { issues });
157
197
  }
158
198
  const document = input;
159
- const unknownTypes = registry.findUnknownTypes(collectSerializedTypes(document.objects));
199
+ const unknownTypes = registry.findUnknownTypes(collectSerializedTypes(documentObjects(document)));
160
200
  if (unknownTypes.length > 0) throw new DocumentEngineError("UNKNOWN_OBJECT_TYPE", `The document uses object types that are not registered: ${unknownTypes.join(", ")}. Register them with customObjects or engine.registerObject before loading.`, { unknownTypes });
161
201
  return document;
162
202
  }
@@ -167,19 +207,50 @@ function createDocumentEngine(options) {
167
207
  if (!storage || discardUnsavedChanges || !saving.state().isDirty) return;
168
208
  throw new DocumentEngineError("UNSAVED_CHANGES", "The current document has unsaved changes. Save first, or pass { discardUnsavedChanges: true } to replace it anyway.");
169
209
  }
170
- function startLoad() {
210
+ const cancelledByCaller = /* @__PURE__ */ new WeakSet();
211
+ const unlinkCallerSignal = /* @__PURE__ */ new WeakMap();
212
+ function startLoad(callerSignal) {
171
213
  activeLoad?.abort();
172
214
  const controller = new AbortController();
173
215
  activeLoad = controller;
216
+ if (callerSignal) {
217
+ const cancel = () => {
218
+ cancelledByCaller.add(controller);
219
+ controller.abort();
220
+ };
221
+ if (callerSignal.aborted) cancel();
222
+ else {
223
+ callerSignal.addEventListener("abort", cancel, { once: true });
224
+ unlinkCallerSignal.set(controller, () => callerSignal.removeEventListener("abort", cancel));
225
+ }
226
+ }
174
227
  return controller;
175
228
  }
229
+ function finishLoad(controller) {
230
+ unlinkCallerSignal.get(controller)?.();
231
+ unlinkCallerSignal.delete(controller);
232
+ }
176
233
  async function loadMigratedDocument(input, loadOptions, importDetails, startedLoad) {
177
234
  ensureUsable();
178
235
  protectUnsavedChanges(loadOptions.discardUnsavedChanges);
179
- const controller = startedLoad ?? startLoad();
236
+ const controller = startedLoad ?? startLoad(loadOptions.signal);
237
+ const mutationsAtStart = mutations;
180
238
  const documentId = describeDocumentId(input);
239
+ const report = (stage, done, total) => {
240
+ if (controller.signal.aborted) return;
241
+ const progress = {
242
+ documentId,
243
+ stage,
244
+ done,
245
+ total
246
+ };
247
+ loadOptions.onProgress?.(progress);
248
+ events.emit("load:progress", progress);
249
+ };
181
250
  events.emit("load:start", { documentId });
182
251
  try {
252
+ if (controller.signal.aborted) throw new Error("aborted");
253
+ report("prepare", 0, 1);
183
254
  await recovery?.markLoadStarted(documentId);
184
255
  if (controller.signal.aborted) throw new Error("aborted");
185
256
  const { document: migrated, migratedFrom } = migrateDocument(secureDocument(input, options.limits), {
@@ -188,13 +259,21 @@ function createDocumentEngine(options) {
188
259
  ...importDetails
189
260
  });
190
261
  const checked = checkDocument(migrated);
191
- const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal, options.limits?.isAllowedUrl);
262
+ report("prepare", 1, 1);
263
+ const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal, options.limits?.isAllowedUrl, (done, total) => report("images", done, total));
192
264
  if (controller.signal.aborted) throw new Error("aborted");
193
265
  await history.withoutRecording(() => loadIntoCanvas(canvas, {
194
266
  version: document.fabricVersion,
195
- background: document.canvas.background,
267
+ ...pickPageFields(document.canvas),
196
268
  objects: document.objects
197
- }, controller.signal));
269
+ }, {
270
+ signal: controller.signal,
271
+ onObjects: (done, total) => report("objects", done, total),
272
+ beforeSwap: () => {
273
+ if (mutations !== mutationsAtStart && !loadOptions.discardUnsavedChanges) throw new DocumentEngineError("LOAD_CONFLICT", "The canvas was edited while the document loaded, so the load was stopped to keep the edits. Pass { discardUnsavedChanges: true } to replace them.");
274
+ session += 1;
275
+ }
276
+ }));
198
277
  if (controller.signal.aborted) throw new Error("aborted");
199
278
  if (loadOptions.restoreCanvasSize ?? true) canvas.setDimensions({
200
279
  width: document.canvas.width,
@@ -211,6 +290,7 @@ function createDocumentEngine(options) {
211
290
  recovery?.cancel();
212
291
  saving.startSession(document.revision ?? 0);
213
292
  canvas.requestRenderAll();
293
+ report("done", 1, 1);
214
294
  if (warnings.length > 0) events.emit("assets:warning", { warnings });
215
295
  events.emit("document:change", { documentId: document.id });
216
296
  events.emit("load:success", {
@@ -220,10 +300,11 @@ function createDocumentEngine(options) {
220
300
  });
221
301
  return document;
222
302
  } catch (error) {
223
- const engineError = toLoadError(error, controller.signal);
303
+ const engineError = toLoadError(error, controller);
224
304
  events.emit("load:error", { error: engineError });
225
305
  throw engineError;
226
306
  } finally {
307
+ finishLoad(controller);
227
308
  if (activeLoad === controller) {
228
309
  activeLoad = null;
229
310
  await recovery?.markLoadFinished();
@@ -244,6 +325,43 @@ function createDocumentEngine(options) {
244
325
  metadata
245
326
  });
246
327
  }
328
+ function setPage(changes, label = "Change page") {
329
+ ensureUsable();
330
+ const problem = canvasSizeProblem(changes.width ?? canvas.getWidth(), changes.height ?? canvas.getHeight(), options.limits);
331
+ if (problem) throw new DocumentEngineError("UNSAFE_DOCUMENT", `The page was not resized: it would be ${problem}`);
332
+ history.transaction(label, () => {
333
+ const width = changes.width ?? canvas.getWidth();
334
+ const height = changes.height ?? canvas.getHeight();
335
+ if (width !== canvas.getWidth() || height !== canvas.getHeight()) canvas.setDimensions({
336
+ width,
337
+ height
338
+ });
339
+ const live = {};
340
+ if (changes.background !== void 0) live.backgroundColor = changes.background ?? "";
341
+ if (changes.backgroundImage !== void 0) live.backgroundImage = changes.backgroundImage ?? void 0;
342
+ if (changes.overlay !== void 0) live.overlayColor = changes.overlay ?? "";
343
+ if (changes.overlayImage !== void 0) live.overlayImage = changes.overlayImage ?? void 0;
344
+ if (changes.clipPath !== void 0) live.clipPath = changes.clipPath ?? void 0;
345
+ canvas.set(live);
346
+ });
347
+ canvas.requestRenderAll();
348
+ }
349
+ async function importSvg(svg, importOptions = {}) {
350
+ ensureUsable();
351
+ const startedIn = session;
352
+ const result = await readSvg(svg, importOptions, options.limits);
353
+ try {
354
+ ensureSameSession(startedIn, "The SVG import");
355
+ } catch (error) {
356
+ result.objects.forEach((object) => object.dispose?.());
357
+ throw error;
358
+ }
359
+ if (result.objects.length > 0) {
360
+ history.transaction("Import SVG", () => canvas.add(...result.objects));
361
+ canvas.requestRenderAll();
362
+ }
363
+ return result;
364
+ }
247
365
  let savesSinceAutomaticVersion = 0;
248
366
  let lastVersionTime = 0;
249
367
  function nextVersionTimestamp() {
@@ -322,15 +440,17 @@ function createDocumentEngine(options) {
322
440
  ensureUsable();
323
441
  const source = requireStorage();
324
442
  protectUnsavedChanges(loadOptions?.discardUnsavedChanges);
325
- const controller = startLoad();
443
+ const controller = startLoad(loadOptions?.signal);
326
444
  let stored;
327
445
  try {
446
+ if (controller.signal.aborted) throw new Error("aborted");
328
447
  stored = await source.loadDocument(documentId);
329
448
  if (controller.signal.aborted) throw new Error("aborted");
330
449
  } catch (error) {
450
+ finishLoad(controller);
331
451
  if (activeLoad === controller) activeLoad = null;
332
452
  if (controller.signal.aborted) {
333
- const aborted = toLoadError(error, controller.signal);
453
+ const aborted = toLoadError(error, controller);
334
454
  events.emit("load:error", { error: aborted });
335
455
  throw aborted;
336
456
  }
@@ -355,6 +475,7 @@ function createDocumentEngine(options) {
355
475
  protectUnsavedChanges(newOptions.discardUnsavedChanges);
356
476
  activeLoad?.abort();
357
477
  history.withoutRecording(() => canvas.clear());
478
+ session += 1;
358
479
  objectsById.clear();
359
480
  documentInfo = createDocumentInfo(newOptions);
360
481
  uploadedUrls.clear();
@@ -381,10 +502,12 @@ function createDocumentEngine(options) {
381
502
  if (isReplaceableImage(object) && isSameUrl(object.getSrc(), oldUrl)) images.push(object);
382
503
  });
383
504
  if (images.length === 0) return 0;
505
+ const startedIn = session;
384
506
  await history.transaction("Replace image", () => Promise.all(images.map(async (image) => {
385
507
  const displayedWidth = image.width * image.scaleX;
386
508
  const displayedHeight = image.height * image.scaleY;
387
509
  await image.setSrc(newUrl, { crossOrigin: image.crossOrigin ?? null });
510
+ ensureSameSession(startedIn, "Replacing the image");
388
511
  image.set({
389
512
  scaleX: displayedWidth / image.width,
390
513
  scaleY: displayedHeight / image.height
@@ -400,8 +523,8 @@ function createDocumentEngine(options) {
400
523
  }
401
524
  async function checkBeforeExport(exportOptions) {
402
525
  ensureUsable();
403
- const { format } = normalizeExportOptions(exportOptions);
404
- return preflightExport(canvas, format, format === "json" ? [] : fontsOnCanvas(), assetOptions);
526
+ const { format, svg } = normalizeExportOptions(exportOptions);
527
+ return preflightExport(canvas, format, format === "json" ? [] : fontsOnCanvas(), assetOptions, svg);
405
528
  }
406
529
  function describeProblems(check) {
407
530
  return check.problems.map((problem) => problem.message).join("; ");
@@ -430,24 +553,58 @@ function createDocumentEngine(options) {
430
553
  if (!check.ok) throw new DocumentEngineError("EXPORT_BLOCKED", `The export cannot run: ${describeProblems(check)}`, { problems: check.problems });
431
554
  const format = settings.format;
432
555
  try {
556
+ const warnings = [...check.warnings];
433
557
  const rendered = withExportView(canvas, settings.background, format, () => {
434
558
  const area = resolveExportArea(canvas, settings.area, settings.padding);
559
+ if (format !== "svg") {
560
+ const problem = canvasSizeProblem(area.width * settings.scale, area.height * settings.scale, options.limits);
561
+ if (problem) {
562
+ const message = `The export would be ${problem}. Lower the scale or export a smaller area.`;
563
+ throw new DocumentEngineError("EXPORT_BLOCKED", message, { problems: [{
564
+ code: "TOO_LARGE",
565
+ message,
566
+ objectIds: []
567
+ }] });
568
+ }
569
+ }
435
570
  return {
436
571
  area,
437
- content: format === "svg" ? Promise.resolve(new Blob([renderSvg(canvas, area, settings.scale)], { type: mimeTypes.svg })) : renderRaster(canvas, area, format, settings.scale, settings.quality)
572
+ content: format === "svg" ? Promise.resolve(renderSvg(canvas, area, settings.scale, settings.svg, options.limits)) : renderRaster(canvas, area, format, settings.scale, settings.quality)
438
573
  };
439
574
  });
440
- const blob = await rendered.content;
575
+ let blob;
576
+ if (format === "svg") {
577
+ const { svg, rasterizedObjectIds } = await rendered.content;
578
+ if (rasterizedObjectIds.length > 0) warnings.push({
579
+ code: "CLIP_PATH_RASTERIZED",
580
+ message: "Objects whose clip path has a clip path of its own were drawn as pictures: Fabric cannot write nested clip paths as SVG.",
581
+ objectIds: rasterizedObjectIds
582
+ });
583
+ const embedded = await embedSvgAssets(svg, canvas, settings.svg, settings.signal, options.limits?.isAllowedUrl ?? isSafeImageUrl);
584
+ stopIfCancelled();
585
+ if (settings.svg.embedImages === "require" && embedded.problems.length > 0) {
586
+ const problems = embedded.warnings.filter((warning) => warning.code === "IMAGE_NOT_EMBEDDED").map((warning) => ({
587
+ code: "IMAGE_NOT_EMBEDDED",
588
+ message: warning.message,
589
+ url: warning.url,
590
+ objectIds: warning.objectIds
591
+ }));
592
+ throw new DocumentEngineError("EXPORT_BLOCKED", `The export cannot run: ${embedded.problems.join("; ")}`, { problems });
593
+ }
594
+ warnings.push(...embedded.warnings);
595
+ blob = new Blob([embedded.svg], { type: mimeTypes.svg });
596
+ } else blob = await rendered.content;
441
597
  return {
442
598
  format,
443
599
  mimeType: blob.type || mimeTypes[format],
444
600
  blob,
445
601
  width: Math.round(rendered.area.width * settings.scale),
446
602
  height: Math.round(rendered.area.height * settings.scale),
447
- warnings: check.warnings
603
+ warnings
448
604
  };
449
605
  } catch (error) {
450
606
  if (isDocumentEngineError(error)) throw error;
607
+ if (settings.signal?.aborted) throw new DocumentEngineError("EXPORT_ABORTED", "The export was cancelled", { cause: error });
451
608
  if (error?.name === "SecurityError") {
452
609
  const problem = {
453
610
  code: "CROSS_ORIGIN_IMAGE",
@@ -485,12 +642,14 @@ function createDocumentEngine(options) {
485
642
  if (!recovery) throw new DocumentEngineError("RECOVERY_MISSING", "Pass recovery: { store } to createDocumentEngine to use recovery");
486
643
  return recovery;
487
644
  }
488
- async function restoreRecovery(documentId, loadOptions) {
645
+ async function restoreRecovery(documentId, restoreOptions = {}) {
489
646
  const source = requireRecovery();
490
647
  const id = documentId ?? documentInfo.id;
491
- const record = await source.read(id);
648
+ const { sessionId: branch, ...loadOptions } = restoreOptions;
649
+ const record = await source.read(id, branch);
492
650
  if (!record) throw new DocumentEngineError("RECOVERY_NOT_FOUND", `There is no recovery copy for document "${id}"`);
493
651
  const loaded = await loadDocument(await restoreRecordedFiles(record), loadOptions);
652
+ source.adopt(loaded.id, record.sessionId);
494
653
  noteContentChange();
495
654
  events.emit("recovery:restored", { document: loaded });
496
655
  return loaded;
@@ -511,12 +670,15 @@ function createDocumentEngine(options) {
511
670
  const { disposed } = canvas;
512
671
  if (recovery && saving.state().isDirty && !disposed) recovery.writeNow();
513
672
  destroyed = true;
673
+ session += 1;
514
674
  activeLoad?.abort();
515
675
  history.destroy();
516
676
  saving.destroy();
517
677
  recovery?.destroy();
518
678
  canvas.off("object:added", handleObjectAdded);
519
679
  canvas.off("object:removed", handleObjectRemoved);
680
+ canvas.off("object:modified", countMutation);
681
+ canvas.off("text:changed", countMutation);
520
682
  objectsById.clear();
521
683
  events.clear();
522
684
  }
@@ -524,7 +686,8 @@ function createDocumentEngine(options) {
524
686
  canvas,
525
687
  getDocumentInfo: () => ({
526
688
  ...documentInfo,
527
- metadata: { ...documentInfo.metadata }
689
+ metadata: { ...documentInfo.metadata },
690
+ session
528
691
  }),
529
692
  updateMetadata(changes) {
530
693
  ensureUsable();
@@ -542,6 +705,7 @@ function createDocumentEngine(options) {
542
705
  loadDocument,
543
706
  load,
544
707
  importFabricJson,
708
+ importSvg,
545
709
  createVersion: (name) => storeVersion(name ?? `Version ${(/* @__PURE__ */ new Date()).toLocaleString()}`, "named"),
546
710
  listVersions: async (documentId) => requireVersions().listVersions(documentId ?? documentInfo.id),
547
711
  restoreVersion,
@@ -564,14 +728,15 @@ function createDocumentEngine(options) {
564
728
  preflightExport: checkBeforeExport,
565
729
  flushRecovery: async () => requireRecovery().flush(),
566
730
  getRecoverableDocuments: async () => requireRecovery().list(),
567
- getRecovery: async (documentId) => requireRecovery().read(documentId ?? documentInfo.id),
731
+ getRecovery: async (documentId, sessionId) => requireRecovery().read(documentId ?? documentInfo.id, sessionId),
568
732
  restoreRecovery,
569
- discardRecovery: async (documentId) => requireRecovery().remove(documentId ?? documentInfo.id),
733
+ discardRecovery: async (documentId, sessionId) => requireRecovery().discard(documentId ?? documentInfo.id, sessionId),
570
734
  getInterruptedLoad: async () => requireRecovery().interruptedLoad(),
571
- transaction(label, work) {
735
+ transaction(label, work, transactionOptions) {
572
736
  ensureUsable();
573
- return history.transaction(label, work);
737
+ return history.transaction(label, work, transactionOptions);
574
738
  },
739
+ setPage,
575
740
  commit(label = "Edit") {
576
741
  ensureUsable();
577
742
  return history.commit(label);
@@ -1,7 +1,7 @@
1
1
  import { FontAsset, ImageAsset } from "../assets/asset-manifest.cjs";
2
2
  import { ExportProblem } from "../export/preflight-export.cjs";
3
3
  //#region src/engine/errors.d.ts
4
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSAFE_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "UNSAVED_CHANGES" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
4
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSAFE_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "UNSAVED_CHANGES" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "SVG_IMPORT_FAILED" | "LOAD_CONFLICT" | "DOCUMENT_CHANGED" | "PDF_UNAVAILABLE" | "PDF_FAILED" | "ENGINE_DESTROYED";
5
5
  export interface DocumentIssue {
6
6
  code: DocumentErrorCode;
7
7
  path: string;
@@ -1,7 +1,7 @@
1
1
  import { FontAsset, ImageAsset } from "../assets/asset-manifest.js";
2
2
  import { ExportProblem } from "../export/preflight-export.js";
3
3
  //#region src/engine/errors.d.ts
4
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSAFE_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "UNSAVED_CHANGES" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
4
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSAFE_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "UNSAVED_CHANGES" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "SVG_IMPORT_FAILED" | "LOAD_CONFLICT" | "DOCUMENT_CHANGED" | "PDF_UNAVAILABLE" | "PDF_FAILED" | "ENGINE_DESTROYED";
5
5
  export interface DocumentIssue {
6
6
  code: DocumentErrorCode;
7
7
  path: string;
@@ -0,0 +1,172 @@
1
+ const require_errors = require("../engine/errors.cjs");
2
+ const require_walk_objects = require("../fabric/walk-objects.cjs");
3
+ const require_create_document_engine = require("../engine/create-document-engine.cjs");
4
+ let fabric = require("fabric");
5
+ //#region src/export/batch-render.ts
6
+ /**
7
+ * Releases a canvas's pixels now. Fabric's `dispose` drops its references,
8
+ * but in browsers the memory behind a canvas comes back only much later,
9
+ * when the garbage collector gets to it.
10
+ */
11
+ function releaseCanvas(value) {
12
+ if (typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement) {
13
+ value.width = 0;
14
+ value.height = 0;
15
+ }
16
+ }
17
+ function disposeObject(object) {
18
+ const owned = object;
19
+ for (const key of [
20
+ "_cacheCanvas",
21
+ "_filteredEl",
22
+ "_element",
23
+ "_originalElement"
24
+ ]) releaseCanvas(owned[key]);
25
+ object.dispose?.();
26
+ }
27
+ /** Frees the objects on a canvas: their cache canvases and image elements. */
28
+ function disposeObjects(canvas) {
29
+ const objects = canvas.getObjects();
30
+ canvas.remove(...objects);
31
+ require_walk_objects.walkObjects(objects, (object) => {
32
+ disposeObject(object);
33
+ if (object.clipPath) disposeObject(object.clipPath);
34
+ });
35
+ for (const key of ["backgroundImage", "overlayImage"]) {
36
+ const image = canvas[key];
37
+ if (image) {
38
+ disposeObject(image);
39
+ canvas[key] = void 0;
40
+ }
41
+ }
42
+ }
43
+ function createSlot(options) {
44
+ if (typeof document === "undefined") throw new require_errors.DocumentEngineError("EXPORT_FAILED", "Rendering documents needs a browser DOM");
45
+ const element = document.createElement("canvas");
46
+ const canvas = new fabric.StaticCanvas(element, {
47
+ renderOnAddRemove: false,
48
+ enableRetinaScaling: false
49
+ });
50
+ return {
51
+ element,
52
+ canvas,
53
+ engine: require_create_document_engine.createDocumentEngine({
54
+ canvas,
55
+ customObjects: options.customObjects,
56
+ assets: options.assets,
57
+ limits: options.limits,
58
+ history: { limit: 1 }
59
+ })
60
+ };
61
+ }
62
+ async function releaseSlot(slot) {
63
+ disposeObjects(slot.canvas);
64
+ slot.engine.destroy();
65
+ await slot.canvas.dispose();
66
+ slot.element.width = 0;
67
+ slot.element.height = 0;
68
+ }
69
+ function toEngineError(error) {
70
+ if (require_errors.isDocumentEngineError(error)) return error;
71
+ const reason = error instanceof Error ? error.message : String(error);
72
+ return new require_errors.DocumentEngineError("EXPORT_FAILED", `The document could not be rendered: ${reason}`, { cause: error });
73
+ }
74
+ function documentIdOf(value) {
75
+ const id = value?.id;
76
+ return typeof id === "string" ? id : void 0;
77
+ }
78
+ async function renderOne(slot, input, options, signal) {
79
+ const { concurrency: _concurrency, onProgress: _onProgress, customObjects: _custom, assets: _assets, limits: _limits, ...exportOptions } = options;
80
+ try {
81
+ await slot.engine.loadDocument(input, {
82
+ signal,
83
+ discardUnsavedChanges: true
84
+ });
85
+ return await slot.engine.export({
86
+ ...exportOptions,
87
+ signal
88
+ });
89
+ } finally {
90
+ disposeObjects(slot.canvas);
91
+ slot.engine.clearHistory();
92
+ }
93
+ }
94
+ /**
95
+ * Renders each document and yields the results as they finish, so they can
96
+ * be uploaded or saved one by one instead of all being held in memory.
97
+ * Documents can be `FabricDocument`s or plain Fabric JSON, and can come from
98
+ * an async iterable, such as pages of a database query.
99
+ */
100
+ async function* renderDocuments(documents, options) {
101
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 2));
102
+ const total = Array.isArray(documents) ? documents.length : void 0;
103
+ const controller = new AbortController();
104
+ const stop = () => controller.abort();
105
+ options.signal?.addEventListener("abort", stop, { once: true });
106
+ if (options.signal?.aborted) stop();
107
+ const iterator = Symbol.asyncIterator in documents ? documents[Symbol.asyncIterator]() : documents[Symbol.iterator]();
108
+ const slots = [];
109
+ const idle = [];
110
+ const running = /* @__PURE__ */ new Map();
111
+ let nextIndex = 0;
112
+ let exhausted = false;
113
+ let done = 0;
114
+ async function take() {
115
+ if (exhausted) return void 0;
116
+ const next = await iterator.next();
117
+ if (next.done) {
118
+ exhausted = true;
119
+ return;
120
+ }
121
+ return {
122
+ index: nextIndex++,
123
+ value: next.value
124
+ };
125
+ }
126
+ function start(slot, job) {
127
+ const task = renderOne(slot, job.value, options, controller.signal).then((result) => ({
128
+ index: job.index,
129
+ documentId: documentIdOf(job.value),
130
+ result
131
+ }), (error) => ({
132
+ index: job.index,
133
+ documentId: documentIdOf(job.value),
134
+ error: toEngineError(error)
135
+ }));
136
+ running.set(job.index, task.finally(() => idle.push(slot)));
137
+ }
138
+ try {
139
+ while (true) {
140
+ if (controller.signal.aborted) throw new require_errors.DocumentEngineError("EXPORT_ABORTED", "Rendering was cancelled");
141
+ while (running.size < concurrency && !exhausted) {
142
+ const job = await take();
143
+ if (!job) break;
144
+ let slot = idle.pop();
145
+ if (!slot) {
146
+ slot = createSlot(options);
147
+ slots.push(slot);
148
+ }
149
+ start(slot, job);
150
+ }
151
+ if (running.size === 0) break;
152
+ const finished = await Promise.race(running.values());
153
+ running.delete(finished.index);
154
+ done += 1;
155
+ options.onProgress?.({
156
+ done,
157
+ total
158
+ });
159
+ if (controller.signal.aborted) throw new require_errors.DocumentEngineError("EXPORT_ABORTED", "Rendering was cancelled");
160
+ yield finished;
161
+ }
162
+ } finally {
163
+ controller.abort();
164
+ options.signal?.removeEventListener("abort", stop);
165
+ await Promise.allSettled(running.values());
166
+ await Promise.all(slots.map(releaseSlot));
167
+ if (!exhausted) await iterator.return?.();
168
+ }
169
+ }
170
+ //#endregion
171
+ exports.disposeObjects = disposeObjects;
172
+ exports.renderDocuments = renderDocuments;