@embedpdf/plugin-stamp 3.0.0-next.10 → 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,12 +1,42 @@
1
- import { createCapabilityToken, 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";
2
3
  import { EngineError, EngineErrorCode, resolveBinarySource, sniffBinaryMetadata } from "@embedpdf/engine-core/runtime";
3
- import { AnnotationToken } from "@embedpdf/plugin-annotation";
4
- import { createFormScriptingController } from "@embedpdf/plugin-form";
4
+ import { javaScriptProgramFromActionTree } from "@embedpdf/core-acrojs";
5
+ import { ActionsToken } from "@embedpdf/plugin-actions/contract/host";
6
+ import { AnnotationToken } from "@embedpdf/plugin-annotation/contract";
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
5
38
  //#region src/capability.ts
6
39
  const DEFAULT_PREVIEW_WIDTH = 256;
7
- const LIBRARY_PIECEINFO_APP = "EMBD_StampLibrary";
8
- const STAMP_PIECEINFO_APP = "EMBD_Stamp";
9
- const STAMP_SCHEMA_VERSION = 1;
10
40
  /** Session-unique ids. Assets are session-scoped for now (no persistence),
11
41
  * so a timestamp + counter is enough — durable ids come with the store port. */
12
42
  let seq = 0;
@@ -23,7 +53,6 @@ const entryStringArray = (entries, key) => {
23
53
  const entry = entries[key];
24
54
  return entry?.type === "string-array" ? [...entry.value] : void 0;
25
55
  };
26
- const kindToPdfName = (kind) => kind === "signature" ? "Signature" : kind === "initials" ? "Initials" : "Stamp";
27
56
  const kindFromPdfName = (name) => {
28
57
  switch (name?.toLowerCase()) {
29
58
  case "stamp": return "stamp";
@@ -32,21 +61,6 @@ const kindFromPdfName = (name) => {
32
61
  default: return;
33
62
  }
34
63
  };
35
- const metadataPatch = (id, name, kind, subject, categories) => ({
36
- Version: STAMP_SCHEMA_VERSION,
37
- Id: id,
38
- Name: name,
39
- Kind: { name: kindToPdfName(kind) },
40
- Subject: subject ?? null,
41
- Categories: categories ?? null
42
- });
43
- const libraryMetadataPatch = (id, name, categories) => ({
44
- Version: STAMP_SCHEMA_VERSION,
45
- Id: id,
46
- Name: name,
47
- Kind: { name: "StampLibrary" },
48
- Categories: categories ?? null
49
- });
50
64
  function createStampCapability(ctx, config = {}) {
51
65
  /** Binary sidecar of the serializable store: asset bytes + cached preview,
52
66
  * keyed by asset id. The reducer never sees these (kernel rule 1). */
@@ -73,16 +87,13 @@ function createStampCapability(ctx, config = {}) {
73
87
  }
74
88
  return assetEngineRef;
75
89
  };
76
- const openAssetDocument = async (bytes, identity) => {
90
+ const openAssetDocument = async (bytes) => {
77
91
  try {
78
92
  return await (await assetEngine()).open({
79
93
  kind: "bytes",
80
94
  id: uid("stamp-import"),
81
95
  bytes
82
- }, {
83
- scope: ["*"],
84
- ...identity ? { identity } : {}
85
- });
96
+ }, { scope: ["*"] });
86
97
  } catch (err) {
87
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.");
88
99
  throw err;
@@ -105,94 +116,151 @@ function createStampCapability(ctx, config = {}) {
105
116
  taken.add(id);
106
117
  return id;
107
118
  };
108
- const createLooseLibrary = (name) => {
109
- 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);
110
144
  ctx.dispatch({
111
145
  type: "LIBRARY_ADDED",
112
146
  library: {
113
147
  id,
114
148
  name,
115
- storage: "loose",
116
- assetIds: []
149
+ assetIds: [],
150
+ ...opts?.categories ? { categories: opts.categories } : {}
117
151
  }
118
152
  });
153
+ libraryChanged.emit({
154
+ libraryId: id,
155
+ reason: "created"
156
+ });
119
157
  return id;
120
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
+ };
121
163
  const importLibraryPdf = async (source, opts) => {
122
164
  const resolved = await resolveBinarySource(source);
123
165
  if (sniffBinaryMetadata(resolved.bytes)?.mimeType !== "application/pdf") throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] importLibraryPdf needs PDF bytes (use addAsset for a raster image)");
124
166
  const doc = await openAssetDocument(new Uint8Array(resolved.bytes));
125
167
  let imported;
126
168
  try {
127
- if (!doc.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need an asset engine with pieceInfo");
128
- const snapshot = await doc.pages.list();
129
- if (snapshot.pageCount === 0) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a canonical stamp library PDF must contain at least one page");
130
- const pages = snapshot.pages.map((layout) => {
131
- const handle = doc.page(layout.pageObjectNumber);
132
- if (!handle.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need page pieceInfo support");
133
- return {
134
- layout,
135
- handle,
136
- metadata: null
137
- };
138
- });
139
- const catalogMetadata = await doc.pieceInfo.read(LIBRARY_PIECEINFO_APP);
140
- 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 ?? {};
141
174
  const state = ctx.getState();
142
175
  const takenLibraryIds = new Set(Object.keys(state.libraries));
143
- const takenAssetIds = new Set(Object.keys(state.assets));
144
- const catalogEntries = catalogMetadata?.entries ?? {};
145
176
  const libraryId = allocateId(entryString(catalogEntries, "Id"), "stamp-lib", takenLibraryIds);
146
- 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 });
147
180
  const libraryCategories = opts?.categories ?? entryStringArray(catalogEntries, "Categories");
148
- const descriptors = pages.map(({ layout, handle, metadata: pageMetadata }) => {
149
- const entries = pageMetadata?.entries ?? {};
150
- const id = allocateId(entryString(entries, "Id"), "stamp", takenAssetIds);
151
- const kind = opts?.kind ?? kindFromPdfName(entryName(entries, "Kind")) ?? "stamp";
152
- return {
153
- handle,
154
- asset: {
155
- id,
156
- libraryId,
157
- kind,
158
- name: opts?.assetName?.(layout.index) ?? entryString(entries, "Name") ?? `Stamp ${layout.index + 1}`,
159
- size: {
160
- width: layout.size.width,
161
- height: layout.size.height
162
- },
163
- format: "pdf",
164
- pageObjectNumber: layout.pageObjectNumber,
165
- subject: entryString(entries, "Subject"),
166
- categories: entryStringArray(entries, "Categories")
167
- }
168
- };
169
- });
170
- 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
+ }));
171
225
  const assets = [];
