fabricjs-document-engine 0.1.0 → 0.3.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 +162 -21
  2. package/dist/assets/asset-manifest.cjs +43 -0
  3. package/dist/assets/asset-manifest.d.cts +16 -0
  4. package/dist/assets/asset-manifest.d.ts +16 -0
  5. package/dist/assets/asset-manifest.js +41 -0
  6. package/dist/assets/asset-pipeline.cjs +174 -0
  7. package/dist/assets/asset-pipeline.d.cts +31 -0
  8. package/dist/assets/asset-pipeline.d.ts +31 -0
  9. package/dist/assets/asset-pipeline.js +172 -0
  10. package/dist/assets/asset-references.cjs +83 -0
  11. package/dist/assets/asset-references.js +82 -0
  12. package/dist/assets/font-check.cjs +59 -0
  13. package/dist/assets/font-check.d.cts +4 -0
  14. package/dist/assets/font-check.d.ts +4 -0
  15. package/dist/assets/font-check.js +57 -0
  16. package/dist/assets/image-check.cjs +36 -0
  17. package/dist/assets/image-check.js +33 -0
  18. package/dist/document/document-format.d.cts +3 -0
  19. package/dist/document/document-format.d.ts +3 -0
  20. package/dist/document/validate-document.cjs +5 -0
  21. package/dist/document/validate-document.js +5 -0
  22. package/dist/engine/create-document-engine.cjs +82 -18
  23. package/dist/engine/create-document-engine.d.cts +21 -5
  24. package/dist/engine/create-document-engine.d.ts +21 -5
  25. package/dist/engine/create-document-engine.js +82 -18
  26. package/dist/engine/errors.cjs +7 -0
  27. package/dist/engine/errors.d.cts +9 -1
  28. package/dist/engine/errors.d.ts +9 -1
  29. package/dist/engine/errors.js +7 -1
  30. package/dist/history/create-history.cjs +3 -1
  31. package/dist/history/create-history.js +3 -1
  32. package/dist/index.cjs +4 -1
  33. package/dist/index.d.cts +11 -3
  34. package/dist/index.d.ts +11 -3
  35. package/dist/index.js +3 -2
  36. package/dist/save/autosave-scheduler.cjs +28 -0
  37. package/dist/save/autosave-scheduler.d.cts +6 -0
  38. package/dist/save/autosave-scheduler.d.ts +6 -0
  39. package/dist/save/autosave-scheduler.js +28 -0
  40. package/dist/save/retry.cjs +33 -0
  41. package/dist/save/retry.d.cts +7 -0
  42. package/dist/save/retry.d.ts +7 -0
  43. package/dist/save/retry.js +31 -0
  44. package/dist/save/save-controller.cjs +149 -0
  45. package/dist/save/save-controller.d.cts +20 -0
  46. package/dist/save/save-controller.d.ts +20 -0
  47. package/dist/save/save-controller.js +149 -0
  48. package/dist/save/unsaved-changes-warning.cjs +12 -0
  49. package/dist/save/unsaved-changes-warning.d.cts +6 -0
  50. package/dist/save/unsaved-changes-warning.d.ts +6 -0
  51. package/dist/save/unsaved-changes-warning.js +12 -0
  52. package/dist/storage/key-value-storage.cjs +35 -0
  53. package/dist/storage/key-value-storage.d.cts +14 -0
  54. package/dist/storage/key-value-storage.d.ts +14 -0
  55. package/dist/storage/key-value-storage.js +35 -0
  56. package/dist/storage/local-storage.cjs +24 -0
  57. package/dist/storage/local-storage.d.cts +8 -0
  58. package/dist/storage/local-storage.d.ts +8 -0
  59. package/dist/storage/local-storage.js +24 -0
  60. package/dist/storage/memory-storage.cjs +17 -0
  61. package/dist/storage/memory-storage.d.cts +4 -0
  62. package/dist/storage/memory-storage.d.ts +4 -0
  63. package/dist/storage/memory-storage.js +17 -0
  64. package/dist/storage/storage-contract.d.cts +14 -0
  65. package/dist/storage/storage-contract.d.ts +14 -0
  66. package/dist/storage.cjs +7 -0
  67. package/dist/storage.d.cts +5 -0
  68. package/dist/storage.d.ts +5 -0
  69. package/dist/storage.js +4 -0
  70. package/package.json +14 -3
package/README.md CHANGED
@@ -8,7 +8,9 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
8
8
  - **A versioned document format.** It records the schema version, canvas size, background, object order and your own metadata.
