@reekon-tools/boldr-utils 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/annotation/canvas/pdfCanvasPages.d.ts +25 -0
  2. package/dist/annotation/canvas/pdfCanvasPages.js +255 -0
  3. package/dist/annotation/data/AnnotationDataProvider.d.ts +6 -0
  4. package/dist/annotation/data/InMemoryAnnotationProvider.d.ts +6 -0
  5. package/dist/annotation/data/InMemoryAnnotationProvider.js +25 -0
  6. package/dist/annotation/data/hooks/usePdfCanvasDoc.d.ts +29 -0
  7. package/dist/annotation/data/hooks/usePdfCanvasDoc.js +344 -0
  8. package/dist/export/pdf/annots.d.ts +23 -0
  9. package/dist/export/pdf/annots.js +135 -0
  10. package/dist/export/pdf/decrypt.d.ts +21 -0
  11. package/dist/export/pdf/decrypt.js +121 -0
  12. package/dist/export/pdf/fonts.d.ts +3 -4
  13. package/dist/export/pdf/fonts.js +2 -14
  14. package/dist/export/pdf/index.d.ts +4 -2
  15. package/dist/export/pdf/index.js +3 -1
  16. package/dist/export/pdf/marks.d.ts +66 -5
  17. package/dist/export/pdf/marks.js +150 -7
  18. package/dist/export/pdf/pageMapping.d.ts +2 -0
  19. package/dist/export/pdf/pageMapping.js +10 -0
  20. package/dist/export/pdf/renderCanvasToPdf.d.ts +9 -1
  21. package/dist/export/pdf/renderCanvasToPdf.js +303 -120
  22. package/dist/export/pdf/resources.d.ts +35 -0
  23. package/dist/export/pdf/resources.js +95 -0
  24. package/dist/export/pdf/tiles.d.ts +5 -9
  25. package/dist/export/pdf/tiles.js +20 -20
  26. package/dist/export/pdf/types.d.ts +25 -1
  27. package/dist/exports.d.ts +3 -0
  28. package/dist/exports.js +3 -0
  29. package/dist/server.d.ts +2 -0
  30. package/dist/server.js +2 -0
  31. package/dist/types/firestore.d.ts +6 -0
  32. package/dist/types/firestore.js +5 -0
  33. package/dist/types/pdfCanvas.d.ts +38 -0
  34. package/dist/types/pdfCanvas.js +16 -0
  35. package/package.json +6 -1
@@ -2,16 +2,27 @@
2
2
  // canvas's layers, marks and tiles to each page's content stream under one
3
3
  // matrix per page. The original page content and its own /Annots are never
4
4
  // touched, so the source stays vector, searchable and selectable.
5
- import { EncryptedPDFError, PDFDocument, } from 'pdf-lib';
5
+ //
6
+ // Two entry points share one page renderer:
7
+ // - renderCanvasToPdf: a Canvas file carrying a `viewport.pdfDocument`
8
+ // filmstrip — every element lives in one stacked doc space and is drawn on
9
+ // each page its bounds intersect.
10
+ // - renderPdfCanvasToPdf: a PdfCanvas file — one page state per page, each in
11
+ // that page's own doc space, with the user's per-page rotation folded into
12
+ // /Rotate and the page matrix.
13
+ import { EncryptedPDFError, PDFDocument, degrees, } from 'pdf-lib';
6
14
  import { backgroundLayersOf } from '../../annotation/canvas/backgroundLayers.js';
15
+ import { effectivePageRotation, pdfPageDisplaySize, } from '../../annotation/canvas/pdfCanvasPages.js';
7
16
  import { fitRectToScreen } from '../../annotation/canvas/viewport.js';
8
17
  import { STAMP_TILE_SIZE } from '../../annotation/canvas/stampLayout.js';
9
18
  import { clipRectOps, withMatrix } from './emit.js';
10
19
  import { PdfFonts } from './fonts.js';
11
20
  import { layerOps, layerVisibleRect } from './layers.js';
