@embedpdf/plugin-stamp 3.0.0-next.13 → 3.0.0-next.15

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/dist/index.js CHANGED
@@ -1,14 +1,686 @@
1
- import { a as STAMP_PIECEINFO_VERSION, c as libraryKindFromPdfName, d as stampKey, f as stampKindToPdfName, i as STAMP_PIECEINFO_APP, l as libraryKindToPdfName, m as stampPieceInfo, n as DEFAULT_LIBRARY_KIND, o as assetIdFor, p as stampLibraryPieceInfo, r as STAMP_LIBRARY_PIECEINFO_APP, s as customStampName, t as StampToken, u as parseStampKey } from "./contract-B79Onij2.js";
2
- import { createEventHook, definePlugin } from "@embedpdf/core";
3
- import { EngineError, EngineErrorCode, resolveBinarySource, sniffBinaryMetadata } from "@embedpdf/engine-core/runtime";
4
- import { javaScriptProgramFromActionTree } from "@embedpdf/core-acrojs";
5
- import { ActionsToken } from "@embedpdf/plugin-actions/contract/host";
1
+ import { a as assetIdFor, c as parseStampKey, d as stampPieceInfo, f as StampToken, i as STAMP_PIECEINFO_APP, l as stampKey, n as DEFAULT_LIBRARY_KIND, o as customStampName, r as STAMP_LIBRARY_PIECEINFO_APP, s as libraryKindFromPdfName, t as STAMP_DEFAULTS, u as stampLibraryPieceInfo } from "./contract-CJBvneyw.js";
2
+ import { DocumentsToken, PluginError, composeApi, definePlugin, defineState, toPluginError, toPluginErrorInfo } from "@embedpdf/core";
3
+ import { ActionsToken } from "@embedpdf/plugin-actions/contract";
6
4
  import { AnnotationToken } from "@embedpdf/plugin-annotation/contract";
5
+ import { formEffectOps, isLocalEngine, resolveBinarySource, sniffBinaryMetadata } from "@embedpdf/engine-core/runtime";
6
+ import { javaScriptProgramFromActionTree } from "@embedpdf/core-acrojs";
7
+ import { ActionsToken as ActionsToken$1 } from "@embedpdf/plugin-actions/contract/host";
7
8
  import { AnnotationToken as AnnotationToken$1 } from "@embedpdf/plugin-annotation/contract/host";
8
9
  import { createFormScriptingController } from "@embedpdf/plugin-form/scripting";
