@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.
- package/dist/export/pdf/annots.d.ts +23 -0
- package/dist/export/pdf/annots.js +135 -0
- package/dist/export/pdf/decrypt.d.ts +21 -0
- package/dist/export/pdf/decrypt.js +121 -0
- package/dist/export/pdf/fonts.d.ts +3 -4
- package/dist/export/pdf/fonts.js +2 -14
- package/dist/export/pdf/index.d.ts +2 -0
- package/dist/export/pdf/index.js +2 -0
- package/dist/export/pdf/marks.d.ts +66 -5
- package/dist/export/pdf/marks.js +150 -7
- package/dist/export/pdf/pageMapping.d.ts +2 -0
- package/dist/export/pdf/pageMapping.js +10 -0
- package/dist/export/pdf/renderCanvasToPdf.js +127 -40
- package/dist/export/pdf/resources.d.ts +35 -0
- package/dist/export/pdf/resources.js +95 -0
- package/dist/export/pdf/tiles.d.ts +5 -9
- package/dist/export/pdf/tiles.js +20 -20
- package/dist/export/pdf/types.d.ts +12 -1
- package/package.json +6 -1
|
@@ -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,
|
|
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(
|
|
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
|
}
|
package/dist/export/pdf/fonts.js
CHANGED
|
@@ -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(
|
|
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:
|
|
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';
|
package/dist/export/pdf/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/dist/export/pdf/marks.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
+
const bounds = layerVisibleRect(layer);
|
|
235
|
+
if (!img || !onPage(bounds))
|
|
163
236
|
continue;
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
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?:
|
|
12
|
+
thumbnail?: PDFImage;
|
|
17
13
|
}
|
|
18
14
|
export interface TileFootprint extends DocRect {
|
|
19
15
|
/** Design-space box the painter lays out in. */
|
package/dist/export/pdf/tiles.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
220
|
-
ops.push(...ctx.fonts.textOps(ctx.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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'
|
|
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.
|
|
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
|
},
|