9
9
  - **Safe loading.** Documents are validated first. Unknown object types are refused before the canvas is touched. A missing image fails the load instead of silently disappearing. When loads overlap, the newest one wins.
10
10
  - **Custom objects.** Register your own Fabric classes and the extra properties they need to keep.
11
- - **Your storage.** Plug in any backend with two functions. No hosted service is needed.
11
+ - **Safe saving.** It tracks unsaved changes and can autosave. Only one save runs at a time, so a slow older save can never overwrite newer work. Revision checks catch another tab or device saving the same document, and failed saves are retried with backoff.
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
+ - **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.
12
14
  - **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.
13
15
  - **Fabric 6 and 7.** Every release is tested against both.
14
16
 
@@ -37,20 +39,142 @@ await engine.loadDocument(JSON.parse(localStorage.getItem(document.id)!));
37
39
 
38
40
  ## Saving and loading through storage
39
41
 
42
+ The built-in adapters are the quickest way to start:
43
+
40
44
  ```ts
45
+ import { createLocalStorage, createMemoryStorage } from 'fabricjs-document-engine/storage';
46
+
41
47
  const engine = createDocumentEngine({
42
48
  canvas,
43
- storage: {
44
- loadDocument: (id) => fetch(`/api/documents/${id}`).then((response) => response.json()),
45
- saveDocument: (document) =>
46
- fetch(`/api/documents/${document.id}`, { method: 'PUT', body: JSON.stringify(document) }).then(() => {}),
47
- },
49
+ storage: createLocalStorage({ prefix: 'my-app:' }),
50
+ autosave: true,
48
51
  });
49
52
 
50
53
  await engine.load('project-42');
51
54
  await engine.save();
52
55
  ```
53
56
 