12
- import { measurementMarkBounds, measurementMarkOps, shapeBounds, shapeOps, strokeBounds, strokeOps, } from './marks.js';
21
+ import { addMarkAnnotation } from './annots.js';
22
+ import { measurementMark, measurementMarkBounds, measurementMarkOps, shapeBounds, shapeMark, shapeOps, strokeBounds, strokeMark, strokeOps, } from './marks.js';
23
+ import { FormScope, PageScope } from './resources.js';
13
24
  import { docToPageMatrix, normalizeRotation, pageRectsOf, rectsIntersect, rotatedBoxSize, } from './pageMapping.js';
14
- import { designBoxFor, tileFootprint, tileOps, } from './tiles.js';
25
+ import { designBoxFor, tileFootprint, tileOps } from './tiles.js';
15
26
  import { PdfExportError, } from './types.js';
16
27
  /** Stored page sizes may differ from the file's boxes by rounding only. */
17
28
  const PAGE_SIZE_TOLERANCE_PT = 1.5;
@@ -41,161 +52,333 @@ const loadSource = async (bytes) => {
41
52
  return await PDFDocument.load(bytes, { updateMetadata: false });
42
53
  }
43
54
  catch (e) {
44
- if (e instanceof EncryptedPDFError) {
45
- throw new PdfExportError('encrypted', 'The source PDF is encrypted and cannot be exported');
55
+ // Both checks: pdf-lib's dual CJS/ESM build can hand back an
56
+ // EncryptedPDFError whose class identity differs from the one imported.
57
+ if (e instanceof EncryptedPDFError ||
58
+ (e instanceof Error && /is encrypted/i.test(e.message))) {
59
+ throw new PdfExportError('encrypted', 'The source PDF is encrypted; strip it with stripPdfEncryption before rendering');
46
60
  }
47
61
  throw new PdfExportError('corrupt', `The source PDF could not be parsed: ${e instanceof Error ? e.message : String(e)}`);
48
62
  }
49
63
  };