10
+ //#region src/services/errors.ts
11
+ /** A `PluginError` raised by the stamp plugin. */
12
+ const stampError = (code, message) => new PluginError(code, "stamp", message);
13
+ const notFound = (what, id) => stampError("not-found", `unknown ${what} '${id}'`);
14
+ /**
15
+ * A refusal that names the missing permission. The stamp plugin acts on a
16
+ * document it isn't bound to, so it asks that document's checks instead of
17
+ * `ctx.assertAllowed`, and refuses with the same error.
18
+ */
19
+ const permissionDenied = (permission, operation) => new PluginError("permission-denied", "stamp", `${operation} requires ${permission}`, { permission });
20
+ /** Stop before the next step, or before anything is kept, once the caller cancelled. */
21
+ const throwIfCancelled = (signal) => {
22
+ if (signal?.aborted) throw new PluginError("operation-cancelled", "stamp", "operation cancelled", { cause: signal.reason });
23
+ };
24
+ /**
25
+ * A capability verb: whatever the asset engine, a sibling plugin or a binary
26
+ * source rejects with reaches the caller as a `PluginError`.
27
+ */
28
+ const verb = (run) => async (...args) => {
29
+ try {
30
+ return await run(...args);
31
+ } catch (error) {
32
+ throw toPluginError("stamp", error);
33
+ }
34
+ };
35
+ //#endregion
36
+ //#region src/read/catalog.ts
37
+ /** Reads over the state and the binaries resource: libraries, assets,
38
+ * previews, the library's PDF, and the checks. */
39
+ function createCatalog(ctx, { binaries, assetEngine, ghosts, targets }) {
40
+ const { binaries: assetBinaries, libraryBinaries } = binaries;
41
+ const { ghostProvider } = ghosts;
42
+ const { documentIdOf } = targets;
43
+ const listAssets = (filter) => {
44
+ const state = ctx.state.get();
45
+ return (filter?.libraryId ? [filter.libraryId] : state.libraryOrder).flatMap((libraryId) => (state.libraries[libraryId]?.assetIds ?? []).map((id) => state.assets[id])).filter((asset) => asset != null && (!filter?.kind || asset.kind === filter.kind) && (!filter?.category || (asset.categories ?? []).includes(filter.category)));
46
+ };
47
+ /** Placing is annotation work: the document's annotation plugin answers. */
48
+ const canPlace = (documentId) => {
49
+ const id = documentIdOf(documentId);
50
+ if (!id) return false;
51
+ return ctx.tryForDocument(AnnotationToken, id)?.canCreate() ?? false;
52
+ };
53
+ /** A stamp made from annotations copies them out of the document, as a download does. */
54
+ const canCreateFromAnnotations = (documentId) => {
55
+ const id = documentIdOf(documentId);
56
+ return id !== null && ctx.get(DocumentsToken).canDownload(id);
57
+ };
58
+ return {
59
+ canPlace,
60
+ canCreateFromAnnotations,
61
+ api: {
62
+ listLibraries: (filter) => {
63
+ const state = ctx.state.get();
64
+ const kinds = filter?.kind === void 0 ? null : new Set(typeof filter.kind === "string" ? [filter.kind] : filter.kind);
65
+ return state.libraryOrder.map((id) => state.libraries[id]).filter((library) => library != null && (kinds === null || kinds.has(library.kind)));
66
+ },
67
+ getLibrary: (libraryId) => ctx.state.get().libraries[libraryId] ?? null,
68
+ listAssets,
69
+ getAsset: (assetId) => ctx.state.get().assets[assetId] ?? null,
70
+ getAssetPreview: (assetId) => assetBinaries.get(assetId)?.preview ?? null,
71
+ renderAssetPreview: verb(async (assetId, { width, signal }) => {
72
+ throwIfCancelled(signal);
73
+ const binary = assetBinaries.get(assetId);
74
+ if (!binary) throw notFound("asset", assetId);
75
+ const preview = await ctx.cancellable(signal, ghostProvider(binary.bytes, binary.preview, assetId)(width));
76
+ return preview ? {
77
+ bytes: preview.bytes,
78
+ mimeType: preview.mimeType ?? "image/png"
79
+ } : null;
80
+ }),
81
+ readAssetBytes: (assetId) => {
82
+ const bytes = assetBinaries.get(assetId)?.bytes;
83
+ return bytes ? new Uint8Array(bytes) : null;
84
+ },
85
+ exportLibrary: async (libraryId, options) => {
86
+ throwIfCancelled(options?.signal);
87
+ const bytes = libraryBinaries.get(libraryId);
88
+ if (!bytes) throw notFound("library", libraryId);
89
+ return new Uint8Array(bytes);
90
+ },
91
+ canPlace,
92
+ canCreateFromAnnotations,
93
+ canImport: assetEngine.canImport
94
+ }
95
+ };
96
+ }
97
+ //#endregion
98
+ //#region src/services/asset-engine.ts
99
+ /**
100
+ * The asset engine port and the canonical-library services every write
101
+ * needs: opening library bytes as a document on a local engine, import-time
102
+ * previews, and the import-support fact learned from the first import.
103
+ */
104
+ /** Session-unique ids (scratch documents, library ids a file does not carry):
105
+ * a timestamp plus a counter. */
106
+ let idCounter = 0;
107
+ const uid = (prefix) => `${prefix}-${Date.now().toString(36)}-${(idCounter++).toString(36)}`;
108
+ function createAssetEngine(ctx) {
109
+ const settings = ctx.settings();
110
+ /** Set once an import finds the asset engine is not local (cloud). */
111
+ let importSupported = null;
112
+ let resolved = null;
113
+ /** Engines this plugin made through a configured function: destroyed with it. */
114
+ const owned = [];
115
+ const assetEngine = () => {
116
+ const configured = settings.get().assetEngine;
117
+ if (resolved?.from !== configured) {
118
+ const engine = !configured ? ctx.engine : typeof configured === "function" ? configured() : configured;
119
+ if (typeof configured === "function") owned.push(engine);
120
+ resolved = {
121
+ from: configured,
122
+ engine
123
+ };
124
+ importSupported = null;
125
+ }
126
+ return resolved.engine;
127
+ };
128
+ /** Library PDFs are sliced and tagged (`/PieceInfo`) on a local engine only. */
129
+ const openAssetDocument = async (bytes) => {
130
+ const engine = await assetEngine();
131
+ if (!isLocalEngine(engine)) {
132
+ if (importSupported !== false) {
133
+ importSupported = false;
134
+ ctx.notify();
135
+ }
136
+ throw stampError("unsupported", settings.get().assetEngine ? "the stamp plugin's assetEngine must be a local engine (createLocalEngine())" : "importing a library PDF needs a local engine, and this viewer's engine is a cloud engine. Pass stampPlugin({ assetEngine: () => import('@embedpdf/engine').then((module) => module.createLocalEngine()) }): it loads lazily, on first import.");
137
+ }
138
+ return engine.open({
139
+ kind: "bytes",
140
+ id: uid("stamp-import"),
141
+ bytes
142
+ }, { scope: ["*"] });
143
+ };
144
+ /** Import-time preview render to bytes. The asset engine is local by
145
+ * definition, so the image source is always inline bytes. */
146
+ const imageToPreview = (image) => {
147
+ if (image.source.kind !== "bytes") throw stampError("unsupported", "the asset engine returned a URL-sourced render; asset engines must be local");
148
+ return {
149
+ bytes: image.source.bytes,
150
+ mimeType: image.contentType
151
+ };
152
+ };
153
+ /** Thumbnail render of one library page (the small picker image). */
154
+ const renderThumbnail = async (handle) => imageToPreview(await handle.render.image({
155
+ viewport: {
156
+ kind: "width",
157
+ width: settings.get().previewWidth
158
+ },
159
+ background: "transparent",
160
+ includeAnnotations: true,
161
+ format: "png"
162
+ }));
163
+ ctx.cleanup(() => {
164
+ resolved = null;
165
+ for (const engine of owned.splice(0)) Promise.resolve(engine).then((made) => made.destroy()).catch(() => {});
166
+ });
167
+ return {
168
+ openAssetDocument,
169
+ imageToPreview,
170
+ renderThumbnail,
171
+ /** Optimistic until an import finds the asset engine is not local (cloud). */
172
+ canImport: () => {
173
+ if (resolved && resolved.from !== settings.get().assetEngine) return true;
174
+ return importSupported !== false;
175
+ }
176
+ };
177
+ }
178
+ //#endregion
179
+ //#region src/services/binaries.ts
180
+ function createBinaries(ctx) {
181
+ /** Asset bytes (a one-page placement PDF) and the cached preview, by asset id. */
182
+ const binaries = /* @__PURE__ */ new Map();
183
+ /** The durable source of truth for a canonical library. Asset PDFs above
184
+ * are derived page extractions; this map owns the rewritten whole PDF. */
185
+ const libraryBinaries = /* @__PURE__ */ new Map();
186
+ /** Whole-PDF rewrites are serialized per library, or two concurrent
187
+ * appends could both start from the same bytes and lose one page. A
188
+ * mutation whose signal fired while it waited never starts. */
189
+ const mutateLibrary = (libraryId, mutation, signal) => ctx.serialQueue(`library:${libraryId}`)(mutation, { signal });
190
+ /**
191
+ * Ghost renders, one per size bucket per asset, rendered lazily on the
192
+ * asset engine the first time the ghost is shown at that size and kept
193
+ * until the asset goes away. Materialized (dynamic) placements are
194
+ * user/time specific and cache only for their own arm (`cacheKey` null).
195
+ */
196
+ const ghostRenders = /* @__PURE__ */ new Map();
197
+ ctx.cleanup(() => {
198
+ binaries.clear();
199
+ ghostRenders.clear();
200
+ libraryBinaries.clear();
201
+ });
202
+ return {
203
+ binaries,
204
+ libraryBinaries,
205
+ ghostRenders,
206
+ mutateLibrary
207
+ };
208
+ }
209
+ //#endregion
210
+ //#region src/services/documents.ts
211
+ /**
212
+ * The document a placing verb acts on: the one its options name, else the
213
+ * active one (a document's scope fills `documentId` in through the plugin's
214
+ * `inScope`), and the page arguments resolved in it.
215
+ */
216
+ function createTargets(ctx) {
217
+ const documents = () => ctx.get(DocumentsToken);
218
+ /** The document's id, or null with no document open. */
219
+ const documentIdOf = (documentId) => documentId ?? documents().getActiveId();
220
+ /** The document a verb acts on. Rejects `not-ready` with no document open. */
221
+ const targetOf = (options) => {
222
+ const documentId = documentIdOf(options?.documentId);
223
+ if (!documentId) throw stampError("not-ready", "no document is open");
224
+ return documentId;
225
+ };
226
+ /** A page argument of the document, as a ref or an index. Rejects `not-found`. */
227
+ const pageOf = (documentId, page) => {
228
+ const found = documents().getPage(page, documentId);
229
+ if (!found) throw stampError("not-found", `no page ${typeof page === "number" ? page : page.objectNumber} in document '${documentId}'`);
230
+ return found;
231
+ };
232
+ return {
233
+ documentIdOf,
234
+ targetOf,
235
+ pageOf
236
+ };
237
+ }
238
+ //#endregion
239
+ //#region src/services/events.ts
240
+ function createEvents(ctx) {
241
+ return {
242
+ libraryChanged: ctx.events.source(),
243
+ libraryCreated: ctx.events.source(),
244
+ libraryUpdated: ctx.events.source(),
245
+ libraryDeleted: ctx.events.source(),
246
+ assetCreated: ctx.events.source(),
247
+ assetUpdated: ctx.events.source(),
248
+ assetDeleted: ctx.events.source(),
249
+ armChanged: ctx.events.source()
250
+ };
251
+ }
252
+ //#endregion
253
+ //#region src/services/ghosts.ts
254
+ function createGhosts({ openAssetDocument, imageToPreview }, { ghostRenders }) {
255
+ const renderGhost = async (bytes, devicePixelWidth) => {
256
+ const doc = await openAssetDocument(new Uint8Array(bytes));
257
+ try {
258
+ const page = (await doc.pages.list()).pages[0];
259
+ if (!page) return null;
260
+ return imageToPreview(await doc.page(page.ref).render.image({
261
+ viewport: {
262
+ kind: "width",
263
+ width: devicePixelWidth
264
+ },
265
+ background: "transparent",
266
+ includeAnnotations: true,
267
+ format: "png"
268
+ }));
269
+ } finally {
270
+ await doc.close();
271
+ }
272
+ };
273
+ /** A resolution-aware ghost source for placement bytes: the page renders
274
+ * at the requested width, cached per bucket under `cacheKey` when given. */
275
+ const ghostProvider = (bytes, fallback, cacheKey) => {
276
+ const local = cacheKey ? ghostRenders.get(cacheKey) ?? /* @__PURE__ */ new Map() : /* @__PURE__ */ new Map();
277
+ if (cacheKey) ghostRenders.set(cacheKey, local);
278
+ return (devicePixelWidth) => {
279
+ let pending = local.get(devicePixelWidth);
280
+ if (!pending) {
281
+ pending = renderGhost(bytes, devicePixelWidth).catch((error) => {
282
+ globalThis.console?.warn("[stamp] ghost render failed, using the thumbnail:", error);
283
+ return fallback;
284
+ });
285
+ local.set(devicePixelWidth, pending);
286
+ }
287
+ return pending;
288
+ };
289
+ };
290
+ return { ghostProvider };
291
+ }
292
+ //#endregion
293
+ //#region src/services/index.ts
294
+ /**
295
+ * Plugin-private services every area is built on (not the kernel): the
296
+ * events, the binaries resource, the asset engine port, the ghost renderer
297
+ * and the document a placing verb acts on.
298
+ */
299
+ function createServices(ctx) {
300
+ const binaries = createBinaries(ctx);
301
+ const assetEngine = createAssetEngine(ctx);
302
+ return {
303
+ events: createEvents(ctx),
304
+ binaries,
305
+ assetEngine,
306
+ ghosts: createGhosts(assetEngine, binaries),
307
+ targets: createTargets(ctx)
308
+ };
309
+ }
310
+ //#endregion
311
+ //#region src/model.ts
312
+ const initialStampState = () => ({
313
+ libraries: {},
314
+ libraryOrder: [],
315
+ assets: {},
316
+ armed: {}
317
+ });
318
+ /** Remember the asset armed on a document. */
319
+ function armAsset(state, documentId, assetId) {
320
+ if (state.armed[documentId] === assetId) return state;
321
+ return {
322
+ ...state,
323
+ armed: {
324
+ ...state.armed,
325
+ [documentId]: assetId
326
+ }
327
+ };
328
+ }
329
+ /** Forget what was armed on these documents. */
330
+ function disarmDocuments(state, documentIds) {
331
+ if (!documentIds.some((documentId) => documentId in state.armed)) return state;
332
+ const armed = { ...state.armed };
333
+ for (const documentId of documentIds) delete armed[documentId];
334
+ return {
335
+ ...state,
336
+ armed
337
+ };
338
+ }
339
+ /** Append a library to the display order. */
340
+ function addLibrary(state, library) {
341
+ return {
342
+ ...state,
343
+ libraries: {
344
+ ...state.libraries,
345
+ [library.id]: library
346
+ },
347
+ libraryOrder: [...state.libraryOrder, library.id]
348
+ };
349
+ }
350
+ /** Replace a known library's descriptor; an unknown id changes nothing. */
351
+ function setLibrary(state, library) {
352
+ if (!state.libraries[library.id]) return state;
353
+ return {
354
+ ...state,
355
+ libraries: {
356
+ ...state.libraries,
357
+ [library.id]: library
358
+ }
359
+ };
360
+ }
361
+ /** Remove a library together with its assets. */
362
+ function removeLibrary(state, libraryId) {
363
+ const library = state.libraries[libraryId];
364
+ if (!library) return state;
365
+ const { [libraryId]: _removed, ...libraries } = state.libraries;
366
+ const assets = { ...state.assets };
367
+ for (const id of library.assetIds) delete assets[id];
368
+ return {
369
+ ...state,
370
+ libraries,
371
+ libraryOrder: state.libraryOrder.filter((id) => id !== libraryId),
372
+ assets
373
+ };
374
+ }
375
+ /** Add an asset to the end of its library; an asset of an unknown library changes nothing. */
376
+ function addAsset(state, asset) {
377
+ const library = state.libraries[asset.libraryId];
378
+ if (!library) return state;
379
+ return {
380
+ ...state,
381
+ assets: {
382
+ ...state.assets,
383
+ [asset.id]: asset
384
+ },
385
+ libraries: {
386
+ ...state.libraries,
387
+ [library.id]: {
388
+ ...library,
389
+ assetIds: [...library.assetIds, asset.id]
390
+ }
391
+ }
392
+ };
393
+ }
394
+ /** Replace a known asset's descriptor; an unknown id changes nothing. */
395
+ function setAsset(state, asset) {
396
+ if (!state.assets[asset.id]) return state;
397
+ return {
398
+ ...state,
399
+ assets: {
400
+ ...state.assets,
401
+ [asset.id]: asset
402
+ }
403
+ };
404
+ }
405
+ /** Remove an asset and its entry in its library's order. */
406
+ function removeAsset(state, assetId) {
407
+ const asset = state.assets[assetId];
408
+ if (!asset) return state;
409
+ const { [assetId]: _removed, ...assets } = state.assets;
410
+ const library = state.libraries[asset.libraryId];
411
+ return {
412
+ ...state,
413
+ assets,
414
+ libraries: library ? {
415
+ ...state.libraries,
416
+ [library.id]: {
417
+ ...library,
418
+ assetIds: library.assetIds.filter((id) => id !== assetId)
419
+ }
420
+ } : state.libraries
421
+ };
422
+ }
423
+ //#endregion
424
+ //#region src/write/assets.ts
425
+ function createAssetWrites(ctx, { events, binaries, assetEngine, targets }, { resolveAssetSource }, { createLibrary }, { canCreateFromAnnotations }) {
426
+ const { libraryChanged, assetCreated, assetUpdated, assetDeleted } = events;
427
+ const { binaries: assetBinaries, libraryBinaries, ghostRenders, mutateLibrary } = binaries;
428
+ const { openAssetDocument, renderThumbnail } = assetEngine;
429
+ const { targetOf, pageOf } = targets;
430
+ const createAsset = async (input, options) => {
431
+ const signal = options?.signal;
432
+ throwIfCancelled(signal);
433
+ if (input.libraryId && !ctx.state.get().libraries[input.libraryId]) throw notFound("library", input.libraryId);
434
+ const resolved = await ctx.cancellable(signal, resolveAssetSource(input));
435
+ const meta = sniffBinaryMetadata(resolved.bytes);
436
+ if (!meta) throw stampError("invalid-input", "asset source must be PNG, JPEG, or single-page PDF bytes");
437
+ const isPdf = meta.mimeType === "application/pdf";
438
+ const name = input.name ?? customStampName();
439
+ const label = input.label ?? name;
440
+ if (name.length === 0 || name.includes("=")) throw stampError("invalid-input", `invalid stamp identifier '${name}': must be non-empty and contain no '='`);
441
+ const rasterSize = input.size ?? ("width" in meta ? {
442
+ width: meta.width,
443
+ height: meta.height
444
+ } : null);
445
+ if (!isPdf && (!rasterSize || rasterSize.width <= 0 || rasterSize.height <= 0)) throw stampError("invalid-input", "a raster asset needs a positive `size` (its page size in points)");
446
+ let suppliedPreview = null;
447
+ if (input.preview) {
448
+ const preview = await resolveBinarySource(input.preview);
449
+ suppliedPreview = {
450
+ bytes: new Uint8Array(preview.bytes),
451
+ mimeType: preview.mimeType ?? "image/png"
452
+ };
453
+ }
454
+ const libraryId = input.libraryId ?? (await createLibrary(label, { signal })).library.id;
455
+ return mutateLibrary(libraryId, async () => {
456
+ const liveLibrary = ctx.state.get().libraries[libraryId];
457
+ if (!liveLibrary) throw stampError("not-found", `library '${libraryId}' no longer exists`);
458
+ const assetId = assetIdFor(liveLibrary.id, name);
459
+ if (ctx.state.get().assets[assetId]) throw stampError("invalid-input", `library '${liveLibrary.name}' already has a stamp named '${name}'`);
460
+ const canonicalBytes = libraryBinaries.get(liveLibrary.id);
461
+ if (!canonicalBytes) throw stampError("operation-failed", `canonical bytes are missing for library '${liveLibrary.id}'`);
462
+ const doc = await ctx.cancellable(signal, openAssetDocument(canonicalBytes));
463
+ let appended;
464
+ try {
465
+ let page;
466
+ if (isPdf) {
467
+ const result = await doc.pages.insert(new Uint8Array(resolved.bytes));
468
+ if (result.insertedPages.length !== 1) throw stampError("invalid-input", "a library asset must be a single-page PDF");
469
+ page = result.insertedPages[0];
470
+ } else {
471
+ if (!doc.pages.flatten) throw stampError("unsupported", "raster assets need an asset engine with pages.flatten");
472
+ page = (await doc.pages.insertBlank({ size: rasterSize })).insertedPages[0];
473
+ await doc.page(page).annotations.create({
474
+ subtype: "stamp",
475
+ box: {
476
+ x: 0,
477
+ y: 0,
478
+ width: rasterSize.width,
479
+ height: rasterSize.height
480
+ },
481
+ fit: "fill"
482
+ }, { resources: { appearance: new Uint8Array(resolved.bytes) } });
483
+ if ((await doc.pages.flatten([page], { usage: "display" })).results.some(({ status }) => status !== "applied")) throw stampError("operation-failed", "flattening the raster into its page failed");
484
+ }
485
+ const layout = (await doc.pages.list()).pages.find((candidate) => candidate.ref.objectNumber === page.objectNumber);
486
+ const handle = doc.page(page);
487
+ if (!layout) throw stampError("operation-failed", "the new page is missing from the library layout");
488
+ await doc.pages.setName({
489
+ name: stampKey(name, label),
490
+ page
491
+ });
492
+ const asset = {
493
+ id: assetId,
494
+ libraryId: liveLibrary.id,
495
+ kind: input.kind ?? "stamp",
496
+ name,
497
+ label,
498
+ size: {
499
+ width: layout.size.width,
500
+ height: layout.size.height
501
+ },
502
+ page,
503
+ ...input.subject !== void 0 ? { subject: input.subject } : {},
504
+ ...input.categories !== void 0 ? { categories: input.categories } : {}
505
+ };
506
+ await handle.pieceInfo.update(STAMP_PIECEINFO_APP, stampPieceInfo(asset.kind, {
507
+ subject: asset.subject,
508
+ categories: asset.categories
509
+ }));
510
+ appended = {
511
+ asset,
512
+ bytes: await doc.pages.extract([page]),
513
+ preview: suppliedPreview ?? await renderThumbnail(handle),
514
+ canonical: await doc.download()
515
+ };
516
+ } finally {
517
+ await doc.close();
518
+ }
519
+ throwIfCancelled(signal);
520
+ libraryBinaries.set(liveLibrary.id, appended.canonical);
521
+ assetBinaries.set(appended.asset.id, {
522
+ bytes: appended.bytes,
523
+ preview: appended.preview
524
+ });
525
+ ctx.state.update(addAsset, appended.asset);
526
+ libraryChanged.emit({
527
+ libraryId: liveLibrary.id,
528
+ reason: "asset-added"
529
+ });
530
+ assetCreated.emit({
531
+ assetId: appended.asset.id,
532
+ libraryId: liveLibrary.id,
533
+ asset: appended.asset
534
+ });
535
+ return { asset: appended.asset };
536
+ }, signal);
537
+ };
538
+ const createAssetFromAnnotations = async (page, refs, input, options) => {
539
+ const signal = options?.signal;
540
+ throwIfCancelled(signal);
541
+ const documentId = targetOf(options);
542
+ if (!canCreateFromAnnotations(documentId)) throw permissionDenied("doc.download", "stamp.createAssetFromAnnotations");
543
+ const doc = ctx.documentHandle(documentId);
544
+ if (!doc) throw stampError("not-found", `document '${documentId}' is not open`);
545
+ const handle = doc.page(pageOf(documentId, page).ref);
546
+ const source = await ctx.cancellable(signal, handle.annotations.exportAppearance([...refs]));
547
+ return createAsset({
548
+ ...input,
549
+ source
550
+ }, { signal });
551
+ };
552
+ const deleteAsset = async (id, options) => {
553
+ const signal = options?.signal;
554
+ throwIfCancelled(signal);
555
+ const initialAsset = ctx.state.get().assets[id];
556
+ if (!initialAsset) return;
557
+ return mutateLibrary(initialAsset.libraryId, async () => {
558
+ const asset = ctx.state.get().assets[id];
559
+ if (!asset) return;
560
+ const library = ctx.state.get().libraries[asset.libraryId];
561
+ if (!library) return;
562
+ const canonicalBytes = libraryBinaries.get(library.id);
563
+ if (!canonicalBytes) throw stampError("operation-failed", `canonical bytes are missing for library '${library.id}'`);
564
+ const doc = await ctx.cancellable(signal, openAssetDocument(canonicalBytes));
565
+ let rewritten;
566
+ try {
567
+ await doc.pages.delete([asset.page]);
568
+ rewritten = await doc.download();
569
+ } finally {
570
+ await doc.close();
571
+ }
572
+ throwIfCancelled(signal);
573
+ libraryBinaries.set(library.id, rewritten);
574
+ assetBinaries.delete(id);
575
+ ghostRenders.delete(id);
576
+ ctx.state.update(removeAsset, id);
577
+ libraryChanged.emit({
578
+ libraryId: library.id,
579
+ reason: "asset-removed"
580
+ });
581
+ assetDeleted.emit({
582
+ assetId: id,
583
+ libraryId: library.id,
584
+ asset: null
585
+ });
586
+ }, signal);
587
+ };
588
+ const updateAsset = async (id, patch, options) => {
589
+ const signal = options?.signal;
590
+ throwIfCancelled(signal);
591
+ const initial = ctx.state.get().assets[id];
592
+ if (!initial) throw notFound("asset", id);
593
+ return mutateLibrary(initial.libraryId, async () => {
594
+ const asset = ctx.state.get().assets[id];
595
+ const library = asset ? ctx.state.get().libraries[asset.libraryId] : void 0;
596
+ if (!asset || !library) throw notFound("asset", id);
597
+ const next = {
598
+ ...asset,
599
+ ...patch.label !== void 0 ? { label: patch.label } : {},
600
+ ...patch.categories !== void 0 ? { categories: patch.categories } : {}
601
+ };
602
+ if (patch.subject === null) delete next.subject;
603
+ else if (patch.subject !== void 0) next.subject = patch.subject;
604
+ const canonicalBytes = libraryBinaries.get(library.id);
605
+ if (!canonicalBytes) throw stampError("operation-failed", `canonical bytes are missing for library '${library.id}'`);
606
+ const doc = await ctx.cancellable(signal, openAssetDocument(canonicalBytes));
607
+ let rewritten;
608
+ try {
609
+ if (next.label !== asset.label) await doc.pages.setName({
610
+ name: stampKey(asset.name, next.label),
611
+ page: asset.page,
612
+ replace: stampKey(asset.name, asset.label)
613
+ });
614
+ await doc.page(asset.page).pieceInfo.update(STAMP_PIECEINFO_APP, stampPieceInfo(next.kind, {
615
+ subject: next.subject,
616
+ categories: next.categories
617
+ }));
618
+ rewritten = await doc.download();
619
+ } finally {
620
+ await doc.close();
621
+ }
622
+ throwIfCancelled(signal);
623
+ libraryBinaries.set(library.id, rewritten);
624
+ ctx.state.update(setAsset, next);
625
+ libraryChanged.emit({
626
+ libraryId: library.id,
627
+ reason: "asset-updated"
628
+ });
629
+ assetUpdated.emit({
630
+ assetId: next.id,
631
+ libraryId: library.id,
632
+ asset: next
633
+ });
634
+ return { asset: next };
635
+ }, signal);
636
+ };
637
+ const moveAsset = async (id, to, options) => {
638
+ const signal = options?.signal;
639
+ throwIfCancelled(signal);
640
+ const asset = ctx.state.get().assets[id];
641
+ const binary = assetBinaries.get(id);
642
+ if (!asset || !binary) throw notFound("asset", id);
643
+ if (to.libraryId === asset.libraryId) return { asset };
644
+ const copy = await createAsset({
645
+ libraryId: to.libraryId,
646
+ source: new Uint8Array(binary.bytes),
647
+ kind: asset.kind,
648
+ label: asset.label,
649
+ ...asset.subject !== void 0 ? { subject: asset.subject } : {},
650
+ ...asset.categories ? { categories: [...asset.categories] } : {},
651
+ ...binary.preview ? { preview: binary.preview.bytes } : {}
652
+ }, { signal });
653
+ await deleteAsset(id);
654
+ return copy;
655
+ };
656
+ const duplicateAsset = async (id, options) => {
657
+ throwIfCancelled(options?.signal);
658
+ const asset = ctx.state.get().assets[id];
659
+ const binary = assetBinaries.get(id);
660
+ if (!asset || !binary) throw notFound("asset", id);
661
+ return createAsset({
662
+ libraryId: asset.libraryId,
663
+ source: new Uint8Array(binary.bytes),
664
+ kind: asset.kind,
665
+ label: options?.label ?? `${asset.label} copy`,
666
+ ...asset.subject !== void 0 ? { subject: asset.subject } : {},
667
+ ...asset.categories ? { categories: [...asset.categories] } : {},
668
+ ...binary.preview ? { preview: binary.preview.bytes } : {}
669
+ }, { signal: options?.signal });
670
+ };
671
+ return { api: {
672
+ createAsset: verb(createAsset),
673
+ createAssetFromAnnotations: verb(createAssetFromAnnotations),
674
+ updateAsset: verb(updateAsset),
675
+ deleteAsset: verb(deleteAsset),
676
+ moveAsset: verb(moveAsset),
677
+ duplicateAsset: verb(duplicateAsset)
678
+ } };
679
+ }
680
+ //#endregion
9
681
  //#region src/blank-library.ts
