fabricjs-document-engine 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,7 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
12
12
  - **Your storage.** Plug in any backend with two functions, or use the built-in memory and localStorage adapters. No hosted service is needed.
13
13
  - **Assets and fonts.** Documents record the images and fonts they need. When a document is opened, every image and font is checked first. You get the exact list of what is missing, can offer replacements, and tab-only images are uploaded when you save.
14
14
  - **Recovery.** Unsaved work is copied to IndexedDB while the user edits, and again at the moment the tab is closed or refreshed. After a crash or refresh you can offer to restore it, including images that only existed in the old tab.
15
+ - **Dependable export.** PNG, JPEG, WebP, SVG and editable JSON. You choose the area, scale and background. A preflight check means an export either succeeds or tells you exactly which image or font prevents it.
15
16
  - **Reliable undo and redo.** One user action is one undo step. Transactions group several code changes into one labelled step, and ids survive undo and redo.
16
17
  - **Fabric 6 and 7.** Every release is tested against both.
17
18
 
@@ -176,6 +177,47 @@ await engine.replaceImage('/old-logo.png', '/new-logo.png');
176
177
 
177
178
  `replaceImage` swaps every image that uses a URL. Each image keeps its size on the page, and the change is one undo step. `engine.getAssetManifest()` returns the manifest for the current canvas.
178
179
 
180
+ ## Export
181
+
182
+ ```ts
183
+ import { downloadExport } from 'fabricjs-document-engine';
184
+
185
+ const result = await engine.export({ format: 'png', scale: 2 });
186
+ downloadExport(result, 'poster.png');
187
+ ```
188
+
189
+ `result` is `{ format, mimeType, blob, width, height, warnings }`. JSON exports also include `document`.
190
+
191
+ | Option | Values | Default |
192
+ | --- | --- | --- |
193
+ | `format` | `'png'`, `'jpeg'`, `'webp'`, `'svg'` or `'json'` | required |
194
+ | `scale` | Output size multiplier, such as `2` for retina | `1` |
195
+ | `quality` | 0 to 1, for JPEG and WebP | `0.92` |
196
+ | `area` | `'canvas'`, `'content'` (every object), `'selection'`, or `{ left, top, width, height }` | `'canvas'` |
197
+ | `padding` | Extra space around `content` or `selection` | `0` |
198
+ | `background` | `'keep'`, `'transparent'` or any CSS color | `'keep'` |
199
+ | `signal` | An `AbortSignal` to cancel | |
200
+
201
+ - The current zoom and pan do not matter. Exports always use document coordinates, and the view is restored afterwards.
202
+ - A JPEG has no transparency, so an empty or transparent background becomes white instead of black.
203
+ - The export never changes the canvas, the history or the unsaved state.
204
+ - A JSON export is the same portable document a save produces, including uploaded images when `assets.upload` is set.
205
+
206
+ ### Preflight and errors
207
+
208
+ Before rendering, the engine checks the objects on the canvas:
209
+
210
+ - **`MISSING_IMAGE`**: an image failed to load.
211
+ - **`CROSS_ORIGIN_IMAGE`**: an image from another site without CORS would make the browser block a PNG, JPEG or WebP export. SVG and JSON are not affected.
212
+ - **`MISSING_FONT`**: a font is not available and `assets.requireFonts` is on. Otherwise you get a `FONT_UNAVAILABLE` warning.
213
+
214
+ If any problem is found, `export` rejects with `EXPORT_BLOCKED`, and `error.problems` lists each `{ code, message, url?, family?, objectIds }`. You can run the same check first to show it in your UI:
215
+
216
+ ```ts
217
+ const check = await engine.preflightExport({ format: 'png' });
218
+ if (!check.ok) showProblems(check.problems);
219
+ ```
220
+
179
221
  ## Recovery
180
222
 
181
223
  ```ts
@@ -311,6 +353,9 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
311
353
  | `engine.getAssetManifest()` | Lists the images and fonts used on the canvas. |
312
354
  | `engine.checkAssets()` | Resolves to `{ manifest, missingImages, unavailableFonts, warnings }` for the current canvas. |
313
355
  | `engine.replaceImage(oldUrl, newUrl)` | Replaces every image with that URL as one undo step. Resolves to the number of images replaced. |
356
+ | `engine.export(options)` | Exports PNG, JPEG, WebP, SVG or JSON. See [Export](#export). |
357
+ | `engine.preflightExport(options)` | Resolves to `{ ok, problems, warnings }` without exporting. |
358
+ | `downloadExport(result, fileName?)` | Starts a browser download of an export result. |
314
359
  | `engine.getRecoverableDocuments()` | Lists recovery copies, newest first. |
315
360
  | `engine.restoreRecovery(id?)` / `engine.discardRecovery(id?)` | Loads or deletes a recovery copy. The default is the current document. |
316
361
  | `engine.getRecovery(id?)` / `engine.flushRecovery()` | Reads a copy, or writes one now. |
@@ -322,7 +367,7 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
322
367
  | `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
323
368
  | `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
324
369
  | `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
325
- | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `assets:warning`, `recovery:checkpoint`, `recovery:restored`, `recovery:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
370
+ | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `assets:warning`, `recovery:checkpoint`, `recovery:restored`, `recovery:error`, `export:success`, `export:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
326
371
  | `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
327
372
  | `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
328
373
 
@@ -345,6 +390,10 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
345
390
  | `SAVE_CANCELLED` | A queued save was dropped because another document was opened. |
346
391
  | `DOCUMENT_NOT_FOUND` | The built-in adapters have no document with that id. |
347
392
  | `HISTORY_FAILED` | Undo or redo could not rebuild an object, for example because an image is gone. The step is kept and the canvas is unchanged. |
393
+ | `EXPORT_BLOCKED` | The preflight found problems. `error.problems` lists each one with the objects involved. |
394
+ | `INVALID_EXPORT_OPTIONS` | The format, scale, quality, area or padding is not valid, or the area is empty. |
395
+ | `EXPORT_ABORTED` | The export was cancelled with its `signal`. |
396
+ | `EXPORT_FAILED` | Fabric could not render the export. `error.cause` holds the original error. |
348
397
  | `RECOVERY_MISSING` | A recovery method was called without `recovery: { store }`. |
349
398
  | `RECOVERY_NOT_FOUND` | There is no recovery copy for that document. |
350
399
  | `RECOVERY_FAILED` | Writing a recovery copy failed, for example because storage is full. It is delivered as a `recovery:error` event and never interrupts editing. |
@@ -363,7 +412,7 @@ A failed load never clears or half-fills your canvas.
363
412
  | 3 | Safe saving: dirty state, autosave, stale-response protection | 0.2.0 |
364
413
  | 4 | Assets and fonts | 0.3.0 |
365
414
  | 5 | Recovery after a refresh or crash | 0.4.0 |
366
- | 6 | PNG, JPEG, SVG and JSON export with preflight checks | |
415
+ | 6 | PNG, JPEG, SVG and JSON export with preflight checks | 0.5.0 |
367
416
  | 7 | Named versions and schema migrations | |
368
417
  | 8 | React adapter and examples | |
369
418
  | 9 | Hardening and benchmarks | |
@@ -2,13 +2,17 @@ const require_asset_manifest = require("../assets/asset-manifest.cjs");
2
2
  const require_errors = require("./errors.cjs");
3
3
  const require_image_check = require("../assets/image-check.cjs");
4
4
  const require_asset_pipeline = require("../assets/asset-pipeline.cjs");
5
+ const require_export_area = require("../export/export-area.cjs");
6
+ const require_export_options = require("../export/export-options.cjs");
7
+ const require_object_ids = require("../fabric/object-ids.cjs");
8
+ const require_walk_objects = require("../fabric/walk-objects.cjs");
9
+ const require_preflight_export = require("../export/preflight-export.cjs");
10
+ const require_render_export = require("../export/render-export.cjs");
5
11
  require("../document/document-format.cjs");
