fabricjs-document-engine 1.1.0 → 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 (70) hide show
  1. package/README.md +39 -1
  2. package/README.zh-CN.md +39 -1
  3. package/dist/assets/asset-pipeline.cjs +10 -8
  4. package/dist/assets/asset-pipeline.d.cts +1 -1
  5. package/dist/assets/asset-pipeline.d.ts +1 -1
  6. package/dist/assets/asset-pipeline.js +10 -8
  7. package/dist/assets/image-check.cjs +4 -3
  8. package/dist/assets/image-check.d.cts +1 -1
  9. package/dist/assets/image-check.d.ts +1 -1
  10. package/dist/assets/image-check.js +4 -3
  11. package/dist/commands/clipboard.cjs +5 -0
  12. package/dist/commands/clipboard.js +5 -0
  13. package/dist/document/document-format.d.cts +15 -0
  14. package/dist/document/document-format.d.ts +15 -0
  15. package/dist/document/validate-document.cjs +8 -0
  16. package/dist/document/validate-document.js +8 -0
  17. package/dist/engine/create-document-engine.cjs +115 -21
  18. package/dist/engine/create-document-engine.d.cts +24 -6
  19. package/dist/engine/create-document-engine.d.ts +24 -6
  20. package/dist/engine/create-document-engine.js +116 -22
  21. package/dist/engine/errors.d.cts +1 -1
  22. package/dist/engine/errors.d.ts +1 -1
  23. package/dist/export/export-options.cjs +3 -0
  24. package/dist/export/export-options.d.cts +6 -0
  25. package/dist/export/export-options.d.ts +6 -0
  26. package/dist/export/export-options.js +3 -0
  27. package/dist/export/preflight-export.d.cts +1 -1
  28. package/dist/export/preflight-export.d.ts +1 -1
  29. package/dist/export/render-export.cjs +42 -4
  30. package/dist/export/render-export.js +43 -5
  31. package/dist/export/svg/clip-masks.cjs +39 -0
  32. package/dist/export/svg/clip-masks.js +37 -0
  33. package/dist/export/svg/embed-assets.cjs +1 -0
  34. package/dist/export/svg/embed-assets.js +1 -0
  35. package/dist/export/svg/raster-object.cjs +82 -0
  36. package/dist/export/svg/raster-object.js +81 -0
  37. package/dist/{pdf → export/svg}/text-decorations.cjs +3 -3
  38. package/dist/{pdf → export/svg}/text-decorations.js +3 -3
  39. package/dist/export/svg/text-on-path.cjs +0 -10
  40. package/dist/export/svg/text-on-path.js +1 -10
  41. package/dist/fabric/fabric-adapter.cjs +5 -3
  42. package/dist/fabric/fabric-adapter.js +5 -3
  43. package/dist/fabric/page-state.cjs +69 -0
  44. package/dist/fabric/page-state.js +64 -0
  45. package/dist/history/apply-state.cjs +19 -2
  46. package/dist/history/apply-state.js +19 -2
  47. package/dist/history/create-history.cjs +53 -13
  48. package/dist/history/create-history.d.cts +8 -0
  49. package/dist/history/create-history.d.ts +8 -0
  50. package/dist/history/create-history.js +53 -13
  51. package/dist/history/snapshot.cjs +10 -1
  52. package/dist/history/snapshot.js +10 -2
  53. package/dist/index.d.cts +3 -3
  54. package/dist/index.d.ts +3 -3
  55. package/dist/migrations/migrate-document.cjs +2 -1
  56. package/dist/migrations/migrate-document.js +2 -1
  57. package/dist/pdf/export-pdf.cjs +8 -67
  58. package/dist/pdf/export-pdf.js +8 -67
  59. package/dist/recovery/recovery-controller.cjs +148 -29
  60. package/dist/recovery/recovery-controller.d.cts +8 -0
  61. package/dist/recovery/recovery-controller.d.ts +8 -0
  62. package/dist/recovery/recovery-controller.js +148 -29
  63. package/dist/security/content-limits.cjs +43 -3
  64. package/dist/security/content-limits.d.cts +8 -0
  65. package/dist/security/content-limits.d.ts +8 -0
  66. package/dist/security/content-limits.js +39 -4
  67. package/dist/util/concurrency.cjs +16 -3
  68. package/dist/util/concurrency.js +16 -3
  69. package/package.json +1 -1
  70. 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 { isSafeImageUrl, 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";
@@ -38,11 +39,21 @@ function createDocumentEngine(options) {
38
39
  const registry = createObjectRegistry(options.customObjects);
39
40
  const events = createEventEmitter();
40
41
  const objectsById = /* @__PURE__ */ new Map();
41
- const assetOptions = options.assets ?? {};
42
+ const assetOptions = {
43
+ ...options.assets,
44
+ maxImagePixels: options.limits?.maxImagePixels ?? 67108864
45
+ };
42
46
  const uploadedUrls = /* @__PURE__ */ new Map();
43
47
  let documentInfo = createDocumentInfo(options.document);
44
48
  let activeLoad = null;
45
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
+ }
46
57
  function ensureUsable() {
47
58
  if (destroyed) throw new DocumentEngineError("ENGINE_DESTROYED", "This document engine was destroyed");
48
59
  }
@@ -67,21 +78,41 @@ function createDocumentEngine(options) {
67
78
  objectsById.clear();
68
79
  canvas.getObjects().forEach(indexObjectTree);
69
80
  }
70
- const handleObjectAdded = ({ target }) => indexObjectTree(target);
71
- 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
+ };
72
92
  canvas.on("object:added", handleObjectAdded);
