@embedpdf/plugin-stamp 3.0.0-next.11 → 3.0.0-next.12

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,13 +1,42 @@
1
- import { t as StampToken } from "./contract-CkPaVNAJ.js";
2
- import { definePlugin } from "@embedpdf/core";
1
+ import { a as assetIdFor, c as stampKey, d as stampPieceInfo, i as STAMP_PIECEINFO_VERSION, l as stampKindToPdfName, n as STAMP_LIBRARY_PIECEINFO_APP, o as customStampName, r as STAMP_PIECEINFO_APP, s as parseStampKey, t as StampToken, u as stampLibraryPieceInfo } from "./contract-BFCBVNRX.js";
2
+ import { createEventHook, definePlugin } from "@embedpdf/core";
3
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";
4
6
  import { AnnotationToken } from "@embedpdf/plugin-annotation/contract";
5
7
  import { createFormScriptingController } from "@embedpdf/plugin-form/scripting";
8
+ //#region src/blank-library.ts
9
+ /**
10
+ * The seed of a new library: a one-page PDF whose page is UNREGISTERED — the
11
+ * shape Acrobat itself gives a fresh stamp library (its first page is a
12
+ * blank starter that no `/Names /Pages` key points at). A library must be a
13
+ * PDF from the moment it exists, and a PDF must have a page; the unregistered
14
+ * blank is that page. It is never an asset, never placed, never listed.
15
+ */
16
+ const BLANK_OBJECTS = [
17
+ "<< /Type /Catalog /Pages 2 0 R >>",
18
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
19
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"
20
+ ];
21
+ let cached = null;
22
+ function blankLibraryPdf() {
23
+ if (cached) return new Uint8Array(cached);
24
+ let body = "%PDF-1.7\n";
25
+ const offsets = [];
26
+ BLANK_OBJECTS.forEach((object, index) => {
27
+ offsets.push(body.length);
28
+ body += `${index + 1} 0 obj\n${object}\nendobj\n`;
29
+ });
30
+ const xref = body.length;
31
+ body += `xref\n0 ${BLANK_OBJECTS.length + 1}\n0000000000 65535 f \n`;
32
+ for (const offset of offsets) body += `${String(offset).padStart(10, "0")} 00000 n \n`;
33
+ body += `trailer\n<< /Size ${BLANK_OBJECTS.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
34
+ cached = new TextEncoder().encode(body);
35
+ return new Uint8Array(cached);
36
+ }
37
+ //#endregion
6
38
  //#region src/capability.ts
7
39
  const DEFAULT_PREVIEW_WIDTH = 256;
8
- const LIBRARY_PIECEINFO_APP = "EMBD_StampLibrary";
9
- const STAMP_PIECEINFO_APP = "EMBD_Stamp";
10
- const STAMP_SCHEMA_VERSION = 1;
11
40
  /** Session-unique ids. Assets are session-scoped for now (no persistence),
12
41
  * so a timestamp + counter is enough — durable ids come with the store port. */
13
42
  let seq = 0;
@@ -24,7 +53,6 @@ const entryStringArray = (entries, key) => {
24
53
  const entry = entries[key];
25
54
  return entry?.type === "string-array" ? [...entry.value] : void 0;
26
55
  };
27
- const kindToPdfName = (kind) => kind === "signature" ? "Signature" : kind === "initials" ? "Initials" : "Stamp";
28
56
  const kindFromPdfName = (name) => {
29
57
  switch (name?.toLowerCase()) {
30
58
  case "stamp": return "stamp";
@@ -33,21 +61,6 @@ const kindFromPdfName = (name) => {
33
61
  default: return;
34
62
  }
35
63
  };
36
- const metadataPatch = (id, name, kind, subject, categories) => ({
37
- Version: STAMP_SCHEMA_VERSION,
38
- Id: id,
39
- Name: name,
40
- Kind: { name: kindToPdfName(kind) },
41
- Subject: subject ?? null,
42
- Categories: categories ?? null
43
- });
44
- const libraryMetadataPatch = (id, name, categories) => ({
45
- Version: STAMP_SCHEMA_VERSION,
46
- Id: id,
47
- Name: name,
48
- Kind: { name: "StampLibrary" },
49
- Categories: categories ?? null
50
- });
51
64
  function createStampCapability(ctx, config = {}) {
52
65
  /** Binary sidecar of the serializable store: asset bytes + cached preview,
53
66
  * keyed by asset id. The reducer never sees these (kernel rule 1). */
@@ -74,16 +87,13 @@ function createStampCapability(ctx, config = {}) {
74
87
  }
75
88
  return assetEngineRef;
76
89
  };
77
- const openAssetDocument = async (bytes, identity) => {
90
+ const openAssetDocument = async (bytes) => {
78
91
  try {
79
92
  return await (await assetEngine()).open({
80
93
  kind: "bytes",
81
94
  id: uid("stamp-import"),
82
95
  bytes
83
- }, {
84
- scope: ["*"],
85
- ...identity ? { identity } : {}
86
- });
96
+ }, { scope: ["*"] });
87
97
  } catch (err) {
88
98
  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.");
89
99
  throw err;
@@ -106,94 +116,151 @@ function createStampCapability(ctx, config = {}) {
106
116
  taken.add(id);
107
117
  return id;
108
118
  };
109
- const createLooseLibrary = (name) => {
110
- const id = uid("stamp-lib");
119
+ const libraryChanged = createEventHook((error) => globalThis.console?.error("[stamp] onLibraryChanged observer failed:", error));
120
+ /** Thumbnail render of one library page (the small picker image). */
121
+ const renderThumbnail = async (handle) => imageToPreview(await handle.render.image({
122
+ viewport: {
123
+ kind: "width",
124
+ width: config.previewWidth ?? DEFAULT_PREVIEW_WIDTH
125
+ },
126
+ background: "transparent",
127
+ includeAnnotations: true,
128
+ format: "png"
129
+ }));
130
+ const createLibrary = async (name, opts) => {
131
+ const taken = new Set(Object.keys(ctx.getState().libraries));
132
+ const id = allocateId(opts?.id, "stamp-lib", taken);
133
+ const doc = await openAssetDocument(blankLibraryPdf());
134
+ let bytes;
135
+ try {
136
+ requireCanonicalServices(doc);
137
+ await doc.metadata.update({ title: name });
138
+ await doc.pieceInfo.update(STAMP_LIBRARY_PIECEINFO_APP, stampLibraryPieceInfo(id, { categories: opts?.categories }));
139
+ bytes = await doc.download();
140
+ } finally {
141
+ await doc.close();
142
+ }
143
+ libraryBinaries.set(id, bytes);
111
144
  ctx.dispatch({
112
145
  type: "LIBRARY_ADDED",
113
146
  library: {
114
147
  id,
115
148
  name,
116
- storage: "loose",
117
- assetIds: []
149
+ assetIds: [],
150
+ ...opts?.categories ? { categories: opts.categories } : {}
118
151
  }
119
152
  });
153
+ libraryChanged.emit({
154
+ libraryId: id,
155
+ reason: "created"
156
+ });
120
157
  return id;
121
158
  };
159
+ const requireCanonicalServices = (doc) => {
160
+ if (!doc.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need an asset engine with pieceInfo");
161
+ 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)");
162
+ };
122
163
  const importLibraryPdf = async (source, opts) => {
123
164
  const resolved = await resolveBinarySource(source);
124
165
  if (sniffBinaryMetadata(resolved.bytes)?.mimeType !== "application/pdf") throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] importLibraryPdf needs PDF bytes (use addAsset for a raster image)");
125
166
  const doc = await openAssetDocument(new Uint8Array(resolved.bytes));
126
167
  let imported;
127
168
  try {
128
- if (!doc.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need an asset engine with pieceInfo");
129
- const snapshot = await doc.pages.list();
130
- if (snapshot.pageCount === 0) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a canonical stamp library PDF must contain at least one page");
131
- const pages = snapshot.pages.map((layout) => {
132
- const handle = doc.page(layout.pageObjectNumber);
133
- if (!handle.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need page pieceInfo support");
134
- return {
135
- layout,
136
- handle,
137
- metadata: null
138
- };
139
- });
140
- const catalogMetadata = await doc.pieceInfo.read(LIBRARY_PIECEINFO_APP);
141
- for (const page of pages) page.metadata = await page.handle.pieceInfo.read(STAMP_PIECEINFO_APP);
169
+ requireCanonicalServices(doc);
170
+ const layout = await doc.pages.list();
171
+ if (layout.pageCount === 0) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a canonical stamp library PDF must contain at least one page");
172
+ const byPon = new Map(layout.pages.map((page) => [page.pageObjectNumber, page]));
173
+ const catalogEntries = (await doc.pieceInfo.read("EMBD_StampLibrary"))?.entries ?? {};
142
174
  const state = ctx.getState();
143
175
  const takenLibraryIds = new Set(Object.keys(state.libraries));
144
- const takenAssetIds = new Set(Object.keys(state.assets));
145
- const catalogEntries = catalogMetadata?.entries ?? {};
146
176
  const libraryId = allocateId(entryString(catalogEntries, "Id"), "stamp-lib", takenLibraryIds);
147
- const libraryName = opts?.name ?? entryString(catalogEntries, "Name") ?? "Stamps";
177
+ const docMeta = await doc.metadata.read();
178
+ const libraryName = (docMeta.title && docMeta.title.length > 0 ? docMeta.title : void 0) ?? entryString(catalogEntries, "Name") ?? opts?.name ?? "Stamps";
179
+ if (!docMeta.title) await doc.metadata.update({ title: libraryName });
148
180
  const libraryCategories = opts?.categories ?? entryStringArray(catalogEntries, "Categories");
149
- const descriptors = pages.map(({ layout, handle, metadata: pageMetadata }) => {
150
- const entries = pageMetadata?.entries ?? {};
151
- const id = allocateId(entryString(entries, "Id"), "stamp", takenAssetIds);
152
- const kind = opts?.kind ?? kindFromPdfName(entryName(entries, "Kind")) ?? "stamp";
153
- return {
154
- handle,
155
- asset: {
156
- id,
157
- libraryId,
158
- kind,
159
- name: opts?.assetName?.(layout.index) ?? entryString(entries, "Name") ?? `Stamp ${layout.index + 1}`,
160
- size: {
161
- width: layout.size.width,
162
- height: layout.size.height
163
- },
164
- format: "pdf",
165
- pageObjectNumber: layout.pageObjectNumber,
166
- subject: entryString(entries, "Subject"),
167
- categories: entryStringArray(entries, "Categories")
168
- }
169
- };
170
- });
171
- await doc.pieceInfo.update(LIBRARY_PIECEINFO_APP, libraryMetadataPatch(libraryId, libraryName, libraryCategories));
181
+ const libraryLocale = entryString(catalogEntries, "Locale");
182
+ const registry = (layout.namedPages ?? []).filter((entry) => entry.target.kind === "page");
183
+ const hidden = (layout.namedPages ?? []).filter((entry) => entry.target.kind !== "page");
184
+ 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`);
185
+ let descriptors;
186
+ if (registry.length > 0) {
187
+ const seen = /* @__PURE__ */ new Set();
188
+ descriptors = registry.map((entry) => {
189
+ const { name, label } = parseStampKey(entry.name);
190
+ if (!name) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] library '${libraryName}': empty stamp identifier in key '${entry.name}'`);
191
+ if (seen.has(name)) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] library '${libraryName}': duplicate stamp identifier '${name}'`);
192
+ seen.add(name);
193
+ const page = byPon.get(entry.target.pageObjectNumber);
194
+ return {
195
+ pageObjectNumber: page.pageObjectNumber,
196
+ index: page.index,
197
+ name,
198
+ label
199
+ };
200
+ });
201
+ descriptors.sort((a, b) => a.index - b.index);
202
+ } 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");
203
+ else {
204
+ descriptors = [];
205
+ for (const page of layout.pages) {
206
+ const v1 = (await doc.page(page.pageObjectNumber).pieceInfo?.read(STAMP_PIECEINFO_APP))?.entries;
207
+ const name = (v1 && entryString(v1, "Name")) ?? `Stamp${page.index + 1}`;
208
+ const label = (v1 && entryString(v1, "Subject")) ?? opts?.assetName?.(page.index) ?? name;
209
+ descriptors.push({
210
+ pageObjectNumber: page.pageObjectNumber,
211
+ index: page.index,
212
+ name,
213
+ label
214
+ });
215
+ }
216
+ for (const d of descriptors) await doc.pages.setName({
217
+ name: stampKey(d.name, d.label),
218
+ pageObjectNumber: d.pageObjectNumber
219
+ });
220
+ }
221
+ await doc.pieceInfo.update(STAMP_LIBRARY_PIECEINFO_APP, stampLibraryPieceInfo(libraryId, {
222
+ categories: libraryCategories,
223
+ locale: libraryLocale
224
+ }));
172
225
  const assets = [];