50
- export const renderCanvasToPdf = async (input) => {
51
- const { canvas } = input;
52
- const pdfDoc = canvas.viewport.pdfDocument;
53
- if (!pdfDoc) {
54
- throw new PdfExportError('no-pdf-document', 'This canvas has no PDF document to export');
64
+ const assertPageCount = (pages, recorded, stored) => {
65
+ if (pages.length !== recorded || stored !== pages.length) {
66
+ throw new PdfExportError('page-count-mismatch', `The canvas records ${stored} pages but the PDF has ${pages.length}`);
55
67
  }
56
- const doc = await loadSource(input.sourcePdf);
57
- const pages = doc.getPages();
58
- if (pages.length !== pdfDoc.pageCount ||
59
- pdfDoc.pages.length !== pages.length) {
60
- throw new PdfExportError('page-count-mismatch', `The canvas records ${pdfDoc.pages.length} pages but the PDF has ${pages.length}`);
68
+ };
69
+ const assertPageSize = (index, actual, stored) => {
70
+ if (Math.abs(actual.width - stored.width) > PAGE_SIZE_TOLERANCE_PT ||
71
+ Math.abs(actual.height - stored.height) > PAGE_SIZE_TOLERANCE_PT) {
72
+ throw new PdfExportError('page-geometry-mismatch', `Page ${index + 1} is ${actual.width}×${actual.height} pt in the PDF but ${stored.width}×${stored.height} pt on the canvas`);
61
73
  }
62
- const pageRects = pageRectsOf(pdfDoc);
63
- const skipped = [];
64
- // Per-page geometry, validated against what the canvas was laid out from.
65
- const geometry = pages.map((page, i) => {
66
- const box = pageBox(page);
67
- const rotation = normalizeRotation(page.getRotation().angle);
68
- const size = rotatedBoxSize(box, rotation);
69
- const stored = pdfDoc.pages[i];
70
- if (Math.abs(size.width - stored.width) > PAGE_SIZE_TOLERANCE_PT ||
71
- Math.abs(size.height - stored.height) > PAGE_SIZE_TOLERANCE_PT) {
72
- throw new PdfExportError('page-geometry-mismatch', `Page ${i + 1} is ${size.width}×${size.height} pt in the PDF but ${stored.width}×${stored.height} pt on the canvas`);
73
- }
74
- return { page, box, rotation, rect: pageRects[i] };
75
- });
76
- const fonts = await PdfFonts.create(doc, input.primaryFont);
74
+ };
75
+ /** Content string for a tile's annotation (what Acrobat's comment shows). */
76
+ const tileContents = (content) => {
77
+ switch (content.kind) {
78
+ case 'measurement':
79
+ return [
80
+ content.label,
81
+ `${content.negative ? '-' : ''}${content.value} ${content.unit}`.trim(),
82
+ ]
83
+ .filter(Boolean)
84
+ .join(': ');
85
+ case 'planeDimension':
86
+ return `${content.value} ${content.unit}`.trim();
87
+ case 'header':
88
+ return (content.descriptor.name ??
89
+ content.descriptor.badges.map((b) => b.text).join(' '));
90
+ case 'object':
91
+ return content.name;
92
+ case 'literal':
93
+ return content.value || undefined;
94
+ case 'instructions':
95
+ return content.text || undefined;
96
+ default:
97
+ return undefined;
98
+ }
99
+ };
100
+ const renderJobs = async (doc, jobs, options, skipped) => {
101
+ const fonts = await PdfFonts.create(doc, options.primaryFont);
77
102
  // Background layers: embed once, draw on every page they overlap.
78
- const layers = backgroundLayersOf(canvas.viewport);
79
103
  const layerImages = new Map();
80
- for (const layer of layers) {
81
- if (layer.format === 'svg') {
82
- skipped.push({
83
- id: layer.id,
84
- kind: 'layer',
85
- reason: 'SVG background layers are not exported yet',
86
- });
87
- continue;
88
- }
89
- const bytes = input.layerImages[layer.id];
90
- if (!bytes) {
91
- skipped.push({
92
- id: layer.id,
93
- kind: 'layer',
94
- reason: 'Layer image bytes were not provided',
95
- });
96
- continue;
97
- }
98
- try {
99
- layerImages.set(layer.id, await embedImage(doc, bytes));
100
- }
101
- catch (e) {
102
- skipped.push({
103
- id: layer.id,
104
- kind: 'layer',
105
- reason: `Layer image could not be embedded: ${e instanceof Error ? e.message : String(e)}`,
106
- });
107
- }
108
- }
109
- // Tiles: resolve content once; embed object thumbnails once.
110
- const tiles = canvas.placedMeasurements.map((placed) => {
111
- const content = input.resolveTile(placed) ?? {
112
- kind: 'empty',
113
- };
114
- return { placed, content };
115
- });
116
- const thumbnails = new Map();
117
- for (const { placed, content } of tiles) {
118
- if (content.kind === 'object' && content.thumbnail) {
104
+ const seenLayers = new Set();
105
+ for (const job of jobs) {
106
+ for (const layer of job.layers) {
107
+ if (seenLayers.has(layer.id))
108
+ continue;
109
+ seenLayers.add(layer.id);
110
+ if (layer.format === 'svg') {
111
+ skipped.push({
112
+ id: layer.id,
113
+ kind: 'layer',
114
+ reason: 'SVG background layers are not exported yet',
115
+ });
116
+ continue;
117
+ }
118
+ const bytes = options.layerImages[layer.id];
119
+ if (!bytes) {
120
+ skipped.push({
121
+ id: layer.id,
122
+ kind: 'layer',
123
+ reason: 'Layer image bytes were not provided',
124
+ });
125
+ continue;
126
+ }
119
127
  try {
120
- thumbnails.set(placed.id, await embedImage(doc, content.thumbnail));
128
+ layerImages.set(layer.id, await embedImage(doc, bytes));
121
129
  }
122
130
  catch (e) {
123
131
  skipped.push({
124
- id: placed.id,
125
- kind: 'tile',
126
- reason: `Object thumbnail could not be embedded: ${e instanceof Error ? e.message : String(e)}`,
132
+ id: layer.id,
133
+ kind: 'layer',
134
+ reason: `Layer image could not be embedded: ${e instanceof Error ? e.message : String(e)}`,
127
135
  });
128
136
  }
129
137
  }
130
138
  }
131
- const firstRect = pageRects[0];
139
+ // Tiles: resolve content once per placed ref; embed object thumbnails once.
140
+ const tileContent = new Map();
141
+ const thumbnails = new Map();
142
+ for (const job of jobs) {
143
+ for (const placed of job.placed) {
144
+ if (tileContent.has(placed.id))
145
+ continue;
146
+ const content = options.resolveTile(placed) ?? {
147
+ kind: 'empty',
148
+ };
149
+ tileContent.set(placed.id, content);
150
+ if (content.kind === 'object' && content.thumbnail) {
151
+ try {
152
+ thumbnails.set(placed.id, await embedImage(doc, content.thumbnail));
153
+ }
154
+ catch (e) {
155
+ skipped.push({
156
+ id: placed.id,
157
+ kind: 'tile',
158
+ reason: `Object thumbnail could not be embedded: ${e instanceof Error ? e.message : String(e)}`,
159
+ });
160
+ }
161
+ }
162
+ }
163
+ }
164
+ // Calibration fallback from the first page's rect (the tile-scale reference
165
+ // on screen is the first page too).
166
+ const firstRect = jobs[0]?.rect ?? { x: 0, y: 0, width: 612, height: 792 };
132
167
  const fallbackDocEdge = STAMP_TILE_SIZE /
133
168
  Math.max(fitRectToScreen(firstRect, FALLBACK_VIEW.width, FALLBACK_VIEW.height)
134
169
  .zoom, 1e-6);
135
- const hiddenLayerIds = new Set(canvas.layers.filter((l) => l.visible === false).map((l) => l.id));
136
- const visible = (layerId) => !hiddenLayerIds.has(layerId);
137
- for (const { page, box, rotation, rect } of geometry) {
138
- // Per-page resource registries.
139
- const alphaStates = new Map();
140
- const alphaState = (alpha) => {
141
- const a = Math.round(Math.min(1, Math.max(0, alpha)) * 1000) / 1000;
142
- if (a >= 1)
143
- return undefined;
144
- let name = alphaStates.get(a);
145
- if (!name) {
146
- name = page.node.newExtGState('GSa', doc.context.obj({ Type: 'ExtGState', CA: a, ca: a }));
147
- alphaStates.set(a, name);
170
+ let annotationCount = 0;
171
+ for (const job of jobs) {
172
+ const { page, box, rotation, rect } = job;
173
+ const matrix = docToPageMatrix(rect, box, rotation);
174
+ const onPage = (b) => b !== null && rectsIntersect(b, rect);
175
+ const hiddenLayerIds = new Set(job.layerDefs.filter((l) => l.visible === false).map((l) => l.id));
176
+ const visible = (layerId) => !hiddenLayerIds.has(layerId);
177
+ const footprintFor = (placed, content) => tileFootprint(placed, { tileDocSize: job.tileDocSize }, options.calibration, fallbackDocEdge, designBoxFor(placed, content));
178
+ const contentOf = (placed) => tileContent.get(placed.id) ?? { kind: 'empty' };
179
+ if (options.mode === 'flatten') {
180
+ // Everything into the page's content stream, under one matrix + clip.
181
+ const scope = new PageScope(page, doc.context);
182
+ const ctx = { scope, fonts, skipped };
183
+ const ops = [];
184
+ for (const layer of job.layers) {
185
+ const img = layerImages.get(layer.id);
186
+ if (!img || !onPage(layerVisibleRect(layer)))
187
+ continue;
188
+ ops.push(...layerOps(layer, scope.imageKey(img)));
189
+ }
190
+ for (const s of job.strokes) {
191
+ if (!visible(s.layerId) || !onPage(strokeBounds(s)))
192
+ continue;
193
+ ops.push(...strokeOps(s, ctx));
148
194
  }
149
- return name;
195
+ for (const sh of job.shapes) {
196
+ if (!visible(sh.layerId) || !onPage(shapeBounds(sh)))
197
+ continue;
198
+ ops.push(...shapeOps(sh, ctx));
199
+ }
200
+ for (const placed of job.placed) {
201
+ if (!visible(placed.layerId) || !onPage(measurementMarkBounds(placed)))
202
+ continue;
203
+ ops.push(...measurementMarkOps(placed, ctx));
204
+ }
205
+ for (const placed of job.placed) {
206
+ if (!visible(placed.layerId))
207
+ continue;
208
+ const content = contentOf(placed);
209
+ const footprint = footprintFor(placed, content);
210
+ if (!onPage(footprint))
211
+ continue;
212
+ ops.push(...tileOps({ scope, fonts, thumbnail: thumbnails.get(placed.id) }, content, footprint));
213
+ }
214
+ if (ops.length === 0)
215
+ continue;
216
+ page.pushOperators(...withMatrix(matrix, [
217
+ ...clipRectOps(rect.x, rect.y, rect.width, rect.height),
218
+ ...ops,
219
+ ]));
220
+ continue;
221
+ }
222
+ // Annotation mode: one annotation per element, each with its own
223
+ // appearance stream and resources. Same z-order as the screen.
224
+ const add = (mark, scope, groupWith) => {
225
+ if (!mark)
226
+ return null;
227
+ const ref = addMarkAnnotation({
228
+ doc,
229
+ page,
230
+ mark,
231
+ pageRect: rect,
232
+ matrix,
233
+ scope,
234
+ groupWith,
235
+ });
236
+ if (ref)
237
+ annotationCount++;
238
+ return ref;
150
239
  };
151
- const registerImage = (img) => ({
152
- name: page.node.newXObject('Im', img.ref),
153
- width: img.width,
154
- height: img.height,
155
- });
156
- const ctx = { page, fonts, alphaState, skipped };
157
- const onPage = (b) => b !== null && rectsIntersect(b, rect);
158
- const ops = [];
159
- // 1. Background image layers, array order.
160
- for (const layer of layers) {
240
+ for (const layer of job.layers) {
161
241
  const img = layerImages.get(layer.id);
162
- if (!img || !onPage(layerVisibleRect(layer)))
242
+ const bounds = layerVisibleRect(layer);
243
+ if (!img || !onPage(bounds))
163
244
  continue;
164
- ops.push(...layerOps(layer, registerImage(img).name));
245
+ const scope = new FormScope(doc.context);
246
+ add({
247
+ id: layer.id,
248
+ ops: layerOps(layer, scope.imageKey(img)),
249
+ bounds,
250
+ annot: {
251
+ subtype: 'Stamp',
252
+ contents: layer.fileId ? 'Placed file' : 'Background image',
253
+ },
254
+ }, scope);
165
255
  }
166
- // 2. Strokes, then shapes, then measurement lines / rects — the Skia order.
167
- for (const s of canvas.strokes) {
256
+ for (const s of job.strokes) {
168
257
  if (!visible(s.layerId) || !onPage(strokeBounds(s)))
169
258
  continue;
170
- ops.push(...strokeOps(s, ctx));
259
+ const scope = new FormScope(doc.context);
260
+ add(strokeMark(s, { scope, fonts, skipped }), scope);
171
261
  }
172
- for (const sh of canvas.shapes) {
262
+ for (const sh of job.shapes) {
173
263
  if (!visible(sh.layerId) || !onPage(shapeBounds(sh)))
174
264
  continue;
175
- ops.push(...shapeOps(sh, ctx));
265
+ const scope = new FormScope(doc.context);
266
+ add(shapeMark(sh, { scope, fonts, skipped }), scope);
176
267
  }
177
- for (const { placed } of tiles) {
268
+ // A measurement's line/rect and its tile are grouped (Acrobat honours
269
+ // /IRT + /RT /Group; other viewers treat them independently).
270
+ const markRefs = new Map();
271
+ for (const placed of job.placed) {
178
272
  if (!visible(placed.layerId) || !onPage(measurementMarkBounds(placed)))
179
273
  continue;
180
- ops.push(...measurementMarkOps(placed, ctx));
274
+ const scope = new FormScope(doc.context);
275
+ const ref = add(measurementMark(placed, { scope, fonts, skipped }), scope);
276
+ if (ref)
277
+ markRefs.set(placed.id, ref);
181
278
  }
182
- // 3. Tiles on top, like the overlay.
183
- for (const { placed, content } of tiles) {
279
+ for (const placed of job.placed) {
184
280
  if (!visible(placed.layerId))
185
281
  continue;
186
- const footprint = tileFootprint(placed, canvas, input.calibration, fallbackDocEdge, designBoxFor(placed, content));
282
+ const content = contentOf(placed);
283
+ const footprint = footprintFor(placed, content);
187
284
  if (!onPage(footprint))
188
285
  continue;
189
- const thumb = thumbnails.get(placed.id);
190
- ops.push(...tileOps({ page, fonts, thumbnail: thumb ? registerImage(thumb) : undefined }, content, footprint));
286
+ const scope = new FormScope(doc.context);
287
+ add({
288
+ id: placed.id,
289
+ ops: tileOps({ scope, fonts, thumbnail: thumbnails.get(placed.id) }, content, footprint),
290
+ bounds: footprint,
291
+ annot: { subtype: 'Stamp', contents: tileContents(content) },
292
+ }, scope, markRefs.get(placed.id));
191
293
  }
192
- if (ops.length === 0)
193
- continue;
194
- page.pushOperators(...withMatrix(docToPageMatrix(rect, box, rotation), [
195
- ...clipRectOps(rect.x, rect.y, rect.width, rect.height),
196
- ...ops,
197
- ]));
198
294
  }
295
+ return annotationCount;
296
+ };
297
+ export const renderCanvasToPdf = async (input) => {
298
+ const { canvas } = input;
299
+ const pdfDoc = canvas.viewport.pdfDocument;
300
+ if (!pdfDoc) {
301
+ throw new PdfExportError('no-pdf-document', 'This canvas has no PDF document to export');
302
+ }
303
+ const doc = await loadSource(input.sourcePdf);
304
+ const pages = doc.getPages();
305
+ assertPageCount(pages, pdfDoc.pageCount, pdfDoc.pages.length);
306
+ const pageRects = pageRectsOf(pdfDoc);
307
+ const layers = backgroundLayersOf(canvas.viewport);
308
+ // Per-page geometry, validated against what the canvas was laid out from.
309
+ const jobs = pages.map((page, i) => {
310
+ const box = pageBox(page);
311
+ const rotation = normalizeRotation(page.getRotation().angle);
312
+ assertPageSize(i, rotatedBoxSize(box, rotation), pdfDoc.pages[i]);
313
+ return {
314
+ page,
315
+ box,
316
+ rotation,
317
+ rect: pageRects[i],
318
+ layers,
319
+ layerDefs: canvas.layers,
320
+ strokes: canvas.strokes,
321
+ shapes: canvas.shapes,
322
+ placed: canvas.placedMeasurements,
323
+ tileDocSize: canvas.tileDocSize,
324
+ };
325
+ });
326
+ const skipped = [];
327
+ const annotationCount = await renderJobs(doc, jobs, {
328
+ resolveTile: input.resolveTile,
329
+ layerImages: input.layerImages,
330
+ calibration: input.calibration,
331
+ primaryFont: input.primaryFont,
332
+ mode: input.mode ?? 'annotations',
333
+ }, skipped);
334
+ const pdf = await doc.save({ useObjectStreams: false });
335
+ return { pdf, pageCount: pages.length, annotationCount, skipped };
336
+ };
337
+ /**
338
+ * Export a PdfCanvas file. Each page's state is drawn in that page's own doc
339
+ * space (the DISPLAYED page: rotated extents, origin top-left). A user
340
+ * rotation is written into the page's /Rotate — so the recipient sees the
341
+ * page as the user did — and the page matrix undoes the combined rotation.
342
+ * Pages without a state are still rotated.
343
+ */
344
+ export const renderPdfCanvasToPdf = async (input) => {
345
+ const { pdfCanvas } = input;
346
+ const doc = await loadSource(input.sourcePdf);
347
+ const pages = doc.getPages();
348
+ assertPageCount(pages, pdfCanvas.document.pageCount, pdfCanvas.document.pages.length);
349
+ const jobs = [];
350
+ pages.forEach((page, i) => {
351
+ const stored = pdfCanvas.document.pages[i];
352
+ const box = pageBox(page);
353
+ const intrinsic = normalizeRotation(page.getRotation().angle);
354
+ // The stored size is the page after its own /Rotate, before the user's.
355
+ assertPageSize(i, rotatedBoxSize(box, intrinsic), stored);
356
+ const effective = effectivePageRotation(intrinsic, stored.rotation);
357
+ if (effective !== intrinsic)
358
+ page.setRotation(degrees(effective));
359
+ const display = pdfPageDisplaySize(stored);
360
+ const state = input.pageStates[i] ?? undefined;
361
+ jobs.push({
362
+ page,
363
+ box,
364
+ rotation: effective,
365
+ rect: { x: 0, y: 0, width: display.width, height: display.height },
366
+ layers: [],
367
+ layerDefs: state?.layers ?? [],
368
+ strokes: state?.strokes ?? [],
369
+ shapes: state?.shapes ?? [],
370
+ placed: state?.placedMeasurements ?? [],
371
+ tileDocSize: pdfCanvas.tileDocSize,
372
+ });
373
+ });
374
+ const skipped = [];
375
+ const annotationCount = await renderJobs(doc, jobs, {
376
+ resolveTile: input.resolveTile,
377
+ layerImages: {},
378
+ calibration: input.calibration,
379
+ primaryFont: input.primaryFont,
380
+ mode: input.mode ?? 'annotations',
381
+ }, skipped);
199
382
  const pdf = await doc.save({ useObjectStreams: false });
200
- return { pdf, pageCount: pages.length, skipped };
383
+ return { pdf, pageCount: pages.length, annotationCount, skipped };
201
384
  };
@@ -0,0 +1,35 @@
1
+ import { PDFContext, PDFDict, PDFFont, PDFImage, PDFName, PDFPage } from 'pdf-lib';
2
+ export interface ResourceScope {
3
+ fontKey(font: PDFFont): PDFName;
4
+ imageKey(image: PDFImage): PDFName;
5
+ /** An ExtGState carrying `alpha` (CA/ca), or undefined at full opacity. */
6
+ alphaState(alpha: number): PDFName | undefined;
7
+ }
8
+ /** Resources on the page itself — content-stream (flatten) output. */
9
+ export declare class PageScope implements ResourceScope {
10
+ private readonly page;
11
+ private readonly context;
12
+ private readonly fonts;
13
+ private readonly images;
14
+ private readonly alphas;
15
+ constructor(page: PDFPage, context: PDFContext);
16
+ fontKey(font: PDFFont): PDFName;
17
+ imageKey(image: PDFImage): PDFName;
18
+ alphaState(alpha: number): PDFName | undefined;
19
+ }
20
+ /** Resources collected for ONE appearance stream (annotation mode). */
21
+ export declare class FormScope implements ResourceScope {
22
+ private readonly context;
23
+ private readonly fontDict;
24
+ private readonly xobjectDict;
25
+ private readonly gsDict;
26
+ private readonly fonts;
27
+ private readonly images;
28
+ private readonly alphas;
29
+ constructor(context: PDFContext);
30
+ fontKey(font: PDFFont): PDFName;
31
+ imageKey(image: PDFImage): PDFName;
32
+ alphaState(alpha: number): PDFName | undefined;
33
+ /** The /Resources dictionary for the appearance stream. */
34
+ resources(): PDFDict;
35
+ }
@@ -0,0 +1,95 @@
1
+ // Where an element's PDF resources (fonts, images, opacity states) get
2
+ // registered. The emitters only ask a scope for a resource NAME; whether that
3
+ // name lands in the page's resource dictionary (flatten mode) or in one
4
+ // annotation appearance stream's own /Resources (annotation mode) is the
5
+ // scope's business.
6
+ import { PDFName, } from 'pdf-lib';
7
+ const roundAlpha = (alpha) => Math.round(Math.min(1, Math.max(0, alpha)) * 1000) / 1000;
8
+ const extGState = (context, alpha) => context.obj({ Type: 'ExtGState', CA: alpha, ca: alpha });
9
+ /** Resources on the page itself — content-stream (flatten) output. */
10
+ export class PageScope {
11
+ constructor(page, context) {
12
+ this.page = page;
13
+ this.context = context;
14
+ this.fonts = new Map();
15
+ this.images = new Map();
16
+ this.alphas = new Map();
17
+ }
18
+ fontKey(font) {
19
+ let key = this.fonts.get(font);
20
+ if (!key) {
21
+ key = this.page.node.newFontDictionary(font.name, font.ref);
22
+ this.fonts.set(font, key);
23
+ }
24
+ return key;
25
+ }
26
+ imageKey(image) {
27
+ let key = this.images.get(image);
28
+ if (!key) {
29
+ key = this.page.node.newXObject('Im', image.ref);
30
+ this.images.set(image, key);
31
+ }
32
+ return key;
33
+ }
34
+ alphaState(alpha) {
35
+ const a = roundAlpha(alpha);
36
+ if (a >= 1)
37
+ return undefined;
38
+ let key = this.alphas.get(a);
39
+ if (!key) {
40
+ key = this.page.node.newExtGState('GSa', extGState(this.context, a));
41
+ this.alphas.set(a, key);
42
+ }
43
+ return key;
44
+ }
45
+ }
46
+ /** Resources collected for ONE appearance stream (annotation mode). */
47
+ export class FormScope {
48
+ constructor(context) {
49
+ this.context = context;
50
+ this.fonts = new Map();
51
+ this.images = new Map();
52
+ this.alphas = new Map();
53
+ this.fontDict = context.obj({});
54
+ this.xobjectDict = context.obj({});
55
+ this.gsDict = context.obj({});
56
+ }
57
+ fontKey(font) {
58
+ let key = this.fonts.get(font);
59
+ if (!key) {
60
+ key = PDFName.of(`F${this.fonts.size + 1}`);
61
+ this.fontDict.set(key, font.ref);
62
+ this.fonts.set(font, key);
63
+ }
64
+ return key;
65
+ }
66
+ imageKey(image) {
67
+ let key = this.images.get(image);
68
+ if (!key) {
69
+ key = PDFName.of(`Im${this.images.size + 1}`);
70
+ this.xobjectDict.set(key, image.ref);
71
+ this.images.set(image, key);
72
+ }
73
+ return key;
74
+ }
75
+ alphaState(alpha) {
76
+ const a = roundAlpha(alpha);
77
+ if (a >= 1)
78
+ return undefined;
79
+ let key = this.alphas.get(a);
80
+ if (!key) {
81
+ key = PDFName.of(`GS${this.alphas.size + 1}`);
82
+ this.gsDict.set(key, extGState(this.context, a));
83
+ this.alphas.set(a, key);
84
+ }
85
+ return key;
86
+ }
87
+ /** The /Resources dictionary for the appearance stream. */
88
+ resources() {
89
+ return this.context.obj({
90
+ Font: this.fontDict,
91
+ XObject: this.xobjectDict,
92
+ ExtGState: this.gsDict,
93
+ });
94
+ }
95
+ }
@@ -1,19 +1,15 @@
1
- import { type PDFName, type PDFOperator, type PDFPage } from 'pdf-lib';
1
+ import { type PDFImage, type PDFOperator } from 'pdf-lib';
2
2
  import type { AnnotationCanvasState, PlacedMeasurementRef } from '../../types/annotation.js';
3
3
  import type { PdfFonts } from './fonts.js';
4
+ import type { ResourceScope } from './resources.js';
4
5
  import type { AffineMatrix, DocRect } from './pageMapping.js';
5
6
  import type { PdfExportCalibration, ResolvedTileContent } from './types.js';
6
- /** An image XObject already registered on the page, with its pixel size. */
7
- export interface PageImage {
8
- name: PDFName;
9
- width: number;
10
- height: number;
11
- }
12
7
  export interface TilePaintContext {
13
- page: PDFPage;
8
+ /** Where fonts, images and opacity states get registered. */
9
+ scope: ResourceScope;
14
10
  fonts: PdfFonts;
15
11
  /** Object-tile thumbnail, when the content carries one and it embedded. */
16
- thumbnail?: PageImage;
12
+ thumbnail?: PDFImage;
17
13
  }
18
14
  export interface TileFootprint extends DocRect {
19
15
  /** Design-space box the painter lays out in. */