@reekon-tools/boldr-utils 1.22.0 → 1.23.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.
@@ -0,0 +1,23 @@
1
+ import { PDFDocument, PDFPage, PDFRef } from 'pdf-lib';
2
+ import type { Mark } from './marks.js';
3
+ import { type AffineMatrix, type DocRect } from './pageMapping.js';
4
+ import type { FormScope } from './resources.js';
5
+ export interface AddAnnotationInput {
6
+ doc: PDFDocument;
7
+ page: PDFPage;
8
+ mark: Mark;
9
+ /** The page's doc-space rect; the appearance is clipped to it. */
10
+ pageRect: DocRect;
11
+ matrix: AffineMatrix;
12
+ /** The scope the mark's operators registered their resources in. */
13
+ scope: FormScope;
14
+ /** A sibling annotation this one belongs with (Acrobat groups them). */
15
+ groupWith?: PDFRef;
16
+ }
17
+ /**
18
+ * Wrap a mark as an annotation on `page`. Returns the annotation's ref, or
19
+ * null when the mark lies entirely outside the page.
20
+ */
21
+ export declare const addMarkAnnotation: ({ doc, page, mark, pageRect, matrix, scope, groupWith, }: AddAnnotationInput) => PDFRef | null;
22
+ /** How many annotations a page carries (for tests and the result). */
23
+ export declare const annotationCount: (page: PDFPage) => number;
@@ -0,0 +1,135 @@
1
+ // Annotation mode: every element becomes a PDF annotation object — what
2
+ // Preview, Acrobat and friends show as selectable, movable, deletable
3
+ // markup — carrying the element's exact drawing as its appearance stream
4
+ // plus the standard fields a viewer reads back (colour, width, points).
5
+ //
6
+ // The appearance stream is a Form XObject whose space IS doc space: its
7
+ // /Matrix is the page's doc→user matrix and its /BBox the element's doc
8
+ // bounds clipped to the page, so the operators the flatten path emits are
9
+ // reused byte for byte. /Rect is set to exactly the transformed /BBox, which
10
+ // makes the spec's rect-fitting step (§12.5.5) the identity.
11
+ import { PDFHexString, PDFString, } from 'pdf-lib';
12
+ import { dashIntervals } from '../../annotation/canvas/strokeGeometry.js';
13
+ import { parseHexColor } from './color.js';
14
+ import { applyMatrix, intersectRects, } from './pageMapping.js';
15
+ /** /F flags: Print (bit 3). Not Hidden, not NoView, not Locked. */
16
+ const ANNOT_FLAG_PRINT = 4;
17
+ const rgbArray = (hex) => {
18
+ const { rgb } = parseHexColor(hex);
19
+ return [rgb.red, rgb.green, rgb.blue];
20
+ };
21
+ const round = (n) => Math.round(n * 1000) / 1000;
22
+ const mapPoint = (m, p) => {
23
+ const q = applyMatrix(m, p.x, p.y);
24
+ return [round(q.x), round(q.y)];
25
+ };
26
+ const flatPoints = (m, pts) => pts.flatMap((p) => mapPoint(m, p));
27
+ /** Axis-aligned user-space rect [llx lly urx ury] of a doc rect under `m`. */
28
+ const userRect = (m, r) => {
29
+ const corners = [
30
+ applyMatrix(m, r.x, r.y),
31
+ applyMatrix(m, r.x + r.width, r.y),
32
+ applyMatrix(m, r.x, r.y + r.height),
33
+ applyMatrix(m, r.x + r.width, r.y + r.height),
34
+ ];
35
+ const xs = corners.map((c) => c.x);
36
+ const ys = corners.map((c) => c.y);
37
+ return [
38
+ round(Math.min(...xs)),
39
+ round(Math.min(...ys)),
40
+ round(Math.max(...xs)),
41
+ round(Math.max(...ys)),
42
+ ];
43
+ };
44
+ const lineEnding = (arrow) => (arrow ? 'ClosedArrow' : 'None');
45
+ /** Subtype-specific dictionary entries, in page user space. */
46
+ const metaEntries = (meta, m) => {
47
+ switch (meta.subtype) {
48
+ case 'Ink':
49
+ return {
50
+ C: rgbArray(meta.color),
51
+ BS: { W: meta.width, S: 'S' },
52
+ InkList: meta.paths.map((path) => flatPoints(m, path)),
53
+ };
54
+ case 'Line':
55
+ return {
56
+ C: rgbArray(meta.color),
57
+ IC: rgbArray(meta.color), // arrowhead fill
58
+ BS: borderStyle(meta.width, meta.dash),
59
+ L: [...mapPoint(m, meta.a), ...mapPoint(m, meta.b)],
60
+ LE: [lineEnding(meta.startArrow), lineEnding(meta.endArrow)],
61
+ };
62
+ case 'Square':
63
+ case 'Circle':
64
+ return {
65
+ C: rgbArray(meta.color),
66
+ ...(meta.fill ? { IC: rgbArray(meta.fill) } : {}),
67
+ BS: borderStyle(meta.width, meta.dash),
68
+ };
69
+ case 'Polygon':
70
+ case 'PolyLine':
71
+ return {
72
+ C: rgbArray(meta.color),
73
+ ...(meta.fill ? { IC: rgbArray(meta.fill) } : {}),
74
+ BS: borderStyle(meta.width, meta.dash),
75
+ Vertices: flatPoints(m, meta.vertices),
76
+ ...(meta.subtype === 'PolyLine'
77
+ ? { LE: [lineEnding(meta.startArrow), lineEnding(meta.endArrow)] }
78
+ : {}),
79
+ };
80
+ case 'FreeText': {
81
+ const [r, g, b] = rgbArray(meta.color);
82
+ return {
83
+ DA: PDFString.of(`/Helv ${round(meta.fontSize)} Tf ${r} ${g} ${b} rg`),
84
+ Contents: PDFHexString.fromText(meta.text),
85
+ };
86
+ }
87
+ case 'Stamp':
88
+ return meta.contents
89
+ ? { Contents: PDFHexString.fromText(meta.contents) }
90
+ : {};
91
+ }
92
+ };
93
+ const borderStyle = (width, dash) => dash ? { W: width, S: 'D', D: dashIntervals(width) } : { W: width, S: 'S' };
94
+ const alphaOf = (meta) => ('alpha' in meta ? meta.alpha : 1);
95
+ /**
96
+ * Wrap a mark as an annotation on `page`. Returns the annotation's ref, or
97
+ * null when the mark lies entirely outside the page.
98
+ */
99
+ export const addMarkAnnotation = ({ doc, page, mark, pageRect, matrix, scope, groupWith, }) => {
100
+ const bbox = intersectRects(mark.bounds, pageRect);
101
+ if (!bbox || mark.ops.length === 0)
102
+ return null;
103
+ const { context } = doc;
104
+ const form = context.formXObject(mark.ops, {
105
+ BBox: [
106
+ round(bbox.x),
107
+ round(bbox.y),
108
+ round(bbox.x + bbox.width),
109
+ round(bbox.y + bbox.height),
110
+ ],
111
+ Matrix: matrix.map(round),
112
+ Resources: scope.resources(),
113
+ });
114
+ const formRef = context.register(form);
115
+ const alpha = alphaOf(mark.annot);
116
+ const dict = {
117
+ Type: 'Annot',
118
+ Subtype: mark.annot.subtype,
119
+ Rect: userRect(matrix, bbox),
120
+ F: ANNOT_FLAG_PRINT,
121
+ P: page.ref,
122
+ NM: PDFString.of(mark.id),
123
+ AP: { N: formRef },
124
+ ...(alpha < 1 ? { CA: round(alpha) } : {}),
125
+ ...metaEntries(mark.annot, matrix),
126
+ ...(groupWith ? { IRT: groupWith, RT: 'Group' } : {}),
127
+ };
128
+ // `LiteralObject` is not re-exported by pdf-lib, so the shape is asserted
129
+ // from `obj`'s own parameter type.
130
+ const ref = context.register(context.obj(dict));
131
+ page.node.addAnnot(ref);
132
+ return ref;
133
+ };
134
+ /** How many annotations a page carries (for tests and the result). */
135
+ export const annotationCount = (page) => page.node.Annots()?.size() ?? 0;
@@ -0,0 +1,21 @@
1
+ export interface StripPdfEncryptionOptions {
2
+ /** User password, when the document needs one. Never stored. */
3
+ password?: string;
4
+ /** The PDFium wasm bytes; resolved from `@embedpdf/pdfium` when absent. */
5
+ wasmBinary?: ArrayBuffer;
6
+ }
7
+ export interface StripPdfEncryptionResult {
8
+ bytes: Uint8Array;
9
+ /** False when the input had no /Encrypt and was returned untouched. */
10
+ wasEncrypted: boolean;
11
+ }
12
+ /**
13
+ * Open `bytes` with PDFium and return a plaintext copy when the document is
14
+ * encrypted, or the input untouched when it is not.
15
+ *
16
+ * @throws PdfExportError `password-required` when the document needs a user
17
+ * password and none was given; `password-incorrect` when the given one was
18
+ * refused; `unsupported` for a security handler PDFium cannot open;
19
+ * `corrupt` when the bytes are not a PDF or the copy could not be written.
20
+ */
21
+ export declare const stripPdfEncryption: (bytes: Uint8Array, options?: StripPdfEncryptionOptions) => Promise<StripPdfEncryptionResult>;
@@ -0,0 +1,121 @@
1
+ // Strip a PDF's encryption so pdf-lib can append to it.
2
+ //
3
+ // Files saved by annotation tools (Nutrient included) and by many CAD /
4
+ // Bluebeam exports carry an /Encrypt dictionary with an EMPTY user password:
5
+ // every viewer opens them without a prompt, but every stream inside is still
6
+ // RC4 / AES ciphertext keyed off that empty password. PDFium decrypts on the
7
+ // fly, which is why the canvas renders such a file; pdf-lib cannot decrypt at
8
+ // all and refuses to load it. Appending plaintext marks to a document whose
9
+ // trailer says "encrypted" is not an option either — a viewer would run the
10
+ // document key over our plaintext and paint garbage.
11
+ //
12
+ // So the fix is to open with PDFium (the same wasm build the web canvas
13
+ // renders with), drop the encryption, and save a plaintext copy. A document
14
+ // that genuinely needs a user password is reported as such so a host can
15
+ // prompt for it and call again with `password`; nothing here persists it.
16
+ //
17
+ // NODE ONLY. Reached from the `./server` entry, never from the app barrel:
18
+ // it resolves the wasm off disk and loads `@embedpdf/pdfium` lazily (an
19
+ // optional peer), so a consumer that never calls it never pays for it.
20
+ import { readFile } from 'node:fs/promises';
21
+ import { createRequire } from 'node:module';
22
+ import { PdfExportError } from './types.js';
23
+ /** PDFium's FPDF_GetLastError codes. */
24
+ const FPDF_ERR_PASSWORD = 4;
25
+ const FPDF_ERR_SECURITY = 5;
26
+ let modulePromise = null;
27
+ // `HEAPU8` is an Emscripten runtime field the wrapper's typings omit. Read it
28
+ // through a call every time: Emscripten REPLACES the view when memory grows.
29
+ const heapU8 = (m) => m.pdfium.HEAPU8;
30
+ const loadPdfium = (wasmBinary) => {
31
+ if (wasmBinary)
32
+ return initPdfium(wasmBinary);
33
+ if (modulePromise === null) {
34
+ modulePromise = (async () => {
35
+ const require_ = createRequire(import.meta.url);
36
+ const bytes = await readFile(require_.resolve('@embedpdf/pdfium/pdfium.wasm'));
37
+ return initPdfium(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
38
+ })();
39
+ }
40
+ return modulePromise;
41
+ };
42
+ const initPdfium = async (wasmBinary) => {
43
+ const { init } = await import('@embedpdf/pdfium');
44
+ const m = await init({ wasmBinary });
45
+ m.PDFiumExt_Init();
46
+ return m;
47
+ };
48
+ /**
49
+ * Open `bytes` with PDFium and return a plaintext copy when the document is
50
+ * encrypted, or the input untouched when it is not.
51
+ *
52
+ * @throws PdfExportError `password-required` when the document needs a user
53
+ * password and none was given; `password-incorrect` when the given one was
54
+ * refused; `unsupported` for a security handler PDFium cannot open;
55
+ * `corrupt` when the bytes are not a PDF or the copy could not be written.
56
+ */
57
+ export const stripPdfEncryption = async (bytes, options = {}) => {
58
+ const m = await loadPdfium(options.wasmBinary);
59
+ const { malloc, free } = m.pdfium.wasmExports;
60
+ const ptr = malloc(bytes.byteLength);
61
+ heapU8(m).set(bytes, ptr);
62
+ const doc = m.FPDF_LoadMemDocument(ptr, bytes.byteLength, options.password ?? '');
63
+ if (!doc) {
64
+ const err = m.FPDF_GetLastError();
65
+ free(ptr);
66
+ if (err === FPDF_ERR_PASSWORD) {
67
+ throw options.password !== undefined
68
+ ? new PdfExportError('password-incorrect', 'The PDF password is incorrect')
69
+ : new PdfExportError('password-required', 'This PDF is protected by a password');
70
+ }
71
+ if (err === FPDF_ERR_SECURITY) {
72
+ throw new PdfExportError('unsupported', 'This PDF uses a security handler that cannot be opened');
73
+ }
74
+ throw new PdfExportError('corrupt', `PDFium could not open the PDF (error ${err})`);
75
+ }
76
+ try {
77
+ if (!m.EPDF_IsEncrypted(doc))
78
+ return { bytes, wasEncrypted: false };
79
+ if (!m.EPDF_RemoveEncryption(doc)) {
80
+ throw new PdfExportError('corrupt', 'The PDF encryption could not be removed');
81
+ }
82
+ return { bytes: saveCopy(m, doc), wasEncrypted: true };
83
+ }
84
+ finally {
85
+ m.FPDF_CloseDocument(doc);
86
+ free(ptr);
87
+ }
88
+ };
89
+ /**
90
+ * FPDF_SaveAsCopy through an FPDF_FILEWRITE: `{ int version; int (*WriteBlock)
91
+ * (FPDF_FILEWRITE*, const void*, unsigned long); }` — two 32-bit words in
92
+ * wasm32, the callback registered into the function table for the duration.
93
+ */
94
+ const saveCopy = (m, doc) => {
95
+ const { malloc, free } = m.pdfium.wasmExports;
96
+ const chunks = [];
97
+ const writeBlock = m.pdfium.addFunction((_self, data, size) => {
98
+ // Re-read the heap each call: it is replaced when memory grows.
99
+ chunks.push(heapU8(m).slice(data, data + size));
100
+ return 1;
101
+ }, 'iiii');
102
+ const fileWrite = malloc(8);
103
+ try {
104
+ m.pdfium.setValue(fileWrite, 1, 'i32');
105
+ m.pdfium.setValue(fileWrite + 4, writeBlock, 'i32');
106
+ if (!m.FPDF_SaveAsCopy(doc, fileWrite, 0)) {
107
+ throw new PdfExportError('corrupt', 'PDFium could not write the decrypted copy');
108
+ }
109
+ }
110
+ finally {
111
+ free(fileWrite);
112
+ m.pdfium.removeFunction(writeBlock);
113
+ }
114
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
115
+ let o = 0;
116
+ for (const c of chunks) {
117
+ out.set(c, o);
118
+ o += c.length;
119
+ }
120
+ return out;
121
+ };
@@ -1,4 +1,5 @@
1
- import { PDFDocument, PDFFont, PDFName, PDFPage, type PDFOperator } from 'pdf-lib';
1
+ import { PDFDocument, PDFFont, PDFName, type PDFOperator } from 'pdf-lib';
2
+ import type { ResourceScope } from './resources.js';
2
3
  export type TextAlign = 'left' | 'center' | 'right';
3
4
  export interface TextOptions {
4
5
  align?: TextAlign;
@@ -8,7 +9,6 @@ export interface TextOptions {
8
9
  export declare class PdfFonts {
9
10
  private readonly primary;
10
11
  private readonly fallback;
11
- private readonly keys;
12
12
  private constructor();
13
13
  static create(doc: PDFDocument, primaryBytes: Uint8Array): Promise<PdfFonts>;
14
14
  /** Whether the primary face has a glyph for every code point of `text`. */
@@ -26,13 +26,12 @@ export declare class PdfFonts {
26
26
  dropped: boolean;
27
27
  };
28
28
  width(text: string, size: number): number;
29
- private keyFor;
30
29
  /**
31
30
  * Operators drawing one line of text in a y-DOWN space, with its baseline at
32
31
  * `yBaseline` and its left / centre / right edge at `x`. Returns [] when
33
32
  * nothing of the text is drawable.
34
33
  */
35
- textOps(page: PDFPage, text: string, size: number, color: string, x: number, yBaseline: number, options?: TextOptions): PDFOperator[];
34
+ textOps(scope: ResourceScope, text: string, size: number, color: string, x: number, yBaseline: number, options?: TextOptions): PDFOperator[];
36
35
  /** Largest size ≤ `max` at which `text` fits in `maxWidth`. */
37
36
  fitSize(text: string, maxWidth: number, max: number, min?: number): number;
38
37
  }
@@ -10,7 +10,6 @@ export class PdfFonts {
10
10
  constructor(primary, fallback) {
11
11
  this.primary = primary;
12
12
  this.fallback = fallback;
13
- this.keys = new Map();
14
13
  }
15
14
  static async create(doc, primaryBytes) {
16
15
  doc.registerFontkit(fontkit);
@@ -52,23 +51,12 @@ export class PdfFonts {
52
51
  const p = this.pick(text);
53
52
  return p.text ? p.font.widthOfTextAtSize(p.text, size) : 0;
54
53
  }
55
- keyFor(page, font) {
56
- let keys = this.keys.get(page);
57
- if (!keys) {
58
- keys = {
59
- primary: page.node.newFontDictionary(this.primary.name, this.primary.ref),
60
- fallback: page.node.newFontDictionary(this.fallback.name, this.fallback.ref),
61
- };
62
- this.keys.set(page, keys);
63
- }
64
- return font === this.primary ? keys.primary : keys.fallback;
65
- }
66
54
  /**
67
55
  * Operators drawing one line of text in a y-DOWN space, with its baseline at
68
56
  * `yBaseline` and its left / centre / right edge at `x`. Returns [] when
69
57
  * nothing of the text is drawable.
70
58
  */
71
- textOps(page, text, size, color, x, yBaseline, options = {}) {
59
+ textOps(scope, text, size, color, x, yBaseline, options = {}) {
72
60
  const picked = this.pick(text);
73
61
  if (!picked.text || size <= 0)
74
62
  return [];
@@ -77,7 +65,7 @@ export class PdfFonts {
77
65
  const left = align === 'center' ? x - w / 2 : align === 'right' ? x - w : x;
78
66
  return withMatrix(uprightAt(left, yBaseline), drawText(picked.font.encodeText(picked.text), {
79
67
  color: parseHexColor(color).rgb,
80
- font: this.keyFor(page, picked.font),
68
+ font: scope.fontKey(picked.font),
81
69
  size,
82
70
  rotate: degrees(0),
83
71
  xSkew: degrees(0),
@@ -1,5 +1,7 @@
1
1
  export { renderCanvasToPdf } from './renderCanvasToPdf.js';
2
+ export { stripPdfEncryption, type StripPdfEncryptionOptions, type StripPdfEncryptionResult, } from './decrypt.js';
2
3
  export { docToPageMatrix, pageRectsOf, normalizeRotation, rotatedBoxSize, applyMatrix, type AffineMatrix, type PageRotation, type PdfBox, } from './pageMapping.js';
3
4
  export { resolveTileContent, measurementTileFillFor, FORMULA_COLUMN_TILE_FILL, PLANE_COMPUTED_TILE_FILL, type TileFileInfo, type TileResolutionContext, } from './tileContent.js';
4
5
  export { tileFootprint, type TileFootprint } from './tiles.js';
6
+ export { annotationCount } from './annots.js';
5
7
  export { PdfExportError, type EmbeddedImage, type PdfExportCalibration, type RenderCanvasToPdfInput, type RenderCanvasToPdfResult, type ResolvedTileContent, type MeasurementTileContent, type LiteralTileContent, type EmptyTileContent, type HeaderTileContent, type ObjectTileContent, type PlaneDimensionTileContent, type InstructionsTileContent, type SkippedItem, } from './types.js';
@@ -1,5 +1,7 @@
1
1
  export { renderCanvasToPdf } from './renderCanvasToPdf.js';
2
+ export { stripPdfEncryption, } from './decrypt.js';
2
3
  export { docToPageMatrix, pageRectsOf, normalizeRotation, rotatedBoxSize, applyMatrix, } from './pageMapping.js';
3
4
  export { resolveTileContent, measurementTileFillFor, FORMULA_COLUMN_TILE_FILL, PLANE_COMPUTED_TILE_FILL, } from './tileContent.js';
4
5
  export { tileFootprint } from './tiles.js';
6
+ export { annotationCount } from './annots.js';
5
7
  export { PdfExportError, } from './types.js';
@@ -1,16 +1,73 @@
1
- import { type PDFName, type PDFOperator, type PDFPage } from 'pdf-lib';
2
- import type { AnnotationShape, AnnotationStroke, PlacedMeasurementRef } from '../../types/annotation.js';
1
+ import { type PDFOperator } from 'pdf-lib';
2
+ import type { AnnotationShape, AnnotationStroke, PlacedMeasurementRef, Vec2 } from '../../types/annotation.js';
3
3
  import { dashIntervals } from '../../annotation/canvas/strokeGeometry.js';
4
4
  import type { PdfFonts } from './fonts.js';
5
5
  import { type DocRect } from './pageMapping.js';
6
+ import type { ResourceScope } from './resources.js';
6
7
  import type { SkippedItem } from './types.js';
7
8
  export interface MarkContext {
8
- page: PDFPage;
9
+ /** Where fonts, images and opacity states get registered. */
10
+ scope: ResourceScope;
9
11
  fonts: PdfFonts;
10
- /** An ExtGState carrying `alpha` (CA/ca) for this page; undefined at 1. */
11
- alphaState: (alpha: number) => PDFName | undefined;
12
12
  skipped: SkippedItem[];
13
13
  }
14
+ /**
15
+ * What an element IS, for the annotation dictionary that wraps its
16
+ * appearance stream in annotation mode: the standard fields a viewer reads
17
+ * back (and regenerates from after an edit). Points and rects are in DOC
18
+ * space; the annotation builder maps them onto the page.
19
+ */
20
+ export type AnnotMeta = {
21
+ subtype: 'Ink';
22
+ color: string;
23
+ width: number;
24
+ alpha: number;
25
+ paths: Vec2[][];
26
+ } | {
27
+ subtype: 'Line';
28
+ color: string;
29
+ width: number;
30
+ alpha: number;
31
+ dash: boolean;
32
+ a: Vec2;
33
+ b: Vec2;
34
+ startArrow: boolean;
35
+ endArrow: boolean;
36
+ } | {
37
+ subtype: 'Square' | 'Circle';
38
+ color: string;
39
+ fill?: string;
40
+ width: number;
41
+ alpha: number;
42
+ dash: boolean;
43
+ rect: DocRect;
44
+ } | {
45
+ subtype: 'Polygon' | 'PolyLine';
46
+ color: string;
47
+ fill?: string;
48
+ width: number;
49
+ alpha: number;
50
+ dash: boolean;
51
+ vertices: Vec2[];
52
+ startArrow: boolean;
53
+ endArrow: boolean;
54
+ } | {
55
+ subtype: 'FreeText';
56
+ color: string;
57
+ fontSize: number;
58
+ text: string;
59
+ alpha: number;
60
+ } | {
61
+ subtype: 'Stamp';
62
+ contents?: string;
63
+ };
64
+ /** One drawable element: its operators, its doc-space bounds, its identity. */
65
+ export interface Mark {
66
+ id: string;
67
+ ops: PDFOperator[];
68
+ bounds: DocRect;
69
+ annot: AnnotMeta;
70
+ }
14
71
  export declare const strokeBounds: (s: AnnotationStroke) => DocRect | null;
15
72
  export declare const strokeOps: (s: AnnotationStroke, ctx: MarkContext) => PDFOperator[];
16
73
  export declare const shapeBounds: (shape: AnnotationShape) => DocRect | null;
@@ -18,3 +75,7 @@ export declare const shapeOps: (shape: AnnotationShape, ctx: MarkContext) => PDF
18
75
  export declare const measurementMarkBounds: (placed: PlacedMeasurementRef) => DocRect | null;
19
76
  export declare const measurementMarkOps: (placed: PlacedMeasurementRef, ctx: MarkContext) => PDFOperator[];
20
77
  export { dashIntervals };
78
+ export declare const strokeMark: (s: AnnotationStroke, ctx: MarkContext) => Mark | null;
79
+ export declare const shapeMark: (shape: AnnotationShape, ctx: MarkContext) => Mark | null;
80
+ /** The measurement annotation's line / rectangle (not its tile). */
81
+ export declare const measurementMark: (placed: PlacedMeasurementRef, ctx: MarkContext) => Mark | null;
@@ -66,7 +66,7 @@ export const strokeOps = (s, ctx) => {
66
66
  return [];
67
67
  const opacity = s.tool === 'highlighter' ? 0.3 : 1;
68
68
  const alpha = opacity * parseHexColor(s.color).alpha;
69
- const gs = ctx.alphaState(alpha);
69
+ const gs = ctx.scope.alphaState(alpha);
70
70
  const isDot = n === 4 && s.points[0] === s.points[2] && s.points[1] === s.points[3];
71
71
  if (isDot) {
72
72
  // Skia paints a zero-length round-capped contour as a dot; PDF only does
@@ -137,7 +137,7 @@ const textShapeOps = (shape, ctx) => {
137
137
  const colorStr = shape.style.stroke ?? '#000000';
138
138
  const decoration = shape.style.textDecoration;
139
139
  const glyphColor = decoration === 'highlight' ? highlightTextColor(colorStr) : colorStr;
140
- const gs = ctx.alphaState(parseHexColor(colorStr).alpha);
140
+ const gs = ctx.scope.alphaState(parseHexColor(colorStr).alpha);
141
141
  const lineHeight = TEXT_LINE_HEIGHT_FACTOR * fontSize;
142
142
  const ops = [];
143
143
  let lost = false;
@@ -151,7 +151,7 @@ const textShapeOps = (shape, ctx) => {
151
151
  if (decoration === 'highlight' && width > 0) {
152
152
  ops.push(pushGraphicsState(), ...fillStateOps(colorStr, gs), rectangle(origin.x, top, width, lineHeight), fill(), popGraphicsState());
153
153
  }
154
- ops.push(...ctx.fonts.textOps(ctx.page, line, fontSize, glyphColor, origin.x, baseline, {
154
+ ops.push(...ctx.fonts.textOps(ctx.scope, line, fontSize, glyphColor, origin.x, baseline, {
155
155
  graphicsState: gs,
156
156
  }));
157
157
  if ((decoration === 'underline' || decoration === 'squiggle') &&
@@ -177,7 +177,7 @@ export const shapeOps = (shape, ctx) => {
177
177
  return textShapeOps(shape, ctx);
178
178
  const strokeColor = style.stroke ?? '#000000';
179
179
  const strokeWidth = style.strokeWidth ?? 2;
180
- const gs = ctx.alphaState(parseHexColor(strokeColor).alpha);
180
+ const gs = ctx.scope.alphaState(parseHexColor(strokeColor).alpha);
181
181
  const closed = geometry.closed !== false;
182
182
  const pts = geometry.points;
183
183
  const strokeWith = (pathOps, join, cap) => [
@@ -197,7 +197,7 @@ export const shapeOps = (shape, ctx) => {
197
197
  const fillWith = (pathOps) => style.fill
198
198
  ? [
199
199
  pushGraphicsState(),
200
- ...fillStateOps(style.fill, ctx.alphaState(parseHexColor(style.fill).alpha)),
200
+ ...fillStateOps(style.fill, ctx.scope.alphaState(parseHexColor(style.fill).alpha)),
201
201
  ...pathOps,
202
202
  fill(),
203
203
  popGraphicsState(),
@@ -298,7 +298,7 @@ export const measurementMarkOps = (placed, ctx) => {
298
298
  width,
299
299
  join: 'miter',
300
300
  dash: !!placed.lineDash,
301
- graphicsState: ctx.alphaState(parseHexColor(color).alpha),
301
+ graphicsState: ctx.scope.alphaState(parseHexColor(color).alpha),
302
302
  }),
303
303
  ...rectPathOps(n.minX, n.minY, n.maxX - n.minX, n.maxY - n.minY),
304
304
  stroke(),
@@ -309,7 +309,7 @@ export const measurementMarkOps = (placed, ctx) => {
309
309
  const { a, b } = placed.line;
310
310
  const color = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
311
311
  const width = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
312
- const gs = ctx.alphaState(parseHexColor(color).alpha);
312
+ const gs = ctx.scope.alphaState(parseHexColor(color).alpha);
313
313
  const ops = [
314
314
  pushGraphicsState(),
315
315
  ...strokeStateOps({
@@ -334,3 +334,146 @@ export const measurementMarkOps = (placed, ctx) => {
334
334
  };
335
335
  // Re-exported so the renderer can size dash tests without a second import.
336
336
  export { dashIntervals };
337
+ /* ------------------------------------------------------------------ *
338
+ * Marks: ops + bounds + annotation metadata, per element
339
+ * ------------------------------------------------------------------ */
340
+ const strokePaths = (s) => {
341
+ const pts = [];
342
+ for (let i = 0; i + 1 < s.points.length; i += 2) {
343
+ pts.push({ x: s.points[i], y: s.points[i + 1] });
344
+ }
345
+ return pts;
346
+ };
347
+ export const strokeMark = (s, ctx) => {
348
+ const bounds = strokeBounds(s);
349
+ if (!bounds)
350
+ return null;
351
+ const opacity = s.tool === 'highlighter' ? 0.3 : 1;
352
+ return {
353
+ id: s.id,
354
+ ops: strokeOps(s, ctx),
355
+ bounds,
356
+ annot: {
357
+ subtype: 'Ink',
358
+ color: s.color,
359
+ width: s.width,
360
+ alpha: opacity * parseHexColor(s.color).alpha,
361
+ paths: [strokePaths(s)],
362
+ },
363
+ };
364
+ };
365
+ export const shapeMark = (shape, ctx) => {
366
+ const bounds = shapeBounds(shape);
367
+ if (!bounds)
368
+ return null;
369
+ const ops = shapeOps(shape, ctx);
370
+ const { kind, geometry, style } = shape;
371
+ const color = style.stroke ?? '#000000';
372
+ const width = style.strokeWidth ?? 2;
373
+ const alpha = parseHexColor(color).alpha;
374
+ const dash = !!style.dash;
375
+ const pts = geometry.points;
376
+ let annot;
377
+ switch (kind) {
378
+ case 'rect':
379
+ case 'ellipse': {
380
+ const b = visualShapeBounds(shape);
381
+ annot = {
382
+ subtype: kind === 'rect' ? 'Square' : 'Circle',
383
+ color,
384
+ fill: style.fill,
385
+ width,
386
+ alpha,
387
+ dash,
388
+ rect: boundsToRect(b),
389
+ };
390
+ break;
391
+ }
392
+ case 'line':
393
+ case 'arrow':
394
+ annot = {
395
+ subtype: 'Line',
396
+ color,
397
+ width,
398
+ alpha,
399
+ dash,
400
+ a: pts[0],
401
+ b: pts[1],
402
+ startArrow: style.startCap === 'arrow',
403
+ endArrow: kind === 'arrow' || style.cap === 'arrow',
404
+ };
405
+ break;
406
+ case 'polygon': {
407
+ const closed = geometry.closed !== false;
408
+ annot = {
409
+ subtype: closed ? 'Polygon' : 'PolyLine',
410
+ color,
411
+ fill: closed ? style.fill : undefined,
412
+ width,
413
+ alpha,
414
+ dash,
415
+ vertices: pts,
416
+ startArrow: !closed && style.startCap === 'arrow',
417
+ endArrow: !closed && style.cap === 'arrow',
418
+ };
419
+ break;
420
+ }
421
+ case 'text':
422
+ annot = {
423
+ subtype: 'FreeText',
424
+ color,
425
+ fontSize: style.fontSize ?? DEFAULT_TEXT_FONT_SIZE,
426
+ text: shape.text ?? '',
427
+ alpha,
428
+ };
429
+ break;
430
+ default:
431
+ return null;
432
+ }
433
+ return { id: shape.id, ops, bounds, annot };
434
+ };
435
+ /** The measurement annotation's line / rectangle (not its tile). */
436
+ export const measurementMark = (placed, ctx) => {
437
+ const bounds = measurementMarkBounds(placed);
438
+ if (!bounds)
439
+ return null;
440
+ const ops = measurementMarkOps(placed, ctx);
441
+ const color = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
442
+ const alpha = parseHexColor(color).alpha;
443
+ const dash = !!placed.lineDash;
444
+ if (placementOf(placed) === 'rectangle' && placed.rect) {
445
+ const n = normalizeRect(placed.rect);
446
+ return {
447
+ id: `${placed.id}:rect`,
448
+ ops,
449
+ bounds,
450
+ annot: {
451
+ subtype: 'Square',
452
+ color,
453
+ width: placed.lineWidth ?? MEASUREMENT_RECT_WIDTH,
454
+ alpha,
455
+ dash,
456
+ rect: boundsToRect(n),
457
+ },
458
+ };
459
+ }
460
+ if (placementOf(placed) === 'line' && placed.line) {
461
+ return {
462
+ id: `${placed.id}:line`,
463
+ ops,
464
+ bounds,
465
+ annot: {
466
+ subtype: 'Line',
467
+ color,
468
+ width: placed.lineWidth ?? MEASUREMENT_LINE_WIDTH,
469
+ alpha,
470
+ dash,
471
+ a: placed.line.a,
472
+ b: placed.line.b,
473
+ startArrow: placed.lineStartCap === 'arrow',
474
+ endArrow: placed.lineCap === 'arrow',
475
+ },
476
+ };
477
+ }
478
+ return null;
479
+ };
@@ -39,6 +39,8 @@ export declare const applyMatrix: (m: AffineMatrix, x: number, y: number) => {
39
39
  x: number;
40
40
  y: number;
41
41
  };
42
+ /** The overlap of two rects, or null when they do not overlap. */
43
+ export declare const intersectRects: (a: DocRect, b: DocRect) => DocRect | null;
42
44
  export declare const rectsIntersect: (a: DocRect, b: DocRect) => boolean;
43
45
  /** Axis-aligned bounds → DocRect, inflated by `pad` on every side. */
44
46
  export declare const boundsToRect: (b: {
@@ -54,6 +54,16 @@ export const applyMatrix = (m, x, y) => ({
54
54
  x: m[0] * x + m[2] * y + m[4],
55
55
  y: m[1] * x + m[3] * y + m[5],
56
56
  });
57
+ /** The overlap of two rects, or null when they do not overlap. */
58
+ export const intersectRects = (a, b) => {
59
+ const x = Math.max(a.x, b.x);
60
+ const y = Math.max(a.y, b.y);
61
+ const right = Math.min(a.x + a.width, b.x + b.width);
62
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
63
+ if (right <= x || bottom <= y)
64
+ return null;
65
+ return { x, y, width: right - x, height: bottom - y };
66
+ };
57
67
  export const rectsIntersect = (a, b) => a.x < b.x + b.width &&
58
68
  a.x + a.width > b.x &&
59
69
  a.y < b.y + b.height &&
@@ -9,9 +9,11 @@ import { STAMP_TILE_SIZE } from '../../annotation/canvas/stampLayout.js';
9
9
  import { clipRectOps, withMatrix } from './emit.js';
10
10
  import { PdfFonts } from './fonts.js';
11
11
  import { layerOps, layerVisibleRect } from './layers.js';
12
- import { measurementMarkBounds, measurementMarkOps, shapeBounds, shapeOps, strokeBounds, strokeOps, } from './marks.js';
12
+ import { addMarkAnnotation } from './annots.js';
13
+ import { measurementMark, measurementMarkBounds, measurementMarkOps, shapeBounds, shapeMark, shapeOps, strokeBounds, strokeMark, strokeOps, } from './marks.js';
14
+ import { FormScope, PageScope } from './resources.js';
13
15
  import { docToPageMatrix, normalizeRotation, pageRectsOf, rectsIntersect, rotatedBoxSize, } from './pageMapping.js';
14
- import { designBoxFor, tileFootprint, tileOps, } from './tiles.js';
16
+ import { designBoxFor, tileFootprint, tileOps } from './tiles.js';
15
17
  import { PdfExportError, } from './types.js';
16
18
  /** Stored page sizes may differ from the file's boxes by rounding only. */
17
19
  const PAGE_SIZE_TOLERANCE_PT = 1.5;
@@ -41,8 +43,11 @@ const loadSource = async (bytes) => {
41
43
  return await PDFDocument.load(bytes, { updateMetadata: false });
42
44
  }
43
45
  catch (e) {
44
- if (e instanceof EncryptedPDFError) {
45
- throw new PdfExportError('encrypted', 'The source PDF is encrypted and cannot be exported');
46
+ // Both checks: pdf-lib's dual CJS/ESM build can hand back an
47
+ // EncryptedPDFError whose class identity differs from the one imported.
48
+ if (e instanceof EncryptedPDFError ||
49
+ (e instanceof Error && /is encrypted/i.test(e.message))) {
50
+ throw new PdfExportError('encrypted', 'The source PDF is encrypted; strip it with stripPdfEncryption before rendering');
46
51
  }
47
52
  throw new PdfExportError('corrupt', `The source PDF could not be parsed: ${e instanceof Error ? e.message : String(e)}`);
48
53
  }
@@ -134,68 +139,150 @@ export const renderCanvasToPdf = async (input) => {
134
139
  .zoom, 1e-6);
135
140
  const hiddenLayerIds = new Set(canvas.layers.filter((l) => l.visible === false).map((l) => l.id));
136
141
  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)
142
+ const mode = input.mode ?? 'annotations';
143
+ let annotationCount = 0;
144
+ /** Content string for a tile's annotation (what Acrobat's comment shows). */
145
+ const tileContents = (content) => {
146
+ switch (content.kind) {
147
+ case 'measurement':
148
+ return [
149
+ content.label,
150
+ `${content.negative ? '-' : ''}${content.value} ${content.unit}`.trim(),
151
+ ]
152
+ .filter(Boolean)
153
+ .join(': ');
154
+ case 'planeDimension':
155
+ return `${content.value} ${content.unit}`.trim();
156
+ case 'header':
157
+ return (content.descriptor.name ??
158
+ content.descriptor.badges.map((b) => b.text).join(' '));
159
+ case 'object':
160
+ return content.name;
161
+ case 'literal':
162
+ return content.value || undefined;
163
+ case 'instructions':
164
+ return content.text || undefined;
165
+ default:
143
166
  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);
167
+ }
168
+ };
169
+ for (const { page, box, rotation, rect } of geometry) {
170
+ const matrix = docToPageMatrix(rect, box, rotation);
171
+ const onPage = (b) => b !== null && rectsIntersect(b, rect);
172
+ if (mode === 'flatten') {
173
+ // Everything into the page's content stream, under one matrix + clip.
174
+ const scope = new PageScope(page, doc.context);
175
+ const ctx = { scope, fonts, skipped };
176
+ const ops = [];
177
+ for (const layer of layers) {
178
+ const img = layerImages.get(layer.id);
179
+ if (!img || !onPage(layerVisibleRect(layer)))
180
+ continue;
181
+ ops.push(...layerOps(layer, scope.imageKey(img)));
182
+ }
183
+ for (const s of canvas.strokes) {
184
+ if (!visible(s.layerId) || !onPage(strokeBounds(s)))
185
+ continue;
186
+ ops.push(...strokeOps(s, ctx));
187
+ }
188
+ for (const sh of canvas.shapes) {
189
+ if (!visible(sh.layerId) || !onPage(shapeBounds(sh)))
190
+ continue;
191
+ ops.push(...shapeOps(sh, ctx));
192
+ }
193
+ for (const { placed } of tiles) {
194
+ if (!visible(placed.layerId) || !onPage(measurementMarkBounds(placed)))
195
+ continue;
196
+ ops.push(...measurementMarkOps(placed, ctx));
197
+ }
198
+ for (const { placed, content } of tiles) {
199
+ if (!visible(placed.layerId))
200
+ continue;
201
+ const footprint = tileFootprint(placed, canvas, input.calibration, fallbackDocEdge, designBoxFor(placed, content));
202
+ if (!onPage(footprint))
203
+ continue;
204
+ ops.push(...tileOps({ scope, fonts, thumbnail: thumbnails.get(placed.id) }, content, footprint));
148
205
  }
149
- return name;
206
+ if (ops.length === 0)
207
+ continue;
208
+ page.pushOperators(...withMatrix(matrix, [
209
+ ...clipRectOps(rect.x, rect.y, rect.width, rect.height),
210
+ ...ops,
211
+ ]));
212
+ continue;
213
+ }
214
+ // Annotation mode: one annotation per element, each with its own
215
+ // appearance stream and resources. Same z-order as the screen.
216
+ const add = (mark, scope, groupWith) => {
217
+ if (!mark)
218
+ return null;
219
+ const ref = addMarkAnnotation({
220
+ doc,
221
+ page,
222
+ mark,
223
+ pageRect: rect,
224
+ matrix,
225
+ scope,
226
+ groupWith,
227
+ });
228
+ if (ref)
229
+ annotationCount++;
230
+ return ref;
150
231
  };
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
232
  for (const layer of layers) {
161
233
  const img = layerImages.get(layer.id);
162
- if (!img || !onPage(layerVisibleRect(layer)))
234
+ const bounds = layerVisibleRect(layer);
235
+ if (!img || !onPage(bounds))
163
236
  continue;
164
- ops.push(...layerOps(layer, registerImage(img).name));
237
+ const scope = new FormScope(doc.context);
238
+ add({
239
+ id: layer.id,
240
+ ops: layerOps(layer, scope.imageKey(img)),
241
+ bounds,
242
+ annot: {
243
+ subtype: 'Stamp',
244
+ contents: layer.fileId ? 'Placed file' : 'Background image',
245
+ },
246
+ }, scope);
165
247
  }
166
- // 2. Strokes, then shapes, then measurement lines / rects — the Skia order.
167
248
  for (const s of canvas.strokes) {
168
249
  if (!visible(s.layerId) || !onPage(strokeBounds(s)))
169
250
  continue;
170
- ops.push(...strokeOps(s, ctx));
251
+ const scope = new FormScope(doc.context);
252
+ add(strokeMark(s, { scope, fonts, skipped }), scope);
171
253
  }
172
254
  for (const sh of canvas.shapes) {
173
255
  if (!visible(sh.layerId) || !onPage(shapeBounds(sh)))
174
256
  continue;
175
- ops.push(...shapeOps(sh, ctx));
257
+ const scope = new FormScope(doc.context);
258
+ add(shapeMark(sh, { scope, fonts, skipped }), scope);
176
259
  }
260
+ // A measurement's line/rect and its tile are grouped (Acrobat honours
261
+ // /IRT + /RT /Group; other viewers treat them independently).
262
+ const markRefs = new Map();
177
263
  for (const { placed } of tiles) {
178
264
  if (!visible(placed.layerId) || !onPage(measurementMarkBounds(placed)))
179
265
  continue;
180
- ops.push(...measurementMarkOps(placed, ctx));
266
+ const scope = new FormScope(doc.context);
267
+ const ref = add(measurementMark(placed, { scope, fonts, skipped }), scope);
268
+ if (ref)
269
+ markRefs.set(placed.id, ref);
181
270
  }
182
- // 3. Tiles on top, like the overlay.
183
271
  for (const { placed, content } of tiles) {
184
272
  if (!visible(placed.layerId))
185
273
  continue;
186
274
  const footprint = tileFootprint(placed, canvas, input.calibration, fallbackDocEdge, designBoxFor(placed, content));
187
275
  if (!onPage(footprint))
188
276
  continue;
189
- const thumb = thumbnails.get(placed.id);
190
- ops.push(...tileOps({ page, fonts, thumbnail: thumb ? registerImage(thumb) : undefined }, content, footprint));
277
+ const scope = new FormScope(doc.context);
278
+ add({
279
+ id: placed.id,
280
+ ops: tileOps({ scope, fonts, thumbnail: thumbnails.get(placed.id) }, content, footprint),
281
+ bounds: footprint,
282
+ annot: { subtype: 'Stamp', contents: tileContents(content) },
283
+ }, scope, markRefs.get(placed.id));
191
284
  }
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
285
  }
199
286
  const pdf = await doc.save({ useObjectStreams: false });
200
- return { pdf, pageCount: pages.length, skipped };
287
+ return { pdf, pageCount: pages.length, annotationCount, skipped };
201
288
  };
@@ -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. */
@@ -133,15 +133,15 @@ const valueRowOps = (ctx, value, cx, cy, color, availableWidth, tileWidth) => {
133
133
  tokens.forEach((t, i) => {
134
134
  const w = widths[i];
135
135
  if (!t.fraction) {
136
- ops.push(...ctx.fonts.textOps(ctx.page, t.text, fs, color, x, baseline));
136
+ ops.push(...ctx.fonts.textOps(ctx.scope, t.text, fs, color, x, baseline));
137
137
  }
138
138
  else {
139
139
  const stackW = Math.max(ctx.fonts.width(t.fraction.num, fracFs), ctx.fonts.width(t.fraction.den, fracFs));
140
140
  const scx = x + stackW / 2;
141
141
  // Numerator above the rule, denominator below; rule at the row centre.
142
- ops.push(...ctx.fonts.textOps(ctx.page, t.fraction.num, fracFs, color, scx, cy - fracFs * 0.15, { align: 'center' }), pushGraphicsState(), ...fillStateOps(color), rectangle(x, cy - fracFs * 0.05, stackW, Math.max(fracFs * 0.08, 0.8)), fill(), popGraphicsState(), ...ctx.fonts.textOps(ctx.page, t.fraction.den, fracFs, color, scx, cy + fracFs * 0.85, { align: 'center' }));
142
+ ops.push(...ctx.fonts.textOps(ctx.scope, t.fraction.num, fracFs, color, scx, cy - fracFs * 0.15, { align: 'center' }), pushGraphicsState(), ...fillStateOps(color), rectangle(x, cy - fracFs * 0.05, stackW, Math.max(fracFs * 0.08, 0.8)), fill(), popGraphicsState(), ...ctx.fonts.textOps(ctx.scope, t.fraction.den, fracFs, color, scx, cy + fracFs * 0.85, { align: 'center' }));
143
143
  if (t.fraction.suffix) {
144
- ops.push(...ctx.fonts.textOps(ctx.page, t.fraction.suffix, fs, color, x + stackW, baseline));
144
+ ops.push(...ctx.fonts.textOps(ctx.scope, t.fraction.suffix, fs, color, x + stackW, baseline));
145
145
  }
146
146
  }
147
147
  x += w + gap;
@@ -156,7 +156,7 @@ const measurementTileOps = (ctx, c) => {
156
156
  const white = GeneralColors.white;
157
157
  // Header row: position label, bold, top-left.
158
158
  if (c.positionLabel) {
159
- ops.push(...ctx.fonts.textOps(ctx.page, c.positionLabel, 12, white, 6, 14));
159
+ ops.push(...ctx.fonts.textOps(ctx.scope, c.positionLabel, 12, white, 6, 14));
160
160
  }
161
161
  // Value row, centred on the tile, between the two unit slots.
162
162
  const hasUnit = !!c.unit;
@@ -164,7 +164,7 @@ const measurementTileOps = (ctx, c) => {
164
164
  const valueText = (c.negative ? '-' : '') + c.value;
165
165
  ops.push(...valueRowOps(ctx, valueText, SQUARE / 2, 48, white, available, SQUARE));
166
166
  if (hasUnit) {
167
- ops.push(...ctx.fonts.textOps(ctx.page, c.unit, UNIT_FONT, white, SQUARE - UNIT_RESERVE / 2, 52, {
167
+ ops.push(...ctx.fonts.textOps(ctx.scope, c.unit, UNIT_FONT, white, SQUARE - UNIT_RESERVE / 2, 52, {
168
168
  align: 'center',
169
169
  }));
170
170
  }
@@ -175,7 +175,7 @@ const measurementTileOps = (ctx, c) => {
175
175
  ops.push(...filledRoundedRect(4, SQUARE - 20, SQUARE - 8, 16, 3, FormulaColors.darkPurple), ...strokedRoundedRect(4, SQUARE - 20, SQUARE - 8, 16, 3, FormulaColors.purple, 1));
176
176
  }
177
177
  const text = truncate(ctx.fonts, c.label, labelSize, SQUARE - 12);
178
- ops.push(...ctx.fonts.textOps(ctx.page, text, labelSize, white, SQUARE / 2, SQUARE - 8, { align: 'center' }));
178
+ ops.push(...ctx.fonts.textOps(ctx.scope, text, labelSize, white, SQUARE / 2, SQUARE - 8, { align: 'center' }));
179
179
  }
180
180
  return ops;
181
181
  };
@@ -186,9 +186,9 @@ const emptyTileOps = (ctx, c) => {
186
186
  ...strokedRoundedRect(0, 0, SQUARE, SQUARE, CARD_RADIUS, GeneralColors.grey3, 1),
187
187
  ];
188
188
  if (c.topLeftLabel)
189
- ops.push(...ctx.fonts.textOps(ctx.page, c.topLeftLabel, 10, ink, 8, 18));
189
+ ops.push(...ctx.fonts.textOps(ctx.scope, c.topLeftLabel, 10, ink, 8, 18));
190
190
  if (c.topRightLabel) {
191
- ops.push(...ctx.fonts.textOps(ctx.page, c.topRightLabel, 10, ink, SQUARE - 8, 18, {
191
+ ops.push(...ctx.fonts.textOps(ctx.scope, c.topRightLabel, 10, ink, SQUARE - 8, 18, {
192
192
  align: 'right',
193
193
  }));
194
194
  }
@@ -205,7 +205,7 @@ const emptyTileOps = (ctx, c) => {
205
205
  ]), stroke(), popGraphicsState());
206
206
  if (c.bottomLabel) {
207
207
  const text = truncate(ctx.fonts, c.bottomLabel, 10, SQUARE - 16);
208
- ops.push(...ctx.fonts.textOps(ctx.page, text, 10, ink, cx, SQUARE - 10, {
208
+ ops.push(...ctx.fonts.textOps(ctx.scope, text, 10, ink, cx, SQUARE - 10, {
209
209
  align: 'center',
210
210
  }));
211
211
  }
@@ -216,17 +216,17 @@ const literalTileOps = (ctx, c) => {
216
216
  const fillColor = filled ? TileColors.activeBlue : GeneralColors.grey2;
217
217
  const ink = filled ? GeneralColors.white : GeneralColors.grey4;
218
218
  const ops = filledRoundedRect(0, 0, SQUARE, SQUARE, CARD_RADIUS, fillColor);
219
- ops.push(...ctx.fonts.textOps(ctx.page, c.topLeftLabel, 10, ink, 8, 18));
220
- ops.push(...ctx.fonts.textOps(ctx.page, c.topRightLabel, 10, ink, SQUARE - 8, 18, {
219
+ ops.push(...ctx.fonts.textOps(ctx.scope, c.topLeftLabel, 10, ink, 8, 18));
220
+ ops.push(...ctx.fonts.textOps(ctx.scope, c.topRightLabel, 10, ink, SQUARE - 8, 18, {
221
221
  align: 'right',
222
222
  }));
223
223
  ops.push(...filledRoundedRect(8, 28, SQUARE - 16, 40, 4, filled ? TileColors.activeBlue : GeneralColors.grey3));
224
224
  if (filled) {
225
225
  const size = ctx.fonts.fitSize(c.value, SQUARE - 24, 16, 6);
226
- ops.push(...ctx.fonts.textOps(ctx.page, c.value, size, ink, SQUARE / 2, 48 + size * 0.35, { align: 'center' }));
226
+ ops.push(...ctx.fonts.textOps(ctx.scope, c.value, size, ink, SQUARE / 2, 48 + size * 0.35, { align: 'center' }));
227
227
  }
228
228
  const bottom = truncate(ctx.fonts, c.bottomLabel, 10, SQUARE - 16);
229
- ops.push(...ctx.fonts.textOps(ctx.page, bottom, 10, ink, SQUARE / 2, SQUARE - 10, {
229
+ ops.push(...ctx.fonts.textOps(ctx.scope, bottom, 10, ink, SQUARE / 2, SQUARE - 10, {
230
230
  align: 'center',
231
231
  }));
232
232
  return ops;
@@ -273,7 +273,7 @@ const headerTileOps = (ctx, c) => {
273
273
  ops.push(...globeOps(W / 2, by + S.badgeHeight / 2, S.globeSize, S.globeColor));
274
274
  else {
275
275
  const text = truncate(ctx.fonts, b.text, S.badgeFontSize, inner - 10);
276
- ops.push(...ctx.fonts.textOps(ctx.page, text, S.badgeFontSize, S.badgeTextColor, W / 2, by + S.badgeHeight / 2 + S.badgeFontSize * 0.35, { align: 'center' }));
276
+ ops.push(...ctx.fonts.textOps(ctx.scope, text, S.badgeFontSize, S.badgeTextColor, W / 2, by + S.badgeHeight / 2 + S.badgeFontSize * 0.35, { align: 'center' }));
277
277
  }
278
278
  });
279
279
  }
@@ -287,7 +287,7 @@ const headerTileOps = (ctx, c) => {
287
287
  ops.push(...globeOps(bx + bw / 2, y + S.badgeHeight / 2, S.globeSize, S.globeColor));
288
288
  else {
289
289
  const text = truncate(ctx.fonts, b.text, S.badgeFontSize, bw - 10);
290
- ops.push(...ctx.fonts.textOps(ctx.page, text, S.badgeFontSize, S.badgeTextColor, bx + bw / 2, y + S.badgeHeight / 2 + S.badgeFontSize * 0.35, { align: 'center' }));
290
+ ops.push(...ctx.fonts.textOps(ctx.scope, text, S.badgeFontSize, S.badgeTextColor, bx + bw / 2, y + S.badgeHeight / 2 + S.badgeFontSize * 0.35, { align: 'center' }));
291
291
  }
292
292
  });
293
293
  }
@@ -295,7 +295,7 @@ const headerTileOps = (ctx, c) => {
295
295
  }
296
296
  if (d.name !== null) {
297
297
  const text = truncate(ctx.fonts, d.name, S.nameFontSize, inner);
298
- ops.push(...ctx.fonts.textOps(ctx.page, text, S.nameFontSize, S.nameColor, W / 2, y + S.nameGap + S.nameFontSize * 0.95, { align: 'center' }));
298
+ ops.push(...ctx.fonts.textOps(ctx.scope, text, S.nameFontSize, S.nameColor, W / 2, y + S.nameGap + S.nameFontSize * 0.95, { align: 'center' }));
299
299
  }
300
300
  return ops;
301
301
  };
@@ -311,14 +311,14 @@ const objectTileOps = (ctx, c) => {
311
311
  if (!thumb) {
312
312
  ops.push(...filledRoundedRect(x, y, w, h, CARD_RADIUS, GeneralColors.grey2));
313
313
  const name = truncate(ctx.fonts, c.name || 'Object removed', 9, w - 8);
314
- ops.push(...ctx.fonts.textOps(ctx.page, name, 9, GeneralColors.grey4, SQUARE / 2, SQUARE / 2 + 3, { align: 'center' }));
314
+ ops.push(...ctx.fonts.textOps(ctx.scope, name, 9, GeneralColors.grey4, SQUARE / 2, SQUARE / 2 + 3, { align: 'center' }));
315
315
  return ops;
316
316
  }
317
317
  // Thumbnail, cover-fit and clipped to the rounded card.
318
318
  const scale = Math.max(w / thumb.width, h / thumb.height);
319
319
  const iw = thumb.width * scale;
320
320
  const ih = thumb.height * scale;
321
- ops.push(pushGraphicsState(), ...roundedRectPathOps(x, y, w, h, CARD_RADIUS), clip(), endPath(), ...imageOps(thumb.name, x + (w - iw) / 2, y + (h - ih) / 2, iw, ih), popGraphicsState());
321
+ ops.push(pushGraphicsState(), ...roundedRectPathOps(x, y, w, h, CARD_RADIUS), clip(), endPath(), ...imageOps(ctx.scope.imageKey(thumb), x + (w - iw) / 2, y + (h - ih) / 2, iw, ih), popGraphicsState());
322
322
  // Footer bar by object type: a layout group gets the magenta bar with white
323
323
  // ink, a plain photo/background none, every document a white bar with dark ink.
324
324
  const type = c.fileType;
@@ -330,7 +330,7 @@ const objectTileOps = (ctx, c) => {
330
330
  if (bar) {
331
331
  ops.push(pushGraphicsState(), ...roundedRectPathOps(x, y, w, h, CARD_RADIUS), clip(), endPath(), ...fillStateOps(bar.fill), rectangle(x, y + h - OBJECT_BAR_HEIGHT, w, OBJECT_BAR_HEIGHT), fill(), popGraphicsState());
332
332
  const name = truncate(ctx.fonts, c.name, 9, w - 8);
333
- ops.push(...ctx.fonts.textOps(ctx.page, name, 9, bar.ink, SQUARE / 2, y + h - OBJECT_BAR_HEIGHT / 2 + 3, { align: 'center' }));
333
+ ops.push(...ctx.fonts.textOps(ctx.scope, name, 9, bar.ink, SQUARE / 2, y + h - OBJECT_BAR_HEIGHT / 2 + 3, { align: 'center' }));
334
334
  }
335
335
  return ops;
336
336
  };
@@ -365,7 +365,7 @@ const instructionsTileOps = (ctx, c) => {
365
365
  const lh = size * 1.25;
366
366
  const top = SQUARE / 2 - (lines.length * lh) / 2;
367
367
  lines.forEach((line, i) => {
368
- ops.push(...ctx.fonts.textOps(ctx.page, truncate(ctx.fonts, line, size, maxW), size, color, SQUARE / 2, top + i * lh + size * 0.9, { align: 'center' }));
368
+ ops.push(...ctx.fonts.textOps(ctx.scope, truncate(ctx.fonts, line, size, maxW), size, color, SQUARE / 2, top + i * lh + size * 0.9, { align: 'center' }));
369
369
  });
370
370
  return ops;
371
371
  };
@@ -89,13 +89,24 @@ export interface RenderCanvasToPdfInput {
89
89
  calibration?: PdfExportCalibration;
90
90
  /** TTF/OTF bytes of the primary font (Metropolis Bold in the apps). */
91
91
  primaryFont: Uint8Array;
92
+ /**
93
+ * 'annotations' (default): every layer, mark and tile becomes a PDF
94
+ * annotation object — selectable, movable and deletable in Preview /
95
+ * Acrobat, with the element's exact drawing as its appearance. 'flatten':
96
+ * everything is appended to the page content stream and cannot be edited.
97
+ */
98
+ mode?: 'annotations' | 'flatten';
92
99
  }
93
100
  export interface RenderCanvasToPdfResult {
94
101
  pdf: Uint8Array;
95
102
  pageCount: number;
103
+ /** Annotations written across all pages (0 in flatten mode). */
104
+ annotationCount: number;
96
105
  skipped: SkippedItem[];
97
106
  }
98
107
  export declare class PdfExportError extends Error {
99
- readonly code: 'no-pdf-document' | 'page-count-mismatch' | 'page-geometry-mismatch' | 'encrypted' | 'corrupt';
108
+ readonly code: 'no-pdf-document' | 'page-count-mismatch' | 'page-geometry-mismatch'
109
+ /** The renderer was handed an encrypted PDF — run stripPdfEncryption first. */
110
+ | 'encrypted' | 'password-required' | 'password-incorrect' | 'unsupported' | 'corrupt';
100
111
  constructor(code: PdfExportError['code'], message: string);
101
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",
@@ -52,7 +52,9 @@
52
52
  "pdf-lib": "1.17.1",
53
53
  "zod": "^4.4.3"
54
54
  },
55
+ "//peerDependencies-embedpdf": "OPTIONAL, server only: `@embedpdf/pdfium` is imported lazily by exactly one module (export/pdf/decrypt.ts, reached through the ./server entry) to strip PDF encryption before pdf-lib appends to a page. The apps never resolve it.",
55
56
  "peerDependencies": {
57
+ "@embedpdf/pdfium": ">=2.15.0",
56
58
  "@shopify/react-native-skia": ">=2.5.0",
57
59
  "react": "^18.0.0 || ^19.0.0",
58
60
  "react-native": ">=0.74.0",
@@ -60,6 +62,9 @@
60
62
  "react-native-reanimated": ">=3.0.0"
61
63
  },
62
64
  "peerDependenciesMeta": {
65
+ "@embedpdf/pdfium": {
66
+ "optional": true
67
+ },
63
68
  "react-native": {
64
69
  "optional": true
65
70
  },