10
682
  /**
11
- * The seed of a new library: a one-page PDF whose page is UNREGISTERED — the
683
+ * The seed of a new library: a one-page PDF whose page is unregistered, the
12
684
  * shape Acrobat itself gives a fresh stamp library (its first page is a
13
685
  * blank starter that no `/Names /Pages` key points at). A library must be a
14
686
  * PDF from the moment it exists, and a PDF must have a page; the unregistered
@@ -36,57 +708,13 @@ function blankLibraryPdf() {
36
708
  return new Uint8Array(cached);
37
709
  }
38
710
  //#endregion
39
- //#region src/capability.ts
40
- const DEFAULT_PREVIEW_WIDTH = 256;
41
- /** `#rrggbb` → the engine's sRGB triplet. */
42
- const hexColor = (hex) => {
43
- const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
44
- if (!m) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] mark color must be #rrggbb, got '${hex}'`);
45
- const n = parseInt(m[1], 16);
46
- return {
47
- r: n >> 16 & 255,
48
- g: n >> 8 & 255,
49
- b: n & 255
50
- };
51
- };
52
- /** An exact ArrayBuffer over a view (the engine's binary idiom). */
53
- const toArrayBuffer = (bytes) => {
54
- const out = new ArrayBuffer(bytes.byteLength);
55
- new Uint8Array(out).set(bytes);
56
- return out;
57
- };
58
- /** The rect an ink mark occupies, padded by its stroke. */
59
- const inkBounds = (strokes, strokeWidth) => {
60
- let left = Infinity;
61
- let bottom = Infinity;
62
- let right = -Infinity;
63
- let top = -Infinity;
64
- for (const stroke of strokes) for (const { x, y } of stroke) {
65
- if (x < left) left = x;
66
- if (x > right) right = x;
67
- if (y < bottom) bottom = y;
68
- if (y > top) top = y;
69
- }
70
- if (!Number.isFinite(left)) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] an ink mark needs at least one point");
71
- const pad = strokeWidth;
72
- return {
73
- left: left - pad,
74
- bottom: bottom - pad,
75
- right: right + pad,
76
- top: top + pad
77
- };
78
- };
79
- /** A generous box for typed text; the export crops to the glyphs. */
80
- const textBounds = (text, fontSize) => ({
81
- left: 0,
82
- bottom: 0,
83
- right: Math.max(1, text.length) * fontSize * .75 + fontSize,
84
- top: fontSize * 1.6
85
- });
86
- /** Session-unique ids. Assets are session-scoped for now (no persistence),
87
- * so a timestamp + counter is enough — durable ids come with the store port. */
88
- let seq = 0;
89
- const uid = (prefix) => `${prefix}-${Date.now().toString(36)}-${(seq++).toString(36)}`;
711
+ //#region src/write/libraries.ts
712
+ /**
713
+ * Library writes. A library is a PDF: its `/Title` is the name, its
714
+ * `/Names /Pages` registry the assets, its pages the artwork, PieceInfo only
715
+ * what has no standard home. Every whole-PDF rewrite rides the per-library
716
+ * mutation queue.
717
+ */
90
718
  const entryString = (entries, key) => {
91
719
  const entry = entries[key];
92
720
  return entry?.type === "string" && entry.value.length > 0 ? entry.value : void 0;
@@ -107,130 +735,76 @@ const kindFromPdfName = (name) => {
107
735
  default: return;
108
736
  }
109
737
  };