57
+ Both adapters also have `listDocuments()` and `deleteDocument(id)`. To build on another key-value store, use `createKeyValueStorage({ read, write, remove, keys }, prefix)`.
58
+
59
+ ### Your own backend
60
+
61
+ A storage adapter is two functions:
62
+
63
+ ```ts
64
+ import { createDocumentEngine, createConflictError } from 'fabricjs-document-engine';
65
+ import type { DocumentStorage } from 'fabricjs-document-engine';
66
+
67
+ const storage: DocumentStorage = {
68
+ async loadDocument(id) {
69
+ const response = await fetch(`/api/documents/${id}`);
70
+ return response.json();
71
+ },
72
+ async saveDocument(document, { expectedRevision, signal }) {
73
+ const response = await fetch(`/api/documents/${document.id}`, {
74
+ method: 'PUT',
75
+ headers: { 'If-Match': String(expectedRevision ?? '*') },
76
+ body: JSON.stringify(document),
77
+ signal,
78
+ });
79
+ if (response.status === 409) throw Object.assign(new Error('Saved elsewhere'), { code: 'SAVE_CONFLICT' });
80
+ if (response.status === 403) throw Object.assign(new Error('Not allowed'), { retryable: false });
81
+ if (!response.ok) throw new Error(`Save failed with ${response.status}`);
82
+ return { revision: document.revision };
83
+ },
84
+ };
85
+ ```
86
+
87
+ - `expectedRevision` is the revision this editor last saved or loaded. Reject the save when the stored document has a different revision. It is `null` when the user chose to overwrite.
88
+ - `document.revision` is the next revision. If your backend assigns its own number, return `{ revision }`.
89
+ - Throw an error with `code: 'SAVE_CONFLICT'`, or use `createConflictError(id, expected, actual)`, to report a conflict. Conflicts are never retried.
90
+ - Throw an error with `retryable: false` for failures that retrying cannot fix. Every other error is retried.
91
+ - Pass `signal` to `fetch`. The engine aborts it when another document is opened.
92
+
93
+ ## Safe saving
94
+
95
+ ```ts
96
+ const engine = createDocumentEngine({
97
+ canvas,
98
+ storage,
99
+ autosave: { delay: 1000, maxWait: 10000 },
100
+ saveRetry: { attempts: 3, baseDelay: 500, maxDelay: 8000 },
101
+ });
102
+
103
+ engine.on('save:status', ({ status, isDirty, revision, lastSavedAt, error }) => {
104
+ statusLabel.textContent = status;
105
+ });
106
+ ```
107
+
108
+ `status` is one of `saved`, `unsaved`, `saving`, `error` or `conflict`.
109
+
110
+ - **Unsaved changes.** Every recorded history step, undo, redo and metadata change marks the document as changed. `engine.isDirty()` tells you whether anything is unsaved. Edits made while a save is running stay unsaved until the next save.
111
+ - **Autosave.** It saves after `delay` ms without edits, and at the latest `maxWait` ms after the first unsaved edit, even while the user keeps editing. `autosave: true` uses the defaults shown above.
112
+ - **One save at a time.** Calling `save()` while a save is running queues exactly one follow-up save of the latest content. Responses can never arrive out of order.
113
+ - **Stale responses.** If another document is loaded while a save is running, that save's response is ignored and any queued save is cancelled with `SAVE_CANCELLED`.
114
+ - **Conflicts.** When another tab or device saved first, the save fails with `SAVE_CONFLICT` and the status becomes `conflict`. Either reload the document with `engine.load(id)`, or keep your version with `engine.save({ overwrite: true })`.
115
+ - **Retries.** Temporary failures are retried with exponential backoff and jitter. Each retry emits `save:retry` with `{ attempt, delay, error }`.
116
+
117
+ ### Warn before leaving
118
+
119
+ ```ts
120
+ import { bindUnsavedChangesWarning } from 'fabricjs-document-engine';
121
+
122
+ const unbind = bindUnsavedChangesWarning(engine);
123
+ ```
124
+
125
+ ## Assets and fonts
126
+
127
+ Every saved document carries an `assets` manifest that lists each image URL and font variant, together with the ids of the objects that use them. Images embedded as `data:` URLs are left out of the manifest because they need no fetching.
128
+
129
+ ```ts
130
+ const engine = createDocumentEngine({
131
+ canvas,
132
+ storage,
133
+ assets: {
134
+ resolveUrl: (url) => url.replace('asset://', 'https://cdn.example.com/'),
135
+ replaceMissingImage: (image) => '/placeholder.png',
136
+ upload: async ({ blob }) => uploadToYourBucket(blob),
137
+ loadFont: async ({ family, weight, style }) => {
138
+ const face = new FontFace(family, `url(/fonts/${family}-${weight}.woff2)`, { weight, style });
139
+ document.fonts.add(await face.load());
140
+ },
141
+ requireFonts: false,
142
+ },
143
+ });
144
+
145
+ engine.on('assets:warning', ({ warnings }) => warnings.forEach((warning) => console.warn(warning.message)));
146
+ ```
147
+
148
+ ### When a document is opened
149
+
150
+ 1. `resolveUrl` can rewrite each stored URL, for example to sign it or to map asset ids to a CDN.
151
+ 2. `loadFont` runs for each font variant. Then the engine checks that the font really renders, rather than silently falling back to a default.
152
+ 3. Every image loads in parallel. If any are missing, `replaceMissingImage` can supply a replacement URL for each one. Return `null` to leave it missing.
153
+ 4. If images are still missing, loading fails with `MISSING_ASSETS`, and `error.missingAssets` lists each `{ url, objectIds }`. The canvas is not touched.
154
+
155
+ Fonts that are not available produce a `FONT_UNAVAILABLE` warning and the text uses a fallback font. Set `requireFonts: true` to fail with `MISSING_FONTS` instead. Warnings are also delivered with `load:success` as `{ document, warnings }`.
156
+
157
+ ### When a document is saved
158
+
159
+ Images that exist only in this tab (`blob:` URLs) and embedded `data:` images are passed to `upload` once, and the document stores the returned URL. Without an `upload` handler, `blob:` images produce an `ASSET_NOT_PORTABLE` warning, because another device cannot open them.
160
+
161
+ ### Cross-origin images
162
+
163
+ An image from another site without `crossOrigin: 'anonymous'` taints the canvas, and exporting it will fail. The engine warns with `IMAGE_CROSS_ORIGIN` so you can fix it before the user tries to export.
164
+
165
+ ### Checking and replacing at any time
166
+
167
+ ```ts
168
+ const report = await engine.checkAssets();
169
+ report.missingImages;
170
+ report.unavailableFonts;
171
+ report.warnings;
172
+
173
+ await engine.replaceImage('/old-logo.png', '/new-logo.png');
174
+ ```
175
+
176
+ `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.
177
+
54
178
  ## Custom objects
55
179
 
56
180
  ```ts