6
12
  const require_ids = require("../document/ids.cjs");
7
13
  const require_create_document = require("../document/create-document.cjs");
8
14
  const require_validate_document = require("../document/validate-document.cjs");
9
- const require_walk_objects = require("../fabric/walk-objects.cjs");
10
15
  const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
11
- const require_object_ids = require("../fabric/object-ids.cjs");
12
16
  const require_object_registry = require("../fabric/object-registry.cjs");
13
17
  const require_create_history = require("../history/create-history.cjs");
14
18
  const require_recovery_controller = require("../recovery/recovery-controller.cjs");
@@ -270,6 +274,92 @@ function createDocumentEngine(options) {
270
274
  canvas.requestRenderAll();
271
275
  return images.length;
272
276
  }
277
+ function fontsOnCanvas() {
278
+ rebuildIndex();
279
+ return require_asset_manifest.buildAssetManifest(require_fabric_adapter.serializeCanvas(canvas, registry.propertiesToInclude()).objects).fonts;
280
+ }
281
+ async function checkBeforeExport(exportOptions) {
282
+ ensureUsable();
283
+ const { format } = require_export_options.normalizeExportOptions(exportOptions);
284
+ return require_preflight_export.preflightExport(canvas, format, format === "json" ? [] : fontsOnCanvas(), assetOptions);
285
+ }
286
+ function describeProblems(check) {
287
+ return check.problems.map((problem) => problem.message).join("; ");
288
+ }
289
+ async function exportContent(exportOptions) {
290
+ const settings = require_export_options.normalizeExportOptions(exportOptions);
291
+ const stopIfCancelled = () => {
292
+ if (settings.signal?.aborted) throw new require_errors.DocumentEngineError("EXPORT_ABORTED", "The export was cancelled");
293
+ };
294
+ stopIfCancelled();
295
+ if (settings.format === "json") {
296
+ const prepared = await require_asset_pipeline.prepareAssetsForSave(toDocument(), assetOptions, uploadedUrls);
297
+ stopIfCancelled();
298
+ return {
299
+ format: "json",
300
+ mimeType: require_render_export.mimeTypes.json,
301
+ blob: new Blob([JSON.stringify(prepared.document)], { type: require_render_export.mimeTypes.json }),
302
+ width: prepared.document.canvas.width,
303
+ height: prepared.document.canvas.height,
304
+ warnings: prepared.warnings,
305
+ document: prepared.document
306
+ };
307
+ }
308
+ const check = await checkBeforeExport(settings);
309
+ stopIfCancelled();
310
+ if (!check.ok) throw new require_errors.DocumentEngineError("EXPORT_BLOCKED", `The export cannot run: ${describeProblems(check)}`, { problems: check.problems });
311
+ const format = settings.format;
312
+ try {
313
+ const rendered = require_render_export.withExportView(canvas, settings.background, format, () => {
314
+ const area = require_export_area.resolveExportArea(canvas, settings.area, settings.padding);
315
+ return {
316
+ area,
317
+ content: format === "svg" ? require_render_export.renderSvg(canvas, area, settings.scale) : require_render_export.renderRaster(canvas, area, format, settings.scale, settings.quality)
318
+ };
319
+ });
320
+ const blob = format === "svg" ? new Blob([rendered.content], { type: require_render_export.mimeTypes.svg }) : require_render_export.dataUrlToBlob(rendered.content);
321
+ return {
322
+ format,
323
+ mimeType: blob.type || require_render_export.mimeTypes[format],
324
+ blob,
325
+ width: Math.round(rendered.area.width * settings.scale),
326
+ height: Math.round(rendered.area.height * settings.scale),
327
+ warnings: check.warnings
328
+ };
329
+ } catch (error) {
330
+ if (require_errors.isDocumentEngineError(error)) throw error;
331
+ if (error?.name === "SecurityError") {
332
+ const problem = {
333
+ code: "CROSS_ORIGIN_IMAGE",
334
+ message: "The browser blocked the export because the canvas shows an image from another site without CORS",
335
+ objectIds: []
336
+ };
337
+ throw new require_errors.DocumentEngineError("EXPORT_BLOCKED", problem.message, {
338
+ problems: [problem],
339
+ cause: error
340
+ });
341
+ }
342
+ const reason = error instanceof Error ? error.message : String(error);
343
+ throw new require_errors.DocumentEngineError("EXPORT_FAILED", `Fabric could not export the canvas: ${reason}`, { cause: error });
344
+ }
345
+ }
346
+ async function exportDocument(exportOptions) {
347
+ ensureUsable();
348
+ try {
349
+ const result = await exportContent(exportOptions);
350
+ events.emit("export:success", {
351
+ format: result.format,
352
+ width: result.width,
353
+ height: result.height,
354
+ warnings: result.warnings
355
+ });
356
+ return result;
357
+ } catch (error) {
358
+ const engineError = require_errors.isDocumentEngineError(error) ? error : new require_errors.DocumentEngineError("EXPORT_FAILED", String(error), { cause: error });
359
+ events.emit("export:error", { error: engineError });
360
+ throw engineError;
361
+ }
362
+ }
273
363
  function requireRecovery() {
274
364
  ensureUsable();
275
365
  if (!recovery) throw new require_errors.DocumentEngineError("RECOVERY_MISSING", "Pass recovery: { store } to createDocumentEngine to use recovery");
@@ -343,6 +433,8 @@ function createDocumentEngine(options) {
343
433
  },
344
434
  checkAssets,
345
435
  replaceImage,
436
+ export: exportDocument,
437
+ preflightExport: checkBeforeExport,
346
438
  flushRecovery: async () => requireRecovery().flush(),
347
439
  getRecoverableDocuments: async () => requireRecovery().list(),
348
440
  getRecovery: async (documentId) => requireRecovery().read(documentId ?? documentInfo.id),
@@ -1,6 +1,8 @@
1
1
  import { DocumentInfo, FabricDocument } from "../document/document-format.cjs";
2
2
  import { AssetManifest } from "../assets/asset-manifest.cjs";
3
3
  import { AssetOptions, AssetReport, AssetWarning } from "../assets/asset-pipeline.cjs";
4
+ import { ExportFormat, ExportOptions } from "../export/export-options.cjs";
5
+ import { ExportPreflight } from "../export/preflight-export.cjs";
4
6
  import { NewDocumentOptions } from "../document/create-document.cjs";
5
7
  import { CustomObjectDefinition } from "../fabric/object-registry.cjs";
6
8
  import { HistoryOptions, HistoryState } from "../history/create-history.cjs";
@@ -27,6 +29,15 @@ export interface DocumentEngineOptions {
27
29
  export interface LoadOptions {
28
30
  restoreCanvasSize?: boolean;
29
31
  }
32
+ export interface ExportResult {
33
+ format: ExportFormat;
34
+ mimeType: string;
35
+ blob: Blob;
36
+ width: number;
37
+ height: number;
38
+ warnings: AssetWarning[];
39
+ document?: FabricDocument;
40
+ }
30
41
  export interface DocumentEngineEvents {
31
42
  "load:start": {
32
43
  documentId: string | undefined;
@@ -66,6 +77,15 @@ export interface DocumentEngineEvents {
66
77
  "recovery:error": {
67
78
  error: DocumentEngineError;
68
79
  };
80
+ "export:success": {
81
+ format: ExportFormat;
82
+ width: number;
83
+ height: number;
84
+ warnings: AssetWarning[];
85
+ };
86
+ "export:error": {
87
+ error: DocumentEngineError;
88
+ };
69
89
  }
70
90
  export interface DocumentEngine {
71
91
  readonly canvas: StaticCanvas;
@@ -94,6 +114,8 @@ export interface DocumentEngine {
94
114
  getAssetManifest(): AssetManifest;
95
115
  checkAssets(): Promise<AssetReport>;
96
116
  replaceImage(oldUrl: string, newUrl: string): Promise<number>;
117
+ export(options: ExportOptions): Promise<ExportResult>;
118
+ preflightExport(options: ExportOptions): Promise<ExportPreflight>;
97
119
  flushRecovery(): Promise<void>;
98
120
  getRecoverableDocuments(): Promise<RecoveryRecord[]>;
99
121
  getRecovery(documentId?: string): Promise<RecoveryRecord | undefined>;
@@ -1,6 +1,8 @@
1
1
  import { DocumentInfo, FabricDocument } from "../document/document-format.js";
2
2
  import { AssetManifest } from "../assets/asset-manifest.js";
3
3
  import { AssetOptions, AssetReport, AssetWarning } from "../assets/asset-pipeline.js";
4
+ import { ExportFormat, ExportOptions } from "../export/export-options.js";
5
+ import { ExportPreflight } from "../export/preflight-export.js";
4
6
  import { NewDocumentOptions } from "../document/create-document.js";
5
7
  import { CustomObjectDefinition } from "../fabric/object-registry.js";
6
8
  import { HistoryOptions, HistoryState } from "../history/create-history.js";
@@ -27,6 +29,15 @@ export interface DocumentEngineOptions {
27
29
  export interface LoadOptions {
28
30
  restoreCanvasSize?: boolean;
29
31
  }
32
+ export interface ExportResult {
33
+ format: ExportFormat;
34
+ mimeType: string;
35
+ blob: Blob;
36
+ width: number;
37
+ height: number;
38
+ warnings: AssetWarning[];
39
+ document?: FabricDocument;
40
+ }
30
41
  export interface DocumentEngineEvents {
31
42
  "load:start": {
32
43
  documentId: string | undefined;
@@ -66,6 +77,15 @@ export interface DocumentEngineEvents {
66
77
  "recovery:error": {
67
78
  error: DocumentEngineError;
68
79
  };
80
+ "export:success": {
81
+ format: ExportFormat;
82
+ width: number;
83
+ height: number;
84
+ warnings: AssetWarning[];
85
+ };
86
+ "export:error": {
87
+ error: DocumentEngineError;
88
+ };
69
89
  }
70
90
  export interface DocumentEngine {
71
91
  readonly canvas: StaticCanvas;
@@ -94,6 +114,8 @@ export interface DocumentEngine {
94
114
  getAssetManifest(): AssetManifest;
95
115
  checkAssets(): Promise<AssetReport>;
96
116
  replaceImage(oldUrl: string, newUrl: string): Promise<number>;
117
+ export(options: ExportOptions): Promise<ExportResult>;
118
+ preflightExport(options: ExportOptions): Promise<ExportPreflight>;
97
119
  flushRecovery(): Promise<void>;
98
120
  getRecoverableDocuments(): Promise<RecoveryRecord[]>;
99
121
  getRecovery(documentId?: string): Promise<RecoveryRecord | undefined>;
@@ -2,13 +2,17 @@ import { buildAssetManifest } from "../assets/asset-manifest.js";
2
2
  import { DocumentEngineError, isDocumentEngineError } from "./errors.js";
3
3
  import { isSameUrl } from "../assets/image-check.js";
4
4
  import { inspectAssets, prepareAssetsForLoad, prepareAssetsForSave } from "../assets/asset-pipeline.js";
5
+ import { resolveExportArea } from "../export/export-area.js";
6
+ import { normalizeExportOptions } from "../export/export-options.js";
7
+ import { readObjectId, writeObjectId } from "../fabric/object-ids.js";
8
+ import { collectSerializedTypes, walkObjects } from "../fabric/walk-objects.js";
9
+ import { preflightExport } from "../export/preflight-export.js";
10
+ import { dataUrlToBlob, mimeTypes, renderRaster, renderSvg, withExportView } from "../export/render-export.js";
5
11
  import "../document/document-format.js";
6
12
  import { createId } from "../document/ids.js";
7
13
  import { createDocumentInfo } from "../document/create-document.js";
8
14
  import { validateDocument } from "../document/validate-document.js";
9
- import { collectSerializedTypes, walkObjects } from "../fabric/walk-objects.js";
10
15
  import { loadIntoCanvas, serializeCanvas } from "../fabric/fabric-adapter.js";
11
- import { readObjectId, writeObjectId } from "../fabric/object-ids.js";
12
16
  import { createObjectRegistry } from "../fabric/object-registry.js";
13
17
  import { createHistory } from "../history/create-history.js";
14
18
  import { createRecoveryController, restoreRecordedFiles } from "../recovery/recovery-controller.js";
@@ -270,6 +274,92 @@ function createDocumentEngine(options) {
270
274
  canvas.requestRenderAll();
271
275
  return images.length;
272
276
  }
277
+ function fontsOnCanvas() {
278
+ rebuildIndex();
279
+ return buildAssetManifest(serializeCanvas(canvas, registry.propertiesToInclude()).objects).fonts;
280
+ }
281
+ async function checkBeforeExport(exportOptions) {
282
+ ensureUsable();
283
+ const { format } = normalizeExportOptions(exportOptions);
284
+ return preflightExport(canvas, format, format === "json" ? [] : fontsOnCanvas(), assetOptions);
285
+ }
286
+ function describeProblems(check) {
287
+ return check.problems.map((problem) => problem.message).join("; ");
288
+ }
289
+ async function exportContent(exportOptions) {
290
+ const settings = normalizeExportOptions(exportOptions);
291
+ const stopIfCancelled = () => {
292
+ if (settings.signal?.aborted) throw new DocumentEngineError("EXPORT_ABORTED", "The export was cancelled");
293
+ };
294
+ stopIfCancelled();
295
+ if (settings.format === "json") {
296
+ const prepared = await prepareAssetsForSave(toDocument(), assetOptions, uploadedUrls);
297
+ stopIfCancelled();
298
+ return {
299
+ format: "json",
300
+ mimeType: mimeTypes.json,
301
+ blob: new Blob([JSON.stringify(prepared.document)], { type: mimeTypes.json }),
302
+ width: prepared.document.canvas.width,
303
+ height: prepared.document.canvas.height,
304
+ warnings: prepared.warnings,
305
+ document: prepared.document
306
+ };
307
+ }
308
+ const check = await checkBeforeExport(settings);
309
+ stopIfCancelled();
310
+ if (!check.ok) throw new DocumentEngineError("EXPORT_BLOCKED", `The export cannot run: ${describeProblems(check)}`, { problems: check.problems });
311
+ const format = settings.format;
312
+ try {
313
+ const rendered = withExportView(canvas, settings.background, format, () => {
314
+ const area = resolveExportArea(canvas, settings.area, settings.padding);
315
+ return {
316
+ area,
317
+ content: format === "svg" ? renderSvg(canvas, area, settings.scale) : renderRaster(canvas, area, format, settings.scale, settings.quality)
318
+ };
319
+ });
320
+ const blob = format === "svg" ? new Blob([rendered.content], { type: mimeTypes.svg }) : dataUrlToBlob(rendered.content);
321
+ return {
322
+ format,
323
+ mimeType: blob.type || mimeTypes[format],
324
+ blob,
325
+ width: Math.round(rendered.area.width * settings.scale),
326
+ height: Math.round(rendered.area.height * settings.scale),
327
+ warnings: check.warnings
328
+ };
329
+ } catch (error) {
330
+ if (isDocumentEngineError(error)) throw error;
331
+ if (error?.name === "SecurityError") {
332
+ const problem = {
333
+ code: "CROSS_ORIGIN_IMAGE",
334
+ message: "The browser blocked the export because the canvas shows an image from another site without CORS",
335
+ objectIds: []
336
+ };
337
+ throw new DocumentEngineError("EXPORT_BLOCKED", problem.message, {
338
+ problems: [problem],
339
+ cause: error
340
+ });
341
+ }
342
+ const reason = error instanceof Error ? error.message : String(error);
343
+ throw new DocumentEngineError("EXPORT_FAILED", `Fabric could not export the canvas: ${reason}`, { cause: error });
344
+ }
345
+ }
346
+ async function exportDocument(exportOptions) {
347
+ ensureUsable();
348
+ try {
349
+ const result = await exportContent(exportOptions);
350
+ events.emit("export:success", {
351
+ format: result.format,
352
+ width: result.width,
353
+ height: result.height,
354
+ warnings: result.warnings
355
+ });
356
+ return result;
357
+ } catch (error) {
358
+ const engineError = isDocumentEngineError(error) ? error : new DocumentEngineError("EXPORT_FAILED", String(error), { cause: error });
359
+ events.emit("export:error", { error: engineError });
360
+ throw engineError;
361
+ }
362
+ }
273
363
  function requireRecovery() {
274
364
  ensureUsable();
275
365
  if (!recovery) throw new DocumentEngineError("RECOVERY_MISSING", "Pass recovery: { store } to createDocumentEngine to use recovery");
@@ -343,6 +433,8 @@ function createDocumentEngine(options) {
343
433
  },
344
434
  checkAssets,
345
435
  replaceImage,
436
+ export: exportDocument,
437
+ preflightExport: checkBeforeExport,
346
438
  flushRecovery: async () => requireRecovery().flush(),
347
439
  getRecoverableDocuments: async () => requireRecovery().list(),
348
440
  getRecovery: async (documentId) => requireRecovery().read(documentId ?? documentInfo.id),
@@ -8,6 +8,7 @@ var DocumentEngineError = class extends Error {
8
8
  this.unknownTypes = details.unknownTypes ?? [];
9
9
  this.missingAssets = details.missingAssets ?? [];
10
10
  this.missingFonts = details.missingFonts ?? [];
11
+ this.problems = details.problems ?? [];
11
12
  this.cause = details.cause;
12
13
  this.retryable = details.retryable ?? false;
13
14
  }
@@ -1,6 +1,7 @@
1
1
  import { FontAsset, ImageAsset } from "../assets/asset-manifest.cjs";
2
+ import { ExportProblem } from "../export/preflight-export.cjs";
2
3
  //#region src/engine/errors.d.ts
3
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "ENGINE_DESTROYED";
4
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
4
5
  export interface DocumentIssue {
5
6
  code: DocumentErrorCode;
6
7
  path: string;
@@ -11,6 +12,7 @@ export interface DocumentEngineErrorDetails {
11
12
  unknownTypes?: string[];
12
13
  missingAssets?: ImageAsset[];
13
14
  missingFonts?: FontAsset[];
15
+ problems?: ExportProblem[];
14
16
  cause?: unknown;
15
17
  retryable?: boolean;
16
18
  }
@@ -20,6 +22,7 @@ export declare class DocumentEngineError extends Error {
20
22
  readonly unknownTypes: string[];
21
23
  readonly missingAssets: ImageAsset[];
22
24
  readonly missingFonts: FontAsset[];
25
+ readonly problems: ExportProblem[];
23
26
  readonly cause: unknown;
24
27
  readonly retryable: boolean;
25
28
  constructor(code: DocumentErrorCode, message: string, details?: DocumentEngineErrorDetails);
@@ -1,6 +1,7 @@
1
1
  import { FontAsset, ImageAsset } from "../assets/asset-manifest.js";
2
+ import { ExportProblem } from "../export/preflight-export.js";
2
3
  //#region src/engine/errors.d.ts
3
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "ENGINE_DESTROYED";
4
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
4
5
  export interface DocumentIssue {
5
6
  code: DocumentErrorCode;
6
7
  path: string;
@@ -11,6 +12,7 @@ export interface DocumentEngineErrorDetails {
11
12
  unknownTypes?: string[];
12
13
  missingAssets?: ImageAsset[];
13
14
  missingFonts?: FontAsset[];
15
+ problems?: ExportProblem[];
14
16
  cause?: unknown;
15
17
  retryable?: boolean;
16
18
  }
@@ -20,6 +22,7 @@ export declare class DocumentEngineError extends Error {
20
22
  readonly unknownTypes: string[];
21
23
  readonly missingAssets: ImageAsset[];
22
24
  readonly missingFonts: FontAsset[];
25
+ readonly problems: ExportProblem[];
23
26
  readonly cause: unknown;
24
27
  readonly retryable: boolean;
25
28
  constructor(code: DocumentErrorCode, message: string, details?: DocumentEngineErrorDetails);
@@ -8,6 +8,7 @@ var DocumentEngineError = class extends Error {
8
8
  this.unknownTypes = details.unknownTypes ?? [];
9
9
  this.missingAssets = details.missingAssets ?? [];
10
10
  this.missingFonts = details.missingFonts ?? [];
11
+ this.problems = details.problems ?? [];
11
12
  this.cause = details.cause;
12
13
  this.retryable = details.retryable ?? false;
13
14
  }
@@ -0,0 +1,14 @@
1
+ //#region src/export/download-export.ts
2
+ function downloadExport(result, fileName = `document.${result.format === "jpeg" ? "jpg" : result.format}`) {
3
+ const url = URL.createObjectURL(result.blob);
4
+ const link = document.createElement("a");
5
+ link.href = url;
6
+ link.download = fileName;
7
+ link.style.display = "none";
8
+ document.body.append(link);
9
+ link.click();
10
+ link.remove();
11
+ setTimeout(() => URL.revokeObjectURL(url), 0);
12
+ }
13
+ //#endregion
14
+ exports.downloadExport = downloadExport;
@@ -0,0 +1,7 @@
1
+ //#region src/export/download-export.d.ts
2
+ export interface DownloadableExport {
3
+ blob: Blob;
4
+ format: string;
5
+ }
6
+ export declare function downloadExport(result: DownloadableExport, fileName?: string): void;
7
+ //#endregion
@@ -0,0 +1,7 @@
1
+ //#region src/export/download-export.d.ts
2
+ export interface DownloadableExport {
3
+ blob: Blob;
4
+ format: string;
5
+ }
6
+ export declare function downloadExport(result: DownloadableExport, fileName?: string): void;
7
+ //#endregion
@@ -0,0 +1,14 @@
1
+ //#region src/export/download-export.ts
2
+ function downloadExport(result, fileName = `document.${result.format === "jpeg" ? "jpg" : result.format}`) {
3
+ const url = URL.createObjectURL(result.blob);
4
+ const link = document.createElement("a");
5
+ link.href = url;
6
+ link.download = fileName;
7
+ link.style.display = "none";
8
+ document.body.append(link);
9
+ link.click();
10
+ link.remove();
11
+ setTimeout(() => URL.revokeObjectURL(url), 0);
12
+ }
13
+ //#endregion
14
+ export { downloadExport };
@@ -0,0 +1,46 @@
1
+ const require_errors = require("../engine/errors.cjs");
2
+ //#region src/export/export-area.ts
3
+ function boundsOf(objects) {
4
+ if (objects.length === 0) return null;
5
+ let left = Infinity;
6
+ let top = Infinity;
7
+ let right = -Infinity;
8
+ let bottom = -Infinity;
9
+ for (const object of objects) {
10
+ const box = object.getBoundingRect();
11
+ left = Math.min(left, box.left);
12
+ top = Math.min(top, box.top);
13
+ right = Math.max(right, box.left + box.width);
14
+ bottom = Math.max(bottom, box.top + box.height);
15
+ }
16
+ return {
17
+ left,
18
+ top,
19
+ width: right - left,
20
+ height: bottom - top
21
+ };
22
+ }
23
+ function padAndRound(rect, padding) {
24
+ const left = Math.floor(rect.left - padding);
25
+ const top = Math.floor(rect.top - padding);
26
+ return {
27
+ left,
28
+ top,
29
+ width: Math.max(1, Math.ceil(rect.left + rect.width + padding) - left),
30
+ height: Math.max(1, Math.ceil(rect.top + rect.height + padding) - top)
31
+ };
32
+ }
33
+ function resolveExportArea(canvas, area, padding) {
34
+ if (typeof area === "object") return padAndRound(area, padding);
35
+ if (area === "canvas") return {
36
+ left: 0,
37
+ top: 0,
38
+ width: canvas.getWidth(),
39
+ height: canvas.getHeight()
40
+ };
41
+ const bounds = boundsOf(area === "selection" ? canvas.getActiveObjects?.() ?? [] : canvas.getObjects());
42
+ if (bounds === null) throw new require_errors.DocumentEngineError("INVALID_EXPORT_OPTIONS", `Cannot export the ${area} because ${area === "selection" ? "nothing is selected" : "the canvas is empty"}`);
43
+ return padAndRound(bounds, padding);
44
+ }
45
+ //#endregion
46
+ exports.resolveExportArea = resolveExportArea;
@@ -0,0 +1,46 @@
1
+ import { DocumentEngineError } from "../engine/errors.js";
2
+ //#region src/export/export-area.ts
3
+ function boundsOf(objects) {
4
+ if (objects.length === 0) return null;
5
+ let left = Infinity;
6
+ let top = Infinity;
7
+ let right = -Infinity;
8
+ let bottom = -Infinity;
9
+ for (const object of objects) {
10
+ const box = object.getBoundingRect();
11
+ left = Math.min(left, box.left);
12
+ top = Math.min(top, box.top);
13
+ right = Math.max(right, box.left + box.width);
14
+ bottom = Math.max(bottom, box.top + box.height);
15
+ }
16
+ return {
17
+ left,
18
+ top,
19
+ width: right - left,
20
+ height: bottom - top
21
+ };
22
+ }
23
+ function padAndRound(rect, padding) {
24
+ const left = Math.floor(rect.left - padding);
25
+ const top = Math.floor(rect.top - padding);
26
+ return {
27
+ left,
28
+ top,
29
+ width: Math.max(1, Math.ceil(rect.left + rect.width + padding) - left),
30
+ height: Math.max(1, Math.ceil(rect.top + rect.height + padding) - top)
31
+ };
32
+ }
33
+ function resolveExportArea(canvas, area, padding) {
34
+ if (typeof area === "object") return padAndRound(area, padding);
35
+ if (area === "canvas") return {
36
+ left: 0,
37
+ top: 0,
38
+ width: canvas.getWidth(),
39
+ height: canvas.getHeight()
40
+ };
41
+ const bounds = boundsOf(area === "selection" ? canvas.getActiveObjects?.() ?? [] : canvas.getObjects());
42
+ if (bounds === null) throw new DocumentEngineError("INVALID_EXPORT_OPTIONS", `Cannot export the ${area} because ${area === "selection" ? "nothing is selected" : "the canvas is empty"}`);
43
+ return padAndRound(bounds, padding);
44
+ }
45
+ //#endregion
46
+ export { resolveExportArea };
@@ -0,0 +1,47 @@
1
+ const require_errors = require("../engine/errors.cjs");
2
+ //#region src/export/export-options.ts
3
+ const formats = /* @__PURE__ */ new Set([
4
+ "png",
5
+ "jpeg",
6
+ "webp",
7
+ "svg",
8
+ "json"
9
+ ]);
10
+ function invalid(message) {
11
+ return new require_errors.DocumentEngineError("INVALID_EXPORT_OPTIONS", message);
12
+ }
13
+ function isPositive(value) {
14
+ return Number.isFinite(value) && value > 0;
15
+ }
16
+ function isRasterFormat(format) {
17
+ return format === "png" || format === "jpeg" || format === "webp";
18
+ }
19
+ function normalizeExportOptions(options) {
20
+ if (!formats.has(options.format)) throw invalid(`Unknown export format "${String(options.format)}", use png, jpeg, webp, svg or json`);
21
+ const scale = options.scale ?? 1;
22
+ if (!isPositive(scale)) throw invalid("scale must be a number greater than zero");
23
+ const quality = options.quality ?? .92;
24
+ if (!(quality >= 0 && quality <= 1)) throw invalid("quality must be between 0 and 1");
25
+ const padding = options.padding ?? 0;
26
+ if (!(Number.isFinite(padding) && padding >= 0)) throw invalid("padding must be zero or more");
27
+ const area = options.area ?? "canvas";
28
+ if (typeof area === "object") {
29
+ if (![area.left, area.top].every(Number.isFinite) || !isPositive(area.width) || !isPositive(area.height)) throw invalid("area needs finite left and top and a positive width and height");
30
+ } else if (![
31
+ "canvas",
32
+ "content",
33
+ "selection"
34
+ ].includes(area)) throw invalid(`Unknown export area "${String(area)}", use canvas, content, selection or a rectangle`);
35
+ return {
36
+ format: options.format,
37
+ scale,
38
+ quality,
39
+ area,
40
+ padding,
41
+ background: options.background ?? "keep",
42
+ signal: options.signal
43
+ };
44
+ }
45
+ //#endregion
46
+ exports.isRasterFormat = isRasterFormat;
47
+ exports.normalizeExportOptions = normalizeExportOptions;
@@ -0,0 +1,20 @@
1
+ //#region src/export/export-options.d.ts
2
+ export type ExportFormat = "png" | "jpeg" | "webp" | "svg" | "json";
3
+ export interface ExportRect {
4
+ left: number;
5
+ top: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export type ExportArea = "canvas" | "content" | "selection" | ExportRect;
10
+ export type ExportBackground = "keep" | "transparent" | (string & {});
11
+ export interface ExportOptions {
12
+ format: ExportFormat;
13
+ scale?: number;
14
+ quality?: number;
15
+ area?: ExportArea;
16
+ padding?: number;
17
+ background?: ExportBackground;
18
+ signal?: AbortSignal;
19
+ }
20
+ //#endregion
@@ -0,0 +1,20 @@
1
+ //#region src/export/export-options.d.ts
2
+ export type ExportFormat = "png" | "jpeg" | "webp" | "svg" | "json";
3
+ export interface ExportRect {
4
+ left: number;
5
+ top: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export type ExportArea = "canvas" | "content" | "selection" | ExportRect;
10
+ export type ExportBackground = "keep" | "transparent" | (string & {});
11
+ export interface ExportOptions {
12
+ format: ExportFormat;
13
+ scale?: number;
14
+ quality?: number;
15
+ area?: ExportArea;
16
+ padding?: number;
17
+ background?: ExportBackground;
18
+ signal?: AbortSignal;
19
+ }
20
+ //#endregion
@@ -0,0 +1,46 @@
1
+ import { DocumentEngineError } from "../engine/errors.js";
2
+ //#region src/export/export-options.ts
3
+ const formats = /* @__PURE__ */ new Set([
4
+ "png",
5
+ "jpeg",
6
+ "webp",
7
+ "svg",
8
+ "json"
9
+ ]);
10
+ function invalid(message) {
11
+ return new DocumentEngineError("INVALID_EXPORT_OPTIONS", message);
12
+ }
13
+ function isPositive(value) {
14
+ return Number.isFinite(value) && value > 0;
15
+ }
16
+ function isRasterFormat(format) {
17
+ return format === "png" || format === "jpeg" || format === "webp";
18
+ }
19
+ function normalizeExportOptions(options) {
20
+ if (!formats.has(options.format)) throw invalid(`Unknown export format "${String(options.format)}", use png, jpeg, webp, svg or json`);
21
+ const scale = options.scale ?? 1;
22
+ if (!isPositive(scale)) throw invalid("scale must be a number greater than zero");
23
+ const quality = options.quality ?? .92;
24
+ if (!(quality >= 0 && quality <= 1)) throw invalid("quality must be between 0 and 1");
25
+ const padding = options.padding ?? 0;
26
+ if (!(Number.isFinite(padding) && padding >= 0)) throw invalid("padding must be zero or more");
27
+ const area = options.area ?? "canvas";
28
+ if (typeof area === "object") {
29
+ if (![area.left, area.top].every(Number.isFinite) || !isPositive(area.width) || !isPositive(area.height)) throw invalid("area needs finite left and top and a positive width and height");
30
+ } else if (![
31
+ "canvas",
32
+ "content",
33
+ "selection"
34
+ ].includes(area)) throw invalid(`Unknown export area "${String(area)}", use canvas, content, selection or a rectangle`);
35
+ return {
36
+ format: options.format,
37
+ scale,
38
+ quality,
39
+ area,
40
+ padding,
41
+ background: options.background ?? "keep",
42
+ signal: options.signal
43
+ };
44
+ }
45
+ //#endregion
46
+ export { isRasterFormat, normalizeExportOptions };
@@ -0,0 +1,105 @@
1
+ const require_font_check = require("../assets/font-check.cjs");
2
+ const require_image_check = require("../assets/image-check.cjs");
3
+ const require_export_options = require("./export-options.cjs");
4
+ const require_object_ids = require("../fabric/object-ids.cjs");
5
+ const require_walk_objects = require("../fabric/walk-objects.cjs");
6
+ //#region src/export/preflight-export.ts
7
+ function imageElementsOf(object) {
8
+ const elements = [];
9
+ const withElement = object;
10
+ if (typeof withElement.getElement === "function") elements.push(withElement.getElement());
11
+ for (const paint of [object.fill, object.stroke]) {
12
+ const source = paint?.source;
13
+ if (source !== void 0) elements.push(source);
14
+ }
15
+ return elements.filter((element) => typeof HTMLImageElement !== "undefined" && element instanceof HTMLImageElement);
16
+ }
17
+ function collectImageUses(objects) {
18
+ const uses = /* @__PURE__ */ new Map();
19
+ const pending = objects.map((object) => ({
20
+ object,
21
+ ownerId: require_object_ids.readObjectId(object)
22
+ }));
23
+ while (pending.length > 0) {
24
+ const { object, ownerId } = pending.pop();
25
+ const id = require_object_ids.readObjectId(object) ?? ownerId;
26
+ for (const element of imageElementsOf(object)) {
27
+ const url = element.currentSrc || element.src;
28
+ const use = uses.get(url) ?? {
29
+ element,
30
+ objectIds: /* @__PURE__ */ new Set()
31
+ };
32
+ if (id !== void 0) use.objectIds.add(id);
33
+ uses.set(url, use);
34
+ }
35
+ require_walk_objects.childrenOf(object).forEach((child) => pending.push({
36
+ object: child,
37
+ ownerId: id
38
+ }));
39
+ if (object.clipPath) pending.push({
40
+ object: object.clipPath,
41
+ ownerId: id
42
+ });
43
+ }
44
+ return uses;
45
+ }
46
+ function isBroken(element) {
47
+ return element.complete && element.naturalWidth === 0;
48
+ }
49
+ function shortUrl(url) {
50
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
51
+ }
52
+ async function preflightExport(canvas, format, fonts, assetOptions) {
53
+ const problems = [];
54
+ const warnings = [];
55
+ if (format === "json") return {
56
+ ok: true,
57
+ problems,
58
+ warnings
59
+ };
60
+ for (const [url, use] of collectImageUses(canvas.getObjects())) {
61
+ const objectIds = [...use.objectIds];
62
+ if (isBroken(use.element)) problems.push({
63
+ code: "MISSING_IMAGE",
64
+ message: `The image ${shortUrl(url)} did not load`,
65
+ url,
66
+ objectIds
67
+ });
68
+ else if (require_export_options.isRasterFormat(format) && require_image_check.isCrossOriginUrl(url) && !use.element.crossOrigin) problems.push({
69
+ code: "CROSS_ORIGIN_IMAGE",
70
+ message: `The image ${shortUrl(url)} comes from another site without crossOrigin, so the browser blocks exporting it. Load it with crossOrigin: 'anonymous' from a server that allows CORS.`,
71
+ url,
72
+ objectIds
73
+ });
74
+ else if (format === "svg" && !require_image_check.isPortableUrl(url)) warnings.push({
75
+ code: "ASSET_NOT_PORTABLE",
76
+ message: `The SVG links to ${shortUrl(url)}, which only exists in this tab`,
77
+ url,
78
+ objectIds
79
+ });
80
+ }
81
+ const fontSet = typeof document === "undefined" ? void 0 : document.fonts;
82
+ if (fontSet) await fontSet.ready;
83
+ for (const font of await require_font_check.findUnavailableFonts(fonts, assetOptions.loadFont)) {
84
+ const message = `The font "${font.family}" (${font.weight}, ${font.style}) is not available`;
85
+ if (assetOptions.requireFonts) problems.push({
86
+ code: "MISSING_FONT",
87
+ message,
88
+ family: font.family,
89
+ objectIds: font.objectIds
90
+ });
91
+ else warnings.push({
92
+ code: "FONT_UNAVAILABLE",
93
+ message: `${message}, text will use a fallback font`,
94
+ family: font.family,
95
+ objectIds: font.objectIds
96
+ });
97
+ }
98
+ return {
99
+ ok: problems.length === 0,
100
+ problems,
101
+ warnings
102
+ };
103
+ }
104
+ //#endregion
105
+ exports.preflightExport = preflightExport;
@@ -0,0 +1,17 @@
1
+ import { AssetWarning } from "../assets/asset-pipeline.cjs";
2
+ import { StaticCanvas } from "fabric";
3
+ //#region src/export/preflight-export.d.ts
4
+ export type ExportProblemCode = "MISSING_IMAGE" | "CROSS_ORIGIN_IMAGE" | "MISSING_FONT";
5
+ export interface ExportProblem {
6
+ code: ExportProblemCode;
7
+ message: string;
8
+ url?: string;
9
+ family?: string;
10
+ objectIds: string[];
11
+ }
12
+ export interface ExportPreflight {
13
+ ok: boolean;
14
+ problems: ExportProblem[];
15
+ warnings: AssetWarning[];
16
+ }
17
+ //#endregion
@@ -0,0 +1,17 @@
1
+ import { AssetWarning } from "../assets/asset-pipeline.js";
2
+ import { StaticCanvas } from "fabric";
3
+ //#region src/export/preflight-export.d.ts
4
+ export type ExportProblemCode = "MISSING_IMAGE" | "CROSS_ORIGIN_IMAGE" | "MISSING_FONT";
5
+ export interface ExportProblem {
6
+ code: ExportProblemCode;
7
+ message: string;
8
+ url?: string;
9
+ family?: string;
10
+ objectIds: string[];
11
+ }
12
+ export interface ExportPreflight {
13
+ ok: boolean;
14
+ problems: ExportProblem[];
15
+ warnings: AssetWarning[];
16
+ }
17
+ //#endregion
@@ -0,0 +1,105 @@
1
+ import { findUnavailableFonts } from "../assets/font-check.js";
2
+ import { isCrossOriginUrl, isPortableUrl } from "../assets/image-check.js";
3
+ import { isRasterFormat } from "./export-options.js";
4
+ import { readObjectId } from "../fabric/object-ids.js";
5
+ import { childrenOf } from "../fabric/walk-objects.js";
6
+ //#region src/export/preflight-export.ts
7
+ function imageElementsOf(object) {
8
+ const elements = [];
9
+ const withElement = object;
10
+ if (typeof withElement.getElement === "function") elements.push(withElement.getElement());
11
+ for (const paint of [object.fill, object.stroke]) {
12
+ const source = paint?.source;
13
+ if (source !== void 0) elements.push(source);
14
+ }
15
+ return elements.filter((element) => typeof HTMLImageElement !== "undefined" && element instanceof HTMLImageElement);
16
+ }
17
+ function collectImageUses(objects) {
18
+ const uses = /* @__PURE__ */ new Map();
19
+ const pending = objects.map((object) => ({
20
+ object,
21
+ ownerId: readObjectId(object)
22
+ }));
23
+ while (pending.length > 0) {
24
+ const { object, ownerId } = pending.pop();
25
+ const id = readObjectId(object) ?? ownerId;
26
+ for (const element of imageElementsOf(object)) {
27
+ const url = element.currentSrc || element.src;
28
+ const use = uses.get(url) ?? {
29
+ element,
30
+ objectIds: /* @__PURE__ */ new Set()
31
+ };
32
+ if (id !== void 0) use.objectIds.add(id);
33
+ uses.set(url, use);
34
+ }
35
+ childrenOf(object).forEach((child) => pending.push({
36
+ object: child,
37
+ ownerId: id
38
+ }));
39
+ if (object.clipPath) pending.push({
40
+ object: object.clipPath,
41
+ ownerId: id
42
+ });
43
+ }
44
+ return uses;
45
+ }
46
+ function isBroken(element) {
47
+ return element.complete && element.naturalWidth === 0;
48
+ }
49
+ function shortUrl(url) {
50
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
51
+ }
52
+ async function preflightExport(canvas, format, fonts, assetOptions) {
53
+ const problems = [];
54
+ const warnings = [];
55
+ if (format === "json") return {
56
+ ok: true,
57
+ problems,
58
+ warnings
59
+ };
60
+ for (const [url, use] of collectImageUses(canvas.getObjects())) {
61
+ const objectIds = [...use.objectIds];
62
+ if (isBroken(use.element)) problems.push({
63
+ code: "MISSING_IMAGE",
64
+ message: `The image ${shortUrl(url)} did not load`,
65
+ url,
66
+ objectIds
67
+ });
68
+ else if (isRasterFormat(format) && isCrossOriginUrl(url) && !use.element.crossOrigin) problems.push({
69
+ code: "CROSS_ORIGIN_IMAGE",
70
+ message: `The image ${shortUrl(url)} comes from another site without crossOrigin, so the browser blocks exporting it. Load it with crossOrigin: 'anonymous' from a server that allows CORS.`,
71
+ url,
72
+ objectIds
73
+ });
74
+ else if (format === "svg" && !isPortableUrl(url)) warnings.push({
75
+ code: "ASSET_NOT_PORTABLE",
76
+ message: `The SVG links to ${shortUrl(url)}, which only exists in this tab`,
77
+ url,
78
+ objectIds
79
+ });
80
+ }
81
+ const fontSet = typeof document === "undefined" ? void 0 : document.fonts;
82
+ if (fontSet) await fontSet.ready;
83
+ for (const font of await findUnavailableFonts(fonts, assetOptions.loadFont)) {
84
+ const message = `The font "${font.family}" (${font.weight}, ${font.style}) is not available`;
85
+ if (assetOptions.requireFonts) problems.push({
86
+ code: "MISSING_FONT",
87
+ message,
88
+ family: font.family,
89
+ objectIds: font.objectIds
90
+ });
91
+ else warnings.push({
92
+ code: "FONT_UNAVAILABLE",
93
+ message: `${message}, text will use a fallback font`,
94
+ family: font.family,
95
+ objectIds: font.objectIds
96
+ });
97
+ }
98
+ return {
99
+ ok: problems.length === 0,
100
+ problems,
101
+ warnings
102
+ };
103
+ }
104
+ //#endregion
105
+ export { preflightExport };
@@ -0,0 +1,69 @@
1
+ //#region src/export/render-export.ts
2
+ const identity = [
3
+ 1,
4
+ 0,
5
+ 0,
6
+ 1,
7
+ 0,
8
+ 0
9
+ ];
10
+ const mimeTypes = {
11
+ png: "image/png",
12
+ jpeg: "image/jpeg",
13
+ webp: "image/webp",
14
+ svg: "image/svg+xml",
15
+ json: "application/json"
16
+ };
17
+ function chooseBackground(background, format, current) {
18
+ if (background === "transparent") return format === "jpeg" ? "white" : "";
19
+ if (background !== "keep") return background;
20
+ return format === "jpeg" && !(current !== void 0 && current !== null && current !== "") ? "white" : current;
21
+ }
22
+ function withExportView(canvas, background, format, work) {
23
+ const savedTransform = [...canvas.viewportTransform];
24
+ const savedBackground = canvas.backgroundColor;
25
+ canvas.setViewportTransform(identity);
26
+ canvas.backgroundColor = chooseBackground(background, format, savedBackground);
27
+ try {
28
+ return work();
29
+ } finally {
30
+ canvas.backgroundColor = savedBackground;
31
+ canvas.setViewportTransform(savedTransform);
32
+ canvas.requestRenderAll();
33
+ }
34
+ }
35
+ function renderRaster(canvas, area, format, scale, quality) {
36
+ return canvas.toDataURL({
37
+ ...area,
38
+ format,
39
+ quality,
40
+ multiplier: scale,
41
+ enableRetinaScaling: false
42
+ });
43
+ }
44
+ function renderSvg(canvas, area, scale) {
45
+ return canvas.toSVG({
46
+ viewBox: {
47
+ x: area.left,
48
+ y: area.top,
49
+ width: area.width,
50
+ height: area.height
51
+ },
52
+ width: String(area.width * scale),
53
+ height: String(area.height * scale)
54
+ });
55
+ }
56
+ function dataUrlToBlob(dataUrl) {
57
+ const [header, payload = ""] = dataUrl.split(",");
58
+ const mimeType = /data:([^;]+)/.exec(header ?? "")?.[1] ?? "application/octet-stream";
59
+ const binary = atob(payload);
60
+ const bytes = new Uint8Array(binary.length);
61
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
62
+ return new Blob([bytes], { type: mimeType });
63
+ }
64
+ //#endregion
65
+ exports.dataUrlToBlob = dataUrlToBlob;
66
+ exports.mimeTypes = mimeTypes;
67
+ exports.renderRaster = renderRaster;
68
+ exports.renderSvg = renderSvg;
69
+ exports.withExportView = withExportView;
@@ -0,0 +1,65 @@
1
+ //#region src/export/render-export.ts
2
+ const identity = [
3
+ 1,
4
+ 0,
5
+ 0,
6
+ 1,
7
+ 0,
8
+ 0
9
+ ];
10
+ const mimeTypes = {
11
+ png: "image/png",
12
+ jpeg: "image/jpeg",
13
+ webp: "image/webp",
14
+ svg: "image/svg+xml",
15
+ json: "application/json"
16
+ };
17
+ function chooseBackground(background, format, current) {
18
+ if (background === "transparent") return format === "jpeg" ? "white" : "";
19
+ if (background !== "keep") return background;
20
+ return format === "jpeg" && !(current !== void 0 && current !== null && current !== "") ? "white" : current;
21
+ }
22
+ function withExportView(canvas, background, format, work) {
23
+ const savedTransform = [...canvas.viewportTransform];
24
+ const savedBackground = canvas.backgroundColor;
25
+ canvas.setViewportTransform(identity);
26
+ canvas.backgroundColor = chooseBackground(background, format, savedBackground);
27
+ try {
28
+ return work();
29
+ } finally {
30
+ canvas.backgroundColor = savedBackground;
31
+ canvas.setViewportTransform(savedTransform);
32
+ canvas.requestRenderAll();
33
+ }
34
+ }
35
+ function renderRaster(canvas, area, format, scale, quality) {
36
+ return canvas.toDataURL({
37
+ ...area,
38
+ format,
39
+ quality,
40
+ multiplier: scale,
41
+ enableRetinaScaling: false
42
+ });
43
+ }
44
+ function renderSvg(canvas, area, scale) {
45
+ return canvas.toSVG({
46
+ viewBox: {
47
+ x: area.left,
48
+ y: area.top,
49
+ width: area.width,
50
+ height: area.height
51
+ },
52
+ width: String(area.width * scale),
53
+ height: String(area.height * scale)
54
+ });
55
+ }
56
+ function dataUrlToBlob(dataUrl) {
57
+ const [header, payload = ""] = dataUrl.split(",");
58
+ const mimeType = /data:([^;]+)/.exec(header ?? "")?.[1] ?? "application/octet-stream";
59
+ const binary = atob(payload);
60
+ const bytes = new Uint8Array(binary.length);
61
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
62
+ return new Blob([bytes], { type: mimeType });
63
+ }
64
+ //#endregion
65
+ export { dataUrlToBlob, mimeTypes, renderRaster, renderSvg, withExportView };
@@ -1,5 +1,5 @@
1
- const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
2
1
  const require_object_ids = require("../fabric/object-ids.cjs");
2
+ const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
3
3
  //#region src/history/apply-state.ts
4
4
  function arrangeObjects(current, change, created) {
5
5
  const arranged = [];
@@ -1,5 +1,5 @@
1
- import { createObjects } from "../fabric/fabric-adapter.js";
2
1
  import { readObjectId } from "../fabric/object-ids.js";
2
+ import { createObjects } from "../fabric/fabric-adapter.js";
3
3
  //#region src/history/apply-state.ts
4
4
  function arrangeObjects(current, change, created) {
5
5
  const arranged = [];
package/dist/index.cjs CHANGED
@@ -5,11 +5,13 @@ const require_validate_document = require("./document/validate-document.cjs");
5
5
  const require_create_document_engine = require("./engine/create-document-engine.cjs");
6
6
  const require_keyboard_shortcuts = require("./history/keyboard-shortcuts.cjs");
7
7
  const require_unsaved_changes_warning = require("./save/unsaved-changes-warning.cjs");
8
+ const require_download_export = require("./export/download-export.cjs");
8
9
  exports.CURRENT_SCHEMA_VERSION = require_document_format.CURRENT_SCHEMA_VERSION;
9
10
  exports.DocumentEngineError = require_errors.DocumentEngineError;
10
11
  exports.bindKeyboardShortcuts = require_keyboard_shortcuts.bindKeyboardShortcuts;
11
12
  exports.bindUnsavedChangesWarning = require_unsaved_changes_warning.bindUnsavedChangesWarning;
12
13
  exports.createConflictError = require_errors.createConflictError;
13
14
  exports.createDocumentEngine = require_create_document_engine.createDocumentEngine;
15
+ exports.downloadExport = require_download_export.downloadExport;
14
16
  exports.isDocumentEngineError = require_errors.isDocumentEngineError;
15
17
  exports.validateDocument = require_validate_document.validateDocument;
package/dist/index.d.cts CHANGED
@@ -2,6 +2,8 @@ import { CURRENT_SCHEMA_VERSION, DocumentCanvas, DocumentInfo, FabricDocument, S
2
2
  import { AssetManifest, FontAsset, ImageAsset } from "./assets/asset-manifest.cjs";
3
3
  import { FontLoader } from "./assets/font-check.cjs";
4
4
  import { AssetOptions, AssetReport, AssetWarning, AssetWarningCode, UploadRequest } from "./assets/asset-pipeline.cjs";
5
+ import { ExportArea, ExportBackground, ExportFormat, ExportOptions, ExportRect } from "./export/export-options.cjs";
6
+ import { ExportPreflight, ExportProblem, ExportProblemCode } from "./export/preflight-export.cjs";
5
7
  import { NewDocumentOptions } from "./document/create-document.cjs";
6
8
  import { CustomObjectDefinition } from "./fabric/object-registry.cjs";
7
9
  import { HistoryOptions, HistoryState } from "./history/create-history.cjs";
@@ -13,8 +15,9 @@ import { DocumentEngineError, DocumentEngineErrorDetails, DocumentErrorCode, Doc
13
15
  import { DocumentStorage, SaveContext, SaveResult } from "./storage/storage-contract.cjs";
14
16
  import { SaveOptions, SaveRetryEvent, SaveState, SaveStatus } from "./save/save-controller.cjs";
15
17
  import { Unsubscribe } from "./engine/event-emitter.cjs";
16
- import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, LoadOptions, createDocumentEngine } from "./engine/create-document-engine.cjs";
18
+ import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, ExportResult, LoadOptions, createDocumentEngine } from "./engine/create-document-engine.cjs";
17
19
  import { validateDocument } from "./document/validate-document.cjs";
18
20
  import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.cjs";
19
21
  import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.cjs";
20
- export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
22
+ import { DownloadableExport, downloadExport } from "./export/download-export.cjs";
23
+ export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type DownloadableExport, type ExportArea, type ExportBackground, type ExportFormat, type ExportOptions, type ExportPreflight, type ExportProblem, type ExportProblemCode, type ExportRect, type ExportResult, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, downloadExport, isDocumentEngineError, validateDocument };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ import { CURRENT_SCHEMA_VERSION, DocumentCanvas, DocumentInfo, FabricDocument, S
2
2
  import { AssetManifest, FontAsset, ImageAsset } from "./assets/asset-manifest.js";
3
3
  import { FontLoader } from "./assets/font-check.js";
4
4
  import { AssetOptions, AssetReport, AssetWarning, AssetWarningCode, UploadRequest } from "./assets/asset-pipeline.js";
5
+ import { ExportArea, ExportBackground, ExportFormat, ExportOptions, ExportRect } from "./export/export-options.js";
6
+ import { ExportPreflight, ExportProblem, ExportProblemCode } from "./export/preflight-export.js";
5
7
  import { NewDocumentOptions } from "./document/create-document.js";
6
8
  import { CustomObjectDefinition } from "./fabric/object-registry.js";
7
9
  import { HistoryOptions, HistoryState } from "./history/create-history.js";
@@ -13,8 +15,9 @@ import { DocumentEngineError, DocumentEngineErrorDetails, DocumentErrorCode, Doc
13
15
  import { DocumentStorage, SaveContext, SaveResult } from "./storage/storage-contract.js";
14
16
  import { SaveOptions, SaveRetryEvent, SaveState, SaveStatus } from "./save/save-controller.js";
15
17
  import { Unsubscribe } from "./engine/event-emitter.js";
16
- import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, LoadOptions, createDocumentEngine } from "./engine/create-document-engine.js";
18
+ import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, ExportResult, LoadOptions, createDocumentEngine } from "./engine/create-document-engine.js";
17
19
  import { validateDocument } from "./document/validate-document.js";
18
20
  import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
19
21
  import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.js";
20
- export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
22
+ import { DownloadableExport, downloadExport } from "./export/download-export.js";
23
+ export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type DownloadableExport, type ExportArea, type ExportBackground, type ExportFormat, type ExportOptions, type ExportPreflight, type ExportProblem, type ExportProblemCode, type ExportRect, type ExportResult, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, downloadExport, isDocumentEngineError, validateDocument };
package/dist/index.js CHANGED
@@ -4,4 +4,5 @@ import { validateDocument } from "./document/validate-document.js";
4
4
  import { createDocumentEngine } from "./engine/create-document-engine.js";
5
5
  import { bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
6
6
  import { bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.js";
7
- export { CURRENT_SCHEMA_VERSION, DocumentEngineError, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
7
+ import { downloadExport } from "./export/download-export.js";
8
+ export { CURRENT_SCHEMA_VERSION, DocumentEngineError, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, downloadExport, isDocumentEngineError, validateDocument };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabricjs-document-engine",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Turn an existing Fabric.js canvas into a dependable editable document: stable object ids, versioned save and load, undo and redo, safe autosave with conflict protection, custom objects and clear errors.",
5
5
  "keywords": [
6
6
  "fabric",