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.
- package/README.md +162 -21
- package/dist/assets/asset-manifest.cjs +43 -0
- package/dist/assets/asset-manifest.d.cts +16 -0
- package/dist/assets/asset-manifest.d.ts +16 -0
- package/dist/assets/asset-manifest.js +41 -0
- package/dist/assets/asset-pipeline.cjs +174 -0
- package/dist/assets/asset-pipeline.d.cts +31 -0
- package/dist/assets/asset-pipeline.d.ts +31 -0
- package/dist/assets/asset-pipeline.js +172 -0
- package/dist/assets/asset-references.cjs +83 -0
- package/dist/assets/asset-references.js +82 -0
- package/dist/assets/font-check.cjs +59 -0
- package/dist/assets/font-check.d.cts +4 -0
- package/dist/assets/font-check.d.ts +4 -0
- package/dist/assets/font-check.js +57 -0
- package/dist/assets/image-check.cjs +36 -0
- package/dist/assets/image-check.js +33 -0
- package/dist/document/document-format.d.cts +3 -0
- package/dist/document/document-format.d.ts +3 -0
- package/dist/document/validate-document.cjs +5 -0
- package/dist/document/validate-document.js +5 -0
- package/dist/engine/create-document-engine.cjs +82 -18
- package/dist/engine/create-document-engine.d.cts +21 -5
- package/dist/engine/create-document-engine.d.ts +21 -5
- package/dist/engine/create-document-engine.js +82 -18
- package/dist/engine/errors.cjs +7 -0
- package/dist/engine/errors.d.cts +9 -1
- package/dist/engine/errors.d.ts +9 -1
- package/dist/engine/errors.js +7 -1
- package/dist/history/create-history.cjs +3 -1
- package/dist/history/create-history.js +3 -1
- package/dist/index.cjs +4 -1
- package/dist/index.d.cts +11 -3
- package/dist/index.d.ts +11 -3
- package/dist/index.js +3 -2
- package/dist/save/autosave-scheduler.cjs +28 -0
- package/dist/save/autosave-scheduler.d.cts +6 -0
- package/dist/save/autosave-scheduler.d.ts +6 -0
- package/dist/save/autosave-scheduler.js +28 -0
- package/dist/save/retry.cjs +33 -0
- package/dist/save/retry.d.cts +7 -0
- package/dist/save/retry.d.ts +7 -0
- package/dist/save/retry.js +31 -0
- package/dist/save/save-controller.cjs +149 -0
- package/dist/save/save-controller.d.cts +20 -0
- package/dist/save/save-controller.d.ts +20 -0
- package/dist/save/save-controller.js +149 -0
- package/dist/save/unsaved-changes-warning.cjs +12 -0
- package/dist/save/unsaved-changes-warning.d.cts +6 -0
- package/dist/save/unsaved-changes-warning.d.ts +6 -0
- package/dist/save/unsaved-changes-warning.js +12 -0
- package/dist/storage/key-value-storage.cjs +35 -0
- package/dist/storage/key-value-storage.d.cts +14 -0
- package/dist/storage/key-value-storage.d.ts +14 -0
- package/dist/storage/key-value-storage.js +35 -0
- package/dist/storage/local-storage.cjs +24 -0
- package/dist/storage/local-storage.d.cts +8 -0
- package/dist/storage/local-storage.d.ts +8 -0
- package/dist/storage/local-storage.js +24 -0
- package/dist/storage/memory-storage.cjs +17 -0
- package/dist/storage/memory-storage.d.cts +4 -0
- package/dist/storage/memory-storage.d.ts +4 -0
- package/dist/storage/memory-storage.js +17 -0
- package/dist/storage/storage-contract.d.cts +14 -0
- package/dist/storage/storage-contract.d.ts +14 -0
- package/dist/storage.cjs +7 -0
- package/dist/storage.d.cts +5 -0
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +4 -0
- 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
|
-
- **
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
|
183
|
-
| --- | --- |
|
|
184
|
-
|
|
|
185
|
-
|
|
|
186
|
-
|
|
|
187
|
-
|
|
|
188
|
-
|
|
|
189
|
-
|
|
|
190
|
-
|
|
|
191
|
-
|
|
|
192
|
-
|
|
|
193
|
-
|
|
|
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
|