@@ -125,9 +249,14 @@ interface FabricDocument {
125
249
  id: string;
126
250
  createdAt: string;
127
251
  updatedAt: string;
252
+ revision?: number;
128
253
  fabricVersion?: string;
129
254
  canvas: { width: number; height: number; background?: unknown };
130
255
  objects: SerializedFabricObject[];
256
+ assets?: {
257
+ images: Array<{ url: string; objectIds: string[] }>;
258
+ fonts: Array<{ family: string; weight: string; style: string; objectIds: string[] }>;
259
+ };
131
260
  metadata: Record<string, unknown>;
132
261
  }
133
262
  ```
@@ -141,12 +270,18 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
141
270
  | `createDocumentEngine({ canvas, storage?, customObjects?, document? })` | Connects the engine to your canvas. |
142
271
  | `engine.toDocument()` | Serializes the canvas into the versioned document format. |
143
272
  | `engine.loadDocument(document, { restoreCanvasSize? })` | Validates and loads a document. Resolves when the objects are on the canvas. |
144
- | `engine.load(id)` / `engine.save()` | Reads from or writes to your storage adapter. |
273
+ | `engine.load(id)` / `engine.save({ overwrite? })` | Reads from or writes to your storage adapter. |
274
+ | `engine.isDirty()` | Tells you whether there are unsaved changes. |
275
+ | `engine.getSaveState()` | Returns `{ status, isDirty, isSaving, revision, lastSavedAt, error }`. |
276
+ | `bindUnsavedChangesWarning(engine)` | Asks the browser to confirm before closing a page with unsaved changes. Returns an unbind function. |
145
277
  | `engine.newDocument({ id?, metadata? })` | Clears the canvas and starts a fresh document. |
146
278
  | `engine.getDocumentInfo()` | Returns the current document's id, dates and metadata. |
147
279
  | `engine.updateMetadata(changes)` | Merges changes into the document metadata. |
148
280
  | `engine.getObjectById(id)` | Finds any object by id, including objects inside groups. |
149
281
  | `engine.registerObject({ fabricClass, properties })` | Registers a custom class after the engine is created. |
282
+ | `engine.getAssetManifest()` | Lists the images and fonts used on the canvas. |
283
+ | `engine.checkAssets()` | Resolves to `{ manifest, missingImages, unavailableFonts, warnings }` for the current canvas. |
284
+ | `engine.replaceImage(oldUrl, newUrl)` | Replaces every image with that URL as one undo step. Resolves to the number of images replaced. |
150
285
  | `engine.transaction(label, work)` | Runs `work` and records everything it changed as one undo step. Returns what `work` returns. |
151
286
  | `engine.commit(label?)` | Records changes made since the last step. Returns `false` when nothing changed. |
152
287
  | `engine.undo()` / `engine.redo()` | Resolves to `true` when a step was applied. Calls run one after another. |
@@ -154,7 +289,7 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
154
289
  | `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
155
290
  | `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
156
291
  | `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
157
- | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
292
+ | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `assets:warning`, `history:change` or `history:error`. Returns an unsubscribe function. |
158
293
  | `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
159
294
  | `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
160
295
 
@@ -169,7 +304,13 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
169
304
  | `UNKNOWN_OBJECT_TYPE` | A type is not registered. `error.unknownTypes` lists them. |
170
305
  | `LOAD_FAILED` | Fabric or your storage could not load the document, for example because an image is missing. `error.cause` holds the original error. |
171
306
  | `LOAD_ABORTED` | A newer load started before this one finished. |