172
- for (const descriptor of descriptors) {
173
- const { asset, handle } = descriptor;
174
- await handle.pieceInfo.update(STAMP_PIECEINFO_APP, metadataPatch(asset.id, asset.name, asset.kind, asset.subject, asset.categories));
175
- const bytes = await doc.pages.extract([asset.pageObjectNumber]);
176
- const image = await handle.render.image({
177
- viewport: {
178
- kind: "width",
179
- 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
180
243
  },
181
- background: "transparent",
182
- includeAnnotations: true,
183
- format: "png"
184
- });
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]);
185
253
  assets.push({
186
254
  asset,
187
255
  bytes,
188
- preview: imageToPreview(image)
256
+ preview: await renderThumbnail(handle)
189
257
  });
190
258
  }
191
259
  imported = {
192
260
  library: {
193
261
  id: libraryId,
194
262
  name: libraryName,
195
- storage: "canonical-pdf",
263
+ ...libraryLocale ? { locale: libraryLocale } : {},
196
264
  categories: libraryCategories,
197
265
  assetIds: []
198
266
  },
@@ -217,15 +285,26 @@ function createStampCapability(ctx, config = {}) {
217
285
  asset
218
286
  });
219
287
  }
288
+ libraryChanged.emit({
289
+ libraryId: imported.library.id,
290
+ reason: "imported"
291
+ });
220
292
  return imported.library.id;
221
293
  };
222
294
  const addAsset = async (input) => {
223
- const targetLibrary = input.libraryId ? ctx.getState().libraries[input.libraryId] ?? null : null;
224
- 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}'`);
225
296
  const resolved = await resolveBinarySource(input.source);
226
297
  const meta = sniffBinaryMetadata(resolved.bytes);
227
298
  if (!meta) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] asset source must be PNG, JPEG, or single-page PDF bytes");
228
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)");
229
308
  let suppliedPreview = null;
