@reekon-tools/boldr-utils 1.22.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotation/canvas/pdfCanvasPages.d.ts +25 -0
- package/dist/annotation/canvas/pdfCanvasPages.js +255 -0
- package/dist/annotation/data/AnnotationDataProvider.d.ts +6 -0
- package/dist/annotation/data/InMemoryAnnotationProvider.d.ts +6 -0
- package/dist/annotation/data/InMemoryAnnotationProvider.js +25 -0
- package/dist/annotation/data/hooks/usePdfCanvasDoc.d.ts +29 -0
- package/dist/annotation/data/hooks/usePdfCanvasDoc.js +344 -0
- 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 +4 -2
- package/dist/export/pdf/index.js +3 -1
- 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.d.ts +9 -1
- package/dist/export/pdf/renderCanvasToPdf.js +303 -120
- 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 +25 -1
- package/dist/exports.d.ts +3 -0
- package/dist/exports.js +3 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +2 -0
- package/dist/types/firestore.d.ts +6 -0
- package/dist/types/firestore.js +5 -0
- package/dist/types/pdfCanvas.d.ts +38 -0
- package/dist/types/pdfCanvas.js +16 -0
- package/package.json +6 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { applyPatch, createEmptyCanvasState, } from '../../../types/annotation.js';
|
|
3
|
+
import { canvasStateToPageState, pageStateToCanvasState, pdfCanvasKnobsEqual, pdfCanvasKnobsOf, pdfPageDisplaySize, rotatePageState, rotatePdfCanvasPage, } from '../../canvas/pdfCanvasPages.js';
|
|
4
|
+
import { useAnnotationData } from '../AnnotationDataContext.js';
|
|
5
|
+
import { annotationScopeKey, } from '../AnnotationDataProvider.js';
|
|
6
|
+
import { createCoalescedRunner } from '../coalescedRunner.js';
|
|
7
|
+
import { useAnnotationDoc } from './useAnnotationDoc.js';
|
|
8
|
+
const makeClientId = () => `${Date.now().toString(36)}-${Math.floor(Math.random() * 0x100000000).toString(36)}`;
|
|
9
|
+
const hasContent = (canvas) => canvas.strokes.length > 0 ||
|
|
10
|
+
canvas.shapes.length > 0 ||
|
|
11
|
+
canvas.placedMeasurements.length > 0 ||
|
|
12
|
+
(canvas.planes?.length ?? 0) > 0;
|
|
13
|
+
// Drop undefined keys (Firestore — RN in particular — rejects them) and detach
|
|
14
|
+
// the persisted payload from the working state's object identities.
|
|
15
|
+
const jsonClone = (value) => JSON.parse(JSON.stringify(value));
|
|
16
|
+
// Load + explicit-save orchestration for ONE page of a PdfCanvas file. The
|
|
17
|
+
// canvas engine is page-agnostic: this hook hands it the page's state as an
|
|
18
|
+
// ordinary AnnotationCanvasState (pageStateToCanvasState) and persists it back
|
|
19
|
+
// as a page doc. Mirrors useAnnotationCanvasDoc's echo suppression (a `rev`
|
|
20
|
+
// stamped with this session's clientId) and single-flight flushing.
|
|
21
|
+
export const usePdfCanvasDoc = (options) => {
|
|
22
|
+
const { scope, fileId, pageIndex, onSaveError, captureThumbnail, debugLogging = false, } = options;
|
|
23
|
+
const provider = useAnnotationData();
|
|
24
|
+
const { data: file, loading: fileLoading, error: fileError, } = useAnnotationDoc(scope, fileId);
|
|
25
|
+
const pdfCanvas = file?.fileData.pdfCanvas ?? null;
|
|
26
|
+
const [pageDoc, setPageDoc] = useState(null);
|
|
27
|
+
const [pageLoading, setPageLoading] = useState(true);
|
|
28
|
+
const [pageError, setPageError] = useState(null);
|
|
29
|
+
const [working, setWorking] = useState(null);
|
|
30
|
+
const [saveStatus, setSaveStatus] = useState('idle');
|
|
31
|
+
const workingRef = useRef(null);
|
|
32
|
+
const pdfCanvasRef = useRef(null);
|
|
33
|
+
const pageDocRef = useRef(null);
|
|
34
|
+
const statusRef = useRef('idle');
|
|
35
|
+
const pageIndexRef = useRef(pageIndex);
|
|
36
|
+
const scopeRef = useRef(scope);
|
|
37
|
+
const fileIdRef = useRef(fileId);
|
|
38
|
+
const onSaveErrorRef = useRef(onSaveError);
|
|
39
|
+
const captureThumbnailRef = useRef(captureThumbnail);
|
|
40
|
+
const debugRef = useRef(debugLogging);
|
|
41
|
+
const clientIdRef = useRef(null);
|
|
42
|
+
if (clientIdRef.current === null)
|
|
43
|
+
clientIdRef.current = makeClientId();
|
|
44
|
+
const writeSeqRef = useRef(0);
|
|
45
|
+
const editSeqRef = useRef(0);
|
|
46
|
+
const thumbnailSavingRef = useRef(false);
|
|
47
|
+
const thumbnailRequestedRef = useRef(false);
|
|
48
|
+
const thumbnailPromiseRef = useRef(undefined);
|
|
49
|
+
// A page whose edits must be persisted although it is no longer the current
|
|
50
|
+
// page (the user turned the page with unsaved edits).
|
|
51
|
+
const outgoingRef = useRef(null);
|
|
52
|
+
workingRef.current = working;
|
|
53
|
+
pdfCanvasRef.current = pdfCanvas;
|
|
54
|
+
pageDocRef.current = pageDoc;
|
|
55
|
+
statusRef.current = saveStatus;
|
|
56
|
+
scopeRef.current = scope;
|
|
57
|
+
fileIdRef.current = fileId;
|
|
58
|
+
onSaveErrorRef.current = onSaveError;
|
|
59
|
+
captureThumbnailRef.current = captureThumbnail;
|
|
60
|
+
debugRef.current = debugLogging;
|
|
61
|
+
const setStatus = useCallback((next) => {
|
|
62
|
+
statusRef.current = next;
|
|
63
|
+
setSaveStatus(next);
|
|
64
|
+
}, []);
|
|
65
|
+
// Subscribe to the current page's doc.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!scope || !fileId) {
|
|
68
|
+
setPageDoc(null);
|
|
69
|
+
setPageLoading(false);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setPageLoading(true);
|
|
73
|
+
setPageError(null);
|
|
74
|
+
const unsubscribe = provider.subscribePage(scope, fileId, pageIndex, (doc) => {
|
|
75
|
+
setPageDoc(doc);
|
|
76
|
+
setPageLoading(false);
|
|
77
|
+
}, (err) => {
|
|
78
|
+
setPageError(err);
|
|
79
|
+
setPageLoading(false);
|
|
80
|
+
});
|
|
81
|
+
return unsubscribe;
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, [provider, annotationScopeKey(scope), fileId, pageIndex]);
|
|
84
|
+
// Persist one page: the page doc, then whatever file-level fields it moved
|
|
85
|
+
// (first-annotation marker, tile knobs). Returns normally on failure after
|
|
86
|
+
// reporting it — callers read the status, not a rejection.
|
|
87
|
+
const persist = useCallback(async (target) => {
|
|
88
|
+
const currentScope = scopeRef.current;
|
|
89
|
+
const currentFileId = fileIdRef.current;
|
|
90
|
+
if (!currentScope || !currentFileId)
|
|
91
|
+
return false;
|
|
92
|
+
const rev = {
|
|
93
|
+
clientId: clientIdRef.current,
|
|
94
|
+
seq: ++writeSeqRef.current,
|
|
95
|
+
};
|
|
96
|
+
const state = jsonClone(canvasStateToPageState(target.canvas, target.pageIndex, {
|
|
97
|
+
updatedAt: Date.now(),
|
|
98
|
+
rev,
|
|
99
|
+
}));
|
|
100
|
+
if (debugRef.current) {
|
|
101
|
+
console.log('[usePdfCanvasDoc] save attempt', {
|
|
102
|
+
fileId: currentFileId,
|
|
103
|
+
pageIndex: target.pageIndex,
|
|
104
|
+
strokes: state.strokes.length,
|
|
105
|
+
shapes: state.shapes.length,
|
|
106
|
+
measurements: state.placedMeasurements.length,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await provider.writePage(currentScope, currentFileId, target.pageIndex, state);
|
|
111
|
+
const patch = {};
|
|
112
|
+
if (!target.pdfCanvas.annotatedPages.includes(target.pageIndex)) {
|
|
113
|
+
patch['fileData.pdfCanvas.annotatedPages'] = {
|
|
114
|
+
kind: 'arrayUnion',
|
|
115
|
+
values: [target.pageIndex],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const knobs = pdfCanvasKnobsOf(target.canvas);
|
|
119
|
+
if (!pdfCanvasKnobsEqual(knobs, pdfCanvasKnobsOf(target.pdfCanvas))) {
|
|
120
|
+
for (const [key, value] of Object.entries(knobs)) {
|
|
121
|
+
patch[`fileData.pdfCanvas.${key}`] = value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (Object.keys(patch).length > 0) {
|
|
125
|
+
await provider.update(currentScope, currentFileId, patch);
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
console.error('[usePdfCanvasDoc] save failed', { fileId: currentFileId, pageIndex: target.pageIndex }, e);
|
|
131
|
+
onSaveErrorRef.current?.(e);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}, [provider]);
|
|
135
|
+
const saveThumbnail = useCallback(async (targetFileId) => {
|
|
136
|
+
const capture = captureThumbnailRef.current;
|
|
137
|
+
const currentScope = scopeRef.current;
|
|
138
|
+
if (!capture || !currentScope || thumbnailSavingRef.current)
|
|
139
|
+
return;
|
|
140
|
+
thumbnailSavingRef.current = true;
|
|
141
|
+
try {
|
|
142
|
+
const blob = await capture();
|
|
143
|
+
if (blob) {
|
|
144
|
+
await provider.uploadImage(currentScope, targetFileId, 'thumbnail', blob);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
console.warn('[usePdfCanvasDoc] thumbnail save failed', e);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
thumbnailSavingRef.current = false;
|
|
152
|
+
}
|
|
153
|
+
}, [provider]);
|
|
154
|
+
// One flush pass: an outgoing page first (if a page turn left one behind),
|
|
155
|
+
// then the current page when it is dirty.
|
|
156
|
+
const runFlush = useCallback(async () => {
|
|
157
|
+
const outgoing = outgoingRef.current;
|
|
158
|
+
if (outgoing) {
|
|
159
|
+
outgoingRef.current = null;
|
|
160
|
+
await persist(outgoing);
|
|
161
|
+
}
|
|
162
|
+
const canvas = workingRef.current;
|
|
163
|
+
const data = pdfCanvasRef.current;
|
|
164
|
+
if (!canvas ||
|
|
165
|
+
!data ||
|
|
166
|
+
(statusRef.current !== 'dirty' && statusRef.current !== 'error')) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const editSeqAtFlush = editSeqRef.current;
|
|
170
|
+
const wantThumbnail = thumbnailRequestedRef.current;
|
|
171
|
+
thumbnailRequestedRef.current = false;
|
|
172
|
+
const targetPage = pageIndexRef.current;
|
|
173
|
+
setStatus('saving');
|
|
174
|
+
const ok = await persist({
|
|
175
|
+
pageIndex: targetPage,
|
|
176
|
+
canvas,
|
|
177
|
+
pdfCanvas: data,
|
|
178
|
+
});
|
|
179
|
+
if (!ok) {
|
|
180
|
+
setStatus('error');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const currentFileId = fileIdRef.current;
|
|
184
|
+
if (wantThumbnail && targetPage === 0 && currentFileId) {
|
|
185
|
+
thumbnailPromiseRef.current = saveThumbnail(currentFileId);
|
|
186
|
+
}
|
|
187
|
+
setStatus(editSeqRef.current !== editSeqAtFlush ? 'dirty' : 'saved');
|
|
188
|
+
}, [persist, saveThumbnail, setStatus]);
|
|
189
|
+
const runFlushRef = useRef(runFlush);
|
|
190
|
+
runFlushRef.current = runFlush;
|
|
191
|
+
const flushRunner = useMemo(() => createCoalescedRunner(() => runFlushRef.current()), []);
|
|
192
|
+
const flush = useCallback(() => flushRunner(), [flushRunner]);
|
|
193
|
+
// Page turn: hand the outgoing page to the flusher if it has unsaved edits,
|
|
194
|
+
// then drop the working state so the next snapshot hydrates the new page.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (pageIndexRef.current === pageIndex)
|
|
197
|
+
return;
|
|
198
|
+
const previous = workingRef.current;
|
|
199
|
+
const data = pdfCanvasRef.current;
|
|
200
|
+
if (previous &&
|
|
201
|
+
data &&
|
|
202
|
+
(statusRef.current === 'dirty' || statusRef.current === 'error')) {
|
|
203
|
+
outgoingRef.current = {
|
|
204
|
+
pageIndex: pageIndexRef.current,
|
|
205
|
+
canvas: previous,
|
|
206
|
+
pdfCanvas: data,
|
|
207
|
+
};
|
|
208
|
+
void flush();
|
|
209
|
+
}
|
|
210
|
+
pageIndexRef.current = pageIndex;
|
|
211
|
+
workingRef.current = null;
|
|
212
|
+
setWorking(null);
|
|
213
|
+
setStatus('idle');
|
|
214
|
+
}, [pageIndex, flush, setStatus]);
|
|
215
|
+
// Reset on a different file.
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
workingRef.current = null;
|
|
218
|
+
setWorking(null);
|
|
219
|
+
setStatus('idle');
|
|
220
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
221
|
+
}, [annotationScopeKey(scope), fileId]);
|
|
222
|
+
// Hydrate / reconcile from the persisted docs.
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (!pdfCanvas || pageLoading)
|
|
225
|
+
return;
|
|
226
|
+
if (pageIndex >= pdfCanvas.document.pages.length)
|
|
227
|
+
return;
|
|
228
|
+
if (workingRef.current === null) {
|
|
229
|
+
const next = pageStateToCanvasState(pdfCanvas, pageDoc, pageIndex);
|
|
230
|
+
workingRef.current = next;
|
|
231
|
+
setWorking(next);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Local edits pending or a write in flight: never clobber them.
|
|
235
|
+
if (statusRef.current === 'saving' || statusRef.current === 'dirty')
|
|
236
|
+
return;
|
|
237
|
+
// The echo of this session's own write carries our clientId.
|
|
238
|
+
if (pageDoc?.rev?.clientId === clientIdRef.current)
|
|
239
|
+
return;
|
|
240
|
+
const next = pageStateToCanvasState(pdfCanvas, pageDoc, pageIndex);
|
|
241
|
+
workingRef.current = next;
|
|
242
|
+
setWorking(next);
|
|
243
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
244
|
+
}, [pdfCanvas, pageDoc, pageLoading, pageIndex]);
|
|
245
|
+
const onCommit = useCallback((patch) => {
|
|
246
|
+
editSeqRef.current += 1;
|
|
247
|
+
setWorking((prev) => {
|
|
248
|
+
if (!prev)
|
|
249
|
+
return prev;
|
|
250
|
+
const next = applyPatch(prev, patch);
|
|
251
|
+
workingRef.current = next;
|
|
252
|
+
return next;
|
|
253
|
+
});
|
|
254
|
+
setStatus('dirty');
|
|
255
|
+
}, [setStatus]);
|
|
256
|
+
const save = useCallback(async () => {
|
|
257
|
+
thumbnailRequestedRef.current = true;
|
|
258
|
+
await flush();
|
|
259
|
+
await thumbnailPromiseRef.current;
|
|
260
|
+
}, [flush]);
|
|
261
|
+
const refreshThumbnail = useCallback(async () => {
|
|
262
|
+
const currentFileId = fileIdRef.current;
|
|
263
|
+
if (!currentFileId)
|
|
264
|
+
return;
|
|
265
|
+
await saveThumbnail(currentFileId);
|
|
266
|
+
}, [saveThumbnail]);
|
|
267
|
+
const rotatePage = useCallback(async (delta) => {
|
|
268
|
+
const currentScope = scopeRef.current;
|
|
269
|
+
const currentFileId = fileIdRef.current;
|
|
270
|
+
// Pending edits first, so the rewrite below starts from persisted truth.
|
|
271
|
+
await flush();
|
|
272
|
+
const data = pdfCanvasRef.current;
|
|
273
|
+
const canvas = workingRef.current;
|
|
274
|
+
const index = pageIndexRef.current;
|
|
275
|
+
if (!currentScope || !currentFileId || !data || !canvas)
|
|
276
|
+
return;
|
|
277
|
+
const page = data.document.pages[index];
|
|
278
|
+
if (!page)
|
|
279
|
+
return;
|
|
280
|
+
const size = pdfPageDisplaySize(page);
|
|
281
|
+
const turnedPage = rotatePdfCanvasPage(page, delta);
|
|
282
|
+
const pages = data.document.pages.map((p, i) => i === index ? turnedPage : p);
|
|
283
|
+
// Knobs the flush just wrote may not have echoed back yet; carry them.
|
|
284
|
+
const nextData = {
|
|
285
|
+
...data,
|
|
286
|
+
...pdfCanvasKnobsOf(canvas),
|
|
287
|
+
document: { ...data.document, pages },
|
|
288
|
+
};
|
|
289
|
+
setStatus('saving');
|
|
290
|
+
try {
|
|
291
|
+
const patch = {
|
|
292
|
+
'fileData.pdfCanvas.document.pages': pages,
|
|
293
|
+
};
|
|
294
|
+
let turnedState = null;
|
|
295
|
+
if (hasContent(canvas)) {
|
|
296
|
+
const rev = {
|
|
297
|
+
clientId: clientIdRef.current,
|
|
298
|
+
seq: ++writeSeqRef.current,
|
|
299
|
+
};
|
|
300
|
+
turnedState = jsonClone(rotatePageState(canvasStateToPageState(canvas, index, {
|
|
301
|
+
updatedAt: Date.now(),
|
|
302
|
+
rev,
|
|
303
|
+
}), size, delta));
|
|
304
|
+
await provider.writePage(currentScope, currentFileId, index, turnedState);
|
|
305
|
+
if (!data.annotatedPages.includes(index)) {
|
|
306
|
+
patch['fileData.pdfCanvas.annotatedPages'] = {
|
|
307
|
+
kind: 'arrayUnion',
|
|
308
|
+
values: [index],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
await provider.update(currentScope, currentFileId, patch);
|
|
313
|
+
const next = pageStateToCanvasState(nextData, turnedState, index);
|
|
314
|
+
workingRef.current = next;
|
|
315
|
+
setWorking(next);
|
|
316
|
+
setStatus('saved');
|
|
317
|
+
}
|
|
318
|
+
catch (e) {
|
|
319
|
+
console.error('[usePdfCanvasDoc] rotate failed', { fileId: currentFileId, pageIndex: index, delta }, e);
|
|
320
|
+
onSaveErrorRef.current?.(e);
|
|
321
|
+
setStatus('error');
|
|
322
|
+
}
|
|
323
|
+
}, [flush, provider, setStatus]);
|
|
324
|
+
const page = pdfCanvas?.document.pages[pageIndex] ?? null;
|
|
325
|
+
const canvas = useMemo(() => working ??
|
|
326
|
+
(page
|
|
327
|
+
? createEmptyCanvasState(pdfPageDisplaySize(page))
|
|
328
|
+
: createEmptyCanvasState()), [working, page]);
|
|
329
|
+
return {
|
|
330
|
+
file,
|
|
331
|
+
pdfCanvas,
|
|
332
|
+
pageCount: pdfCanvas?.document.pageCount ?? 0,
|
|
333
|
+
page,
|
|
334
|
+
canvas,
|
|
335
|
+
onCommit,
|
|
336
|
+
loading: fileLoading || pageLoading || working === null,
|
|
337
|
+
error: fileError ?? pageError,
|
|
338
|
+
saveStatus,
|
|
339
|
+
hasUnsavedChanges: saveStatus === 'dirty' || saveStatus === 'error',
|
|
340
|
+
save,
|
|
341
|
+
rotatePage,
|
|
342
|
+
refreshThumbnail,
|
|
343
|
+
};
|
|
344
|
+
};
|
|
@@ -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
|
}
|