172
- | `SAVE_FAILED` | Your storage adapter rejected the save. |
307
+ | `MISSING_ASSETS` | Images could not be loaded and had no replacement. `error.missingAssets` lists each `{ url, objectIds }`. |
308
+ | `MISSING_FONTS` | Fonts are not available and `requireFonts` is on. `error.missingFonts` lists them. |
309
+ | `ASSET_UPLOAD_FAILED` | Your `upload` handler failed while saving. |
310
+ | `SAVE_FAILED` | Your storage adapter rejected the save after all retries. `error.retryable` tells you whether trying again could help. |
311
+ | `SAVE_CONFLICT` | Another tab or device saved this document first. |
312
+ | `SAVE_CANCELLED` | A queued save was dropped because another document was opened. |
313
+ | `DOCUMENT_NOT_FOUND` | The built-in adapters have no document with that id. |
173
314
  | `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. |
174
315
  | `STORAGE_MISSING` | `load` or `save` was called without a storage adapter. |
175
316
  | `INVALID_CUSTOM_OBJECT` | A registered class has no static `type`, or it does not extend a Fabric class. |
@@ -179,18 +320,18 @@ A failed load never clears or half-fills your canvas.
179
320
 
180
321
  ## Roadmap
181
322
 
182
- | Version | Focus |
183
- | --- | --- |
184
- | 0.1 ✓ | Document foundation: ids, save and load, validation, custom objects |
185
- | 0.2 ✓ | Undo and redo with transactions |
186
- | 0.3 | Safe saving: dirty state, autosave, stale-response protection |
187
- | 0.4 | Assets and fonts |
188
- | 0.5 | Recovery after a refresh or crash |
189
- | 0.6 | PNG, JPEG, SVG and JSON export with preflight checks |
190
- | 0.7 | Named versions and schema migrations |
191
- | 0.8 | React adapter and examples |
192
- | 0.9 | Hardening and benchmarks |
193
- | 1.0 | Stable API |
323
+ | Stage | Focus | Released in |
324
+ | --- | --- | --- |
325
+ | 1 | Document foundation: ids, save and load, validation, custom objects | 0.0.0 |
326
+ | 2 | Undo and redo with transactions | 0.1.0 |
327
+ | 3 | Safe saving: dirty state, autosave, stale-response protection | 0.2.0 |
328
+ | 4 | Assets and fonts | 0.3.0 |
329
+ | 5 | Recovery after a refresh or crash | |
330
+ | 6 | PNG, JPEG, SVG and JSON export with preflight checks | |
331
+ | 7 | Named versions and schema migrations | |
332
+ | 8 | React adapter and examples | |
333
+ | 9 | Hardening and benchmarks | |
334
+ | 10 | Stable API | 1.0.0 |
194
335
 
195
336
  ## License
196
337
 
@@ -0,0 +1,43 @@
1
+ const require_asset_references = require("./asset-references.cjs");
2
+ //#region src/assets/asset-manifest.ts
3
+ function isEmbeddedUrl(url) {
4
+ return url.startsWith("data:");
5
+ }
6
+ function fontKey(font) {
7
+ return `${font.style}|${font.weight}|${font.family}`;
8
+ }
9
+ function addObjectId(objectIds, objectId) {
10
+ if (objectId !== void 0 && !objectIds.includes(objectId)) objectIds.push(objectId);
11
+ }
12
+ function buildAssetManifest(objects) {
13
+ const imagesByUrl = /* @__PURE__ */ new Map();
14
+ for (const reference of require_asset_references.findImageReferences(objects)) {
15
+ if (isEmbeddedUrl(reference.url)) continue;
16
+ const image = imagesByUrl.get(reference.url) ?? {
17
+ url: reference.url,
18
+ objectIds: []
19
+ };
20
+ addObjectId(image.objectIds, reference.objectId);
21
+ imagesByUrl.set(reference.url, image);
22
+ }
23
+ const fontsByKey = /* @__PURE__ */ new Map();
24
+ for (const reference of require_asset_references.findFontReferences(objects)) {
25
+ const key = fontKey(reference);
26
+ const font = fontsByKey.get(key) ?? {
27
+ family: reference.family,
28
+ weight: reference.weight,
29
+ style: reference.style,
30
+ objectIds: []
31
+ };
32
+ addObjectId(font.objectIds, reference.objectId);
33
+ fontsByKey.set(key, font);
34
+ }
35
+ return {
36
+ images: [...imagesByUrl.values()],
37
+ fonts: [...fontsByKey.values()]
38
+ };
39
+ }
40
+ //#endregion
41
+ exports.buildAssetManifest = buildAssetManifest;
42
+ exports.fontKey = fontKey;
43
+ exports.isEmbeddedUrl = isEmbeddedUrl;
@@ -0,0 +1,16 @@
1
+ //#region src/assets/asset-manifest.d.ts
2
+ export interface ImageAsset {
3
+ url: string;
4
+ objectIds: string[];
5
+ }
6
+ export interface FontAsset {
7
+ family: string;
8
+ weight: string;
9
+ style: string;
10
+ objectIds: string[];
11
+ }
12
+ export interface AssetManifest {
13
+ images: ImageAsset[];
14
+ fonts: FontAsset[];
15
+ }
16
+ //#endregion
@@ -0,0 +1,16 @@
1
+ //#region src/assets/asset-manifest.d.ts
2
+ export interface ImageAsset {
3
+ url: string;
4
+ objectIds: string[];
5
+ }
6
+ export interface FontAsset {
7
+ family: string;
8
+ weight: string;
9
+ style: string;
10
+ objectIds: string[];
11
+ }
12
+ export interface AssetManifest {
13
+ images: ImageAsset[];
14
+ fonts: FontAsset[];
15
+ }
16
+ //#endregion
@@ -0,0 +1,41 @@
1
+ import { findFontReferences, findImageReferences } from "./asset-references.js";
2
+ //#region src/assets/asset-manifest.ts
3
+ function isEmbeddedUrl(url) {
4
+ return url.startsWith("data:");
5
+ }
6
+ function fontKey(font) {
7
+ return `${font.style}|${font.weight}|${font.family}`;
8
+ }
9
+ function addObjectId(objectIds, objectId) {
10
+ if (objectId !== void 0 && !objectIds.includes(objectId)) objectIds.push(objectId);
11
+ }
12
+ function buildAssetManifest(objects) {
13
+ const imagesByUrl = /* @__PURE__ */ new Map();
14
+ for (const reference of findImageReferences(objects)) {
15
+ if (isEmbeddedUrl(reference.url)) continue;
16
+ const image = imagesByUrl.get(reference.url) ?? {
17
+ url: reference.url,
18
+ objectIds: []
19
+ };
20
+ addObjectId(image.objectIds, reference.objectId);
21
+ imagesByUrl.set(reference.url, image);
22
+ }
23
+ const fontsByKey = /* @__PURE__ */ new Map();
24
+ for (const reference of findFontReferences(objects)) {
25
+ const key = fontKey(reference);
26
+ const font = fontsByKey.get(key) ?? {
27
+ family: reference.family,
28
+ weight: reference.weight,
29
+ style: reference.style,
30
+ objectIds: []
31
+ };
32
+ addObjectId(font.objectIds, reference.objectId);
33
+ fontsByKey.set(key, font);
34
+ }
35
+ return {
36
+ images: [...imagesByUrl.values()],
37
+ fonts: [...fontsByKey.values()]
38
+ };
39
+ }
40
+ //#endregion
41
+ export { buildAssetManifest, fontKey, isEmbeddedUrl };
@@ -0,0 +1,174 @@
1
+ const require_asset_references = require("./asset-references.cjs");
2
+ const require_asset_manifest = require("./asset-manifest.cjs");
3
+ const require_errors = require("../engine/errors.cjs");
4
+ const require_font_check = require("./font-check.cjs");
5
+ const require_image_check = require("./image-check.cjs");
6
+ //#region src/assets/asset-pipeline.ts
7
+ function cloneDocument(document) {
8
+ return JSON.parse(JSON.stringify(document));
9
+ }
10
+ function groupByUrl(references) {
11
+ const groups = /* @__PURE__ */ new Map();
12
+ for (const reference of references) {
13
+ const group = groups.get(reference.url) ?? [];
14
+ group.push(reference);
15
+ groups.set(reference.url, group);
16
+ }
17
+ return groups;
18
+ }
19
+ function objectIdsOf(references) {
20
+ return [...new Set(references.map((reference) => reference.objectId).filter((id) => id !== void 0))];
21
+ }
22
+ function pointTo(references, url) {
23
+ for (const reference of references) {
24
+ reference.holder[reference.key] = url;
25
+ reference.url = url;
26
+ }
27
+ }
28
+ async function rewriteUrls(document, rewrite) {
29
+ const groups = groupByUrl(require_asset_references.findImageReferences(document.objects));
30
+ await Promise.all([...groups].map(async ([url, references]) => {
31
+ const next = await rewrite(url, references);
32
+ if (next !== url) pointTo(references, next);
33
+ }));
34
+ }
35
+ function crossOriginWarnings(document) {
36
+ const warnings = [];
37
+ for (const [url, references] of groupByUrl(require_asset_references.findImageReferences(document.objects))) {
38
+ if (!require_image_check.isCrossOriginUrl(url) || references.every((reference) => reference.crossOrigin)) continue;
39
+ warnings.push({
40
+ code: "IMAGE_CROSS_ORIGIN",
41
+ message: `The image ${url} comes from another site without crossOrigin set, so exporting the canvas will fail`,
42
+ url,
43
+ objectIds: objectIdsOf(references)
44
+ });
45
+ }
46
+ return warnings;
47
+ }
48
+ function fontWarnings(fonts) {
49
+ return fonts.map((font) => ({
50
+ code: "FONT_UNAVAILABLE",
51
+ message: `The font "${font.family}" (${font.weight}, ${font.style}) is not available, text will use a fallback font`,
52
+ family: font.family,
53
+ objectIds: font.objectIds
54
+ }));
55
+ }
56
+ async function inspectAssets(document, options, signal) {
57
+ const manifest = require_asset_manifest.buildAssetManifest(document.objects);
58
+ const uniqueImages = [...groupByUrl(require_asset_references.findImageReferences(document.objects).filter((reference) => !require_asset_manifest.isEmbeddedUrl(reference.url)))].map(([url, references]) => ({
59
+ url,
60
+ crossOrigin: references[0].crossOrigin
61
+ }));
62
+ const [missingUrls, unavailableFonts] = await Promise.all([options.checkImages === false ? Promise.resolve([]) : require_image_check.findMissingImages(uniqueImages, signal), require_font_check.findUnavailableFonts(manifest.fonts, options.loadFont)]);
63
+ const missing = new Set(missingUrls);
64
+ return {
65
+ manifest,
66
+ missingImages: manifest.images.filter((image) => missing.has(image.url)),
67
+ unavailableFonts,
68
+ warnings: [...crossOriginWarnings(document), ...fontWarnings(unavailableFonts)]
69
+ };
70
+ }
71
+ async function replaceMissingImages(document, missingImages, options, signal) {
72
+ const replace = options.replaceMissingImage;
73
+ if (!replace || missingImages.length === 0) return {
74
+ stillMissing: [...missingImages],
75
+ warnings: []
76
+ };
77
+ const replacements = /* @__PURE__ */ new Map();
78
+ for (const image of missingImages) {
79
+ const replacement = await replace(image);
80
+ if (typeof replacement === "string" && replacement.length > 0) replacements.set(image.url, replacement);
81
+ }
82
+ const brokenReplacements = new Set(await require_image_check.findMissingImages([...replacements.values()].map((url) => ({
83
+ url,
84
+ crossOrigin: null
85
+ })), signal));
86
+ const stillMissing = [];
87
+ const warnings = [];
88
+ const groups = groupByUrl(require_asset_references.findImageReferences(document.objects));
89
+ for (const image of missingImages) {
90
+ const replacement = replacements.get(image.url);
91
+ if (replacement === void 0 || brokenReplacements.has(replacement)) {
92
+ stillMissing.push(image);
93
+ continue;
94
+ }
95
+ pointTo(groups.get(image.url) ?? [], replacement);
96
+ warnings.push({
97
+ code: "IMAGE_REPLACED",
98
+ message: `The missing image ${image.url} was replaced with ${replacement}`,
99
+ url: image.url,
100
+ objectIds: image.objectIds
101
+ });
102
+ }
103
+ return {
104
+ stillMissing,
105
+ warnings
106
+ };
107
+ }
108
+ async function prepareAssetsForLoad(input, options, signal) {
109
+ const document = cloneDocument(input);
110
+ const { resolveUrl } = options;
111
+ if (resolveUrl) await rewriteUrls(document, async (url) => resolveUrl(url));
112
+ const report = await inspectAssets(document, options, signal);
113
+ if (options.requireFonts && report.unavailableFonts.length > 0) {
114
+ const families = report.unavailableFonts.map((font) => font.family).join(", ");
115
+ throw new require_errors.DocumentEngineError("MISSING_FONTS", `These fonts are not available: ${families}`, { missingFonts: report.unavailableFonts });
116
+ }
117
+ const { stillMissing, warnings } = await replaceMissingImages(document, report.missingImages, options, signal);
118
+ if (stillMissing.length > 0) {
119
+ const urls = stillMissing.map((image) => image.url).join(", ");
120
+ throw new require_errors.DocumentEngineError("MISSING_ASSETS", `These images could not be loaded: ${urls}`, { missingAssets: stillMissing });
121
+ }
122
+ return {
123
+ document,
124
+ warnings: [...report.warnings, ...warnings]
125
+ };
126
+ }
127
+ async function uploadOnce(url, references, upload) {
128
+ try {
129
+ return await upload({
130
+ url,
131
+ blob: await (await fetch(url)).blob(),
132
+ objectIds: objectIdsOf(references)
133
+ });
134
+ } catch (error) {
135
+ if (require_errors.isDocumentEngineError(error)) throw error;
136
+ const reason = error instanceof Error ? error.message : String(error);
137
+ throw new require_errors.DocumentEngineError("ASSET_UPLOAD_FAILED", `Could not upload the image ${url.slice(0, 60)}: ${reason}`, {
138
+ cause: error,
139
+ retryable: true
140
+ });
141
+ }
142
+ }
143
+ async function prepareAssetsForSave(document, options, uploadedUrls) {
144
+ const { upload } = options;
145
+ const warnings = [];
146
+ await rewriteUrls(document, async (url, references) => {
147
+ if (!(url.startsWith("blob:") || url.startsWith("data:"))) return url;
148
+ if (!upload) {
149
+ if (!require_image_check.isPortableUrl(url)) warnings.push({
150
+ code: "ASSET_NOT_PORTABLE",
151
+ message: `The image ${url} only exists in this browser tab. Pass assets.upload to store it before saving.`,
152
+ url,
153
+ objectIds: objectIdsOf(references)
154
+ });
155
+ return url;
156
+ }
157
+ let uploading = uploadedUrls.get(url);
158
+ if (!uploading) {
159
+ uploading = uploadOnce(url, references, upload);
160
+ uploadedUrls.set(url, uploading);
161
+ uploading.catch(() => uploadedUrls.delete(url));
162
+ }
163
+ return uploading;
164
+ });
165
+ document.assets = require_asset_manifest.buildAssetManifest(document.objects);
166
+ return {
167
+ document,
168
+ warnings
169
+ };
170
+ }
171
+ //#endregion
172
+ exports.inspectAssets = inspectAssets;
173
+ exports.prepareAssetsForLoad = prepareAssetsForLoad;
174
+ exports.prepareAssetsForSave = prepareAssetsForSave;
@@ -0,0 +1,31 @@
1
+ import { AssetManifest, FontAsset, ImageAsset } from "./asset-manifest.cjs";
2
+ import { FontLoader } from "./font-check.cjs";
3
+ //#region src/assets/asset-pipeline.d.ts
4
+ export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED";
5
+ export interface AssetWarning {
6
+ code: AssetWarningCode;
7
+ message: string;
8
+ url?: string;
9
+ family?: string;
10
+ objectIds: string[];
11
+ }
12
+ export interface UploadRequest {
13
+ url: string;
14
+ blob: Blob;
15
+ objectIds: string[];
16
+ }
17
+ export interface AssetOptions {
18
+ resolveUrl?: (url: string) => string | Promise<string>;
19
+ replaceMissingImage?: (image: ImageAsset) => string | null | undefined | Promise<string | null | undefined>;
20
+ upload?: (request: UploadRequest) => Promise<string>;
21
+ loadFont?: FontLoader;
22
+ checkImages?: boolean;
23
+ requireFonts?: boolean;
24
+ }
25
+ export interface AssetReport {
26
+ manifest: AssetManifest;
27
+ missingImages: ImageAsset[];
28
+ unavailableFonts: FontAsset[];
29
+ warnings: AssetWarning[];
30
+ }
31
+ //#endregion
@@ -0,0 +1,31 @@
1
+ import { AssetManifest, FontAsset, ImageAsset } from "./asset-manifest.js";
2
+ import { FontLoader } from "./font-check.js";
3
+ //#region src/assets/asset-pipeline.d.ts
4
+ export type AssetWarningCode = "IMAGE_CROSS_ORIGIN" | "FONT_UNAVAILABLE" | "ASSET_NOT_PORTABLE" | "IMAGE_REPLACED";
5
+ export interface AssetWarning {
6
+ code: AssetWarningCode;
7
+ message: string;
8
+ url?: string;
9
+ family?: string;
10
+ objectIds: string[];
11
+ }
12
+ export interface UploadRequest {
13
+ url: string;
14
+ blob: Blob;
15
+ objectIds: string[];
16
+ }
17
+ export interface AssetOptions {
18
+ resolveUrl?: (url: string) => string | Promise<string>;
19
+ replaceMissingImage?: (image: ImageAsset) => string | null | undefined | Promise<string | null | undefined>;
20
+ upload?: (request: UploadRequest) => Promise<string>;
21
+ loadFont?: FontLoader;
22
+ checkImages?: boolean;
23
+ requireFonts?: boolean;
24
+ }
25
+ export interface AssetReport {
26
+ manifest: AssetManifest;
27
+ missingImages: ImageAsset[];
28
+ unavailableFonts: FontAsset[];
29
+ warnings: AssetWarning[];
30
+ }
31
+ //#endregion