73
93
  canvas.on("object:removed", handleObjectRemoved);
94
+ canvas.on("object:modified", countMutation);
95
+ canvas.on("text:changed", countMutation);
74
96
  rebuildIndex();
75
97
  const history = createHistory({
76
98
  canvas,
77
99
  limit: options.history?.limit,
78
100
  maxBytes: options.history?.maxBytes,
79
- serializeObjects: () => {
101
+ serializeState: () => {
80
102
  rebuildIndex();
81
- 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
+ };
82
112
  },
83
113
  onChange: (state) => events.emit("history:change", state),
84
- onContentChange: () => noteContentChange()
114
+ onContentChange: () => noteContentChange(),
115
+ onLiveChange: () => noteContentChange()
85
116
  });
86
117
  const saving = createSaveController({
87
118
  getStorage: () => requireStorage(),
@@ -116,6 +147,7 @@ function createDocumentEngine(options) {
116
147
  }
117
148
  }) : void 0;
118
149
  function noteContentChange() {
150
+ mutations += 1;
119
151
  saving.noteContentChange();
120
152
  recovery?.schedule();
121
153
  }
@@ -138,11 +170,14 @@ function createDocumentEngine(options) {
138
170
  height: canvas.getHeight()
139
171
  },
140
172
  objects: serialized.objects,
141
- assets: buildAssetManifest(serialized.objects),
173
+ assets: buildAssetManifest(documentObjects({
174
+ objects: serialized.objects,
175
+ canvas: serialized
176
+ })),
142
177
  metadata: { ...documentInfo.metadata }
143
178
  };
144
179
  if (serialized.version !== void 0) document.fabricVersion = serialized.version;
145
- if (serialized.background !== void 0) document.canvas.background = serialized.background;
180
+ Object.assign(document.canvas, pickPageFields(serialized));
146
181
  return document;
147
182
  }
148
183
  function toLoadError(error, controller) {
@@ -161,7 +196,7 @@ function createDocumentEngine(options) {
161
196
  throw new DocumentEngineError(issues[0].code, `The document is not valid: ${summary}`, { issues });
162
197
  }
163
198
  const document = input;
164
- const unknownTypes = registry.findUnknownTypes(collectSerializedTypes(document.objects));
199
+ const unknownTypes = registry.findUnknownTypes(collectSerializedTypes(documentObjects(document)));
165
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 });
166
201
  return document;
167
202
  }