173
- for (const descriptor of descriptors) {
174
- const { asset, handle } = descriptor;
175
- await handle.pieceInfo.update(STAMP_PIECEINFO_APP, metadataPatch(asset.id, asset.name, asset.kind, asset.subject, asset.categories));
176
- const bytes = await doc.pages.extract([asset.pageObjectNumber]);
177
- const image = await handle.render.image({
178
- viewport: {
179
- kind: "width",
180
- width: config.previewWidth ?? DEFAULT_PREVIEW_WIDTH
226
+ for (const d of descriptors) {
227
+ const handle = doc.page(d.pageObjectNumber);
228
+ if (!handle.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need page pieceInfo support");
229
+ const entries = (await handle.pieceInfo.read("EMBD_Stamp"))?.entries ?? {};
230
+ const kind = opts?.kind ?? kindFromPdfName(entryName(entries, "Kind")) ?? "stamp";
231
+ const subject = entryString(entries, "SubjectOverride");
232
+ const categories = entryStringArray(entries, "Categories");
233
+ const page = byPon.get(d.pageObjectNumber);
234
+ const asset = {
235
+ id: assetIdFor(libraryId, d.name),
236
+ libraryId,
237
+ kind,
238
+ name: d.name,
239
+ label: d.label,
240
+ size: {
241
+ width: page.size.width,
242
+ height: page.size.height
181
243
  },
182
- background: "transparent",
183
- includeAnnotations: true,
184
- format: "png"
185
- });
244
+ pageObjectNumber: d.pageObjectNumber,
245
+ ...subject !== void 0 ? { subject } : {},
246
+ ...categories !== void 0 ? { categories } : {}
247
+ };
248
+ await handle.pieceInfo.update(STAMP_PIECEINFO_APP, stampPieceInfo(kind, {
249
+ subject,
250
+ categories
251
+ }));
252
+ const bytes = await doc.pages.extract([d.pageObjectNumber]);
186
253
  assets.push({
187
254
  asset,
188
255
  bytes,
189
- preview: imageToPreview(image)
256
+ preview: await renderThumbnail(handle)
190
257
  });
191
258
  }
192
259
  imported = {
193
260
  library: {
194
261
  id: libraryId,
195
262
  name: libraryName,
196
- storage: "canonical-pdf",
263
+ ...libraryLocale ? { locale: libraryLocale } : {},
197
264
  categories: libraryCategories,
198
265
  assetIds: []
199
266
  },
@@ -218,15 +285,26 @@ function createStampCapability(ctx, config = {}) {
218
285
  asset
219
286
  });
220
287
  }
288
+ libraryChanged.emit({
289
+ libraryId: imported.library.id,
290
+ reason: "imported"
291
+ });
221
292
  return imported.library.id;
222
293
  };
223
294
  const addAsset = async (input) => {
224
- const targetLibrary = input.libraryId ? ctx.getState().libraries[input.libraryId] ?? null : null;
225
- if (input.libraryId && !targetLibrary) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown library '${input.libraryId}'`);
295
+ if (input.libraryId && !ctx.getState().libraries[input.libraryId]) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown library '${input.libraryId}'`);
226
296
  const resolved = await resolveBinarySource(input.source);
227
297
  const meta = sniffBinaryMetadata(resolved.bytes);
228
298
  if (!meta) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] asset source must be PNG, JPEG, or single-page PDF bytes");
