@reekon-tools/react-native-pdf-canvas 0.2.1 → 0.4.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/README.md +61 -6
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +64 -10
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +22 -3
- package/android/src/main/cpp/pdfcanvas-jni.cpp +47 -3
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +31 -1
- package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +79 -9
- package/android/tools/compile-gate.sh +6 -4
- package/dist/controller.d.ts +23 -1
- package/dist/controller.js +33 -16
- package/dist/rasterizer/fake.d.ts +38 -4
- package/dist/rasterizer/fake.js +167 -37
- package/dist/rasterizer/native-bridge.d.ts +48 -1
- package/dist/rasterizer/native-bridge.js +96 -0
- package/dist/rasterizer/native.d.ts +2 -2
- package/dist/rasterizer/native.js +9 -7
- package/dist/rasterizer/text-search.d.ts +22 -0
- package/dist/rasterizer/text-search.js +28 -0
- package/dist/rasterizer/web/client.js +69 -49
- package/dist/rasterizer/web/engine.d.ts +3 -1
- package/dist/rasterizer/web/engine.js +225 -8
- package/dist/rasterizer/web/pdfium.d.ts +64 -2
- package/dist/rasterizer/web/pdfium.js +21 -0
- package/dist/rasterizer/web/protocol.d.ts +23 -6
- package/dist/rasterizer/web/protocol.js +4 -1
- package/dist/rasterizer/web/session.js +30 -13
- package/dist/react/PdfContentView.js +5 -0
- package/dist/react/usePdfDocument.d.ts +27 -2
- package/dist/react/usePdfDocument.js +65 -12
- package/dist/react/usePdfLayer.d.ts +19 -2
- package/dist/react/usePdfLayer.js +51 -8
- package/dist/rotation.d.ts +59 -0
- package/dist/rotation.js +85 -0
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/types.d.ts +86 -0
- package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +71 -2
- package/native/CMakeLists.txt +7 -0
- package/native/core/include/pdfcanvas/document.h +17 -0
- package/native/core/include/pdfcanvas/service.h +14 -1
- package/native/core/include/pdfcanvas/text_search.h +52 -0
- package/native/core/include/pdfcanvas/types.h +49 -0
- package/native/core/pdfcanvas-core.cmake +1 -0
- package/native/core/src/document.cpp +211 -8
- package/native/core/src/service.cpp +43 -28
- package/native/core/src/text_search.cpp +115 -0
- package/native/tests/fixtures.cpp +27 -0
- package/native/tests/fixtures.h +15 -0
- package/native/tests/test_document.cpp +486 -0
- package/native/tests/test_service.cpp +110 -0
- package/package.json +1 -1
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { createRasterizerOverBridge, encodeBase64 } from './native-bridge.js';
|
|
24
24
|
import { PdfError } from '../types.js';
|
|
25
|
-
export { TRANSPORT_PROBE_BYTES, bytesFromResult, decodeBase64, encodeBase64, lookupTakePixels, negotiateTransport, toPageGeometry, } from './native-bridge.js';
|
|
25
|
+
export { TRANSPORT_PROBE_BYTES, bytesFromResult, decodeBase64, encodeBase64, lookupTakePixels, negotiateTransport, parseTextMatches, toPageGeometry, } from './native-bridge.js';
|
|
26
26
|
/**
|
|
27
27
|
* STRAIGHT (un-premultiplied), and — unlike the two constants this replaces —
|
|
28
28
|
* a measurement of the engine rather than of a platform bitmap type.
|
|
@@ -56,14 +56,16 @@ export const NATIVE_CAPABILITIES = Object.freeze({
|
|
|
56
56
|
*/
|
|
57
57
|
interruptibleRender: true,
|
|
58
58
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* the
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* `search` is TRUE since 0.4.0: `searchText` runs PDFium's text API in the
|
|
60
|
+
* core (`Document::searchText`) and maps every rect through the display
|
|
61
|
+
* matrix the rasters use, on both platforms, from one C++ implementation.
|
|
62
|
+
* `text` and `links` stay FALSE, and that is a declaration about the
|
|
63
|
+
* PACKAGE, not the engine: PDFium can extract text and enumerate links, but
|
|
64
|
+
* neither has a surface on the seam, and a capability that says "yes" about
|
|
65
|
+
* an API no consumer can reach is worse than one that says "no".
|
|
64
66
|
*/
|
|
65
67
|
text: false,
|
|
66
|
-
search:
|
|
68
|
+
search: true,
|
|
67
69
|
links: false,
|
|
68
70
|
/**
|
|
69
71
|
* ONE. PDFium is not thread-safe — not across documents, not across threads
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two text-search rules every backend shares, so that a match's `context`
|
|
3
|
+
* reads the same whichever engine produced it.
|
|
4
|
+
*
|
|
5
|
+
* Pure, Skia-free and platform-free: the web engine, the native bridge's tests
|
|
6
|
+
* and the fake all reach it, and nothing here knows which one is calling.
|
|
7
|
+
*/
|
|
8
|
+
/** How much page text a match carries either side of itself, in characters. */
|
|
9
|
+
export declare const CONTEXT_CHARS = 40;
|
|
10
|
+
/**
|
|
11
|
+
* Whitespace runs collapsed to one space, ends trimmed. PDFium's text page
|
|
12
|
+
* carries `\r\n` at every line break and a "find" list wants one line per hit;
|
|
13
|
+
* the native core applies the identical rule in C++ (`collapseWhitespace` in
|
|
14
|
+
* `native/core/src/text_search.cpp`, which matches ECMAScript's `\s` set).
|
|
15
|
+
*/
|
|
16
|
+
export declare function collapseWhitespace(text: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* `CONTEXT_CHARS` either side of `[index, index + count)` of `text`, collapsed.
|
|
19
|
+
* The window is clamped to the text, so a match near an edge carries less on
|
|
20
|
+
* that side rather than padding.
|
|
21
|
+
*/
|
|
22
|
+
export declare function contextAround(text: string, index: number, count: number): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two text-search rules every backend shares, so that a match's `context`
|
|
3
|
+
* reads the same whichever engine produced it.
|
|
4
|
+
*
|
|
5
|
+
* Pure, Skia-free and platform-free: the web engine, the native bridge's tests
|
|
6
|
+
* and the fake all reach it, and nothing here knows which one is calling.
|
|
7
|
+
*/
|
|
8
|
+
/** How much page text a match carries either side of itself, in characters. */
|
|
9
|
+
export const CONTEXT_CHARS = 40;
|
|
10
|
+
/**
|
|
11
|
+
* Whitespace runs collapsed to one space, ends trimmed. PDFium's text page
|
|
12
|
+
* carries `\r\n` at every line break and a "find" list wants one line per hit;
|
|
13
|
+
* the native core applies the identical rule in C++ (`collapseWhitespace` in
|
|
14
|
+
* `native/core/src/text_search.cpp`, which matches ECMAScript's `\s` set).
|
|
15
|
+
*/
|
|
16
|
+
export function collapseWhitespace(text) {
|
|
17
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `CONTEXT_CHARS` either side of `[index, index + count)` of `text`, collapsed.
|
|
21
|
+
* The window is clamped to the text, so a match near an edge carries less on
|
|
22
|
+
* that side rather than padding.
|
|
23
|
+
*/
|
|
24
|
+
export function contextAround(text, index, count) {
|
|
25
|
+
const start = Math.max(0, index - CONTEXT_CHARS);
|
|
26
|
+
const end = Math.min(text.length, index + count + CONTEXT_CHARS);
|
|
27
|
+
return collapseWhitespace(text.slice(start, end));
|
|
28
|
+
}
|
|
@@ -63,10 +63,11 @@ import { PdfError } from '../../types.js';
|
|
|
63
63
|
* from `RasterRequest.annotations` — so unlike Android, which can only ever draw
|
|
64
64
|
* them, this backend genuinely honours both settings.
|
|
65
65
|
*
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
66
|
+
* `search` is true: `searchText` runs PDFium's `FPDFText_Find*` in the worker
|
|
67
|
+
* and maps every rect through the display matrix the rasters use (see
|
|
68
|
+
* `search` in `./engine.ts`). `text` and `links` stay false because neither
|
|
69
|
+
* has a surface on the seam; declaring them would be claiming an API that
|
|
70
|
+
* does not exist.
|
|
70
71
|
*
|
|
71
72
|
* `interruptibleRender: false`, honestly. `FPDF_RenderPageBitmap` is synchronous
|
|
72
73
|
* and a worker reads one message at a time, so nothing can interrupt a render
|
|
@@ -89,7 +90,7 @@ const CAPABILITIES = {
|
|
|
89
90
|
annotations: true,
|
|
90
91
|
interruptibleRender: false,
|
|
91
92
|
text: false,
|
|
92
|
-
search:
|
|
93
|
+
search: true,
|
|
93
94
|
links: false,
|
|
94
95
|
maxConcurrentRenders: 1,
|
|
95
96
|
};
|
|
@@ -259,6 +260,55 @@ function createHandle(connection, backendId, pageCount, pages) {
|
|
|
259
260
|
}
|
|
260
261
|
return geometry;
|
|
261
262
|
};
|
|
263
|
+
/**
|
|
264
|
+
* One job to the worker, with the caller's abort honoured.
|
|
265
|
+
*
|
|
266
|
+
* ABORT DOES TWO THINGS, AND ONLY THE FIRST IS GUARANTEED.
|
|
267
|
+
*
|
|
268
|
+
* It frees the CALLER immediately — the promise rejects `cancelled` and the
|
|
269
|
+
* controller stops waiting on a tile it no longer wants. And it posts a
|
|
270
|
+
* `cancel`, which the worker honours if the job has not started yet (see the
|
|
271
|
+
* yield in `./session.ts`) and cannot honour if it has. The id is abandoned
|
|
272
|
+
* either way, so a result that does arrive is dropped rather than resolving
|
|
273
|
+
* a promise nobody holds.
|
|
274
|
+
*
|
|
275
|
+
* This is `interruptibleRender: false` stated as behaviour: cancellation
|
|
276
|
+
* happens between requests. Renders and searches share it, because the
|
|
277
|
+
* worker queues them together and a search is as synchronous as a render.
|
|
278
|
+
*/
|
|
279
|
+
const exchange = async (what, page, message, signal) => {
|
|
280
|
+
if (closed) {
|
|
281
|
+
throw new PdfError('backend-failure', `${backendId} handle is closed.`);
|
|
282
|
+
}
|
|
283
|
+
requirePage(page);
|
|
284
|
+
if (signal?.aborted === true) {
|
|
285
|
+
throw new PdfError('cancelled', `${what === 'render' ? 'Render' : 'Search'} was aborted before it started.`);
|
|
286
|
+
}
|
|
287
|
+
const id = connection.nextId();
|
|
288
|
+
let abort;
|
|
289
|
+
const cancelled = new Promise((_, reject) => {
|
|
290
|
+
abort = () => {
|
|
291
|
+
connection.abandon(id);
|
|
292
|
+
connection.post({ kind: 'cancel', id });
|
|
293
|
+
reject(new PdfError('cancelled', `${what === 'render' ? 'Render' : 'Search'} was aborted.`));
|
|
294
|
+
};
|
|
295
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
296
|
+
});
|
|
297
|
+
// Nothing awaits this branch when the job wins, and an unhandled
|
|
298
|
+
// rejection on a promise that lost a race is noise in every console.
|
|
299
|
+
cancelled.catch(() => { });
|
|
300
|
+
try {
|
|
301
|
+
const response = await Promise.race([
|
|
302
|
+
connection.request(id, message(id)),
|
|
303
|
+
cancelled,
|
|
304
|
+
]);
|
|
305
|
+
return { id, response };
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
if (abort !== undefined)
|
|
309
|
+
signal?.removeEventListener('abort', abort);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
262
312
|
return {
|
|
263
313
|
pageCount,
|
|
264
314
|
/**
|
|
@@ -268,51 +318,8 @@ function createHandle(connection, backendId, pageCount, pages) {
|
|
|
268
318
|
*/
|
|
269
319
|
pageGeometry: requirePage,
|
|
270
320
|
async render(request, signal) {
|
|
271
|
-
if (closed) {
|
|
272
|
-
throw new PdfError('backend-failure', `${backendId} handle is closed.`);
|
|
273
|
-
}
|
|
274
|
-
requirePage(request.page);
|
|
275
|
-
if (signal?.aborted === true) {
|
|
276
|
-
throw new PdfError('cancelled', 'Render was aborted before it started.');
|
|
277
|
-
}
|
|
278
|
-
const id = connection.nextId();
|
|
279
321
|
const startedAt = monotonicNow();
|
|
280
|
-
|
|
281
|
-
* ABORT DOES TWO THINGS, AND ONLY THE FIRST IS GUARANTEED.
|
|
282
|
-
*
|
|
283
|
-
* It frees the CALLER immediately — the promise rejects `cancelled` and
|
|
284
|
-
* the controller stops waiting on a tile it no longer wants. And it posts
|
|
285
|
-
* a `cancel`, which the worker honours if the render has not started yet
|
|
286
|
-
* (see the yield in `./session.ts`) and cannot honour if it has. The id is
|
|
287
|
-
* abandoned either way, so a raster that does arrive is dropped rather
|
|
288
|
-
* than resolving a promise nobody holds.
|
|
289
|
-
*
|
|
290
|
-
* This is `interruptibleRender: false` stated as behaviour: cancellation
|
|
291
|
-
* happens between requests.
|
|
292
|
-
*/
|
|
293
|
-
let abort;
|
|
294
|
-
const cancelled = new Promise((_, reject) => {
|
|
295
|
-
abort = () => {
|
|
296
|
-
connection.abandon(id);
|
|
297
|
-
connection.post({ kind: 'cancel', id });
|
|
298
|
-
reject(new PdfError('cancelled', 'Render was aborted.'));
|
|
299
|
-
};
|
|
300
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
301
|
-
});
|
|
302
|
-
// Nothing awaits this branch when the render wins, and an unhandled
|
|
303
|
-
// rejection on a promise that lost a race is noise in every console.
|
|
304
|
-
cancelled.catch(() => { });
|
|
305
|
-
let response;
|
|
306
|
-
try {
|
|
307
|
-
response = await Promise.race([
|
|
308
|
-
connection.request(id, { kind: 'render', id, request }),
|
|
309
|
-
cancelled,
|
|
310
|
-
]);
|
|
311
|
-
}
|
|
312
|
-
finally {
|
|
313
|
-
if (abort !== undefined)
|
|
314
|
-
signal?.removeEventListener('abort', abort);
|
|
315
|
-
}
|
|
322
|
+
const { response } = await exchange('render', request.page, id => ({ kind: 'render', id, request }), signal);
|
|
316
323
|
if (response.kind !== 'rendered') {
|
|
317
324
|
throw new PdfError('backend-failure', `The worker answered a render with a ${response.kind} message.`);
|
|
318
325
|
}
|
|
@@ -345,6 +352,19 @@ function createHandle(connection, backendId, pageCount, pages) {
|
|
|
345
352
|
},
|
|
346
353
|
};
|
|
347
354
|
},
|
|
355
|
+
/**
|
|
356
|
+
* The request crosses the channel WHOLE — structured-cloned, never
|
|
357
|
+
* rebuilt field by field — so the flags and the rotation the caller set
|
|
358
|
+
* are the ones the engine searches with. Asserted in
|
|
359
|
+
* `__tests__/text-search.test.ts`.
|
|
360
|
+
*/
|
|
361
|
+
async searchText(request, signal) {
|
|
362
|
+
const { response } = await exchange('search', request.page, id => ({ kind: 'search', id, request }), signal);
|
|
363
|
+
if (response.kind !== 'searched') {
|
|
364
|
+
throw new PdfError('backend-failure', `The worker answered a search with a ${response.kind} message.`);
|
|
365
|
+
}
|
|
366
|
+
return response.matches;
|
|
367
|
+
},
|
|
348
368
|
close() {
|
|
349
369
|
if (closed)
|
|
350
370
|
return;
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* likely to be real.
|
|
41
41
|
*/
|
|
42
42
|
import type { PdfiumBinding } from './pdfium.js';
|
|
43
|
-
import type { AlphaEncoding, PageGeometry, PixelFormat, RasterRequest } from '../../types.js';
|
|
43
|
+
import type { AlphaEncoding, PageGeometry, PixelFormat, RasterRequest, TextMatch, TextSearchRequest } from '../../types.js';
|
|
44
44
|
export interface PdfiumRasterBytes {
|
|
45
45
|
/** Tight: its own exactly-sized ArrayBuffer, ready to transfer. */
|
|
46
46
|
bytes: Uint8Array;
|
|
@@ -55,6 +55,8 @@ export interface PdfiumDocument {
|
|
|
55
55
|
/** Every page, measured once at open. */
|
|
56
56
|
readonly pages: readonly PageGeometry[];
|
|
57
57
|
render(request: RasterRequest): PdfiumRasterBytes;
|
|
58
|
+
/** Every match on one page, rects in the displayed page's doc space. */
|
|
59
|
+
search(request: TextSearchRequest): TextMatch[];
|
|
58
60
|
close(): void;
|
|
59
61
|
}
|
|
60
62
|
/**
|
|
@@ -39,7 +39,9 @@
|
|
|
39
39
|
* exactly where a misregistration would be least likely to be noticed and most
|
|
40
40
|
* likely to be real.
|
|
41
41
|
*/
|
|
42
|
-
import { FPDF_ANNOT, FPDF_BITMAP_BGRA, FPDF_REVERSE_BYTE_ORDER, loadError, } from './pdfium.js';
|
|
42
|
+
import { FPDF_ANNOT, FPDF_BITMAP_BGRA, FPDF_MATCHCASE, FPDF_MATCHWHOLEWORD, FPDF_REVERSE_BYTE_ORDER, loadError, } from './pdfium.js';
|
|
43
|
+
import { isPageRotation, ROTATIONS, swapsExtents } from '../../rotation.js';
|
|
44
|
+
import { CONTEXT_CHARS, collapseWhitespace } from '../text-search.js';
|
|
43
45
|
import { PdfError } from '../../types.js';
|
|
44
46
|
/**
|
|
45
47
|
* MEASURED, not inherited from the other backends. An antialiased opaque-white
|
|
@@ -64,7 +66,22 @@ const OPAQUE_WHITE = 0xffffffff;
|
|
|
64
66
|
const TRANSPARENT = 0x00000000;
|
|
65
67
|
/** PDFium's ints are 32-bit; a request past this cannot be expressed at all. */
|
|
66
68
|
const MAX_INT32 = 0x7fffffff;
|
|
67
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The device box a search rect is mapped through, in units per PDF point.
|
|
71
|
+
*
|
|
72
|
+
* `FPDF_PageToDevice` is the display matrix the rasters use — `/Rotate`, the
|
|
73
|
+
* CropBox origin and the host's `rotate` all folded in by PDFium itself, which
|
|
74
|
+
* is the whole reason to go through it rather than restate the arithmetic —
|
|
75
|
+
* but it ROUNDS ITS OUTPUT TO WHOLE DEVICE UNITS (`FXSYS_roundf`, both axes).
|
|
76
|
+
* Handed the page box in points, a rect would come back quantised to whole
|
|
77
|
+
* points: 4 px at scale 4, visibly off the glyphs. So the box is handed over
|
|
78
|
+
* at this many units per point and the answer divided back down, which gives
|
|
79
|
+
* 1/1024 pt. The matrix is linear in the box, so this is exactly the scale-1
|
|
80
|
+
* mapping with more digits; float32 inside PDFium keeps ~0.06 units at the
|
|
81
|
+
* largest box a 14400 pt page can produce, which is still below 1/10000 pt.
|
|
82
|
+
* Mirrors `kSearchUnitsPerPoint` in `native/core/src/document.cpp`.
|
|
83
|
+
*/
|
|
84
|
+
const SEARCH_UNITS_PER_POINT = 1024;
|
|
68
85
|
/* ------------------------------------------------------------------ *
|
|
69
86
|
* Open
|
|
70
87
|
* ------------------------------------------------------------------ */
|
|
@@ -147,6 +164,40 @@ export function openPdfiumDocument(api, bytes, password) {
|
|
|
147
164
|
}
|
|
148
165
|
return createDocument(api, document, dataPointer, pages);
|
|
149
166
|
}
|
|
167
|
+
/* ------------------------------------------------------------------ *
|
|
168
|
+
* Text
|
|
169
|
+
* ------------------------------------------------------------------ */
|
|
170
|
+
/**
|
|
171
|
+
* `CONTEXT_CHARS` either side of a match, out of the text page as UTF-16.
|
|
172
|
+
*
|
|
173
|
+
* `FPDFText_GetText` wants a buffer of `count + 1` code units and writes the
|
|
174
|
+
* terminator; what it returns counts the terminator too, so the string is one
|
|
175
|
+
* unit shorter than the return. Read through a fresh `heapU8()` — the
|
|
176
|
+
* allocation may have grown the heap.
|
|
177
|
+
*/
|
|
178
|
+
function readContext(api, textPage, totalChars, charIndex, charCount) {
|
|
179
|
+
const start = Math.max(0, charIndex - CONTEXT_CHARS);
|
|
180
|
+
const end = Math.min(totalChars, charIndex + charCount + CONTEXT_CHARS);
|
|
181
|
+
const count = end - start;
|
|
182
|
+
if (count <= 0)
|
|
183
|
+
return '';
|
|
184
|
+
const buffer = api.malloc((count + 1) * 2);
|
|
185
|
+
if (buffer === 0)
|
|
186
|
+
return '';
|
|
187
|
+
try {
|
|
188
|
+
const written = api.FPDFText_GetText(textPage, start, count, buffer);
|
|
189
|
+
const heap = api.heapU8();
|
|
190
|
+
let text = '';
|
|
191
|
+
for (let i = 0; i < written - 1; i++) {
|
|
192
|
+
const at = buffer + 2 * i;
|
|
193
|
+
text += String.fromCharCode((heap[at] ?? 0) | ((heap[at + 1] ?? 0) << 8));
|
|
194
|
+
}
|
|
195
|
+
return collapseWhitespace(text);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
api.free(buffer);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
150
201
|
/* ------------------------------------------------------------------ *
|
|
151
202
|
* Render
|
|
152
203
|
* ------------------------------------------------------------------ */
|
|
@@ -165,7 +216,19 @@ function createDocument(api, document, dataPointer, pages) {
|
|
|
165
216
|
*/
|
|
166
217
|
let loadedIndex = -1;
|
|
167
218
|
let loadedPage = 0;
|
|
219
|
+
/**
|
|
220
|
+
* The loaded page's TEXT page, parsed on the first search of it and held for
|
|
221
|
+
* as long as the page is. A "find" box re-searches on every keystroke, and
|
|
222
|
+
* `FPDFText_LoadPage` is the call that walks the content stream for glyphs,
|
|
223
|
+
* so the cache is what keeps typing cheap. It lives and dies with the page
|
|
224
|
+
* above it: PDFium requires the text page to be closed before its page.
|
|
225
|
+
*/
|
|
226
|
+
let loadedTextPage = 0;
|
|
168
227
|
const releasePage = () => {
|
|
228
|
+
if (loadedTextPage !== 0) {
|
|
229
|
+
api.FPDFText_ClosePage(loadedTextPage);
|
|
230
|
+
loadedTextPage = 0;
|
|
231
|
+
}
|
|
169
232
|
if (loadedPage !== 0) {
|
|
170
233
|
api.FPDF_ClosePage(loadedPage);
|
|
171
234
|
loadedPage = 0;
|
|
@@ -195,9 +258,144 @@ function createDocument(api, document, dataPointer, pages) {
|
|
|
195
258
|
loadedPage = page;
|
|
196
259
|
return page;
|
|
197
260
|
};
|
|
261
|
+
const loadTextPage = (index) => {
|
|
262
|
+
const page = loadPage(index);
|
|
263
|
+
if (loadedTextPage === 0) {
|
|
264
|
+
const textPage = api.FPDFText_LoadPage(page);
|
|
265
|
+
if (textPage === 0) {
|
|
266
|
+
throw new PdfError('corrupt', `PDFium could not read the text of page ${index}.`);
|
|
267
|
+
}
|
|
268
|
+
loadedTextPage = textPage;
|
|
269
|
+
}
|
|
270
|
+
return { page, textPage: loadedTextPage };
|
|
271
|
+
};
|
|
198
272
|
return {
|
|
199
273
|
pageCount: pages.length,
|
|
200
274
|
pages,
|
|
275
|
+
/**
|
|
276
|
+
* THE RECT MAPPING IS PDFIUM'S OWN DISPLAY MATRIX. `FPDFText_GetRect`
|
|
277
|
+
* answers in the page's USER space — y up, CropBox origin wherever the
|
|
278
|
+
* file put it, `/Rotate` not applied — and every raster this engine
|
|
279
|
+
* produces is drawn through `CPDF_Page::GetDisplayMatrix` for the
|
|
280
|
+
* displayed box and the host's `rotate`. Mapping each corner through
|
|
281
|
+
* `FPDF_PageToDevice` with THAT box and THAT rotate is what makes a hit
|
|
282
|
+
* drawn at the returned rect land on the glyphs in the raster, whatever
|
|
283
|
+
* the file's `/Rotate` and whatever the host has turned the page by. The
|
|
284
|
+
* two corners can swap under a turn, so the rect is min/max of both.
|
|
285
|
+
* Measured against the raster in `__tests__/text-search.test.ts`.
|
|
286
|
+
*/
|
|
287
|
+
search(request) {
|
|
288
|
+
if (closed) {
|
|
289
|
+
throw new PdfError('backend-failure', 'The document is closed.');
|
|
290
|
+
}
|
|
291
|
+
const geometry = requirePage(request.page);
|
|
292
|
+
const rotation = request.rotation ?? 0;
|
|
293
|
+
if (!isPageRotation(rotation)) {
|
|
294
|
+
throw new PdfError('backend-failure', `Rotation must be 0, 90, 180 or 270, got ${String(rotation)}.`);
|
|
295
|
+
}
|
|
296
|
+
const query = request.query;
|
|
297
|
+
if (query.length === 0)
|
|
298
|
+
return [];
|
|
299
|
+
const { page, textPage } = loadTextPage(request.page);
|
|
300
|
+
const total = api.FPDFText_CountChars(textPage);
|
|
301
|
+
// The DISPLAYED page's box, as the renderers hand it to PDFium: the
|
|
302
|
+
// intrinsic post-`/Rotate` extents, swapped for a quarter turn — and
|
|
303
|
+
// then scaled up, because `FPDF_PageToDevice` rounds to whole units.
|
|
304
|
+
const turned = swapsExtents(rotation);
|
|
305
|
+
const displayedWidth = turned ? geometry.height : geometry.width;
|
|
306
|
+
const displayedHeight = turned ? geometry.width : geometry.height;
|
|
307
|
+
const sizeX = Math.round(displayedWidth * SEARCH_UNITS_PER_POINT);
|
|
308
|
+
const sizeY = Math.round(displayedHeight * SEARCH_UNITS_PER_POINT);
|
|
309
|
+
// Per axis, from the rounded box itself, so the division undoes exactly
|
|
310
|
+
// the scale PDFium applied rather than the one that was asked for.
|
|
311
|
+
const pointsPerUnitX = displayedWidth / sizeX;
|
|
312
|
+
const pointsPerUnitY = displayedHeight / sizeY;
|
|
313
|
+
const rotate = rotation / 90;
|
|
314
|
+
// One scratch block: four doubles for a rect, two ints for a device
|
|
315
|
+
// point, then the query as UTF-16LE plus its terminator. THE OUT-PARAMS
|
|
316
|
+
// GO FIRST, AND THAT ORDER IS LOAD-BEARING: Emscripten's `getValue` reads
|
|
317
|
+
// a double as `HEAPF64[ptr >> 3]` and an int as `HEAP32[ptr >> 2]`, so an
|
|
318
|
+
// unaligned pointer is silently rounded DOWN and the read returns
|
|
319
|
+
// neighbouring bytes. PDFium's own stores are unaligned-safe, so nothing
|
|
320
|
+
// fails — the rects just come back as garbage. (Measured: with the query
|
|
321
|
+
// first, "measured" put the doubles at +18 and every rect was nonsense.)
|
|
322
|
+
// `malloc` is 8-aligned, and 32 + 8 keeps the query 2-aligned after it.
|
|
323
|
+
// Allocated AFTER the page loads and written through a fresh `heapU8()`
|
|
324
|
+
// — the heap can grow under either.
|
|
325
|
+
const rectOut = 0;
|
|
326
|
+
const deviceOut = 4 * 8;
|
|
327
|
+
const queryAt = deviceOut + 2 * 4;
|
|
328
|
+
const queryBytes = (query.length + 1) * 2;
|
|
329
|
+
const scratch = api.malloc(queryAt + queryBytes);
|
|
330
|
+
if (scratch === 0) {
|
|
331
|
+
throw new PdfError('out-of-memory', `Could not allocate ${queryBytes} bytes for the search query.`);
|
|
332
|
+
}
|
|
333
|
+
let find = 0;
|
|
334
|
+
try {
|
|
335
|
+
const heap = api.heapU8();
|
|
336
|
+
const queryPointer = scratch + queryAt;
|
|
337
|
+
for (let i = 0; i < query.length; i++) {
|
|
338
|
+
const unit = query.charCodeAt(i);
|
|
339
|
+
heap[queryPointer + 2 * i] = unit & 0xff;
|
|
340
|
+
heap[queryPointer + 2 * i + 1] = unit >> 8;
|
|
341
|
+
}
|
|
342
|
+
heap[queryPointer + 2 * query.length] = 0;
|
|
343
|
+
heap[queryPointer + 2 * query.length + 1] = 0;
|
|
344
|
+
const flags = (request.matchCase ? FPDF_MATCHCASE : 0) |
|
|
345
|
+
(request.wholeWord ? FPDF_MATCHWHOLEWORD : 0);
|
|
346
|
+
find = api.FPDFText_FindStart(textPage, queryPointer, flags, 0);
|
|
347
|
+
if (find === 0)
|
|
348
|
+
return [];
|
|
349
|
+
const toDevice = (pageX, pageY) => {
|
|
350
|
+
if (!api.FPDF_PageToDevice(page, 0, 0, sizeX, sizeY, rotate, pageX, pageY, scratch + deviceOut, scratch + deviceOut + 4)) {
|
|
351
|
+
throw new PdfError('backend-failure', `PDFium could not map (${pageX}, ${pageY}) on page ` +
|
|
352
|
+
`${request.page} to device space.`);
|
|
353
|
+
}
|
|
354
|
+
return [
|
|
355
|
+
api.getInt32(scratch + deviceOut),
|
|
356
|
+
api.getInt32(scratch + deviceOut + 4),
|
|
357
|
+
];
|
|
358
|
+
};
|
|
359
|
+
const matches = [];
|
|
360
|
+
while (api.FPDFText_FindNext(find)) {
|
|
361
|
+
const charIndex = api.FPDFText_GetSchResultIndex(find);
|
|
362
|
+
const charCount = api.FPDFText_GetSchCount(find);
|
|
363
|
+
// A zero-length result cannot happen for a non-empty query; the
|
|
364
|
+
// guard is against looping forever if PDFium ever reported one.
|
|
365
|
+
if (charCount <= 0)
|
|
366
|
+
break;
|
|
367
|
+
const rects = [];
|
|
368
|
+
const rectCount = api.FPDFText_CountRects(textPage, charIndex, charCount);
|
|
369
|
+
for (let i = 0; i < rectCount; i++) {
|
|
370
|
+
if (!api.FPDFText_GetRect(textPage, i, scratch + rectOut, scratch + rectOut + 8, scratch + rectOut + 16, scratch + rectOut + 24)) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
// left, top, right, bottom — user space, so top > bottom.
|
|
374
|
+
const [x1, y1] = toDevice(api.getDouble(scratch + rectOut), api.getDouble(scratch + rectOut + 8));
|
|
375
|
+
const [x2, y2] = toDevice(api.getDouble(scratch + rectOut + 16), api.getDouble(scratch + rectOut + 24));
|
|
376
|
+
rects.push({
|
|
377
|
+
x: Math.min(x1, x2) * pointsPerUnitX,
|
|
378
|
+
y: Math.min(y1, y2) * pointsPerUnitY,
|
|
379
|
+
width: Math.abs(x2 - x1) * pointsPerUnitX,
|
|
380
|
+
height: Math.abs(y2 - y1) * pointsPerUnitY,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
matches.push({
|
|
384
|
+
page: request.page,
|
|
385
|
+
charIndex,
|
|
386
|
+
charCount,
|
|
387
|
+
rects,
|
|
388
|
+
context: readContext(api, textPage, total, charIndex, charCount),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return matches;
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
if (find !== 0)
|
|
395
|
+
api.FPDFText_FindClose(find);
|
|
396
|
+
api.free(scratch);
|
|
397
|
+
}
|
|
398
|
+
},
|
|
201
399
|
render(request) {
|
|
202
400
|
if (closed) {
|
|
203
401
|
throw new PdfError('backend-failure', 'The document is closed.');
|
|
@@ -215,6 +413,13 @@ function createDocument(api, document, dataPointer, pages) {
|
|
|
215
413
|
`${docRect.width}x${docRect.height} at ` +
|
|
216
414
|
`(${docRect.x}, ${docRect.y}).`);
|
|
217
415
|
}
|
|
416
|
+
// Read as `?? 0`, so a request built before the field existed renders
|
|
417
|
+
// as it always did; validated, because from here on it is arithmetic
|
|
418
|
+
// and `45 / 90` would silently be "upright".
|
|
419
|
+
const rotation = request.rotation ?? 0;
|
|
420
|
+
if (!isPageRotation(rotation)) {
|
|
421
|
+
throw new PdfError('backend-failure', `Rotation must be 0, 90, 180 or 270, got ${String(rotation)}.`);
|
|
422
|
+
}
|
|
218
423
|
// At least one pixel: a sub-pixel rect is a legitimate ask at low zoom,
|
|
219
424
|
// and a zero-sized bitmap is not a thing PDFium (or Skia) accepts.
|
|
220
425
|
const width = Math.max(1, Math.round(docRect.width * scale));
|
|
@@ -223,8 +428,18 @@ function createDocument(api, document, dataPointer, pages) {
|
|
|
223
428
|
// the negated one.
|
|
224
429
|
const startX = -Math.round(docRect.x * scale);
|
|
225
430
|
const startY = -Math.round(docRect.y * scale);
|
|
226
|
-
|
|
227
|
-
|
|
431
|
+
// THE PAGE BOX PDFIUM IS HANDED IS THE DISPLAYED ONE. `size_x` / `size_y`
|
|
432
|
+
// describe the box the page is drawn INTO after `rotate` is applied —
|
|
433
|
+
// `CPDF_Page::GetDisplayMatrix` maps the page's HEIGHT onto `size_x` for
|
|
434
|
+
// rotate 1 and 3 — so a quarter turn swaps the intrinsic extents here.
|
|
435
|
+
// Without the swap the turned page is squashed into the upright box and
|
|
436
|
+
// every pixel is stretched; with it, `docRect` (already in the turned
|
|
437
|
+
// page's space, see `RasterRequest.rotation`) needs nothing: the start
|
|
438
|
+
// offsets slide the whole turned page exactly as they slide an upright
|
|
439
|
+
// one.
|
|
440
|
+
const turned = swapsExtents(rotation);
|
|
441
|
+
const pageWidth = Math.round((turned ? geometry.height : geometry.width) * scale);
|
|
442
|
+
const pageHeight = Math.round((turned ? geometry.width : geometry.height) * scale);
|
|
228
443
|
if (width * height > MAX_INT32 / 4 ||
|
|
229
444
|
pageWidth > MAX_INT32 ||
|
|
230
445
|
pageHeight > MAX_INT32) {
|
|
@@ -255,10 +470,12 @@ function createDocument(api, document, dataPointer, pages) {
|
|
|
255
470
|
}
|
|
256
471
|
const page = loadPage(request.page);
|
|
257
472
|
api.FPDF_RenderPageBitmap(bitmap, page, startX, startY, pageWidth, pageHeight,
|
|
258
|
-
//
|
|
259
|
-
// page's own
|
|
260
|
-
//
|
|
261
|
-
|
|
473
|
+
// The HOST's rotation, in quarter turns clockwise — and never the
|
|
474
|
+
// page's own `/Rotate`. PDFium has already folded that into
|
|
475
|
+
// `geometry` and into the render, so passing it here would turn the
|
|
476
|
+
// page twice; `rotation` is layered on top of it, which is exactly
|
|
477
|
+
// what this argument exists for.
|
|
478
|
+
rotation / 90, FPDF_REVERSE_BYTE_ORDER | (request.annotations ? FPDF_ANNOT : 0));
|
|
262
479
|
// READ, NEVER ASSUMED. Measured tight (`width * 4`) at 1, 3, 7, 13, 1023
|
|
263
480
|
// and 1025 px — but "tight today" is not "tight always", and a wrong
|
|
264
481
|
// stride shears the image instead of failing.
|
|
@@ -46,6 +46,9 @@ import type { PdfErrorCode } from '../../types.js';
|
|
|
46
46
|
export declare const FPDF_BITMAP_BGRA = 4;
|
|
47
47
|
/** Draw PDF-embedded annotations. Answers `RasterRequest.annotations`. */
|
|
48
48
|
export declare const FPDF_ANNOT = 1;
|
|
49
|
+
/** `FPDFText_FindStart` flags. Answer `TextSearchRequest.matchCase` / `.wholeWord`. */
|
|
50
|
+
export declare const FPDF_MATCHCASE = 1;
|
|
51
|
+
export declare const FPDF_MATCHWHOLEWORD = 2;
|
|
49
52
|
/**
|
|
50
53
|
* Write RGBA instead of BGRA.
|
|
51
54
|
*
|
|
@@ -95,11 +98,18 @@ export interface PdfiumHeap {
|
|
|
95
98
|
free(pointer: number): void;
|
|
96
99
|
/** Reads an `FS_SIZEF` component — PDFium's float32. */
|
|
97
100
|
getFloat(pointer: number): number;
|
|
101
|
+
/** Reads a `double*` out-parameter — `FPDFText_GetRect`'s four edges. */
|
|
102
|
+
getDouble(pointer: number): number;
|
|
103
|
+
/** Reads an `int*` out-parameter — `FPDF_PageToDevice`'s two coordinates. */
|
|
104
|
+
getInt32(pointer: number): number;
|
|
98
105
|
}
|
|
99
106
|
/**
|
|
100
107
|
* Exactly the PDFium entry points this backend calls. Nothing speculative:
|
|
101
|
-
*
|
|
102
|
-
*
|
|
108
|
+
* links and form filling are absent because neither is implemented, and
|
|
109
|
+
* `RasterizerCapabilities` says so. The `FPDFText_*` group is the text search
|
|
110
|
+
* surface (`RasterizerHandle.searchText`), and `FPDF_PageToDevice` is how its
|
|
111
|
+
* rects are mapped into the displayed page's doc space — see `search` in
|
|
112
|
+
* `./engine.ts`.
|
|
103
113
|
*/
|
|
104
114
|
export interface PdfiumBinding extends PdfiumHeap {
|
|
105
115
|
/**
|
|
@@ -149,6 +159,40 @@ export interface PdfiumBinding extends PdfiumHeap {
|
|
|
149
159
|
FPDFBitmap_GetBuffer(bitmap: number): number;
|
|
150
160
|
FPDFBitmap_GetStride(bitmap: number): number;
|
|
151
161
|
FPDF_RenderPageBitmap(bitmap: number, page: number, startX: number, startY: number, sizeX: number, sizeY: number, rotate: number, flags: number): void;
|
|
162
|
+
/**
|
|
163
|
+
* Maps a point in the page's USER space onto a device box. `sizeX`/`sizeY`
|
|
164
|
+
* are the box the page is displayed INTO after `rotate` — the same box the
|
|
165
|
+
* render call takes, extents swapped for a quarter turn — and the output is
|
|
166
|
+
* ROUNDED TO WHOLE DEVICE UNITS by PDFium (`FXSYS_roundf` on both), which is
|
|
167
|
+
* why `search` scales the box up before calling it.
|
|
168
|
+
*/
|
|
169
|
+
FPDF_PageToDevice(page: number, startX: number, startY: number, sizeX: number, sizeY: number, rotate: number, pageX: number, pageY: number, deviceXOut: number, deviceYOut: number): boolean;
|
|
170
|
+
/** Parses the page's text. Balanced by `FPDFText_ClosePage`. */
|
|
171
|
+
FPDFText_LoadPage(page: number): number;
|
|
172
|
+
FPDFText_ClosePage(textPage: number): void;
|
|
173
|
+
FPDFText_CountChars(textPage: number): number;
|
|
174
|
+
/**
|
|
175
|
+
* Writes `count` UTF-16 code units plus a terminating 0 to `buffer` (which
|
|
176
|
+
* must hold `count + 1` units) and returns how many units it wrote, the
|
|
177
|
+
* terminator included.
|
|
178
|
+
*/
|
|
179
|
+
FPDFText_GetText(textPage: number, startIndex: number, count: number, buffer: number): number;
|
|
180
|
+
/**
|
|
181
|
+
* `findWhat` is an `FPDF_WIDESTRING`: UTF-16LE code units in the heap with a
|
|
182
|
+
* terminating 0. Returns a search handle, or 0 for an empty query.
|
|
183
|
+
*/
|
|
184
|
+
FPDFText_FindStart(textPage: number, findWhat: number, flags: number, startIndex: number): number;
|
|
185
|
+
FPDFText_FindNext(handle: number): boolean;
|
|
186
|
+
FPDFText_FindClose(handle: number): void;
|
|
187
|
+
FPDFText_GetSchResultIndex(handle: number): number;
|
|
188
|
+
FPDFText_GetSchCount(handle: number): number;
|
|
189
|
+
/** Counts the rects a char range occupies and caches them for `GetRect`. */
|
|
190
|
+
FPDFText_CountRects(textPage: number, startIndex: number, count: number): number;
|
|
191
|
+
/**
|
|
192
|
+
* One cached rect, as four `double*` out-parameters in the page's USER space
|
|
193
|
+
* (y up, so `top > bottom`).
|
|
194
|
+
*/
|
|
195
|
+
FPDFText_GetRect(textPage: number, rectIndex: number, leftOut: number, topOut: number, rightOut: number, bottomOut: number): boolean;
|
|
152
196
|
}
|
|
153
197
|
/**
|
|
154
198
|
* The shape of `@embedpdf/pdfium`'s `WrappedPdfiumModule`, restated as the
|
|
@@ -189,6 +233,24 @@ export interface PdfiumWasmModule {
|
|
|
189
233
|
FPDFBitmap_GetBuffer(bitmap: number): number;
|
|
190
234
|
FPDFBitmap_GetStride(bitmap: number): number;
|
|
191
235
|
FPDF_RenderPageBitmap(bitmap: number, page: number, startX: number, startY: number, sizeX: number, sizeY: number, rotate: number, flags: number): null;
|
|
236
|
+
FPDF_PageToDevice(page: number, startX: number, startY: number, sizeX: number, sizeY: number, rotate: number, pageX: number, pageY: number, deviceXOut: number, deviceYOut: number): boolean;
|
|
237
|
+
FPDFText_LoadPage(page: number): number;
|
|
238
|
+
FPDFText_ClosePage(textPage: number): null;
|
|
239
|
+
FPDFText_CountChars(textPage: number): number;
|
|
240
|
+
FPDFText_GetText(textPage: number, startIndex: number, count: number, buffer: number): number;
|
|
241
|
+
/**
|
|
242
|
+
* `findWhat` is a POINTER (`number`), not a JS string: the real module's
|
|
243
|
+
* signature is `["number", "number", "number", "number"]`, so the UTF-16LE
|
|
244
|
+
* query is written into the heap by the caller. A `"string"` here would
|
|
245
|
+
* have `tsc` accept a call the wasm would read as a garbage pointer.
|
|
246
|
+
*/
|
|
247
|
+
FPDFText_FindStart(textPage: number, findWhat: number, flags: number, startIndex: number): number;
|
|
248
|
+
FPDFText_FindNext(handle: number): boolean;
|
|
249
|
+
FPDFText_FindClose(handle: number): null;
|
|
250
|
+
FPDFText_GetSchResultIndex(handle: number): number;
|
|
251
|
+
FPDFText_GetSchCount(handle: number): number;
|
|
252
|
+
FPDFText_CountRects(textPage: number, startIndex: number, count: number): number;
|
|
253
|
+
FPDFText_GetRect(textPage: number, rectIndex: number, leftOut: number, topOut: number, rightOut: number, bottomOut: number): boolean;
|
|
192
254
|
}
|
|
193
255
|
/**
|
|
194
256
|
* Turn an initialised `@embedpdf/pdfium` module into a `PdfiumBinding`.
|