@@ -199,6 +234,7 @@ function createDocumentEngine(options) {
199
234
  ensureUsable();
200
235
  protectUnsavedChanges(loadOptions.discardUnsavedChanges);
201
236
  const controller = startedLoad ?? startLoad(loadOptions.signal);
237
+ const mutationsAtStart = mutations;
202
238
  const documentId = describeDocumentId(input);
203
239
  const report = (stage, done, total) => {
204
240
  if (controller.signal.aborted) return;
@@ -228,11 +264,15 @@ function createDocumentEngine(options) {
228
264
  if (controller.signal.aborted) throw new Error("aborted");
229
265
  await history.withoutRecording(() => loadIntoCanvas(canvas, {
230
266
  version: document.fabricVersion,
231
- background: document.canvas.background,
267
+ ...pickPageFields(document.canvas),
232
268
  objects: document.objects
233
269
  }, {
234
270
  signal: controller.signal,
235
- onObjects: (done, total) => report("objects", done, total)
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
+ }
236
276
  }));
237
277
  if (controller.signal.aborted) throw new Error("aborted");
238
278
  if (loadOptions.restoreCanvasSize ?? true) canvas.setDimensions({
@@ -285,10 +325,37 @@ function createDocumentEngine(options) {
285
325
  metadata
286
326
  });
287
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
+ }
288
349
  async function importSvg(svg, importOptions = {}) {
289
350
  ensureUsable();
351
+ const startedIn = session;
290
352
  const result = await readSvg(svg, importOptions, options.limits);
291
- ensureUsable();
353
+ try {
354
+ ensureSameSession(startedIn, "The SVG import");
355
+ } catch (error) {
356
+ result.objects.forEach((object) => object.dispose?.());
357
+ throw error;
358
+ }
292
359
  if (result.objects.length > 0) {
293
360
  history.transaction("Import SVG", () => canvas.add(...result.objects));
294
361
  canvas.requestRenderAll();
@@ -408,6 +475,7 @@ function createDocumentEngine(options) {
408
475
  protectUnsavedChanges(newOptions.discardUnsavedChanges);
409
476
  activeLoad?.abort();
410
477
  history.withoutRecording(() => canvas.clear());
478
+ session += 1;
411
479
  objectsById.clear();
412
480
  documentInfo = createDocumentInfo(newOptions);
413
481
  uploadedUrls.clear();
@@ -434,10 +502,12 @@ function createDocumentEngine(options) {
434
502
  if (isReplaceableImage(object) && isSameUrl(object.getSrc(), oldUrl)) images.push(object);
435
503
  });
436
504
  if (images.length === 0) return 0;
505
+ const startedIn = session;
437
506
  await history.transaction("Replace image", () => Promise.all(images.map(async (image) => {
438
507
  const displayedWidth = image.width * image.scaleX;
439
508
  const displayedHeight = image.height * image.scaleY;
440
509
  await image.setSrc(newUrl, { crossOrigin: image.crossOrigin ?? null });
510
+ ensureSameSession(startedIn, "Replacing the image");
441
511
  image.set({
442
512
  scaleX: displayedWidth / image.width,
443
513
  scaleY: displayedHeight / image.height
@@ -486,14 +556,31 @@ function createDocumentEngine(options) {
486
556
  const warnings = [...check.warnings];
487
557
  const rendered = withExportView(canvas, settings.background, format, () => {
488
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
+ }
489
570
  return {
490
571
  area,
491
- content: format === "svg" ? Promise.resolve(renderSvg(canvas, area, settings.scale, settings.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)
492
573
  };
493
574
  });
494
575
  let blob;
495
576
  if (format === "svg") {
496
- const embedded = await embedSvgAssets(await rendered.content, canvas, settings.svg, settings.signal, options.limits?.isAllowedUrl ?? isSafeImageUrl);
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);
497
584
  stopIfCancelled();
498
585
  if (settings.svg.embedImages === "require" && embedded.problems.length > 0) {
499
586
  const problems = embedded.warnings.filter((warning) => warning.code === "IMAGE_NOT_EMBEDDED").map((warning) => ({
@@ -555,12 +642,14 @@ function createDocumentEngine(options) {
555
642
  if (!recovery) throw new DocumentEngineError("RECOVERY_MISSING", "Pass recovery: { store } to createDocumentEngine to use recovery");
556
643
  return recovery;
557
644
  }
558
- async function restoreRecovery(documentId, loadOptions) {
645
+ async function restoreRecovery(documentId, restoreOptions = {}) {
559
646
  const source = requireRecovery();
560
647
  const id = documentId ?? documentInfo.id;
561
- const record = await source.read(id);
648
+ const { sessionId: branch, ...loadOptions } = restoreOptions;
649
+ const record = await source.read(id, branch);
562
650
  if (!record) throw new DocumentEngineError("RECOVERY_NOT_FOUND", `There is no recovery copy for document "${id}"`);
563
651
  const loaded = await loadDocument(await restoreRecordedFiles(record), loadOptions);
652
+ source.adopt(loaded.id, record.sessionId);
564
653
  noteContentChange();
565
654
  events.emit("recovery:restored", { document: loaded });
566
655
  return loaded;
@@ -581,12 +670,15 @@ function createDocumentEngine(options) {
581
670
  const { disposed } = canvas;
582
671
  if (recovery && saving.state().isDirty && !disposed) recovery.writeNow();
583
672
  destroyed = true;
673
+ session += 1;
584
674
  activeLoad?.abort();
585
675
  history.destroy();
586
676
  saving.destroy();
587
677
  recovery?.destroy();
588
678
  canvas.off("object:added", handleObjectAdded);
589
679
  canvas.off("object:removed", handleObjectRemoved);
680
+ canvas.off("object:modified", countMutation);
681
+ canvas.off("text:changed", countMutation);
590
682
  objectsById.clear();
591
683
  events.clear();
592
684
  }
@@ -594,7 +686,8 @@ function createDocumentEngine(options) {
594
686
  canvas,
595
687
  getDocumentInfo: () => ({
596
688
  ...documentInfo,
597
- metadata: { ...documentInfo.metadata }
689
+ metadata: { ...documentInfo.metadata },
690
+ session
598
691
  }),
599
692
  updateMetadata(changes) {
600
693
  ensureUsable();
@@ -635,14 +728,15 @@ function createDocumentEngine(options) {
635
728
  preflightExport: checkBeforeExport,
636
729
  flushRecovery: async () => requireRecovery().flush(),
637
730
  getRecoverableDocuments: async () => requireRecovery().list(),
638
- getRecovery: async (documentId) => requireRecovery().read(documentId ?? documentInfo.id),
731
+ getRecovery: async (documentId, sessionId) => requireRecovery().read(documentId ?? documentInfo.id, sessionId),
639
732
  restoreRecovery,
640
- discardRecovery: async (documentId) => requireRecovery().remove(documentId ?? documentInfo.id),
733
+ discardRecovery: async (documentId, sessionId) => requireRecovery().discard(documentId ?? documentInfo.id, sessionId),
641
734
  getInterruptedLoad: async () => requireRecovery().interruptedLoad(),
642
- transaction(label, work) {
735
+ transaction(label, work, transactionOptions) {
643
736
  ensureUsable();
644
- return history.transaction(label, work);
737
+ return history.transaction(label, work, transactionOptions);
645
738
  },
739
+ setPage,
646
740
  commit(label = "Edit") {
647
741
  ensureUsable();
648
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" | "SVG_IMPORT_FAILED" | "PDF_UNAVAILABLE" | "PDF_FAILED" | "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" | "SVG_IMPORT_FAILED" | "PDF_UNAVAILABLE" | "PDF_FAILED" | "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;
@@ -36,6 +36,8 @@ function normalizeExportOptions(options) {
36
36
  const svg = options.svg ?? {};
37
37
  const textOnPath = svg.textOnPath ?? "vector";
38
38
  if (textOnPath !== "vector" && textOnPath !== "fabric") throw invalid("svg.textOnPath must be \"vector\" or \"fabric\"");
39
+ const textDecorations = svg.textDecorations ?? "shapes";
40
+ if (textDecorations !== "shapes" && textDecorations !== "css") throw invalid("svg.textDecorations must be \"shapes\" or \"css\"");
39
41
  const embedImages = svg.embedImages ?? false;
40
42
  if (embedImages !== true && embedImages !== false && embedImages !== "require") throw invalid("svg.embedImages must be true, false or \"require\"");
41
43
  const maxEmbeddedImageBytes = svg.maxEmbeddedImageBytes ?? 26214400;
@@ -52,6 +54,7 @@ function normalizeExportOptions(options) {
52
54
  signal: options.signal,
53
55
  svg: {
54
56
  textOnPath,
57
+ textDecorations,
55
58
  embedImages,
56
59
  maxEmbeddedImageBytes,
57
60
  embedFonts
@@ -18,6 +18,12 @@ export interface SvgExportOptions {
18
18
  * backgrounds and underlines straight.
19
19
  */
20
20
  textOnPath?: "vector" | "fabric";
21
+ /**
22
+ * How underlines, overlines and line-throughs of ordinary text are written.
23
+ * `shapes` (the default) draws them where the canvas does; `css` keeps
24
+ * Fabric's `text-decoration`, which readers place in their own way.
25
+ */
26
+ textDecorations?: "shapes" | "css";
21
27
  /**
22
28
  * Puts every image into the file as data, so the SVG opens anywhere
23
29
  * without the original image URLs. `require` blocks the export when an
@@ -18,6 +18,12 @@ export interface SvgExportOptions {
18
18
  * backgrounds and underlines straight.
19
19
  */
20
20
  textOnPath?: "vector" | "fabric";
21
+ /**
22
+ * How underlines, overlines and line-throughs of ordinary text are written.
23
+ * `shapes` (the default) draws them where the canvas does; `css` keeps
24
+ * Fabric's `text-decoration`, which readers place in their own way.
25
+ */
26
+ textDecorations?: "shapes" | "css";
21
27
  /**
22
28
  * Puts every image into the file as data, so the SVG opens anywhere
23
29
  * without the original image URLs. `require` blocks the export when an
@@ -36,6 +36,8 @@ function normalizeExportOptions(options) {
36
36
  const svg = options.svg ?? {};
37
37
  const textOnPath = svg.textOnPath ?? "vector";
38
38
  if (textOnPath !== "vector" && textOnPath !== "fabric") throw invalid("svg.textOnPath must be \"vector\" or \"fabric\"");
39
+ const textDecorations = svg.textDecorations ?? "shapes";
40
+ if (textDecorations !== "shapes" && textDecorations !== "css") throw invalid("svg.textDecorations must be \"shapes\" or \"css\"");
39
41
  const embedImages = svg.embedImages ?? false;
40
42
  if (embedImages !== true && embedImages !== false && embedImages !== "require") throw invalid("svg.embedImages must be true, false or \"require\"");
41
43
  const maxEmbeddedImageBytes = svg.maxEmbeddedImageBytes ?? 26214400;
@@ -52,6 +54,7 @@ function normalizeExportOptions(options) {
52
54
  signal: options.signal,
53
55
  svg: {
54
56
  textOnPath,
57
+ textDecorations,
55
58
  embedImages,
56
59
  maxEmbeddedImageBytes,
57
60
  embedFonts
@@ -1,7 +1,7 @@
1
1
  import { AssetWarning } from "../assets/asset-pipeline.cjs";
2
2
  import { StaticCanvas } from "fabric";
3
3
  //#region src/export/preflight-export.d.ts
4
- export type ExportProblemCode = "MISSING_IMAGE" | "CROSS_ORIGIN_IMAGE" | "MISSING_FONT" | "IMAGE_NOT_EMBEDDED";
4
+ export type ExportProblemCode = "MISSING_IMAGE" | "CROSS_ORIGIN_IMAGE" | "MISSING_FONT" | "IMAGE_NOT_EMBEDDED" | "TOO_LARGE";
5
5
  export interface ExportProblem {
6
6
  code: ExportProblemCode;
7
7
  message: string;
@@ -1,7 +1,7 @@
1
1
  import { AssetWarning } from "../assets/asset-pipeline.js";
2
2
  import { StaticCanvas } from "fabric";
3
3
  //#region src/export/preflight-export.d.ts
4
- export type ExportProblemCode = "MISSING_IMAGE" | "CROSS_ORIGIN_IMAGE" | "MISSING_FONT" | "IMAGE_NOT_EMBEDDED";
4
+ export type ExportProblemCode = "MISSING_IMAGE" | "CROSS_ORIGIN_IMAGE" | "MISSING_FONT" | "IMAGE_NOT_EMBEDDED" | "TOO_LARGE";
5
5
  export interface ExportProblem {
6
6
  code: ExportProblemCode;
7
7
  message: string;
@@ -1,4 +1,9 @@
1
+ const require_object_ids = require("../fabric/object-ids.cjs");
2
+ const require_overrides = require("./svg/overrides.cjs");
1
3
  const require_text_on_path = require("./svg/text-on-path.cjs");
4
+ const require_clip_masks = require("./svg/clip-masks.cjs");
5
+ const require_raster_object = require("./svg/raster-object.cjs");
6
+ const require_text_decorations = require("./svg/text-decorations.cjs");
2
7
  //#region src/export/render-export.ts
3
8
  const identity = [
4
9
  1,
@@ -51,8 +56,38 @@ function renderRaster(canvas, area, format, scale, quality) {
51
56
  function repairFabricSvg(svg) {
52
57
  return svg.replace(/(rotate="[^"]*")(style=)/g, "$1 $2");
53
58
  }
54
- function renderSvg(canvas, area, scale, options) {
55
- const write = () => canvas.toSVG({
59
+ /**
60
+ * Writes the SVG with the engine's corrections on top of Fabric's output:
61
+ * curved text and decorations as the canvas draws them, inverted clip paths
62
+ * as masks, and objects with nested clip paths as pictures. Every other
63
+ * object is written by Fabric unchanged.
64
+ */
65
+ function renderSvg(canvas, area, scale, options, limits) {
66
+ const textOnPath = (options?.textOnPath ?? "vector") === "vector";
67
+ const decorations = (options?.textDecorations ?? "shapes") === "shapes";
68
+ const rasterizedObjectIds = [];
69
+ const pictures = /* @__PURE__ */ new Map();
70
+ for (const object of canvas.getObjects()) {
71
+ if (!require_clip_masks.hasNestedClipPath(object)) continue;
72
+ pictures.set(object, require_raster_object.rasterMarkup(canvas, object, Math.max(2, scale * 2), limits));
73
+ const id = require_object_ids.readObjectId(object);
74
+ if (id !== void 0) rasterizedObjectIds.push(id);
75
+ }
76
+ const baseWriter = (object) => {
77
+ if (textOnPath && require_text_on_path.isTextOnPath(object)) return (reviver) => require_text_on_path.textOnPathToSVG(object, reviver);
78
+ if (decorations && require_text_decorations.hasStraightDecorations(object)) return (reviver) => require_text_decorations.decoratedTextToSVG(object, reviver);
79
+ };
80
+ const writerFor = (object) => {
81
+ const picture = pictures.get(object);
82
+ if (picture !== void 0) return () => picture;
83
+ const base = baseWriter(object);
84
+ if (require_clip_masks.ownClipPathIsInverted(object)) {
85
+ const prototypeWriter = (reviver) => Object.getPrototypeOf(object).toSVG.call(object, reviver);
86
+ return (reviver) => require_clip_masks.writeWithInvertedMask(object, base ?? prototypeWriter, reviver);
87
+ }
88
+ return base;
89
+ };
90
+ const svg = require_overrides.withSvgOverrides(canvas, writerFor, () => canvas.toSVG({
56
91
  viewBox: {
57
92
  x: area.left,
58
93
  y: area.top,
@@ -61,8 +96,11 @@ function renderSvg(canvas, area, scale, options) {
61
96
  },
62
97
  width: String(area.width * scale),
63
98
  height: String(area.height * scale)
64
- });
65
- return repairFabricSvg((options?.textOnPath ?? "vector") === "vector" ? require_text_on_path.withTextOnPathSVG(canvas, write).result : write());
99
+ })).result;
100
+ return {
101
+ svg: repairFabricSvg(svg),
102
+ rasterizedObjectIds
103
+ };
66
104
  }
67
105
  //#endregion
68
106
  exports.mimeTypes = mimeTypes;
@@ -1,4 +1,9 @@
1
- import { withTextOnPathSVG } from "./svg/text-on-path.js";
1
+ import { readObjectId } from "../fabric/object-ids.js";
2
+ import { withSvgOverrides } from "./svg/overrides.js";
3
+ import { isTextOnPath, textOnPathToSVG } from "./svg/text-on-path.js";
4
+ import { hasNestedClipPath, ownClipPathIsInverted, writeWithInvertedMask } from "./svg/clip-masks.js";
5
+ import { rasterMarkup } from "./svg/raster-object.js";
6
+ import { decoratedTextToSVG, hasStraightDecorations } from "./svg/text-decorations.js";
2
7
  //#region src/export/render-export.ts
3
8
  const identity = [
4
9
  1,
@@ -51,8 +56,38 @@ function renderRaster(canvas, area, format, scale, quality) {
51
56
  function repairFabricSvg(svg) {
52
57
  return svg.replace(/(rotate="[^"]*")(style=)/g, "$1 $2");
53
58
  }
54
- function renderSvg(canvas, area, scale, options) {
55
- const write = () => canvas.toSVG({
59
+ /**
60
+ * Writes the SVG with the engine's corrections on top of Fabric's output:
61
+ * curved text and decorations as the canvas draws them, inverted clip paths
62
+ * as masks, and objects with nested clip paths as pictures. Every other
63
+ * object is written by Fabric unchanged.
64
+ */
65
+ function renderSvg(canvas, area, scale, options, limits) {
66
+ const textOnPath = (options?.textOnPath ?? "vector") === "vector";
67
+ const decorations = (options?.textDecorations ?? "shapes") === "shapes";
68
+ const rasterizedObjectIds = [];
69
+ const pictures = /* @__PURE__ */ new Map();
70
+ for (const object of canvas.getObjects()) {
71
+ if (!hasNestedClipPath(object)) continue;
72
+ pictures.set(object, rasterMarkup(canvas, object, Math.max(2, scale * 2), limits));
73
+ const id = readObjectId(object);
74
+ if (id !== void 0) rasterizedObjectIds.push(id);
75
+ }
76
+ const baseWriter = (object) => {
77
+ if (textOnPath && isTextOnPath(object)) return (reviver) => textOnPathToSVG(object, reviver);
78
+ if (decorations && hasStraightDecorations(object)) return (reviver) => decoratedTextToSVG(object, reviver);
79
+ };
80
+ const writerFor = (object) => {
81
+ const picture = pictures.get(object);
82
+ if (picture !== void 0) return () => picture;
83
+ const base = baseWriter(object);
84
+ if (ownClipPathIsInverted(object)) {
85
+ const prototypeWriter = (reviver) => Object.getPrototypeOf(object).toSVG.call(object, reviver);
86
+ return (reviver) => writeWithInvertedMask(object, base ?? prototypeWriter, reviver);
87
+ }
88
+ return base;
89
+ };
90
+ const svg = withSvgOverrides(canvas, writerFor, () => canvas.toSVG({
56
91
  viewBox: {
57
92
  x: area.left,
58
93
  y: area.top,
@@ -61,8 +96,11 @@ function renderSvg(canvas, area, scale, options) {
61
96
  },
62
97
  width: String(area.width * scale),
63
98
  height: String(area.height * scale)
64
- });
65
- return repairFabricSvg((options?.textOnPath ?? "vector") === "vector" ? withTextOnPathSVG(canvas, write).result : write());
99
+ })).result;
100
+ return {
101
+ svg: repairFabricSvg(svg),
102
+ rasterizedObjectIds
103
+ };
66
104
  }
67
105
  //#endregion
68
106
  export { mimeTypes, renderRaster, renderSvg, repairFabricSvg, withExportView };
@@ -0,0 +1,39 @@
1
+ const require_walk_objects = require("../../fabric/walk-objects.cjs");
2
+ //#region src/export/svg/clip-masks.ts
3
+ /** True when a clip path anywhere in the object's tree has a clip path of its own. */
4
+ function hasNestedClipPath(object) {
5
+ let nested = false;
6
+ require_walk_objects.walkObjects([object], (child) => {
7
+ if (child.clipPath?.clipPath) nested = true;
8
+ });
9
+ return nested;
10
+ }
11
+ /** True when the object's own clip path is inverted (and simple). */
12
+ function ownClipPathIsInverted(object) {
13
+ const clipPath = object.clipPath;
14
+ return Boolean(clipPath?.inverted) && !clipPath?.clipPath;
15
+ }
16
+ const FAR = 1e6;
17
+ /**
18
+ * Turns the object's own `<clipPath>` in Fabric's markup into an inverting
19
+ * `<mask>`. `write` produces the markup with the clip path not inverted; the
20
+ * id Fabric gave the clip path picks out exactly this object's definition.
21
+ */
22
+ function writeWithInvertedMask(object, write, reviver) {
23
+ const clipPath = object.clipPath;
24
+ clipPath.inverted = false;
25
+ let markup;
26
+ try {
27
+ markup = write(reviver);
28
+ } finally {
29
+ clipPath.inverted = true;
30
+ }
31
+ const id = clipPath.clipPathId;
32
+ if (!id) return markup;
33
+ const definition = new RegExp(`<clipPath id="${id}"\\s*>([\\s\\S]*?)</clipPath>`);
34
+ return markup.replace(definition, (_, shape) => `<mask id="${id}" maskUnits="userSpaceOnUse" x="-1000000" y="-1000000" width="${2 * FAR}" height="${2 * FAR}">\n<rect x="-1000000" y="-1000000" width="${2 * FAR}" height="${2 * FAR}" fill="white" />\n<g fill="black" stroke="none">${shape}</g>\n</mask>`).replace(`clip-path="url(#${id})"`, `mask="url(#${id})"`);
35
+ }
36
+ //#endregion
37
+ exports.hasNestedClipPath = hasNestedClipPath;
38
+ exports.ownClipPathIsInverted = ownClipPathIsInverted;
39
+ exports.writeWithInvertedMask = writeWithInvertedMask;
@@ -0,0 +1,37 @@
1
+ import { walkObjects } from "../../fabric/walk-objects.js";
2
+ //#region src/export/svg/clip-masks.ts
3
+ /** True when a clip path anywhere in the object's tree has a clip path of its own. */
4
+ function hasNestedClipPath(object) {
5
+ let nested = false;
6
+ walkObjects([object], (child) => {
7
+ if (child.clipPath?.clipPath) nested = true;
8
+ });
9
+ return nested;
10
+ }
11
+ /** True when the object's own clip path is inverted (and simple). */
12
+ function ownClipPathIsInverted(object) {
13
+ const clipPath = object.clipPath;
14
+ return Boolean(clipPath?.inverted) && !clipPath?.clipPath;
15
+ }
16
+ const FAR = 1e6;
17
+ /**
18
+ * Turns the object's own `<clipPath>` in Fabric's markup into an inverting
19
+ * `<mask>`. `write` produces the markup with the clip path not inverted; the
20
+ * id Fabric gave the clip path picks out exactly this object's definition.
21
+ */
22
+ function writeWithInvertedMask(object, write, reviver) {
23
+ const clipPath = object.clipPath;
24
+ clipPath.inverted = false;
25
+ let markup;
26
+ try {
27
+ markup = write(reviver);
28
+ } finally {
29
+ clipPath.inverted = true;
30
+ }
31
+ const id = clipPath.clipPathId;
32
+ if (!id) return markup;
33
+ const definition = new RegExp(`<clipPath id="${id}"\\s*>([\\s\\S]*?)</clipPath>`);
34
+ return markup.replace(definition, (_, shape) => `<mask id="${id}" maskUnits="userSpaceOnUse" x="-1000000" y="-1000000" width="${2 * FAR}" height="${2 * FAR}">\n<rect x="-1000000" y="-1000000" width="${2 * FAR}" height="${2 * FAR}" fill="white" />\n<g fill="black" stroke="none">${shape}</g>\n</mask>`).replace(`clip-path="url(#${id})"`, `mask="url(#${id})"`);
35
+ }
36
+ //#endregion
37
+ export { hasNestedClipPath, ownClipPathIsInverted, writeWithInvertedMask };
@@ -77,6 +77,7 @@ function imagesOnCanvas(canvas) {
77
77
  return images;
78
78
  }
79
79
  function drawToDataUrl(element) {
80
+ if (element.naturalWidth * element.naturalHeight > 67108864) throw new Error("the picture is too large to copy");
80
81
  const canvas = document.createElement("canvas");
81
82
  canvas.width = element.naturalWidth;
82
83
  canvas.height = element.naturalHeight;
@@ -77,6 +77,7 @@ function imagesOnCanvas(canvas) {
77
77
  return images;
78
78
  }
79
79
  function drawToDataUrl(element) {
80
+ if (element.naturalWidth * element.naturalHeight > 67108864) throw new Error("the picture is too large to copy");
80
81
  const canvas = document.createElement("canvas");
81
82
  canvas.width = element.naturalWidth;
82
83
  canvas.height = element.naturalHeight;