229
299
  const isPdf = meta.mimeType === "application/pdf";
300
+ const name = input.name ?? customStampName();
301
+ const label = input.label ?? name;
302
+ if (name.length === 0 || name.includes("=")) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] invalid stamp identifier '${name}': must be non-empty and contain no '='`);
303
+ const rasterSize = input.size ?? ("width" in meta ? {
304
+ width: meta.width,
305
+ height: meta.height
306
+ } : null);
307
+ 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)");
230
308
  let suppliedPreview = null;
231
309
  if (input.preview) {
232
310
  const preview = await resolveBinarySource(input.preview);
@@ -235,111 +313,106 @@ function createStampCapability(ctx, config = {}) {
235
313
  mimeType: preview.mimeType ?? "image/png"
236
314
  };
237
315
  }
238
- if (targetLibrary?.storage === "canonical-pdf") {
239
- if (!isPdf) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] raster assets cannot be appended to a canonical PDF library");
240
- return mutateLibrary(targetLibrary.id, async () => {
241
- const liveLibrary = ctx.getState().libraries[targetLibrary.id];
242
- if (liveLibrary?.storage !== "canonical-pdf") throw new EngineError(EngineErrorCode.NotFound, `[stamp] canonical library '${targetLibrary.id}' no longer exists`);
243
- const canonicalBytes = libraryBinaries.get(liveLibrary.id);
244
- if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${liveLibrary.id}'`);
245
- const assetId = uid("stamp");
246
- const doc = await openAssetDocument(canonicalBytes);
247
- let appended;
248
- try {
316
+ const libraryId = input.libraryId ?? await createLibrary(label);
317
+ return mutateLibrary(libraryId, async () => {
318
+ const liveLibrary = ctx.getState().libraries[libraryId];
319
+ if (!liveLibrary) throw new EngineError(EngineErrorCode.NotFound, `[stamp] library '${libraryId}' no longer exists`);
320
+ const assetId = assetIdFor(liveLibrary.id, name);
321
+ if (ctx.getState().assets[assetId]) throw new EngineError(EngineErrorCode.InvalidArg, `[stamp] library '${liveLibrary.name}' already has a stamp named '${name}'`);
322
+ const canonicalBytes = libraryBinaries.get(liveLibrary.id);
323
+ if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${liveLibrary.id}'`);
324
+ const doc = await openAssetDocument(canonicalBytes);
325
+ let appended;
326
+ try {
327
+ requireCanonicalServices(doc);
328
+ let pageObjectNumber;
329
+ if (isPdf) {
249
330
  const result = await doc.pages.insert(new Uint8Array(resolved.bytes));
250
- if (result.insertedPageObjectNumbers.length !== 1) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a canonical library asset must be a single-page PDF");
251
- const pageObjectNumber = result.insertedPageObjectNumbers[0];
252
- const layout = result.layout.pages.find((candidate) => candidate.pageObjectNumber === pageObjectNumber);
253
- const page = doc.page(pageObjectNumber);
254
- if (!layout || !page.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need page layout and pieceInfo support");
255
- const asset = {
256
- id: assetId,
257
- libraryId: liveLibrary.id,
258
- kind: input.kind ?? "stamp",
259
- name: input.name,
260
- size: {
261
- width: layout.size.width,
262
- height: layout.size.height
263
- },
264
- format: "pdf",
265
- pageObjectNumber,
266
- subject: input.subject,
267
- categories: input.categories
268
- };
269
- await page.pieceInfo.update(STAMP_PIECEINFO_APP, metadataPatch(asset.id, asset.name, asset.kind, asset.subject, asset.categories));
270
- const bytes = await doc.pages.extract([pageObjectNumber]);
271
- let preview = suppliedPreview;
272
- if (!preview) preview = imageToPreview(await page.render.image({
273
- viewport: {
274
- kind: "width",
275
- width: config.previewWidth ?? DEFAULT_PREVIEW_WIDTH
331
+ if (result.insertedPageObjectNumbers.length !== 1) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a library asset must be a single-page PDF");
332
+ pageObjectNumber = result.insertedPageObjectNumbers[0];
333
+ } else {
334
+ if (!doc.pages.flatten) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] raster assets need an asset engine with pages.flatten");
335
+ pageObjectNumber = (await doc.pages.insertBlank({ size: rasterSize })).insertedPageObjectNumbers[0];
336
+ await doc.page(pageObjectNumber).annotations.create({
337
+ subtype: "stamp",
338
+ rect: {
339
+ left: 0,
340
+ bottom: 0,
341
+ right: rasterSize.width,
342
+ top: rasterSize.height
276
343
  },
277
- background: "transparent",
278
- includeAnnotations: true,
279
- format: "png"
280
- }));
281
- appended = {
282
- asset,
283
- bytes,
284
- preview,
285
- canonical: await doc.download()
286
- };
287
- } finally {
288
- await doc.close();
344
+ source: new Uint8Array(resolved.bytes),
345
+ fit: "fill"
346
+ });
347
+ 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");
289
348
  }
290
- libraryBinaries.set(liveLibrary.id, appended.canonical);
291
- binaries.set(appended.asset.id, {
292
- bytes: appended.bytes,
293
- preview: appended.preview
294
- });
295
- ctx.dispatch({
296
- type: "ASSET_ADDED",
297
- asset: appended.asset
349
+ const layout = (await doc.pages.list()).pages.find((candidate) => candidate.pageObjectNumber === pageObjectNumber);
350
+ const page = doc.page(pageObjectNumber);
351
+ if (!layout || !page.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need page layout and pieceInfo support");
352
+ await doc.pages.setName({
353
+ name: stampKey(name, label),
354
+ pageObjectNumber
298
355
  });
299
- return appended.asset.id;
300
- });
301
- }
302
- const size = input.size ?? ("width" in meta ? {
303
- width: meta.width,
304
- height: meta.height
305
- } : null);
306
- if (!size) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a PDF asset needs `size` (its page size in points) — PDF bytes carry no sniffable dimensions");
307
- let preview = suppliedPreview;
308
- if (!preview && !isPdf) preview = {
309
- bytes: new Uint8Array(resolved.bytes),
310
- mimeType: meta.mimeType
311
- };
312
- const commitLooseAsset = (libraryId) => {
313
- const asset = {
314
- id: uid("stamp"),
315
- libraryId,
316
- kind: input.kind ?? "stamp",
317
- name: input.name,
318
- size,
319
- format: isPdf ? "pdf" : meta.mimeType === "image/png" ? "png" : "jpeg",
320
- subject: input.subject,
321
- categories: input.categories
322
- };
323
- binaries.set(asset.id, {
324
- bytes: new Uint8Array(resolved.bytes),
325
- preview
356
+ const asset = {
357
+ id: assetId,
358
+ libraryId: liveLibrary.id,
359
+ kind: input.kind ?? "stamp",
360
+ name,
361
+ label,
362
+ size: {
363
+ width: layout.size.width,
364
+ height: layout.size.height
365
+ },
366
+ pageObjectNumber,
367
+ ...input.subject !== void 0 ? { subject: input.subject } : {},
368
+ ...input.categories !== void 0 ? { categories: input.categories } : {}
369
+ };
370
+ await page.pieceInfo.update(STAMP_PIECEINFO_APP, stampPieceInfo(asset.kind, {
371
+ subject: asset.subject,
372
+ categories: asset.categories
373
+ }));
374
+ appended = {
375
+ asset,
376
+ bytes: await doc.pages.extract([pageObjectNumber]),
377
+ preview: suppliedPreview ?? await renderThumbnail(page),
378
+ canonical: await doc.download()
379
+ };
380
+ } finally {
381
+ await doc.close();
382
+ }
383
+ libraryBinaries.set(liveLibrary.id, appended.canonical);
384
+ binaries.set(appended.asset.id, {
385
+ bytes: appended.bytes,
386
+ preview: appended.preview
326
387
  });
327
388
  ctx.dispatch({
328
389
  type: "ASSET_ADDED",
329
- asset
390
+ asset: appended.asset
330
391
  });
331
- return asset.id;
332
- };
333
- if (!targetLibrary) return commitLooseAsset(createLooseLibrary(input.name));
334
- return mutateLibrary(targetLibrary.id, async () => {
335
- const liveLibrary = ctx.getState().libraries[targetLibrary.id];
336
- if (liveLibrary?.storage !== "loose") throw new EngineError(EngineErrorCode.NotFound, `[stamp] loose library '${targetLibrary.id}' no longer exists`);
337
- return commitLooseAsset(liveLibrary.id);
392
+ libraryChanged.emit({
393
+ libraryId: liveLibrary.id,
394
+ reason: "asset-added"
395
+ });
396
+ return appended.asset.id;
397
+ });
398
+ };
399
+ const addAssetFromAnnotations = async (documentId, pageObjectNumber, refs, input) => {
400
+ const doc = ctx.documentHandle(documentId);
401
+ if (!doc) throw new EngineError(EngineErrorCode.NotFound, `[stamp] target document '${documentId}' is not open`);
402
+ const page = doc.page(pageObjectNumber);
403
+ if (!page.annotations.exportAppearance) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] this document's engine cannot export annotation appearances");
404
+ const source = await page.annotations.exportAppearance(refs);
405
+ return addAsset({
406
+ ...input,
407
+ source
338
408
  });
339
409
  };
340
410
  const dropLibrary = (id) => {
341
411
  const library = ctx.getState().libraries[id];
342
- if (library) for (const assetId of library.assetIds) binaries.delete(assetId);
412
+ if (library) for (const assetId of library.assetIds) {
413
+ binaries.delete(assetId);
414
+ ghostRenders.delete(assetId);
415
+ }
343
416
  libraryBinaries.delete(id);
344
417
  ctx.dispatch({
345
418
  type: "LIBRARY_REMOVED",
@@ -353,54 +426,103 @@ function createStampCapability(ctx, config = {}) {
353
426
  const asset = ctx.getState().assets[id];
354
427
  if (!asset) return;
355
428
  const library = ctx.getState().libraries[asset.libraryId];
356
- if (library?.storage === "canonical-pdf") {
357
- if (library.assetIds.length === 1) {
358
- dropLibrary(library.id);
359
- return;
360
- }
361
- if (asset.pageObjectNumber === void 0) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical asset '${id}' has no page object number`);
362
- const canonicalBytes = libraryBinaries.get(library.id);
363
- if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${library.id}'`);
364
- const doc = await openAssetDocument(canonicalBytes);
365
- let rewritten;
366
- try {
367
- await doc.pages.delete([asset.pageObjectNumber]);
368
- rewritten = await doc.download();
369
- } finally {
370
- await doc.close();
371
- }
372
- libraryBinaries.set(library.id, rewritten);
429
+ if (!library) return;
430
+ const canonicalBytes = libraryBinaries.get(library.id);
431
+ if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${library.id}'`);
432
+ const doc = await openAssetDocument(canonicalBytes);
433
+ let rewritten;
434
+ try {
435
+ await doc.pages.delete([asset.pageObjectNumber]);
436
+ rewritten = await doc.download();
437
+ } finally {
438
+ await doc.close();
373
439
  }
440
+ libraryBinaries.set(library.id, rewritten);
374
441
  binaries.delete(id);
442
+ ghostRenders.delete(id);
375
443
  ctx.dispatch({
376
444
  type: "ASSET_REMOVED",
377
445
  assetId: id
378
446
  });
447
+ libraryChanged.emit({
448
+ libraryId: library.id,
449
+ reason: "asset-removed"
450
+ });
379
451
  });
380
452
  };
381
- const removeLibrary = (id) => mutateLibrary(id, async () => dropLibrary(id));
382
- const surfaceScriptingResult = (result) => {
383
- try {
384
- for (const effect of result.uiEffects) config.scripting?.onUiEffect?.(effect);
385
- for (const diagnostic of result.diagnostics) config.scripting?.onDiagnostic?.(diagnostic);
386
- if (result.error) config.scripting?.onError?.(result.error);
387
- } catch (error) {
388
- globalThis.console?.error("[stamp] scripting observer failed:", error);
389
- }
453
+ const removeLibrary = (id) => mutateLibrary(id, async () => {
454
+ const existed = ctx.getState().libraries[id] !== void 0;
455
+ dropLibrary(id);
456
+ if (existed) libraryChanged.emit({
457
+ libraryId: id,
458
+ reason: "removed"
459
+ });
460
+ });
461
+ const updateAsset = async (id, patch) => {
462
+ const initial = ctx.getState().assets[id];
463
+ if (!initial) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown asset '${id}'`);
464
+ return mutateLibrary(initial.libraryId, async () => {
465
+ const asset = ctx.getState().assets[id];
466
+ const library = asset ? ctx.getState().libraries[asset.libraryId] : void 0;
467
+ if (!asset || !library) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown asset '${id}'`);
468
+ const next = {
469
+ ...asset,
470
+ ...patch.label !== void 0 ? { label: patch.label } : {},
471
+ ...patch.categories !== void 0 ? { categories: patch.categories } : {}
472
+ };
473
+ if (patch.subject === null) delete next.subject;
474
+ else if (patch.subject !== void 0) next.subject = patch.subject;
475
+ const canonicalBytes = libraryBinaries.get(library.id);
476
+ if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${library.id}'`);
477
+ const doc = await openAssetDocument(canonicalBytes);
478
+ let rewritten;
479
+ try {
480
+ requireCanonicalServices(doc);
481
+ if (next.label !== asset.label) await doc.pages.setName({
482
+ name: stampKey(asset.name, next.label),
483
+ pageObjectNumber: asset.pageObjectNumber,
484
+ replace: stampKey(asset.name, asset.label)
485
+ });
486
+ await doc.page(asset.pageObjectNumber).pieceInfo?.update(STAMP_PIECEINFO_APP, stampPieceInfo(next.kind, {
487
+ subject: next.subject,
488
+ categories: next.categories
489
+ }));
490
+ rewritten = await doc.download();
491
+ } finally {
492
+ await doc.close();
493
+ }
494
+ libraryBinaries.set(library.id, rewritten);
495
+ ctx.dispatch({
496
+ type: "ASSET_UPDATED",
497
+ asset: next
498
+ });
499
+ libraryChanged.emit({
500
+ libraryId: library.id,
501
+ reason: "asset-updated"
502
+ });
503
+ });
390
504
  };
391
505
  /**
392
506
  * Form-backed stamps are executable templates only until placement. Work
393
507
  * on an isolated copy so neither the canonical library nor its reusable
394
508
  * per-page extraction becomes target/user/time specific.
509
+ *
510
+ * The realm comes from the TARGET document's actions plugin — a detached
511
+ * realm under that document's policy and environment (WP4: its own
512
+ * sandbox, none of the viewer realm's globals). No actions plugin, or
513
+ * scripting off, or `dynamic: false` → the template is armed as-is.
395
514
  */
396
515
  const materializeForPlacement = async (documentId, asset, bin) => {
397
- if (asset.format !== "pdf" || !config.scripting?.enabled) return bin;
398
- const targetDoc = ctx.documentHandle(documentId);
516
+ if (config.dynamic === false) return bin;
517
+ const actions = ctx.tryForDocument(ActionsToken, documentId);
518
+ const mintRealm = actions?.createDetachedScriptRealm;
519
+ if (!actions || !mintRealm) return bin;
399
520
  const targetMeta = ctx.core().documents[documentId] ?? null;
400
- if (!targetDoc || !targetMeta) throw new EngineError(EngineErrorCode.NotFound, `[stamp] target document '${documentId}' is not open`);
521
+ if (!targetMeta) throw new EngineError(EngineErrorCode.NotFound, `[stamp] target document '${documentId}' is not open`);
401
522
  const canonicalBytes = libraryBinaries.get(asset.libraryId);
402
523
  const sourceBytes = canonicalBytes ?? bin.bytes;
403
- const doc = await openAssetDocument(new Uint8Array(sourceBytes), targetDoc.security.identity);
524
+ const doc = await openAssetDocument(new Uint8Array(sourceBytes));
525
+ let realm = null;
404
526
  let scripting = null;
405
527
  try {
406
528
  const layout = await doc.pages.list();
@@ -408,13 +530,24 @@ function createStampCapability(ctx, config = {}) {
408
530
  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");
409
531
  if (!(await doc.forms.list()).fields.some((field) => field.widgets.some(({ pageObjectNumber }) => pageObjectNumber === selectedPage.pageObjectNumber))) return bin;
410
532
  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");
533
+ realm = mintRealm.call(actions, {
534
+ doc,
535
+ document: () => targetMeta,
536
+ bootSources: async () => {
537
+ return (doc.actions ? await doc.actions.read() : null)?.nameTreeScripts.map(({ action }) => javaScriptProgramFromActionTree(action)) ?? [];
538
+ }
539
+ });
411
540
  scripting = createFormScriptingController({
412
541
  doc,
413
542
  document: () => targetMeta,
414
- config: config.scripting
543
+ transaction: realm.transaction.bind(realm),
544
+ budget: realm.budget
415
545
  });
416
546
  const result = await scripting.recalculate();
417
- surfaceScriptingResult(result);
547
+ actions.surfaceScriptCommit(result, {
548
+ origin: "user",
549
+ realm: "detached"
550
+ });
418
551
  if (result.status === "failed") throw new EngineError(EngineErrorCode.Unknown, `[stamp] dynamic stamp scripting failed: ${result.error?.message ?? "native form effect failed"}`);
419
552
  const pageObjectNumber = selectedPage.pageObjectNumber;
420
553
  const failed = (await doc.pages.flatten([pageObjectNumber], "display")).results.find(({ status }) => status === "failed" || status === "skipped");
@@ -435,6 +568,7 @@ function createStampCapability(ctx, config = {}) {
435
568
  };
436
569
  } finally {
437
570
  scripting?.dispose();
571
+ realm?.dispose();
438
572
  await doc.close();
439
573
  }
440
574
  };
@@ -444,17 +578,72 @@ function createStampCapability(ctx, config = {}) {
444
578
  if (!asset || !bin) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown asset '${assetId}'`);
445
579
  const placement = await materializeForPlacement(documentId, asset, bin);
446
580
  await ctx.forDocument(AnnotationToken, documentId).armStamp({
447
- source: placement.bytes,
448
- preview: placement.preview ? {
449
- data: placement.preview.bytes,
450
- mimeType: placement.preview.mimeType
451
- } : void 0,
452
- intrinsicSize: asset.size,
581
+ ...stampPayload(asset, placement, placement !== bin),
453
582
  targetWidth: opts?.targetWidth
454
583
  });
455
584
  };
585
+ /**
586
+ * Ghost renders, one per size bucket per asset, rendered lazily on the
587
+ * asset engine the first time the ghost is shown at that size and kept
588
+ * until the asset goes away. Materialized (dynamic) placements are
589
+ * user/time specific and cache only for their own arm (`cacheKey` null).
590
+ */
591
+ const ghostRenders = /* @__PURE__ */ new Map();
592
+ const renderGhost = async (bytes, devicePixelWidth) => {
593
+ const doc = await openAssetDocument(new Uint8Array(bytes));
594
+ try {
595
+ const page = (await doc.pages.list()).pages[0];
596
+ if (!page) return null;
597
+ return imageToPreview(await doc.page(page.pageObjectNumber).render.image({
598
+ viewport: {
599
+ kind: "width",
600
+ width: devicePixelWidth
601
+ },
602
+ background: "transparent",
603
+ includeAnnotations: true,
604
+ format: "png"
605
+ }));
606
+ } finally {
607
+ await doc.close();
608
+ }
609
+ };
610
+ /** A resolution-aware ghost source for placement bytes: the page renders
611
+ * at the requested width, cached per bucket under `cacheKey` when given. */
612
+ const ghostProvider = (bytes, fallback, cacheKey) => {
613
+ const local = cacheKey ? ghostRenders.get(cacheKey) ?? /* @__PURE__ */ new Map() : /* @__PURE__ */ new Map();
614
+ if (cacheKey) ghostRenders.set(cacheKey, local);
615
+ return (devicePixelWidth) => {
616
+ let pending = local.get(devicePixelWidth);
617
+ if (!pending) {
618
+ pending = renderGhost(bytes, devicePixelWidth).catch((error) => {
619
+ globalThis.console?.warn("[stamp] ghost render failed, using the thumbnail:", error);
620
+ return fallback;
621
+ });
622
+ local.set(devicePixelWidth, pending);
623
+ }
624
+ return pending;
625
+ };
626
+ };
627
+ /** The one payload both entry points hand the annotation plugin: artwork,
628
+ * a resolution-aware ghost, true size, and the identity a placement
629
+ * writes (`/Name` = identifier, `/Subj` = the subject override or label). */
630
+ const stampPayload = (asset, placement, materialized) => ({
631
+ source: placement.bytes,
632
+ preview: ghostProvider(placement.bytes, placement.preview, materialized ? null : asset.id),
633
+ intrinsicSize: asset.size,
634
+ name: asset.name,
635
+ subject: asset.subject ?? asset.label
636
+ });
637
+ const placeAsset = async (documentId, assetId, placement) => {
638
+ const asset = ctx.getState().assets[assetId];
639
+ const bin = binaries.get(assetId);
640
+ if (!asset || !bin) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown asset '${assetId}'`);
641
+ const materialized = await materializeForPlacement(documentId, asset, bin);
642
+ return ctx.forDocument(AnnotationToken, documentId).placeStamp(stampPayload(asset, materialized, materialized !== bin), placement);
643
+ };
456
644
  ctx.cleanup(() => {
457
645
  binaries.clear();
646
+ ghostRenders.clear();
458
647
  libraryBinaries.clear();
459
648
  libraryMutationTails.clear();
460
649
  const ownedAssetEngine = typeof config.assetEngine === "function" ? assetEngineRef : null;
@@ -478,12 +667,17 @@ function createStampCapability(ctx, config = {}) {
478
667
  asset: (id) => ctx.getState().assets[id] ?? null,
479
668
  assetPreview: (id) => binaries.get(id)?.preview ?? null,
480
669
  assetBytes: (id) => binaries.get(id)?.bytes ?? null,
481
- libraryBytes: (id) => libraryBinaries.get(id) ?? null,
670
+ exportLibrary: (id) => libraryBinaries.get(id) ?? null,
671
+ onLibraryChanged: libraryChanged.on,
672
+ createLibrary,
482
673
  importLibraryPdf,
483
674
  addAsset,
675
+ addAssetFromAnnotations,
676
+ updateAsset,
484
677
  removeAsset,
485
678
  removeLibrary,
486
679
  armAsset,
680
+ placeAsset,
487
681
  disarm: (documentId) => ctx.forDocument(AnnotationToken, documentId).disarmStamp()
488
682
  };
489
683
  }
@@ -538,6 +732,13 @@ const stampReducer = (state, action) => {
538
732
  }
539
733
  };
540
734
  }
735
+ case "ASSET_UPDATED": return state.assets[action.asset.id] ? {
736
+ ...state,
737
+ assets: {
738
+ ...state.assets,
739
+ [action.asset.id]: action.asset
740
+ }
741
+ } : state;
541
742
  case "ASSET_REMOVED": {
542
743
  const asset = state.assets[action.assetId];
543
744
  if (!asset) return state;
@@ -578,6 +779,77 @@ const stampPlugin = (config = {}) => definePlugin({
578
779
  capability: (ctx) => createStampCapability(ctx, config)
579
780
  });
580
781
  //#endregion
581
- export { StampToken, stampPlugin };
782
+ //#region src/persistence.ts
783
+ /** An in-memory store — tests and SSR. */
784
+ function memoryStampStore() {
785
+ const rows = /* @__PURE__ */ new Map();
786
+ return {
787
+ list: async () => [...rows.entries()].map(([id, bytes]) => ({
788
+ id,
789
+ bytes
790
+ })),
791
+ put: async (id, bytes) => {
792
+ rows.set(id, new Uint8Array(bytes));
793
+ },
794
+ delete: async (id) => {
795
+ rows.delete(id);
796
+ }
797
+ };
798
+ }
799
+ /**
800
+ * Bring every stored library back: each PDF is imported as-is (its title,
801
+ * registry, and PieceInfo id come from the file — the store's key is only a
802
+ * hint). Call once at boot, before seeding defaults, so "already has
803
+ * libraries" means the user's own.
804
+ */
805
+ async function restoreStampLibraries(stamp, store) {
806
+ const restored = [];
807
+ for (const { id, bytes } of await store.list()) try {
808
+ restored.push(await stamp.importLibraryPdf(bytes));
809
+ } catch (error) {
810
+ globalThis.console?.warn(`[stamp] stored library '${id}' could not be restored:`, error);
811
+ }
812
+ return restored;
813
+ }
814
+ /**
815
+ * Keep the store in sync from now on: every canonical change writes the
816
+ * library's PDF (coalesced per library so a burst of edits saves once),
817
+ * a removal deletes it. `except` names libraries never to persist — the
818
+ * bundled default set, typically. Returns the unsubscribe.
819
+ */
820
+ function persistStampLibraries(stamp, store, opts = {}) {
821
+ const except = new Set(opts.except ?? []);
822
+ const timers = /* @__PURE__ */ new Map();
823
+ const write = (libraryId) => {
824
+ timers.delete(libraryId);
825
+ const bytes = stamp.exportLibrary(libraryId);
826
+ if (!bytes) return;
827
+ store.put(libraryId, bytes).catch((error) => {
828
+ globalThis.console?.warn(`[stamp] persisting library '${libraryId}' failed:`, error);
829
+ });
830
+ };
831
+ const off = stamp.onLibraryChanged(({ libraryId, reason }) => {
832
+ if (except.has(libraryId)) return;
833
+ const pending = timers.get(libraryId);
834
+ if (pending) clearTimeout(pending);
835
+ if (reason === "removed") {
836
+ timers.delete(libraryId);
837
+ store.delete(libraryId).catch((error) => {
838
+ globalThis.console?.warn(`[stamp] deleting stored library '${libraryId}' failed:`, error);
839
+ });
840
+ return;
841
+ }
842
+ timers.set(libraryId, setTimeout(() => write(libraryId), opts.debounceMs ?? 250));
843
+ });
844
+ return () => {
845
+ off();
846
+ for (const [libraryId, timer] of timers) {
847
+ clearTimeout(timer);
848
+ write(libraryId);
849
+ }
850
+ };
851
+ }
852
+ //#endregion
853
+ export { STAMP_LIBRARY_PIECEINFO_APP, STAMP_PIECEINFO_APP, STAMP_PIECEINFO_VERSION, StampToken, assetIdFor, customStampName, memoryStampStore, parseStampKey, persistStampLibraries, restoreStampLibraries, stampKey, stampKindToPdfName, stampLibraryPieceInfo, stampPieceInfo, stampPlugin };
582
854
 
583
855
  //# sourceMappingURL=index.js.map