230
309
  if (input.preview) {
231
310
  const preview = await resolveBinarySource(input.preview);
@@ -234,111 +313,106 @@ function createStampCapability(ctx, config = {}) {
234
313
  mimeType: preview.mimeType ?? "image/png"
235
314
  };
236
315
  }
237
- if (targetLibrary?.storage === "canonical-pdf") {
238
- if (!isPdf) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] raster assets cannot be appended to a canonical PDF library");
239
- return mutateLibrary(targetLibrary.id, async () => {
240
- const liveLibrary = ctx.getState().libraries[targetLibrary.id];
241
- if (liveLibrary?.storage !== "canonical-pdf") throw new EngineError(EngineErrorCode.NotFound, `[stamp] canonical library '${targetLibrary.id}' no longer exists`);
242
- const canonicalBytes = libraryBinaries.get(liveLibrary.id);
243
- if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${liveLibrary.id}'`);
244
- const assetId = uid("stamp");
245
- const doc = await openAssetDocument(canonicalBytes);
246
- let appended;
247
- 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) {
248
330
  const result = await doc.pages.insert(new Uint8Array(resolved.bytes));
249
- if (result.insertedPageObjectNumbers.length !== 1) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a canonical library asset must be a single-page PDF");
250
- const pageObjectNumber = result.insertedPageObjectNumbers[0];
251
- const layout = result.layout.pages.find((candidate) => candidate.pageObjectNumber === pageObjectNumber);
252
- const page = doc.page(pageObjectNumber);
253
- if (!layout || !page.pieceInfo) throw new EngineError(EngineErrorCode.NotImplemented, "[stamp] canonical PDF libraries need page layout and pieceInfo support");
254
- const asset = {
255
- id: assetId,
256
- libraryId: liveLibrary.id,
257
- kind: input.kind ?? "stamp",
258
- name: input.name,
259
- size: {
260
- width: layout.size.width,
261
- height: layout.size.height
262
- },
263
- format: "pdf",
264
- pageObjectNumber,
265
- subject: input.subject,
266
- categories: input.categories
267
- };
268
- await page.pieceInfo.update(STAMP_PIECEINFO_APP, metadataPatch(asset.id, asset.name, asset.kind, asset.subject, asset.categories));
269
- const bytes = await doc.pages.extract([pageObjectNumber]);
270
- let preview = suppliedPreview;
271
- if (!preview) preview = imageToPreview(await page.render.image({
272
- viewport: {
273
- kind: "width",
274
- 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
275
343
  },
276
- background: "transparent",
277
- includeAnnotations: true,
278
- format: "png"
279
- }));
280
- appended = {
281
- asset,
282
- bytes,
283
- preview,
284
- canonical: await doc.download()
285
- };
286
- } finally {
287
- 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");
288
348
  }
289
- libraryBinaries.set(liveLibrary.id, appended.canonical);
290
- binaries.set(appended.asset.id, {
291
- bytes: appended.bytes,
292
- preview: appended.preview
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
293
355
  });
294
- ctx.dispatch({
295
- type: "ASSET_ADDED",
296
- asset: appended.asset
297
- });
298
- return appended.asset.id;
299
- });
300
- }
301
- const size = input.size ?? ("width" in meta ? {
302
- width: meta.width,
303
- height: meta.height
304
- } : null);
305
- if (!size) throw new EngineError(EngineErrorCode.InvalidArg, "[stamp] a PDF asset needs `size` (its page size in points) — PDF bytes carry no sniffable dimensions");
306
- let preview = suppliedPreview;
307
- if (!preview && !isPdf) preview = {
308
- bytes: new Uint8Array(resolved.bytes),
309
- mimeType: meta.mimeType
310
- };
311
- const commitLooseAsset = (libraryId) => {
312
- const asset = {
313
- id: uid("stamp"),
314
- libraryId,
315
- kind: input.kind ?? "stamp",
316
- name: input.name,
317
- size,
318
- format: isPdf ? "pdf" : meta.mimeType === "image/png" ? "png" : "jpeg",
319
- subject: input.subject,
320
- categories: input.categories
321
- };
322
- binaries.set(asset.id, {
323
- bytes: new Uint8Array(resolved.bytes),
324
- 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
325
387
  });
326
388
  ctx.dispatch({
327
389
  type: "ASSET_ADDED",
328
- asset
390
+ asset: appended.asset
329
391
  });
330
- return asset.id;
331
- };
332
- if (!targetLibrary) return commitLooseAsset(createLooseLibrary(input.name));
333
- return mutateLibrary(targetLibrary.id, async () => {
334
- const liveLibrary = ctx.getState().libraries[targetLibrary.id];
335
- if (liveLibrary?.storage !== "loose") throw new EngineError(EngineErrorCode.NotFound, `[stamp] loose library '${targetLibrary.id}' no longer exists`);
336
- 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
337
408
  });
