fabricjs-document-engine 0.2.0 → 0.4.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 +113 -13
- 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 +175 -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 +2 -0
- package/dist/document/document-format.d.ts +2 -0
- package/dist/document/validate-document.cjs +4 -0
- package/dist/document/validate-document.js +4 -0
- package/dist/engine/create-document-engine.cjs +112 -10
- package/dist/engine/create-document-engine.d.cts +28 -0
- package/dist/engine/create-document-engine.d.ts +28 -0
- package/dist/engine/create-document-engine.js +112 -10
- package/dist/engine/errors.cjs +2 -0
- package/dist/engine/errors.d.cts +6 -1
- package/dist/engine/errors.d.ts +6 -1
- package/dist/engine/errors.js +2 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +6 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +1 -1
- package/dist/recovery/indexed-db-recovery.cjs +80 -0
- package/dist/recovery/indexed-db-recovery.d.cts +8 -0
- package/dist/recovery/indexed-db-recovery.d.ts +8 -0
- package/dist/recovery/indexed-db-recovery.js +80 -0
- package/dist/recovery/recovery-controller.cjs +159 -0
- package/dist/recovery/recovery-controller.d.cts +19 -0
- package/dist/recovery/recovery-controller.d.ts +19 -0
- package/dist/recovery/recovery-controller.js +158 -0
- package/dist/recovery/recovery-store.cjs +19 -0
- package/dist/recovery/recovery-store.d.cts +10 -0
- package/dist/recovery/recovery-store.d.ts +10 -0
- package/dist/recovery/recovery-store.js +19 -0
- package/dist/recovery.cjs +5 -0
- package/dist/recovery.d.cts +4 -0
- package/dist/recovery.d.ts +4 -0
- package/dist/recovery.js +3 -0
- package/dist/save/save-controller.cjs +6 -5
- package/dist/save/save-controller.js +6 -5
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
|
|
|
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
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.
|
|
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.
|
|
13
15
|
- **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.
|
|
14
16
|
- **Fabric 6 and 7.** Every release is tested against both.
|
|
15
17
|
|
|
@@ -121,6 +123,87 @@ import { bindUnsavedChangesWarning } from 'fabricjs-document-engine';
|
|
|
121
123
|
const unbind = bindUnsavedChangesWarning(engine);
|
|
122
124
|
```
|
|
123
125
|
|
|
126
|
+
## Assets and fonts
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const engine = createDocumentEngine({
|
|
132
|
+
canvas,
|
|
133
|
+
storage,
|
|
134
|
+
assets: {
|
|
135
|
+
resolveUrl: (url) => url.replace('asset://', 'https://cdn.example.com/'),
|
|
136
|
+
replaceMissingImage: (image) => '/placeholder.png',
|
|
137
|
+
upload: async ({ blob }) => uploadToYourBucket(blob),
|
|
138
|
+
loadFont: async ({ family, weight, style }) => {
|
|
139
|
+
const face = new FontFace(family, `url(/fonts/${family}-${weight}.woff2)`, { weight, style });
|
|
140
|
+
document.fonts.add(await face.load());
|
|
141
|
+
},
|
|
142
|
+
requireFonts: false,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
engine.on('assets:warning', ({ warnings }) => warnings.forEach((warning) => console.warn(warning.message)));
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### When a document is opened
|
|
150
|
+
|
|
151
|
+
1. `resolveUrl` can rewrite each stored URL, for example to sign it or to map asset ids to a CDN.
|
|
152
|
+
2. `loadFont` runs for each font variant. Then the engine checks that the font really renders, rather than silently falling back to a default.
|
|
153
|
+
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.
|
|
154
|
+
4. If images are still missing, loading fails with `MISSING_ASSETS`, and `error.missingAssets` lists each `{ url, objectIds }`. The canvas is not touched.
|
|
155
|
+
|
|
156
|
+
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 }`.
|
|
157
|
+
|
|
158
|
+
### When a document is saved
|
|
159
|
+
|
|
160
|
+
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.
|
|
161
|
+
|
|
162
|
+
### Cross-origin images
|
|
163
|
+
|
|
164
|
+
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.
|
|
165
|
+
|
|
166
|
+
### Checking and replacing at any time
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const report = await engine.checkAssets();
|
|
170
|
+
report.missingImages;
|
|
171
|
+
report.unavailableFonts;
|
|
172
|
+
report.warnings;
|
|
173
|
+
|
|
174
|
+
await engine.replaceImage('/old-logo.png', '/new-logo.png');
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`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
|
+
## Recovery
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { createIndexedDbRecovery } from 'fabricjs-document-engine/recovery';
|
|
183
|
+
|
|
184
|
+
const engine = createDocumentEngine({
|
|
185
|
+
canvas,
|
|
186
|
+
storage,
|
|
187
|
+
recovery: { store: createIndexedDbRecovery(), interval: 2000 },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const [latest] = await engine.getRecoverableDocuments();
|
|
191
|
+
if (latest && confirm(`Restore unsaved work from ${new Date(latest.savedAt).toLocaleString()}?`)) {
|
|
192
|
+
await engine.restoreRecovery(latest.documentId);
|
|
193
|
+
} else if (latest) {
|
|
194
|
+
await engine.discardRecovery(latest.documentId);
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- **Checkpoints.** While there are unsaved changes, a copy is written at most once every `interval` ms (default 2000). Nothing is written while the document is saved.
|
|
199
|
+
- **Closing or refreshing.** Browsers do not let IndexedDB finish writing while a page unloads. So when the tab is hidden or closed, the engine also writes an immediate copy to localStorage. The newest copy wins when you read it back.
|
|
200
|
+
- **Tab-only images.** Images with `blob:` URLs vanish on refresh. Checkpoints keep the image data, and restoring creates fresh URLs for it.
|
|
201
|
+
- **After a save.** When a save covers every change, the copy is removed. If the tab closes during a save, the copy stays, so work is never lost between the edit and the server.
|
|
202
|
+
- **Restoring.** The restored document is marked as unsaved and keeps the revision it was based on. If the server moved on meanwhile, the next save reports `SAVE_CONFLICT` instead of overwriting newer work.
|
|
203
|
+
- **Interrupted loads.** A marker is kept while a document loads. If the tab crashes during loading, `engine.getInterruptedLoad()` returns `{ documentId, startedAt }` on the next start, so you can skip or discard that document instead of crashing again.
|
|
204
|
+
- `engine.flushRecovery()` writes a copy right now. `engine.getRecovery(id?)` reads one.
|
|
205
|
+
- `createMemoryRecovery()` keeps copies in memory, which is useful for tests. To use your own storage, implement `{ get, set, delete, keys }`, plus an optional synchronous `setNow` for the moment the page closes.
|
|
206
|
+
|
|
124
207
|
## Custom objects
|
|
125
208
|
|
|
126
209
|
```ts
|
|
@@ -199,6 +282,10 @@ interface FabricDocument {
|
|
|
199
282
|
fabricVersion?: string;
|
|
200
283
|
canvas: { width: number; height: number; background?: unknown };
|
|
201
284
|
objects: SerializedFabricObject[];
|
|
285
|
+
assets?: {
|
|
286
|
+
images: Array<{ url: string; objectIds: string[] }>;
|
|
287
|
+
fonts: Array<{ family: string; weight: string; style: string; objectIds: string[] }>;
|
|
288
|
+
};
|
|
202
289
|
metadata: Record<string, unknown>;
|
|
203
290
|
}
|
|
204
291
|
```
|
|
@@ -221,6 +308,13 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
|
|
|
221
308
|
| `engine.updateMetadata(changes)` | Merges changes into the document metadata. |
|
|
222
309
|
| `engine.getObjectById(id)` | Finds any object by id, including objects inside groups. |
|
|
223
310
|
| `engine.registerObject({ fabricClass, properties })` | Registers a custom class after the engine is created. |
|
|
311
|
+
| `engine.getAssetManifest()` | Lists the images and fonts used on the canvas. |
|
|
312
|
+
| `engine.checkAssets()` | Resolves to `{ manifest, missingImages, unavailableFonts, warnings }` for the current canvas. |
|
|
313
|
+
| `engine.replaceImage(oldUrl, newUrl)` | Replaces every image with that URL as one undo step. Resolves to the number of images replaced. |
|
|
314
|
+
| `engine.getRecoverableDocuments()` | Lists recovery copies, newest first. |
|
|
315
|
+
| `engine.restoreRecovery(id?)` / `engine.discardRecovery(id?)` | Loads or deletes a recovery copy. The default is the current document. |
|
|
316
|
+
| `engine.getRecovery(id?)` / `engine.flushRecovery()` | Reads a copy, or writes one now. |
|
|
317
|
+
| `engine.getInterruptedLoad()` | Returns the load that was running when the tab last crashed, if any. |
|
|
224
318
|
| `engine.transaction(label, work)` | Runs `work` and records everything it changed as one undo step. Returns what `work` returns. |
|
|
225
319
|
| `engine.commit(label?)` | Records changes made since the last step. Returns `false` when nothing changed. |
|
|
226
320
|
| `engine.undo()` / `engine.redo()` | Resolves to `true` when a step was applied. Calls run one after another. |
|
|
@@ -228,7 +322,7 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
|
|
|
228
322
|
| `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
|
|
229
323
|
| `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
|
|
230
324
|
| `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
|
|
231
|
-
| `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `history:change` or `history:error`. Returns an unsubscribe 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. |
|
|
232
326
|
| `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
|
|
233
327
|
| `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
|
|
234
328
|
|
|
@@ -243,11 +337,17 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
|
|
|
243
337
|
| `UNKNOWN_OBJECT_TYPE` | A type is not registered. `error.unknownTypes` lists them. |
|
|
244
338
|
| `LOAD_FAILED` | Fabric or your storage could not load the document, for example because an image is missing. `error.cause` holds the original error. |
|
|
245
339
|
| `LOAD_ABORTED` | A newer load started before this one finished. |
|
|
340
|
+
| `MISSING_ASSETS` | Images could not be loaded and had no replacement. `error.missingAssets` lists each `{ url, objectIds }`. |
|
|
341
|
+
| `MISSING_FONTS` | Fonts are not available and `requireFonts` is on. `error.missingFonts` lists them. |
|
|
342
|
+
| `ASSET_UPLOAD_FAILED` | Your `upload` handler failed while saving. |
|
|
246
343
|
| `SAVE_FAILED` | Your storage adapter rejected the save after all retries. `error.retryable` tells you whether trying again could help. |
|
|
247
344
|
| `SAVE_CONFLICT` | Another tab or device saved this document first. |
|
|
248
345
|
| `SAVE_CANCELLED` | A queued save was dropped because another document was opened. |
|
|
249
346
|
| `DOCUMENT_NOT_FOUND` | The built-in adapters have no document with that id. |
|
|
250
347
|
| `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. |
|
|
348
|
+
| `RECOVERY_MISSING` | A recovery method was called without `recovery: { store }`. |
|
|
349
|
+
| `RECOVERY_NOT_FOUND` | There is no recovery copy for that document. |
|
|
350
|
+
| `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. |
|
|
251
351
|
| `STORAGE_MISSING` | `load` or `save` was called without a storage adapter. |
|
|
252
352
|
| `INVALID_CUSTOM_OBJECT` | A registered class has no static `type`, or it does not extend a Fabric class. |
|
|
253
353
|
| `ENGINE_DESTROYED` | The engine was used after `destroy()`. |
|
|
@@ -256,18 +356,18 @@ A failed load never clears or half-fills your canvas.
|
|
|
256
356
|
|
|
257
357
|
## Roadmap
|
|
258
358
|
|
|
259
|
-
|
|
|
260
|
-
| --- | --- |
|
|
261
|
-
|
|
|
262
|
-
|
|
|
263
|
-
|
|
|
264
|
-
|
|
|
265
|
-
|
|
|
266
|
-
|
|
|
267
|
-
|
|
|
268
|
-
|
|
|
269
|
-
|
|
|
270
|
-
|
|
|
359
|
+
| Stage | Focus | Released in |
|
|
360
|
+
| --- | --- | --- |
|
|
361
|
+
| 1 | Document foundation: ids, save and load, validation, custom objects | 0.0.0 |
|
|
362
|
+
| 2 | Undo and redo with transactions | 0.1.0 |
|
|
363
|
+
| 3 | Safe saving: dirty state, autosave, stale-response protection | 0.2.0 |
|
|
364
|
+
| 4 | Assets and fonts | 0.3.0 |
|
|
365
|
+
| 5 | Recovery after a refresh or crash | 0.4.0 |
|
|
366
|
+
| 6 | PNG, JPEG, SVG and JSON export with preflight checks | |
|
|
367
|
+
| 7 | Named versions and schema migrations | |
|
|
368
|
+
| 8 | React adapter and examples | |
|
|
369
|
+
| 9 | Hardening and benchmarks | |
|
|
370
|
+
| 10 | Stable API | 1.0.0 |
|
|
271
371
|
|
|
272
372
|
## License
|
|
273
373
|
|
|
@@ -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,175 @@
|
|
|
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;
|
|
175
|
+
exports.rewriteUrls = rewriteUrls;
|
|
@@ -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
|