110
- function createStampCapability(ctx, config = {}) {
111
- /** Binary sidecar of the serializable store: asset bytes + cached preview,
112
- * keyed by asset id. The reducer never sees these (kernel rule 1). */
113
- const binaries = /* @__PURE__ */ new Map();
114
- /** The durable source of truth for a canonical library. Asset PDFs above
115
- * are derived page extractions; this map owns the rewritten whole PDF. */
116
- const libraryBinaries = /* @__PURE__ */ new Map();
117
- /** Whole-PDF rewrites must be serialized per library or two concurrent
118
- * appends could both start from the same bytes and lose one page. */
119
- const libraryMutationTails = /* @__PURE__ */ new Map();
120
- const mutateLibrary = (libraryId, mutation) => {
121
- const result = (libraryMutationTails.get(libraryId) ?? Promise.resolve()).then(mutation);
122
- const tail = result.then(() => void 0, () => void 0);
123
- libraryMutationTails.set(libraryId, tail);
124
- return result.finally(() => {
125
- if (libraryMutationTails.get(libraryId) === tail) libraryMutationTails.delete(libraryId);
126
- });
127
- };
128
- let assetEngineRef = null;
129
- const assetEngine = () => {
130
- if (!assetEngineRef) {
131
- const cfg = config.assetEngine;
132
- assetEngineRef = !cfg ? ctx.engine : typeof cfg === "function" ? cfg() : cfg;
133
- }
134
- return assetEngineRef;
135
- };
136
- const openAssetDocument = async (bytes) => {
137
- try {
138
- return await (await assetEngine()).open({
139
- kind: "bytes",
140
- id: uid("stamp-import"),
141
- bytes
142
- }, { scope: ["*"] });
143
- } catch (err) {
144
- if (!config.assetEngine && EngineError.is(err, EngineErrorCode.InvalidArg)) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] importing a library PDF needs an engine that can open local bytes, and this viewer's engine cannot (cloud). Pass stampPlugin({ assetEngine: () => import('@embedpdf/engine').then((m) => m.createLocalEngine()) }) — it loads lazily, on first import.");
145
- throw err;
146
- }
147
- };
148
- /** Import-time preview render → bytes. The asset engine is local by
149
- * definition, so the image source is always inline bytes. */
150
- const imageToPreview = (image) => {
151
- if (image.source.kind !== "bytes") throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] asset engine returned a URL-sourced render; asset engines must be local");
152
- return {
153
- bytes: image.source.bytes,
154
- mimeType: image.contentType
155
- };
156
- };
157
- const allocateId = (preferred, prefix, taken) => {
158
- let id = preferred;
159
- if (!id || taken.has(id)) do
160
- id = uid(prefix);
161
- while (taken.has(id));
162
- taken.add(id);
163
- return id;
164
- };
165
- const libraryChanged = createEventHook((error) => globalThis.console?.error("[stamp] onLibraryChanged observer failed:", error));
166
- /** Thumbnail render of one library page (the small picker image). */
167
- const renderThumbnail = async (handle) => imageToPreview(await handle.render.image({
168
- viewport: {
169
- kind: "width",
170
- width: config.previewWidth ?? DEFAULT_PREVIEW_WIDTH
171
- },
172
- background: "transparent",
173
- includeAnnotations: true,
174
- format: "png"
175
- }));
176
- const createLibrary = async (name, opts) => {
177
- const kind = opts?.kind ?? "stamps";
178
- const taken = new Set(Object.keys(ctx.getState().libraries));
179
- const id = allocateId(opts?.id, "stamp-lib", taken);
180
- const doc = await openAssetDocument(blankLibraryPdf());
738
+ const allocateId = (preferred, prefix, taken) => {
739
+ let id = preferred;
740
+ if (!id || taken.has(id)) do
741
+ id = uid(prefix);
742
+ while (taken.has(id));
743
+ taken.add(id);
744
+ return id;
745
+ };
746
+ function createLibraryWrites(ctx, { events, binaries, assetEngine }) {
747
+ const { libraryChanged, libraryCreated, libraryUpdated, libraryDeleted, assetCreated } = events;
748
+ const { binaries: assetBinaries, libraryBinaries, ghostRenders, mutateLibrary } = binaries;
749
+ const { openAssetDocument, renderThumbnail } = assetEngine;
750
+ const createLibrary = async (name, options) => {
751
+ const signal = options?.signal;
752
+ throwIfCancelled(signal);
753
+ const kind = options?.kind ?? "stamps";
754
+ const taken = new Set(Object.keys(ctx.state.get().libraries));
755
+ const id = allocateId(options?.id, "stamp-lib", taken);
756
+ const doc = await ctx.cancellable(signal, openAssetDocument(blankLibraryPdf()));
181
757
  let bytes;
182
758
  try {
183
- requireCanonicalServices(doc);
184
759
  await doc.metadata.update({ title: name });
185
760
  await doc.pieceInfo.update(STAMP_LIBRARY_PIECEINFO_APP, stampLibraryPieceInfo(id, {
186
761
  kind,
187
- categories: opts?.categories
762
+ categories: options?.categories
188
763
  }));
189
764
  bytes = await doc.download();
190
765
  } finally {
191
766
  await doc.close();
192
767
  }
768
+ throwIfCancelled(signal);
769
+ const library = {
770
+ id,
771
+ name,
772
+ kind,
773
+ assetIds: [],
774
+ ...options?.categories ? { categories: options.categories } : {}
775
+ };
193
776
  libraryBinaries.set(id, bytes);
194
- ctx.dispatch({
195
- type: "LIBRARY_ADDED",
196
- library: {
197
- id,
198
- name,
199
- kind,
200
- assetIds: [],
201
- ...opts?.categories ? { categories: opts.categories } : {}
202
- }
203
- });
777
+ ctx.state.update(addLibrary, library);
204
778
  libraryChanged.emit({
205
779
  libraryId: id,
206
780
  reason: "created"
207
781
  });
208
- return id;
209
- };
210
- const requireCanonicalServices = (doc) => {
211
- if (!doc.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need an asset engine with pieceInfo");
212
- if (!doc.pages.setName || !doc.pages.removeName) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need an asset engine with named pages (pages.setName)");
782
+ libraryCreated.emit({
783
+ libraryId: id,
784
+ library
785
+ });
786
+ return { library };
213
787
  };
214
- const importLibraryPdf = async (source, opts) => {
215
- const resolved = await resolveBinarySource(source);
216
- if (sniffBinaryMetadata(resolved.bytes)?.mimeType !== "application/pdf") throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] importLibraryPdf needs PDF bytes (use addAsset for a raster image)");
217
- const doc = await openAssetDocument(new Uint8Array(resolved.bytes));
788
+ const importLibrary = async (source, options) => {
789
+ const signal = options?.signal;
790
+ throwIfCancelled(signal);
791
+ const resolved = await ctx.cancellable(signal, resolveBinarySource(source));
792
+ if (sniffBinaryMetadata(resolved.bytes)?.mimeType !== "application/pdf") throw stampError("invalid-input", "importLibrary needs PDF bytes (use createAsset for a raster image)");
793
+ const doc = await ctx.cancellable(signal, openAssetDocument(new Uint8Array(resolved.bytes)));
218
794
  let imported;
219
795
  try {
220
- requireCanonicalServices(doc);
221
796
  const layout = await doc.pages.list();
222
- if (layout.pageCount === 0) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a canonical stamp library PDF must contain at least one page");
223
- const byPon = new Map(layout.pages.map((page) => [page.pageObjectNumber, page]));
224
- const catalogEntries = (await doc.pieceInfo.read("EMBD_StampLibrary"))?.entries ?? {};
225
- const state = ctx.getState();
226
- const takenLibraryIds = new Set(Object.keys(state.libraries));
797
+ if (layout.pageCount === 0) throw stampError("invalid-input", "a canonical stamp library PDF must contain at least one page");
798
+ const pagesByObjectNumber = new Map(layout.pages.map((page) => [page.ref.objectNumber, page]));
799
+ const catalogEntries = (await doc.pieceInfo.get("EMBD_StampLibrary"))?.entries ?? {};
800
+ const takenLibraryIds = new Set(Object.keys(ctx.state.get().libraries));
227
801
  const libraryId = allocateId(entryString(catalogEntries, "Id"), "stamp-lib", takenLibraryIds);
228
- const docMeta = await doc.metadata.read();
229
- const libraryName = (docMeta.title && docMeta.title.length > 0 ? docMeta.title : void 0) ?? entryString(catalogEntries, "Name") ?? opts?.name ?? "Stamps";
802
+ const docMeta = await doc.metadata.get();
803
+ const libraryName = (docMeta.title && docMeta.title.length > 0 ? docMeta.title : void 0) ?? entryString(catalogEntries, "Name") ?? options?.name ?? "Stamps";
230
804
  if (!docMeta.title) await doc.metadata.update({ title: libraryName });
231
- const libraryCategories = opts?.categories ?? entryStringArray(catalogEntries, "Categories");
805
+ const libraryCategories = options?.categories ?? entryStringArray(catalogEntries, "Categories");
232
806
  const libraryLocale = entryString(catalogEntries, "Locale");
233
- const libraryKind = opts?.libraryKind ?? libraryKindFromPdfName(entryName(catalogEntries, "Kind"));
807
+ const libraryKind = options?.libraryKind ?? libraryKindFromPdfName(entryName(catalogEntries, "Kind"));
234
808
  const registry = (layout.namedPages ?? []).filter((entry) => entry.target.kind === "page");
235
809
  const hidden = (layout.namedPages ?? []).filter((entry) => entry.target.kind !== "page");
236
810
  if (hidden.length > 0) globalThis.console?.warn(`[stamp] library '${libraryName}' carries ${hidden.length} hidden-template or dangling registration(s); they are not stamps and were ignored`);
@@ -239,35 +813,35 @@ function createStampCapability(ctx, config = {}) {
239
813
  const seen = /* @__PURE__ */ new Set();
240
814
  descriptors = registry.map((entry) => {
241
815
  const { name, label } = parseStampKey(entry.name);
242
- if (!name) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] library '${libraryName}': empty stamp identifier in key '${entry.name}'`);
243
- if (seen.has(name)) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] library '${libraryName}': duplicate stamp identifier '${name}'`);
816
+ if (!name) throw stampError("invalid-input", `library '${libraryName}': empty stamp identifier in key '${entry.name}'`);
817
+ if (seen.has(name)) throw stampError("invalid-input", `library '${libraryName}': duplicate stamp identifier '${name}'`);
244
818
  seen.add(name);
245
- const page = byPon.get(entry.target.pageObjectNumber);
819
+ const page = pagesByObjectNumber.get(entry.target.page.objectNumber);
246
820
  return {
247
- pageObjectNumber: page.pageObjectNumber,
821
+ page: page.ref,
248
822
  index: page.index,
249
823
  name,
250
824
  label
251
825
  };
252
826
  });
253
- descriptors.sort((a, b) => a.index - b.index);
254
- } else if (layout.namedPages === void 0) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] the asset engine reports no named-page registry (an older engine); upgrade it to import libraries");
827
+ descriptors.sort((left, right) => left.index - right.index);
828
+ } else if (layout.namedPages === void 0) throw stampError("unsupported", "the asset engine reports no named-page registry; an engine with named pages is needed to import libraries");
255
829
  else {
256
830
  descriptors = [];
257
831
  for (const page of layout.pages) {
258
- const v1 = (await doc.page(page.pageObjectNumber).pieceInfo?.read(STAMP_PIECEINFO_APP))?.entries;
259
- const name = (v1 && entryString(v1, "Name")) ?? `Stamp${page.index + 1}`;
260
- const label = (v1 && entryString(v1, "Subject")) ?? opts?.assetName?.(page.index) ?? name;
832
+ const version1Entries = (await doc.page(page.ref).pieceInfo.get(STAMP_PIECEINFO_APP))?.entries;
833
+ const name = (version1Entries && entryString(version1Entries, "Name")) ?? `Stamp${page.index + 1}`;
834
+ const label = (version1Entries && entryString(version1Entries, "Subject")) ?? options?.assetName?.(page.index) ?? name;
261
835
  descriptors.push({
262
- pageObjectNumber: page.pageObjectNumber,
836
+ page: page.ref,
263
837
  index: page.index,
264
838
  name,
265
839
  label
266
840
  });
267
841
  }
268
- for (const d of descriptors) await doc.pages.setName({
269
- name: stampKey(d.name, d.label),
270
- pageObjectNumber: d.pageObjectNumber
842
+ for (const descriptor of descriptors) await doc.pages.setName({
843
+ name: stampKey(descriptor.name, descriptor.label),
844
+ page: descriptor.page
271
845
  });
272
846
  }
273
847
  await doc.pieceInfo.update(STAMP_LIBRARY_PIECEINFO_APP, stampLibraryPieceInfo(libraryId, {
@@ -276,25 +850,24 @@ function createStampCapability(ctx, config = {}) {
276
850
  locale: libraryLocale
277
851
  }));
278
852
  const assets = [];
279
- for (const d of descriptors) {
280
- const handle = doc.page(d.pageObjectNumber);
281
- if (!handle.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need page pieceInfo support");
282
- const entries = (await handle.pieceInfo.read("EMBD_Stamp"))?.entries ?? {};
283
- const kind = opts?.kind ?? kindFromPdfName(entryName(entries, "Kind")) ?? "stamp";
853
+ for (const descriptor of descriptors) {
854
+ const handle = doc.page(descriptor.page);
855
+ const entries = (await handle.pieceInfo.get("EMBD_Stamp"))?.entries ?? {};
856
+ const kind = options?.kind ?? kindFromPdfName(entryName(entries, "Kind")) ?? "stamp";
284
857
  const subject = entryString(entries, "SubjectOverride");
285
858
  const categories = entryStringArray(entries, "Categories");
286
- const page = byPon.get(d.pageObjectNumber);
859
+ const page = pagesByObjectNumber.get(descriptor.page.objectNumber);
287
860
  const asset = {
288
- id: assetIdFor(libraryId, d.name),
861
+ id: assetIdFor(libraryId, descriptor.name),
289
862
  libraryId,
290
863
  kind,
291
- name: d.name,
292
- label: d.label,
864
+ name: descriptor.name,
865
+ label: descriptor.label,
293
866
  size: {
294
867
  width: page.size.width,
295
868
  height: page.size.height
296
869
  },
297
- pageObjectNumber: d.pageObjectNumber,
870
+ page: descriptor.page,
298
871
  ...subject !== void 0 ? { subject } : {},
299
872
  ...categories !== void 0 ? { categories } : {}
300
873
  };
@@ -302,7 +875,7 @@ function createStampCapability(ctx, config = {}) {
302
875
  subject,
303
876
  categories
304
877
  }));
305
- const bytes = await doc.pages.extract([d.pageObjectNumber]);
878
+ const bytes = await doc.pages.extract([descriptor.page]);
306
879
  assets.push({
307
880
  asset,
308
881
  bytes,
@@ -324,321 +897,71 @@ function createStampCapability(ctx, config = {}) {
324
897
  } finally {
325
898
  await doc.close();
326
899
  }
900
+ throwIfCancelled(signal);
327
901
  libraryBinaries.set(imported.library.id, imported.canonicalBytes);
328
- ctx.dispatch({
329
- type: "LIBRARY_ADDED",
330
- library: imported.library
331
- });
902
+ ctx.state.update(addLibrary, imported.library);
332
903
  for (const { asset, bytes, preview } of imported.assets) {
333
- binaries.set(asset.id, {
904
+ assetBinaries.set(asset.id, {
334
905
  bytes,
335
906
  preview
336
907
  });
337
- ctx.dispatch({
338
- type: "ASSET_ADDED",
339
- asset
340
- });
908
+ ctx.state.update(addAsset, asset);
341
909
  }
342
910
  libraryChanged.emit({
343
911
  libraryId: imported.library.id,
344
- reason: "imported"
345
- });
346
- return imported.library.id;
347
- };
348
- /**
349
- * A drawn or typed mark rendered to a one-page PDF: the annotation is
350
- * authored on a scratch page and exported the way "stamp from selection"
351
- * exports — a fresh page the size of the mark, paths kept as paths, the
352
- * font subset-embedded. Self-contained and Acrobat-readable.
353
- */
354
- const renderMark = async (mark) => {
355
- const doc = await openAssetDocument(blankLibraryPdf());
356
- try {
357
- const scratch = (await doc.pages.list()).pages[0];
358
- if (!scratch) throw new EngineError(EngineErrorCode.Unknown, "[stamp] no scratch page");
359
- const page = doc.page(scratch.pageObjectNumber);
360
- if (!page.annotations.exportAppearance) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] authoring a mark needs an asset engine that can export annotation appearances");
361
- const color = hexColor(mark.color ?? "#1d2b53");
362
- const created = mark.kind === "ink" ? await page.annotations.create({
363
- subtype: "ink",
364
- inkList: mark.strokes.map((stroke) => stroke.map(({ x, y }) => ({
365
- x,
366
- y
367
- }))),
368
- rect: inkBounds(mark.strokes, mark.strokeWidth ?? 2),
369
- color,
370
- strokeWidth: mark.strokeWidth ?? 2
371
- }) : await page.annotations.create({
372
- subtype: "free-text",
373
- intent: "free-text",
374
- contents: mark.text,
375
- fontFamily: mark.fontFamily,
376
- fontSize: mark.fontSize ?? 36,
377
- textAlign: "left",
378
- rect: textBounds(mark.text, mark.fontSize ?? 36),
379
- fontColor: color,
380
- strokeWidth: 0
381
- });
382
- return new Uint8Array(await page.annotations.exportAppearance([created.created.ref]));
383
- } finally {
384
- await doc.close();
385
- }
386
- };
387
- /** One page of a PDF as a single-page PDF (a `pdf` mark with `pageIndex`). */
388
- const extractPage = async (bytes, pageIndex) => {
389
- const doc = await openAssetDocument(bytes);
390
- try {
391
- const layout = await doc.pages.list();
392
- const page = layout.pages[pageIndex];
393
- if (!page) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] the PDF has no page ${pageIndex} (${layout.pageCount} pages)`);
394
- return await doc.pages.extract([page.pageObjectNumber]);
395
- } finally {
396
- await doc.close();
397
- }
398
- };
399
- /** The bytes an asset is made from: the caller's `source`, or its `mark` authored into one. */
400
- const resolveAssetSource = async (input) => {
401
- if (input.source === void 0 === (input.mark === void 0)) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] addAsset takes exactly one of `source` / `mark`");
402
- if (input.source !== void 0) return resolveBinarySource(input.source);
403
- const mark = input.mark;
404
- switch (mark.kind) {
405
- case "image": return resolveBinarySource(mark.source);
406
- case "pdf": {
407
- const resolved = await resolveBinarySource(mark.source);
408
- if (mark.pageIndex === void 0) return resolved;
409
- return { bytes: toArrayBuffer(await extractPage(new Uint8Array(resolved.bytes), mark.pageIndex)) };
410
- }
411
- case "ink":
412
- case "text": return { bytes: toArrayBuffer(await renderMark(mark)) };
413
- }
414
- };
415
- const addAsset = async (input) => {
416
- if (input.libraryId && !ctx.getState().libraries[input.libraryId]) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown library '${input.libraryId}'`);
417
- const resolved = await resolveAssetSource(input);
418
- const meta = sniffBinaryMetadata(resolved.bytes);
419
- if (!meta) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] asset source must be PNG, JPEG, or single-page PDF bytes");
420
- const isPdf = meta.mimeType === "application/pdf";
421
- const name = input.name ?? customStampName();
422
- const label = input.label ?? name;
423
- if (name.length === 0 || name.includes("=")) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] invalid stamp identifier '${name}': must be non-empty and contain no '='`);
424
- const rasterSize = input.size ?? ("width" in meta ? {
425
- width: meta.width,
426
- height: meta.height
427
- } : null);
428
- if (!isPdf && (!rasterSize || rasterSize.width <= 0 || rasterSize.height <= 0)) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a raster asset needs a positive `size` (its page size in points)");
429
- let suppliedPreview = null;
430
- if (input.preview) {
431
- const preview = await resolveBinarySource(input.preview);
432
- suppliedPreview = {
433
- bytes: new Uint8Array(preview.bytes),
434
- mimeType: preview.mimeType ?? "image/png"
435
- };
436
- }
437
- const libraryId = input.libraryId ?? await createLibrary(label);
438
- return mutateLibrary(libraryId, async () => {
439
- const liveLibrary = ctx.getState().libraries[libraryId];
440
- if (!liveLibrary) throw new EngineError(EngineErrorCode.NotFound, `[stamp] library '${libraryId}' no longer exists`);
441
- const assetId = assetIdFor(liveLibrary.id, name);
442
- if (ctx.getState().assets[assetId]) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] library '${liveLibrary.name}' already has a stamp named '${name}'`);
443
- const canonicalBytes = libraryBinaries.get(liveLibrary.id);
444
- if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${liveLibrary.id}'`);
445
- const doc = await openAssetDocument(canonicalBytes);
446
- let appended;
447
- try {
448
- requireCanonicalServices(doc);
449
- let pageObjectNumber;
450
- if (isPdf) {
451
- const result = await doc.pages.insert(new Uint8Array(resolved.bytes));
452
- if (result.insertedPageObjectNumbers.length !== 1) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a library asset must be a single-page PDF");
453
- pageObjectNumber = result.insertedPageObjectNumbers[0];
454
- } else {
455
- if (!doc.pages.flatten) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] raster assets need an asset engine with pages.flatten");
456
- pageObjectNumber = (await doc.pages.insertBlank({ size: rasterSize })).insertedPageObjectNumbers[0];
457
- await doc.page(pageObjectNumber).annotations.create({
458
- subtype: "stamp",
459
- rect: {
460
- left: 0,
461
- bottom: 0,
462
- right: rasterSize.width,
463
- top: rasterSize.height
464
- },
465
- source: new Uint8Array(resolved.bytes),
466
- fit: "fill"
467
- });
468
- if ((await doc.pages.flatten([pageObjectNumber], "display")).results.some(({ status }) => status !== "applied")) throw new EngineError(EngineErrorCode.Unknown, "[stamp] flattening the raster into its page failed");
469
- }
470
- const layout = (await doc.pages.list()).pages.find((candidate) => candidate.pageObjectNumber === pageObjectNumber);
471
- const page = doc.page(pageObjectNumber);
472
- if (!layout || !page.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need page layout and pieceInfo support");
473
- await doc.pages.setName({
474
- name: stampKey(name, label),
475
- pageObjectNumber
476
- });
477
- const asset = {
478
- id: assetId,
479
- libraryId: liveLibrary.id,
480
- kind: input.kind ?? "stamp",
481
- name,
482
- label,
483
- size: {
484
- width: layout.size.width,
485
- height: layout.size.height
486
- },
487
- pageObjectNumber,
488
- ...input.subject !== void 0 ? { subject: input.subject } : {},
489
- ...input.categories !== void 0 ? { categories: input.categories } : {}
490
- };
491
- await page.pieceInfo.update(STAMP_PIECEINFO_APP, stampPieceInfo(asset.kind, {
492
- subject: asset.subject,
493
- categories: asset.categories
494
- }));
495
- appended = {
496
- asset,
497
- bytes: await doc.pages.extract([pageObjectNumber]),
498
- preview: suppliedPreview ?? await renderThumbnail(page),
499
- canonical: await doc.download()
500
- };
501
- } finally {
502
- await doc.close();
503
- }
504
- libraryBinaries.set(liveLibrary.id, appended.canonical);
505
- binaries.set(appended.asset.id, {
506
- bytes: appended.bytes,
507
- preview: appended.preview
508
- });
509
- ctx.dispatch({
510
- type: "ASSET_ADDED",
511
- asset: appended.asset
512
- });
513
- libraryChanged.emit({
514
- libraryId: liveLibrary.id,
515
- reason: "asset-added"
516
- });
517
- return appended.asset.id;
518
- });
519
- };
520
- const addAssetFromAnnotations = async (documentId, pageObjectNumber, refs, input) => {
521
- const doc = ctx.documentHandle(documentId);
522
- if (!doc) throw new EngineError(EngineErrorCode.NotFound, `[stamp] target document '${documentId}' is not open`);
523
- const page = doc.page(pageObjectNumber);
524
- if (!page.annotations.exportAppearance) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] this document's engine cannot export annotation appearances");
525
- const source = await page.annotations.exportAppearance(refs);
526
- return addAsset({
527
- ...input,
528
- source
529
- });
530
- };
531
- const dropLibrary = (id) => {
532
- const library = ctx.getState().libraries[id];
533
- if (library) for (const assetId of library.assetIds) {
534
- binaries.delete(assetId);
535
- ghostRenders.delete(assetId);
536
- }
537
- libraryBinaries.delete(id);
538
- ctx.dispatch({
539
- type: "LIBRARY_REMOVED",
540
- libraryId: id
541
- });
542
- };
543
- const removeAsset = async (id) => {
544
- const initialAsset = ctx.getState().assets[id];
545
- if (!initialAsset) return;
546
- return mutateLibrary(initialAsset.libraryId, async () => {
547
- const asset = ctx.getState().assets[id];
548
- if (!asset) return;
549
- const library = ctx.getState().libraries[asset.libraryId];
550
- if (!library) return;
551
- const canonicalBytes = libraryBinaries.get(library.id);
552
- if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${library.id}'`);
553
- const doc = await openAssetDocument(canonicalBytes);
554
- let rewritten;
555
- try {
556
- await doc.pages.delete([asset.pageObjectNumber]);
557
- rewritten = await doc.download();
558
- } finally {
559
- await doc.close();
560
- }
561
- libraryBinaries.set(library.id, rewritten);
562
- binaries.delete(id);
563
- ghostRenders.delete(id);
564
- ctx.dispatch({
565
- type: "ASSET_REMOVED",
566
- assetId: id
567
- });
568
- libraryChanged.emit({
569
- libraryId: library.id,
570
- reason: "asset-removed"
571
- });
912
+ reason: "imported"
913
+ });
914
+ libraryCreated.emit({
915
+ libraryId: imported.library.id,
916
+ library: imported.library
572
917
  });
918
+ for (const { asset } of imported.assets) assetCreated.emit({
919
+ assetId: asset.id,
920
+ libraryId: imported.library.id,
921
+ asset
922
+ });
923
+ return { library: ctx.state.get().libraries[imported.library.id] ?? imported.library };
924
+ };
925
+ const dropLibrary = (id) => {
926
+ const library = ctx.state.get().libraries[id];
927
+ if (library) for (const assetId of library.assetIds) {
928
+ assetBinaries.delete(assetId);
929
+ ghostRenders.delete(assetId);
930
+ }
931
+ libraryBinaries.delete(id);
932
+ ctx.state.update(removeLibrary, id);
573
933
  };