338
409
  };
339
410
  const dropLibrary = (id) => {
340
411
  const library = ctx.getState().libraries[id];
341
- 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
+ }
342
416
  libraryBinaries.delete(id);
343
417
  ctx.dispatch({
344
418
  type: "LIBRARY_REMOVED",
@@ -352,71 +426,128 @@ function createStampCapability(ctx, config = {}) {
352
426
  const asset = ctx.getState().assets[id];
353
427
  if (!asset) return;
354
428
  const library = ctx.getState().libraries[asset.libraryId];
355
- if (library?.storage === "canonical-pdf") {
356
- if (library.assetIds.length === 1) {
357
- dropLibrary(library.id);
358
- return;
359
- }
360
- if (asset.pageObjectNumber === void 0) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical asset '${id}' has no page object number`);
361
- const canonicalBytes = libraryBinaries.get(library.id);
362
- if (!canonicalBytes) throw new EngineError(EngineErrorCode.Unknown, `[stamp] canonical bytes are missing for library '${library.id}'`);
363
- const doc = await openAssetDocument(canonicalBytes);
364
- let rewritten;
365
- try {
366
- await doc.pages.delete([asset.pageObjectNumber]);
367
- rewritten = await doc.download();
368
- } finally {
369
- await doc.close();
370
- }
371
- 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();
372
439
  }
440
+ libraryBinaries.set(library.id, rewritten);
373
441
  binaries.delete(id);
442
+ ghostRenders.delete(id);
374
443
  ctx.dispatch({
375
444
  type: "ASSET_REMOVED",
376
445
  assetId: id
377
446
  });
447
+ libraryChanged.emit({
448
+ libraryId: library.id,
449
+ reason: "asset-removed"
450
+ });
378
451
  });
379
452
  };
380
- const removeLibrary = (id) => mutateLibrary(id, async () => dropLibrary(id));
381
- const surfaceScriptingResult = (result) => {
382
- try {
383
- for (const effect of result.uiEffects) config.scripting?.onUiEffect?.(effect);
384
- for (const diagnostic of result.diagnostics) config.scripting?.onDiagnostic?.(diagnostic);
385
- if (result.error) config.scripting?.onError?.(result.error);
386
- } catch (error) {
387
- globalThis.console?.error("[stamp] scripting observer failed:", error);
388
- }
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
+ });
389
504
  };
390
505
  /**
391
506
  * Form-backed stamps are executable templates only until placement. Work
392
507
  * on an isolated copy so neither the canonical library nor its reusable
393
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.
394
514
  */
395
515
  const materializeForPlacement = async (documentId, asset, bin) => {
396
- if (asset.format !== "pdf" || !config.scripting?.enabled) return bin;
397
- 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;
398
520
  const targetMeta = ctx.core().documents[documentId] ?? null;
399
- 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`);
400
522
  const canonicalBytes = libraryBinaries.get(asset.libraryId);
401
523
  const sourceBytes = canonicalBytes ?? bin.bytes;
402
- const doc = await openAssetDocument(new Uint8Array(sourceBytes), targetDoc.security.identity);
524
+ const doc = await openAssetDocument(new Uint8Array(sourceBytes));
525
+ let realm = null;
403
526
  let scripting = null;
404
527
  try {
405
528
  const layout = await doc.pages.list();
406
529
  const selectedPage = asset.pageObjectNumber === void 0 ? layout.pageCount === 1 ? layout.pages[0] : void 0 : layout.pages.find(({ pageObjectNumber }) => pageObjectNumber === asset.pageObjectNumber);
407
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");
408
- const snapshot = await doc.forms.list();
409
- if (!snapshot.fields.some((field) => field.widgets.some(({ pageObjectNumber }) => pageObjectNumber === selectedPage.pageObjectNumber))) return bin;
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");
411
- const scriptingConfig = config.scripting;
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
+ });
412
540
  scripting = createFormScriptingController({
413
541
  doc,
414
542
  document: () => targetMeta,
415
- config: scriptingConfig,
416
- sandboxFactory: scriptingConfig.sandboxFactory ?? (() => import("@embedpdf/core-js-sandbox").then(({ createQuickJsSandbox }) => createQuickJsSandbox()))
543
+ transaction: realm.transaction.bind(realm),
544
+ budget: realm.budget
545
+ });
546
+ const result = await scripting.recalculate();
547
+ actions.surfaceScriptCommit(result, {
548
+ origin: "user",
549
+ realm: "detached"
417
550
  });
418
- const result = await scripting.recalculate(snapshot);
419
- surfaceScriptingResult(result);
420
551
  if (result.status === "failed") throw new EngineError(EngineErrorCode.Unknown, `[stamp] dynamic stamp scripting failed: ${result.error?.message ?? "native form effect failed"}`);
421
552
  const pageObjectNumber = selectedPage.pageObjectNumber;
422
553
  const failed = (await doc.pages.flatten([pageObjectNumber], "display")).results.find(({ status }) => status === "failed" || status === "skipped");
@@ -437,6 +568,7 @@ function createStampCapability(ctx, config = {}) {
437
568
  };
438
569
  } finally {
439
570
  scripting?.dispose();
571
+ realm?.dispose();
440
572
  await doc.close();
441
573
  }
442
574
  };