574
- const removeLibrary = (id) => mutateLibrary(id, async () => {
575
- const existed = ctx.getState().libraries[id] !== void 0;
934
+ const deleteLibrary = (id, options) => mutateLibrary(id, async () => {
935
+ const existed = ctx.state.get().libraries[id] !== void 0;
576
936
  dropLibrary(id);
577
- if (existed) libraryChanged.emit({
578
- libraryId: id,
579
- reason: "removed"
580
- });
581
- });
582
- const updateAsset = async (id, patch) => {
583
- const initial = ctx.getState().assets[id];
584
- if (!initial) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown asset '${id}'`);
585
- return mutateLibrary(initial.libraryId, async () => {
586
- const asset = ctx.getState().assets[id];
587
- const library = asset ? ctx.getState().libraries[asset.libraryId] : void 0;
588
- if (!asset || !library) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown asset '${id}'`);
589
- const next = {
590
- ...asset,
591
- ...patch.label !== void 0 ? { label: patch.label } : {},
592
- ...patch.categories !== void 0 ? { categories: patch.categories } : {}
593
- };
594
- if (patch.subject === null) delete next.subject;
595
- else if (patch.subject !== void 0) next.subject = patch.subject;
596
- const canonicalBytes = libraryBinaries.get(library.id);
597
- if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${library.id}'`);
598
- const doc = await openAssetDocument(canonicalBytes);
599
- let rewritten;
600
- try {
601
- requireCanonicalServices(doc);
602
- if (next.label !== asset.label) await doc.pages.setName({
603
- name: stampKey(asset.name, next.label),
604
- pageObjectNumber: asset.pageObjectNumber,
605
- replace: stampKey(asset.name, asset.label)
606
- });
607
- await doc.page(asset.pageObjectNumber).pieceInfo?.update(STAMP_PIECEINFO_APP, stampPieceInfo(next.kind, {
608
- subject: next.subject,
609
- categories: next.categories
610
- }));
611
- rewritten = await doc.download();
612
- } finally {
613
- await doc.close();
614
- }
615
- libraryBinaries.set(library.id, rewritten);
616
- ctx.dispatch({
617
- type: "ASSET_UPDATED",
618
- asset: next
619
- });
937
+ if (existed) {
620
938
  libraryChanged.emit({
621
- libraryId: library.id,
622
- reason: "asset-updated"
939
+ libraryId: id,
940
+ reason: "removed"
623
941
  });
624
- });
625
- };
626
- const updateLibrary = async (id, patch) => {
627
- if (!ctx.getState().libraries[id]) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown library '${id}'`);
942
+ libraryDeleted.emit({
943
+ libraryId: id,
944
+ library: null
945
+ });
946
+ }
947
+ }, options?.signal);
948
+ const updateLibrary = async (id, patch, options) => {
949
+ const signal = options?.signal;
950
+ throwIfCancelled(signal);
951
+ if (!ctx.state.get().libraries[id]) throw notFound("library", id);
628
952
  return mutateLibrary(id, async () => {
629
- const library = ctx.getState().libraries[id];
630
- if (!library) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown library '${id}'`);
953
+ const library = ctx.state.get().libraries[id];
954
+ if (!library) throw notFound("library", id);
631
955
  const canonicalBytes = libraryBinaries.get(id);
632
- if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${id}'`);
956
+ if (!canonicalBytes) throw stampError("operation-failed", `canonical bytes are missing for library '${id}'`);
633
957
  const next = {
634
958
  ...library,
635
959
  ...patch.name !== void 0 ? { name: patch.name } : {},
636
960
  ...patch.categories !== void 0 ? { categories: patch.categories } : {}
637
961
  };