@@ -446,17 +578,72 @@ function createStampCapability(ctx, config = {}) {
446
578
  if (!asset || !bin) throw new EngineError(EngineErrorCode.NotFound, `[stamp] unknown asset '${assetId}'`);
447
579
  const placement = await materializeForPlacement(documentId, asset, bin);
448
580
  await ctx.forDocument(AnnotationToken, documentId).armStamp({
449
- source: placement.bytes,
450
- preview: placement.preview ? {
451
- data: placement.preview.bytes,
452
- mimeType: placement.preview.mimeType
453
- } : void 0,
454
- intrinsicSize: asset.size,
581
+ ...stampPayload(asset, placement, placement !== bin),
455
582
  targetWidth: opts?.targetWidth
456
583
  });
457
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
+ };
458
644
  ctx.cleanup(() => {
459
645
  binaries.clear();
646
+ ghostRenders.clear();
460
647
  libraryBinaries.clear();
461
648
  libraryMutationTails.clear();
462
649
  const ownedAssetEngine = typeof config.assetEngine === "function" ? assetEngineRef : null;
@@ -480,12 +667,17 @@ function createStampCapability(ctx, config = {}) {
480
667
  asset: (id) => ctx.getState().assets[id] ?? null,
481
668
  assetPreview: (id) => binaries.get(id)?.preview ?? null,
482
669
  assetBytes: (id) => binaries.get(id)?.bytes ?? null,
483
- libraryBytes: (id) => libraryBinaries.get(id) ?? null,
670
+ exportLibrary: (id) => libraryBinaries.get(id) ?? null,
671
+ onLibraryChanged: libraryChanged.on,
672
+ createLibrary,
484
673
  importLibraryPdf,
485
674
  addAsset,
675
+ addAssetFromAnnotations,
676
+ updateAsset,
486
677
  removeAsset,
487
678
  removeLibrary,
488
679
  armAsset,
680
+ placeAsset,
489
681
  disarm: (documentId) => ctx.forDocument(AnnotationToken, documentId).disarmStamp()
490
682
  };
491
683
  }
@@ -540,6 +732,13 @@ const stampReducer = (state, action) => {
540
732
  }
541
733
  };
542
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;
543
742
  case "ASSET_REMOVED": {
544
743
  const asset = state.assets[action.assetId];
545
744
  if (!asset) return state;
@@ -562,9 +761,6 @@ const stampReducer = (state, action) => {
562
761
  }
563
762
  };
564
763
  //#endregion
565
- //#region src/types.ts
566
- const StampToken = createCapabilityToken("stamp");
567
- //#endregion
568
764
  //#region src/stamp.plugin.ts
569
765
  /**
570
766
  * The stamp plugin. WORKSPACE-scoped: libraries are shared across every open
@@ -583,6 +779,77 @@ const stampPlugin = (config = {}) => definePlugin({
583
779
  capability: (ctx) => createStampCapability(ctx, config)
584
780
  });
585
781
  //#endregion
586
- 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 };
587
854
 
588
855
  //# sourceMappingURL=index.js.map