638
- const doc = await openAssetDocument(canonicalBytes);
962
+ const doc = await ctx.cancellable(signal, openAssetDocument(canonicalBytes));
639
963
  let rewritten;
640
964
  try {
641
- requireCanonicalServices(doc);
642
965
  if (next.name !== library.name) await doc.metadata.update({ title: next.name });
643
966
  await doc.pieceInfo.update(STAMP_LIBRARY_PIECEINFO_APP, stampLibraryPieceInfo(id, {
644
967
  kind: next.kind,
@@ -649,72 +972,229 @@ function createStampCapability(ctx, config = {}) {
649
972
  } finally {
650
973
  await doc.close();
651
974
  }
975
+ throwIfCancelled(signal);
652
976
  libraryBinaries.set(id, rewritten);
653
- ctx.dispatch({
654
- type: "LIBRARY_UPDATED",
655
- library: next
656
- });
977
+ ctx.state.update(setLibrary, next);
657
978
  libraryChanged.emit({
658
979
  libraryId: id,
659
980
  reason: "updated"
660
981
  });
661
- });
982
+ libraryUpdated.emit({
983
+ libraryId: id,
984
+ library: next
985
+ });
986
+ return { library: next };
987
+ }, signal);
988
+ };
989
+ return {
990
+ createLibrary,
991
+ api: {
992
+ createLibrary: verb(createLibrary),
993
+ updateLibrary: verb(updateLibrary),
994
+ importLibrary: verb(importLibrary),
995
+ deleteLibrary: verb(deleteLibrary)
996
+ }
997
+ };
998
+ }
999
+ //#endregion
1000
+ //#region src/write/marks.ts
1001
+ /**
1002
+ * Authoring: a drawn or typed mark rendered to a one-page PDF (the
1003
+ * annotation is authored on a scratch page and exported the way "stamp from
1004
+ * selection" exports), a page of a PDF as a single-page PDF, and the bytes
1005
+ * an asset is made from.
1006
+ */
1007
+ /** A mark's `#rrggbb` color, checked before anything is drawn. */
1008
+ const hexColor = (hex) => {
1009
+ const color = hex.trim();
1010
+ if (!/^#[0-9a-f]{6}$/i.test(color)) throw stampError("invalid-input", `mark color must be #rrggbb, got '${hex}'`);
1011
+ return color;
1012
+ };
1013
+ /** An exact ArrayBuffer over a view (the engine's binary idiom). */
1014
+ const toArrayBuffer = (bytes) => {
1015
+ const buffer = new ArrayBuffer(bytes.byteLength);
1016
+ new Uint8Array(buffer).set(bytes);
1017
+ return buffer;
1018
+ };
1019
+ /** An ink mark's strokes, as the engine takes them; a mark needs at least one point. */
1020
+ const inkListOf = (strokes) => {
1021
+ if (!strokes.some((stroke) => stroke.length > 0)) throw stampError("invalid-input", "an ink mark needs at least one point");
1022
+ return strokes.map((stroke) => stroke.map(({ x, y }) => ({
1023
+ x,
1024
+ y
1025
+ })));
1026
+ };
1027
+ /**
1028
+ * A generous box for typed text. The export keeps the whole box, so the
1029
+ * text is centered in it: the mark then sits in the middle of a preview
1030
+ * or a signature field.
1031
+ */
1032
+ const textBounds = (text, fontSize) => ({
1033
+ x: 0,
1034
+ y: 0,
1035
+ width: Math.max(1, text.length) * fontSize * .75 + fontSize,
1036
+ height: fontSize * 1.6
1037
+ });
1038
+ function createMarks({ assetEngine }) {
1039
+ const { openAssetDocument } = assetEngine;
1040
+ /**
1041
+ * A drawn or typed mark rendered to a one-page PDF: the annotation is
1042
+ * authored on a scratch page and exported the way "stamp from selection"
1043
+ * exports — a fresh page the size of the mark, paths kept as paths, the
1044
+ * font subset-embedded. Self-contained and Acrobat-readable.
1045
+ */
1046
+ const renderMark = async (mark) => {
1047
+ const doc = await openAssetDocument(blankLibraryPdf());
1048
+ try {
1049
+ const scratch = (await doc.pages.list()).pages[0];
1050
+ if (!scratch) throw stampError("operation-failed", "the scratch document has no page");
1051
+ const page = doc.page(scratch.ref);
1052
+ const color = hexColor(mark.color ?? "#1d2b53");
1053
+ const created = mark.kind === "ink" ? await page.annotations.create({
1054
+ subtype: "ink",
1055
+ inkList: inkListOf(mark.strokes),
1056
+ color,
1057
+ strokeWidth: mark.strokeWidth ?? 2
1058
+ }) : await page.annotations.create({
1059
+ subtype: "free-text",
1060
+ intent: "free-text",
1061
+ contents: mark.text,
1062
+ fontFamily: mark.fontFamily,
1063
+ fontSize: mark.fontSize ?? 36,
1064
+ textAlign: "center",
1065
+ verticalAlign: "middle",
1066
+ box: textBounds(mark.text, mark.fontSize ?? 36),
1067
+ fontColor: color,
1068
+ strokeWidth: 0
1069
+ });
1070
+ return new Uint8Array(await page.annotations.exportAppearance([created.annotation.ref]));
1071
+ } finally {
1072
+ await doc.close();
1073
+ }
1074
+ };
1075
+ /** One page of a PDF as a single-page PDF (a `pdf` mark with `pageIndex`). */
1076
+ const extractPage = async (bytes, pageIndex) => {
1077
+ const doc = await openAssetDocument(bytes);
1078
+ try {
1079
+ const layout = await doc.pages.list();
1080
+ const page = layout.pages[pageIndex];
1081
+ if (!page) throw stampError("invalid-input", `the PDF has no page ${pageIndex} (${layout.pageCount} pages)`);
1082
+ return await doc.pages.extract([page.ref]);
1083
+ } finally {
1084
+ await doc.close();
1085
+ }
1086
+ };
1087
+ /** The bytes an asset is made from: the caller's `source`, or its `mark` authored into one. */
1088
+ const resolveAssetSource = async (input) => {
1089
+ if (input.source === void 0 === (input.mark === void 0)) throw stampError("invalid-input", "createAsset takes exactly one of `source` / `mark`");
1090
+ if (input.source !== void 0) return resolveBinarySource(input.source);
1091
+ const mark = input.mark;
1092
+ switch (mark.kind) {
1093
+ case "image": return resolveBinarySource(mark.source);
1094
+ case "pdf": {
1095
+ const resolved = await resolveBinarySource(mark.source);
1096
+ if (mark.pageIndex === void 0) return resolved;
1097
+ return { bytes: toArrayBuffer(await extractPage(new Uint8Array(resolved.bytes), mark.pageIndex)) };
1098
+ }
1099
+ case "ink":
1100
+ case "text": return { bytes: toArrayBuffer(await renderMark(mark)) };
1101
+ }
662
1102
  };
1103
+ return { resolveAssetSource };
1104
+ }
1105
+ //#endregion
1106
+ //#region src/write/placement.ts
1107
+ /**
1108
+ * Placement: arm an asset on a document (the next click places) or place it
1109
+ * without the pointer. Form-backed stamps are executable templates only
1110
+ * until placement — they are evaluated on an isolated copy under the target
1111
+ * document's detached script realm, so neither the canonical library nor
1112
+ * its reusable per-page extraction becomes target/user/time specific.
1113
+ */
1114
+ function createPlacement(ctx, { binaries, assetEngine, ghosts, targets }, { canPlace }) {
1115
+ const { binaries: assetBinaries, libraryBinaries } = binaries;
1116
+ const { openAssetDocument, imageToPreview } = assetEngine;
1117
+ const { ghostProvider } = ghosts;
1118
+ const { documentIdOf, targetOf, pageOf } = targets;
1119
+ const settings = ctx.settings();
1120
+ const droppedArms = () => Object.keys(ctx.state.get().armed).filter((documentId) => !ctx.tryForDocument(AnnotationToken$1, documentId)?.stamps.isArmed()).join("\n");
1121
+ ctx.watch(droppedArms, (dropped) => {
1122
+ if (dropped) ctx.state.update(disarmDocuments, dropped.split("\n"));
1123
+ });
1124
+ /** The one payload both entry points hand the annotation plugin: artwork,
1125
+ * a resolution-aware ghost, true size, and the identity a placement
1126
+ * writes (`/Name` = identifier, `/Subj` = the subject override or label). */
1127
+ const stampPayload = (asset, placement, materialized) => ({
1128
+ source: placement.bytes,
1129
+ preview: ghostProvider(placement.bytes, placement.preview, materialized ? null : asset.id),
1130
+ intrinsicSize: asset.size,
1131
+ name: asset.name,
1132
+ subject: asset.subject ?? asset.label
1133
+ });
663
1134
  /**
664
1135
  * Form-backed stamps are executable templates only until placement. Work
665
1136
  * on an isolated copy so neither the canonical library nor its reusable
666
1137
  * per-page extraction becomes target/user/time specific.
667
1138
  *
668
- * The realm comes from the TARGET document's actions plugin — a detached
669
- * realm under that document's policy and environment (WP4: its own
670
- * sandbox, none of the viewer realm's globals). No actions plugin, or
671
- * scripting off, or `dynamic: false` → the template is armed as-is.
1139
+ * The realm comes from the target document's actions plugin: a detached
1140
+ * realm under that document's policy and environment, with its own
1141
+ * sandbox and none of the viewer realm's globals. No actions plugin,
1142
+ * scripting off, or `dynamic: false`: the template is armed as-is.
672
1143
  */
673
- const materializeForPlacement = async (documentId, asset, bin) => {
674
- if (config.dynamic === false) return bin;
675
- const actions = ctx.tryForDocument(ActionsToken, documentId);
1144
+ const materializeForPlacement = async (documentId, asset, binary) => {
1145
+ if (!settings.get().dynamic) return binary;
1146
+ const actions = ctx.tryForDocument(ActionsToken$1, documentId);
676
1147
  const mintRealm = actions?.createDetachedScriptRealm;
677
- if (!actions || !mintRealm) return bin;
678
- const targetMeta = ctx.core().documents[documentId] ?? null;
679
- if (!targetMeta) throw new EngineError(EngineErrorCode.NotFound, `[stamp] target document '${documentId}' is not open`);
1148
+ if (!actions || !mintRealm) return binary;
1149
+ const documents = ctx.get(DocumentsToken);
1150
+ const target = documents.get(documentId);
1151
+ if (target?.status !== "ready") throw stampError("not-found", `document '${documentId}' is not open`);
1152
+ const targetDocument = {
1153
+ name: target.name,
1154
+ pageCount: target.pageCount,
1155
+ pages: documents.listPages(documentId)
1156
+ };
680
1157
  const canonicalBytes = libraryBinaries.get(asset.libraryId);
681
- const sourceBytes = canonicalBytes ?? bin.bytes;
1158
+ const sourceBytes = canonicalBytes ?? binary.bytes;
682
1159
  const doc = await openAssetDocument(new Uint8Array(sourceBytes));
683
1160
  let realm = null;
684
1161
  let scripting = null;
685
1162
  try {
686
1163
  const layout = await doc.pages.list();
687
- const selectedPage = asset.pageObjectNumber === void 0 ? layout.pageCount === 1 ? layout.pages[0] : void 0 : layout.pages.find(({ pageObjectNumber }) => pageObjectNumber === asset.pageObjectNumber);
688
- if (!selectedPage) throw new EngineError(EngineErrorCode.InvalidArg, canonicalBytes ? `[stamp] canonical page ${asset.pageObjectNumber ?? "unknown"} no longer exists` : "[stamp] a loose dynamic stamp asset must contain exactly one page");
689
- if (!(await doc.forms.list()).fields.some((field) => field.widgets.some(({ pageObjectNumber }) => pageObjectNumber === selectedPage.pageObjectNumber))) return bin;
690
- if (!doc.pages.flatten || !doc.pages.extract) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] dynamic PDF stamps need an asset engine with pages.flatten and pages.extract");
1164
+ const selectedPage = asset.page === void 0 ? layout.pageCount === 1 ? layout.pages[0] : void 0 : layout.pages.find(({ ref }) => ref.objectNumber === asset.page.objectNumber);
1165
+ if (!selectedPage) throw stampError("invalid-input", canonicalBytes ? `canonical page ${asset.page?.objectNumber ?? "unknown"} no longer exists` : "a loose dynamic stamp asset must contain exactly one page");
1166
+ const snapshot = await doc.forms.list();
1167
+ if (!snapshot.fields.some((field) => field.widgets.some(({ page }) => page?.objectNumber === selectedPage.ref.objectNumber))) return binary;
1168
+ if (!doc.pages.flatten || !doc.pages.extract) throw stampError("unsupported", "dynamic PDF stamps need an asset engine with pages.flatten and pages.extract");
691
1169
  realm = mintRealm.call(actions, {
692
1170
  doc,
693
- document: () => targetMeta,
1171
+ document: () => targetDocument,
694
1172
  bootSources: async () => {
695
- return (doc.actions ? await doc.actions.read() : null)?.nameTreeScripts.map(({ action }) => javaScriptProgramFromActionTree(action)) ?? [];
1173
+ return (doc.actions ? await doc.actions.get() : null)?.nameTreeScripts.map(({ action }) => javaScriptProgramFromActionTree(action)) ?? [];
696
1174
  }
697
1175
  });
698
1176
  scripting = createFormScriptingController({
699
- doc,
700
- document: () => targetMeta,
1177
+ document: () => targetDocument,
701
1178
  transaction: realm.transaction.bind(realm),
702
1179
  budget: realm.budget
703
1180
  });
704
- const result = await scripting.recalculate();
1181
+ const result = await scripting.transaction(async (scripts) => {
1182
+ const calculated = await scripts.recalculate(snapshot);
1183
+ if (calculated.status === "accepted" && calculated.effects.length > 0) await doc.apply({ ops: formEffectOps(calculated.effects) });
1184
+ return calculated;
1185
+ });
705
1186
  actions.surfaceScriptCommit(result, {
706
1187
  origin: "user",
707
1188
  realm: "detached"
708
1189
  });
709
- if (result.status === "failed") throw new EngineError(EngineErrorCode.Unknown, `[stamp] dynamic stamp scripting failed: ${result.error?.message ?? "native form effect failed"}`);
710
- const pageObjectNumber = selectedPage.pageObjectNumber;
711
- const failed = (await doc.pages.flatten([pageObjectNumber], "display")).results.find(({ status }) => status === "failed" || status === "skipped");
712
- if (failed) throw new EngineError(EngineErrorCode.Unknown, `[stamp] dynamic stamp flatten failed for page ${failed.pageObjectNumber}`);
713
- const bytes = await doc.pages.extract([pageObjectNumber]);
714
- const image = await doc.page(pageObjectNumber).render.image({
1190
+ if (result.status === "failed") throw stampError("operation-failed", `dynamic stamp scripting failed: ${result.error?.message ?? "native form effect failed"}`);
1191
+ const page = selectedPage.ref;
1192
+ await doc.pages.flatten([page], { usage: "display" });
1193
+ const bytes = await doc.pages.extract([page]);
1194
+ const image = await doc.page(page).render.image({
715
1195
  viewport: {
716
1196
  kind: "width",
717
- width: config.previewWidth ?? DEFAULT_PREVIEW_WIDTH
1197
+ width: settings.get().previewWidth
718
1198
  },
719
1199
  background: "transparent",
720
1200
  includeAnnotations: false,
@@ -730,242 +1210,199 @@ function createStampCapability(ctx, config = {}) {
730
1210
  await doc.close();
731
1211
  }
732
1212
  };
733
- const armAsset = async (documentId, assetId, opts) => {
734
- const asset = ctx.getState().assets[assetId];
735
- const bin = binaries.get(assetId);
736
- if (!asset || !bin) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown asset '${assetId}'`);
737
- const placement = await materializeForPlacement(documentId, asset, bin);
738
- await ctx.forDocument(AnnotationToken, documentId).armStamp({
739
- ...stampPayload(asset, placement, placement !== bin),
740
- targetWidth: opts?.targetWidth
741
- });
742
- armedByDocument.set(documentId, assetId);
1213
+ /** An asset and its bytes, or `not-found`. */
1214
+ const assetOf = (assetId) => {
1215
+ const asset = ctx.state.get().assets[assetId];
1216
+ const binary = assetBinaries.get(assetId);
1217
+ if (!asset || !binary) throw notFound("asset", assetId);
1218
+ return {
1219
+ asset,
1220
+ binary
1221
+ };
743
1222
  };
744
- /** Which asset this plugin armed per document. The annotation plugin owns
745
- * the arm itself (a tool switch disarms it), so the answer is checked
746
- * against it and forgotten once it no longer holds one. */
747
- const armedByDocument = /* @__PURE__ */ new Map();
748
- const armedAsset = (documentId) => {
749
- const assetId = armedByDocument.get(documentId);
750
- if (assetId === void 0) return null;
751
- if (!ctx.tryForDocument(AnnotationToken$1, documentId)?.hasArmedStamp()) {
752
- armedByDocument.delete(documentId);
753
- return null;
754
- }
755
- return ctx.getState().assets[assetId] ?? null;
1223
+ /** Placing is annotation work: refused where the document's annotation plugin refuses a create. */
1224
+ const assertMayPlace = (documentId, operation) => {
1225
+ if (!canPlace(documentId)) throw permissionDenied("annotations:create", operation);
756
1226
  };
757
- /**
758
- * Ghost renders, one per size bucket per asset, rendered lazily on the
759
- * asset engine the first time the ghost is shown at that size and kept
760
- * until the asset goes away. Materialized (dynamic) placements are
761
- * user/time specific and cache only for their own arm (`cacheKey` null).
762
- */
763
- const ghostRenders = /* @__PURE__ */ new Map();
764
- const renderGhost = async (bytes, devicePixelWidth) => {
765
- const doc = await openAssetDocument(new Uint8Array(bytes));
766
- try {
767
- const page = (await doc.pages.list()).pages[0];
768
- if (!page) return null;
769
- return imageToPreview(await doc.page(page.pageObjectNumber).render.image({
770
- viewport: {
771
- kind: "width",
772
- width: devicePixelWidth
773
- },
774
- background: "transparent",
775
- includeAnnotations: true,
776
- format: "png"
777
- }));
778
- } finally {
779
- await doc.close();
780
- }
1227
+ const armAsset$1 = async (assetId, options) => {
1228
+ const signal = options?.signal;
1229
+ throwIfCancelled(signal);
1230
+ const documentId = targetOf(options);
1231
+ const { asset, binary } = assetOf(assetId);
1232
+ assertMayPlace(documentId, "stamp.armAsset");
1233
+ const placement = await ctx.cancellable(signal, materializeForPlacement(documentId, asset, binary));
1234
+ throwIfCancelled(signal);
1235
+ await ctx.forDocument(AnnotationToken, documentId).stamps.arm({
1236
+ ...stampPayload(asset, placement, placement !== binary),
1237
+ targetWidth: options?.targetWidth
1238
+ }, { signal });
1239
+ ctx.state.update(armAsset, documentId, assetId);
781
1240
  };
782
- /** A resolution-aware ghost source for placement bytes: the page renders
783
- * at the requested width, cached per bucket under `cacheKey` when given. */
784
- const ghostProvider = (bytes, fallback, cacheKey) => {
785
- const local = cacheKey ? ghostRenders.get(cacheKey) ?? /* @__PURE__ */ new Map() : /* @__PURE__ */ new Map();
786
- if (cacheKey) ghostRenders.set(cacheKey, local);
787
- return (devicePixelWidth) => {
788
- let pending = local.get(devicePixelWidth);
789
- if (!pending) {
790
- pending = renderGhost(bytes, devicePixelWidth).catch((error) => {
791
- globalThis.console?.warn("[stamp] ghost render failed, using the thumbnail:", error);
792
- return fallback;
793
- });
794
- local.set(devicePixelWidth, pending);
795
- }
796
- return pending;
797
- };
1241
+ const getArmedAsset = (documentId) => {
1242
+ const id = documentIdOf(documentId);
1243
+ const assetId = id === null ? void 0 : ctx.state.get().armed[id];
1244
+ if (id === null || assetId === void 0) return null;
1245
+ if (!ctx.tryForDocument(AnnotationToken$1, id)?.stamps.isArmed()) return null;
1246
+ return ctx.state.get().assets[assetId] ?? null;
798
1247
  };
799
- /** The one payload both entry points hand the annotation plugin: artwork,
800
- * a resolution-aware ghost, true size, and the identity a placement
801
- * writes (`/Name` = identifier, `/Subj` = the subject override or label). */
802
- const stampPayload = (asset, placement, materialized) => ({
803
- source: placement.bytes,
804
- preview: ghostProvider(placement.bytes, placement.preview, materialized ? null : asset.id),
805
- intrinsicSize: asset.size,
806
- name: asset.name,
807
- subject: asset.subject ?? asset.label
808
- });
809
- const placeAsset = async (documentId, assetId, placement) => {
810
- const asset = ctx.getState().assets[assetId];
811
- const bin = binaries.get(assetId);
812
- if (!asset || !bin) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown asset '${assetId}'`);
813
- const materialized = await materializeForPlacement(documentId, asset, bin);
814
- return ctx.forDocument(AnnotationToken, documentId).placeStamp(stampPayload(asset, materialized, materialized !== bin), placement);
1248
+ const disarm = (documentId) => {
1249
+ const id = documentIdOf(documentId);
1250
+ if (id === null) return;
1251
+ ctx.tryForDocument(AnnotationToken, id)?.stamps.disarm();
1252
+ ctx.state.update(disarmDocuments, [id]);
815
1253
  };
816
- ctx.cleanup(() => {
817
- binaries.clear();
818
- ghostRenders.clear();
819
- libraryBinaries.clear();
820
- libraryMutationTails.clear();
821
- const ownedAssetEngine = typeof config.assetEngine === "function" ? assetEngineRef : null;
822
- assetEngineRef = null;
823
- if (ownedAssetEngine) Promise.resolve(ownedAssetEngine).then((engine) => engine.destroy()).catch(() => {});
824
- });
825
- return {
826
- libraries: (query) => {
827
- const s = ctx.getState();
828
- const kinds = query?.kind === void 0 ? null : new Set(typeof query.kind === "string" ? [query.kind] : query.kind);
829
- return s.libraryOrder.map((id) => s.libraries[id]).filter((l) => l != null && (kinds === null || kinds.has(l.kind)));
830
- },
831
- library: (id) => ctx.getState().libraries[id] ?? null,
832
- assets: (libraryId) => {
833
- const s = ctx.getState();
834
- if (libraryId) {
835
- const library = s.libraries[libraryId];
836
- return library ? library.assetIds.map((id) => s.assets[id]).filter((a) => a != null) : [];
837
- }
838
- return s.libraryOrder.flatMap((lid) => (s.libraries[lid]?.assetIds ?? []).map((id) => s.assets[id]).filter((a) => a != null));
839
- },
840
- asset: (id) => ctx.getState().assets[id] ?? null,
841
- assetPreview: (id) => binaries.get(id)?.preview ?? null,
842
- assetBytes: (id) => binaries.get(id)?.bytes ?? null,
843
- exportLibrary: (id) => libraryBinaries.get(id) ?? null,
844
- onLibraryChanged: libraryChanged.on,
845
- createLibrary,
846
- updateLibrary,
847
- importLibraryPdf,
848
- addAsset,
849
- addAssetFromAnnotations,
850
- updateAsset,
851
- removeAsset,
852
- removeLibrary,
853
- armAsset,
854
- placeAsset,
855
- disarm: (documentId) => {
856
- armedByDocument.delete(documentId);
857
- ctx.forDocument(AnnotationToken, documentId).disarmStamp();
858
- },
859
- armedAsset
1254
+ /** Place on one document, its permission already checked. */
1255
+ const placeOn = async (documentId, assetId, placement, signal) => {
1256
+ const { asset, binary } = assetOf(assetId);
1257
+ const materialized = await ctx.cancellable(signal, materializeForPlacement(documentId, asset, binary));
1258
+ throwIfCancelled(signal);
1259
+ return ctx.forDocument(AnnotationToken, documentId).stamps.place(stampPayload(asset, materialized, materialized !== binary), placement, { signal });
1260
+ };
1261
+ const placeAsset = async (assetId, placement, options) => {
1262
+ throwIfCancelled(options?.signal);
1263
+ const documentId = targetOf(options);
1264
+ assetOf(assetId);
1265
+ assertMayPlace(documentId, "stamp.placeAsset");
1266
+ return placeOn(documentId, assetId, placement, options?.signal);
860
1267
  };
1268
+ const placeAssetOnPages = async (assetId, pages, placement, options) => {
1269
+ const signal = options?.signal;
1270
+ throwIfCancelled(signal);
1271
+ const documentId = targetOf(options);
1272
+ assetOf(assetId);
1273
+ assertMayPlace(documentId, "stamp.placeAssetOnPages");
1274
+ const targetPages = pages === "all" ? ctx.get(DocumentsToken).listPages(documentId).map((page) => page.ref) : pages.map((page) => pageOf(documentId, page).ref);
1275
+ const applied = [];
1276
+ const failed = [];
1277
+ for (const page of targetPages) try {
1278
+ const placed = await placeOn(documentId, assetId, {
1279
+ ...placement,
1280
+ page
1281
+ }, signal);
1282
+ applied.push(placed.annotation);
1283
+ } catch (error) {
1284
+ const refused = toPluginError("stamp", error);
1285
+ if (refused.code === "operation-cancelled") throw refused;
1286
+ failed.push({
1287
+ ref: page,
1288
+ error: toPluginErrorInfo(refused)
1289
+ });
1290
+ }
1291
+ return {
1292
+ applied,
1293
+ skipped: [],
1294
+ failed
1295
+ };
1296
+ };
1297
+ return { api: {
1298
+ armAsset: verb(armAsset$1),
1299
+ disarm,
1300
+ getArmedAsset,
1301
+ placeAsset: verb(placeAsset),
1302
+ placeAssetOnPages: verb(placeAssetOnPages)
1303
+ } };
861
1304
  }
862
1305
  //#endregion
863
- //#region src/reducer.ts
864
- const initialStampState = () => ({
865
- libraries: {},
866
- libraryOrder: [],
867
- assets: {}
868
- });
869
- /** Pure and serializable (kernel rule 1) — descriptors only; the bytes and
870
- * previews live in the capability and never enter the store. */
871
- const stampReducer = (state, action) => {
872
- switch (action.type) {
873
- case "LIBRARY_ADDED": return {
874
- ...state,
875
- libraries: {
876
- ...state.libraries,
877
- [action.library.id]: action.library
878
- },
879
- libraryOrder: [...state.libraryOrder, action.library.id]
880
- };
881
- case "LIBRARY_UPDATED": return state.libraries[action.library.id] ? {
882
- ...state,
883
- libraries: {
884
- ...state.libraries,
885
- [action.library.id]: action.library
886
- }
887
- } : state;
888
- case "LIBRARY_REMOVED": {
889
- const library = state.libraries[action.libraryId];
890
- if (!library) return state;
891
- const libraries = { ...state.libraries };
892
- delete libraries[action.libraryId];
893
- const assets = { ...state.assets };
894
- for (const id of library.assetIds) delete assets[id];
895
- return {
896
- ...state,
897
- libraries,
898
- libraryOrder: state.libraryOrder.filter((id) => id !== action.libraryId),
899
- assets
900
- };
901
- }
902
- case "ASSET_ADDED": {
903
- const library = state.libraries[action.asset.libraryId];
904
- if (!library) return state;
905
- return {
906
- ...state,
907
- assets: {
908
- ...state.assets,
909
- [action.asset.id]: action.asset
910
- },
911
- libraries: {
912
- ...state.libraries,
913
- [library.id]: {
914
- ...library,
915
- assetIds: [...library.assetIds, action.asset.id]
916
- }
917
- }
918
- };
1306
+ //#region src/controller.ts
1307
+ /**
1308
+ * The stamp controller: the composition root. It builds the plugin's
1309
+ * services once, wires each area with the services it declares, and
1310
+ * assembles the capability from the areas' API slices. Every verb and read
1311
+ * has a home in `read/` or `write/`; the one state-change event is derived
1312
+ * here.
1313
+ */
1314
+ function createStampController(ctx) {
1315
+ const services = createServices(ctx);
1316
+ const { events } = services;
1317
+ const settings = ctx.settings();
1318
+ ctx.state.onChange(({ previous, next }) => {
1319
+ if (previous.armed === next.armed) return;
1320
+ const documentIds = /* @__PURE__ */ new Set([...Object.keys(previous.armed), ...Object.keys(next.armed)]);
1321
+ for (const documentId of documentIds) {
1322
+ const assetId = next.armed[documentId];
1323
+ if (previous.armed[documentId] === assetId) continue;
1324
+ events.armChanged.emit({
1325
+ documentId,
1326
+ asset: assetId === void 0 ? null : next.assets[assetId] ?? null
1327
+ });
919
1328
  }
920
- case "ASSET_UPDATED": return state.assets[action.asset.id] ? {
921
- ...state,
922
- assets: {
923
- ...state.assets,
924
- [action.asset.id]: action.asset
925
- }
926
- } : state;
927
- case "ASSET_REMOVED": {
928
- const asset = state.assets[action.assetId];
929
- if (!asset) return state;
930
- const assets = { ...state.assets };
931
- delete assets[action.assetId];
932
- const library = state.libraries[asset.libraryId];
933
- return {
934
- ...state,
935
- assets,
936
- libraries: library ? {
937
- ...state.libraries,
938
- [library.id]: {
939
- ...library,
940
- assetIds: library.assetIds.filter((id) => id !== action.assetId)
941
- }
942
- } : state.libraries
943
- };
1329
+ });
1330
+ const catalog = createCatalog(ctx, services);
1331
+ const marks = createMarks(services);
1332
+ const libraries = createLibraryWrites(ctx, services);
1333
+ const assets = createAssetWrites(ctx, services, marks, libraries, catalog);
1334
+ const placement = createPlacement(ctx, services, catalog);
1335
+ return { api: composeApi("stamp", [
1336
+ settings.api,
1337
+ catalog.api,
1338
+ libraries.api,
1339
+ assets.api,
1340
+ placement.api,
1341
+ {
1342
+ onLibraryChanged: events.libraryChanged.on,
1343
+ onLibraryCreated: events.libraryCreated.on,
1344
+ onLibraryUpdated: events.libraryUpdated.on,
1345
+ onLibraryDeleted: events.libraryDeleted.on,
1346
+ onAssetCreated: events.assetCreated.on,
1347
+ onAssetUpdated: events.assetUpdated.on,
1348
+ onAssetDeleted: events.assetDeleted.on,
1349
+ onArmChanged: events.armChanged.on
944
1350
  }
945
- default: return state;
946
- }
947
- };
1351
+ ]) };
1352
+ }
948
1353
  //#endregion
949
1354
  //#region src/stamp.plugin.ts
950
1355
  /**
951
- * The stamp plugin. WORKSPACE-scoped: libraries are shared across every open
952
- * document; placement names its document explicitly (`armAsset(documentId, …)`)
953
- * and delegates to that document's annotation plugin — no hard `requires`,
954
- * because the annotation token is document-scoped and resolved lazily per
955
- * placement (a viewer without the annotation plugin fails at the arm call,
956
- * with the kernel's own missing-capability error).
1356
+ * Stamp libraries and assets. Workspace-scoped: libraries outlive any one
1357
+ * document; placement targets a document through its annotation plugin.
1358
+ * `config` is the settings the app registers, over {@link STAMP_DEFAULTS}.
957
1359
  */
958
- const stampPlugin = (config = {}) => definePlugin({
1360
+ const stampPlugin = (config) => definePlugin({
959
1361
  id: "stamp",
960
1362
  token: StampToken,
961
1363
  scope: "workspace",
962
- initialState: initialStampState,
963
- reduce: stampReducer,
964
- capability: (ctx) => createStampCapability(ctx, config)
1364
+ optional: [AnnotationToken, ActionsToken],
1365
+ state: initialStampState,
1366
+ settings: {
1367
+ defaults: STAMP_DEFAULTS,
1368
+ registered: config,
1369
+ whole: ["assetEngine"]
1370
+ },
1371
+ create: createStampController,
1372
+ inScope: (stamp, documentId) => {
1373
+ const scoped = (options) => ({
1374
+ ...options,
1375
+ documentId: options?.documentId ?? documentId
1376
+ });
1377
+ return {
1378
+ ...stamp,
1379
+ createAssetFromAnnotations: (page, refs, input, options) => stamp.createAssetFromAnnotations(page, refs, input, scoped(options)),
1380
+ armAsset: (assetId, options) => stamp.armAsset(assetId, scoped(options)),
1381
+ disarm: (target = documentId) => stamp.disarm(target),
1382
+ getArmedAsset: (target = documentId) => stamp.getArmedAsset(target),
1383
+ placeAsset: (assetId, placement, options) => stamp.placeAsset(assetId, placement, scoped(options)),
1384
+ placeAssetOnPages: (assetId, pages, placement, options) => stamp.placeAssetOnPages(assetId, pages, placement, scoped(options)),
1385
+ canPlace: (target = documentId) => stamp.canPlace(target),
1386
+ canCreateFromAnnotations: (target = documentId) => stamp.canCreateFromAnnotations(target)
1387
+ };
1388
+ }
1389
+ });
1390
+ //#endregion
1391
+ //#region src/state.ts
1392
+ /**
1393
+ * The stamps page's State table as code: what `useStampState()` returns, and
1394
+ * the same fields in every other framework. It reads the document in scope,
1395
+ * else the active one. The libraries and their stamps are not here:
1396
+ * `useStampLibraries()` and `useStampAssets()` read them, so a component that
1397
+ * only shows the armed stamp doesn't re-render when a library changes.
1398
+ */
1399
+ const stampState = defineState(StampToken, {
1400
+ read: (stamp) => ({ armedAsset: stamp.getArmedAsset() }),
1401
+ empty: { armedAsset: null }
965
1402
  });
966
1403
  //#endregion
967
1404
  //#region src/persistence.ts
968
- /** An in-memory store — tests and SSR. */
1405
+ /** An in-memory store, for tests and SSR. */
969
1406
  function memoryStampStore() {
970
1407
  const rows = /* @__PURE__ */ new Map();
971
1408
  return {
@@ -983,58 +1420,66 @@ function memoryStampStore() {
983
1420
  }
984
1421
  /**
985
1422
  * Bring every stored library back: each PDF is imported as-is (its title,
986
- * registry, and PieceInfo id come from the file — the store's key is only a
1423
+ * registry, and PieceInfo id come from the file; the store's key is only a
987
1424
  * hint). Call once at boot, before seeding defaults, so "already has
988
1425
  * libraries" means the user's own.
989
1426
  */
990
1427
  async function restoreStampLibraries(stamp, store) {
991
1428
  const restored = [];
992
1429
  for (const { id, bytes } of await store.list()) try {
993
- restored.push(await stamp.importLibraryPdf(bytes));
1430
+ restored.push((await stamp.importLibrary(bytes)).library.id);
994
1431
  } catch (error) {
995
1432
  globalThis.console?.warn(`[stamp] stored library '${id}' could not be restored:`, error);
996
1433
  }
997
1434
  return restored;
998
1435
  }
999
1436
  /**
1000
- * Keep the store in sync from now on: every canonical change writes the
1001
- * library's PDF (coalesced per library so a burst of edits saves once),
1002
- * a removal deletes it. `except` names libraries never to persist — the
1003
- * bundled default set, typically. Returns the unsubscribe.
1437
+ * Keep the store in sync from now on: every canonical change saves the
1438
+ * library's PDF, a removal deletes it. `except` names libraries never to
1439
+ * persist (the bundled default set, typically). Returns the unsubscribe;
1440
+ * work already asked for still finishes.
1441
+ *
1442
+ * Each library has one line of store work, so saves never overlap and a
1443
+ * delete never lands under a late save. The changes of one burst (one call
1444
+ * into the plugin) are one save; the changes made while a save runs are one
1445
+ * more after it.
1004
1446
  */
1005
- function persistStampLibraries(stamp, store, opts = {}) {
1006
- const except = new Set(opts.except ?? []);
1007
- const timers = /* @__PURE__ */ new Map();
1008
- const write = (libraryId) => {
1009
- timers.delete(libraryId);
1010
- const bytes = stamp.exportLibrary(libraryId);
1011
- if (!bytes) return;
1012
- store.put(libraryId, bytes).catch((error) => {
1013
- globalThis.console?.warn(`[stamp] persisting library '${libraryId}' failed:`, error);
1447
+ function persistStampLibraries(stamp, store, options = {}) {
1448
+ const except = new Set(options.except ?? []);
1449
+ /** Per library: the store step waiting or running, and the one to follow it. */
1450
+ const lines = /* @__PURE__ */ new Map();
1451
+ const run = (libraryId, step) => {
1452
+ const line = lines.get(libraryId);
1453
+ if (line) {
1454
+ if (line.started) line.next = step;
1455
+ else line.step = step;
1456
+ return;
1457
+ }
1458
+ const fresh = {
1459
+ step,
1460
+ started: false,
1461
+ next: null
1462
+ };
1463
+ lines.set(libraryId, fresh);
1464
+ Promise.resolve().then(async () => {
1465
+ fresh.started = true;
1466
+ try {
1467
+ if (fresh.step === "save") await store.put(libraryId, await stamp.exportLibrary(libraryId));
1468
+ else await store.delete(libraryId);
1469
+ } catch (error) {
1470
+ const doing = fresh.step === "save" ? "persisting" : "deleting stored";
1471
+ globalThis.console?.warn(`[stamp] ${doing} library '${libraryId}' failed:`, error);
1472
+ }
1473
+ lines.delete(libraryId);
1474
+ if (fresh.next) run(libraryId, fresh.next);
1014
1475
  });
1015
1476
  };
1016
- const off = stamp.onLibraryChanged(({ libraryId, reason }) => {
1477
+ return stamp.onLibraryChanged(({ libraryId, reason }) => {
1017
1478
  if (except.has(libraryId)) return;
1018
- const pending = timers.get(libraryId);
1019
- if (pending) clearTimeout(pending);
1020
- if (reason === "removed") {
1021
- timers.delete(libraryId);
1022
- store.delete(libraryId).catch((error) => {
1023
- globalThis.console?.warn(`[stamp] deleting stored library '${libraryId}' failed:`, error);
1024
- });
1025
- return;
1026
- }
1027
- timers.set(libraryId, setTimeout(() => write(libraryId), opts.debounceMs ?? 250));
1479
+ run(libraryId, reason === "removed" ? "delete" : "save");
1028
1480
  });
1029
- return () => {
1030
- off();
1031
- for (const [libraryId, timer] of timers) {
1032
- clearTimeout(timer);
1033
- write(libraryId);
1034
- }
1035
- };
1036
1481
  }
1037
1482
  //#endregion
1038
- export { DEFAULT_LIBRARY_KIND, STAMP_LIBRARY_PIECEINFO_APP, STAMP_PIECEINFO_APP, STAMP_PIECEINFO_VERSION, StampToken, assetIdFor, customStampName, libraryKindFromPdfName, libraryKindToPdfName, memoryStampStore, parseStampKey, persistStampLibraries, restoreStampLibraries, stampKey, stampKindToPdfName, stampLibraryPieceInfo, stampPieceInfo, stampPlugin };
1483
+ export { DEFAULT_LIBRARY_KIND, STAMP_DEFAULTS, StampToken, memoryStampStore, persistStampLibraries, restoreStampLibraries, stampPlugin, stampState };
1039
1484
 
1040
1485
  //# sourceMappingURL=index.js.map