@reekon-tools/react-native-pdf-canvas 0.3.0 → 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.
Files changed (44) hide show
  1. package/README.md +44 -6
  2. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +30 -0
  3. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +22 -3
  4. package/android/src/main/cpp/pdfcanvas-jni.cpp +43 -1
  5. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +26 -1
  6. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +69 -6
  7. package/android/tools/compile-gate.sh +6 -4
  8. package/dist/controller.js +5 -0
  9. package/dist/rasterizer/fake.d.ts +24 -2
  10. package/dist/rasterizer/fake.js +74 -1
  11. package/dist/rasterizer/native-bridge.d.ts +37 -1
  12. package/dist/rasterizer/native-bridge.js +95 -0
  13. package/dist/rasterizer/native.d.ts +2 -2
  14. package/dist/rasterizer/native.js +9 -7
  15. package/dist/rasterizer/text-search.d.ts +22 -0
  16. package/dist/rasterizer/text-search.js +28 -0
  17. package/dist/rasterizer/web/client.js +69 -49
  18. package/dist/rasterizer/web/engine.d.ts +3 -1
  19. package/dist/rasterizer/web/engine.js +199 -1
  20. package/dist/rasterizer/web/pdfium.d.ts +64 -2
  21. package/dist/rasterizer/web/pdfium.js +21 -0
  22. package/dist/rasterizer/web/protocol.d.ts +23 -6
  23. package/dist/rasterizer/web/protocol.js +4 -1
  24. package/dist/rasterizer/web/session.js +30 -13
  25. package/dist/react/PdfContentView.js +5 -0
  26. package/dist/react/usePdfDocument.d.ts +17 -2
  27. package/dist/react/usePdfDocument.js +44 -0
  28. package/dist/testing/index.d.ts +1 -1
  29. package/dist/testing/index.js +1 -1
  30. package/dist/types.d.ts +66 -0
  31. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +66 -2
  32. package/native/core/include/pdfcanvas/document.h +17 -0
  33. package/native/core/include/pdfcanvas/service.h +14 -1
  34. package/native/core/include/pdfcanvas/text_search.h +52 -0
  35. package/native/core/include/pdfcanvas/types.h +41 -0
  36. package/native/core/pdfcanvas-core.cmake +1 -0
  37. package/native/core/src/document.cpp +188 -5
  38. package/native/core/src/service.cpp +43 -28
  39. package/native/core/src/text_search.cpp +115 -0
  40. package/native/tests/fixtures.cpp +27 -0
  41. package/native/tests/fixtures.h +15 -0
  42. package/native/tests/test_document.cpp +308 -0
  43. package/native/tests/test_service.cpp +110 -0
  44. package/package.json +1 -1
@@ -227,6 +227,63 @@ export function negotiateTransport(native, lookup = lookupTakePixels) {
227
227
  return BASE64_TRANSPORT;
228
228
  }
229
229
  }
230
+ /* ------------------------------------------------------------------ *
231
+ * Text matches
232
+ * ------------------------------------------------------------------ */
233
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
234
+ function toDocRect(value, where) {
235
+ const rect = value;
236
+ if (rect === null ||
237
+ typeof rect !== 'object' ||
238
+ !isFiniteNumber(rect.x) ||
239
+ !isFiniteNumber(rect.y) ||
240
+ !isFiniteNumber(rect.width) ||
241
+ !isFiniteNumber(rect.height)) {
242
+ throw new PdfError('backend-failure', `The native module's search result has a malformed rect at ${where}.`);
243
+ }
244
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
245
+ }
246
+ /**
247
+ * The core's JSON, checked field by field into `TextMatch[]`.
248
+ *
249
+ * Checked rather than cast because the string was built by hand in C++
250
+ * (`native/core/src/text_search.cpp`) with no JSON library on either side of
251
+ * it, and a host drawing hits from a `rects` entry that is `undefined` would
252
+ * fail inside its own draw code with no mention of this package. Every
253
+ * failure here is `backend-failure` naming what was wrong.
254
+ */
255
+ export function parseTextMatches(json) {
256
+ let parsed;
257
+ try {
258
+ parsed = JSON.parse(json);
259
+ }
260
+ catch (error) {
261
+ throw new PdfError('backend-failure', `The native module's search result is not JSON: ` +
262
+ `${error?.message ?? String(error)}`, { cause: error });
263
+ }
264
+ if (!Array.isArray(parsed)) {
265
+ throw new PdfError('backend-failure', "The native module's search result is not an array.");
266
+ }
267
+ return parsed.map((entry, i) => {
268
+ const match = entry;
269
+ if (match === null ||
270
+ typeof match !== 'object' ||
271
+ !isFiniteNumber(match.page) ||
272
+ !isFiniteNumber(match.charIndex) ||
273
+ !isFiniteNumber(match.charCount) ||
274
+ !Array.isArray(match.rects) ||
275
+ typeof match.context !== 'string') {
276
+ throw new PdfError('backend-failure', `The native module's search result has a malformed match at [${i}].`);
277
+ }
278
+ return {
279
+ page: match.page,
280
+ charIndex: match.charIndex,
281
+ charCount: match.charCount,
282
+ rects: match.rects.map((rect, j) => toDocRect(rect, `[${i}].rects[${j}]`)),
283
+ context: match.context,
284
+ };
285
+ });
286
+ }
230
287
  /* ------------------------------------------------------------------ *
231
288
  * Geometry
232
289
  * ------------------------------------------------------------------ */
@@ -387,6 +444,44 @@ export function createNativeHandle(native, opened, transport, alpha, label) {
387
444
  },
388
445
  };
389
446
  },
447
+ async searchText(request, signal) {
448
+ if (closed) {
449
+ throw new PdfError('backend-failure', `${label} rasterizer handle is closed.`);
450
+ }
451
+ requirePage(request.page);
452
+ if (signal?.aborted) {
453
+ throw new PdfError('cancelled', 'Search was aborted before it started.');
454
+ }
455
+ // The capability says true for this binary's JS, but an OTA bundle can
456
+ // be newer than the binary under it, and `NativeModules.PdfCanvas` only
457
+ // has the methods that binary exports. Named here rather than left as
458
+ // "native.search is not a function" from inside the bridge.
459
+ if (typeof native.search !== 'function') {
460
+ throw new PdfError('unsupported', 'This app binary predates text search in the PdfCanvas native ' +
461
+ 'module (0.4.0). A native rebuild is needed; an OTA update ' +
462
+ 'cannot add it.');
463
+ }
464
+ const token = nextToken++;
465
+ const onAbort = () => native.cancel(opened.handle, token);
466
+ signal?.addEventListener('abort', onAbort, { once: true });
467
+ let json;
468
+ try {
469
+ json = await native.search(opened.handle, {
470
+ page: request.page,
471
+ query: request.query,
472
+ matchCase: request.matchCase,
473
+ wholeWord: request.wholeWord,
474
+ rotation: request.rotation,
475
+ }, token);
476
+ }
477
+ catch (error) {
478
+ throw toPdfError(error, `Could not search page ${request.page}`);
479
+ }
480
+ finally {
481
+ signal?.removeEventListener('abort', onAbort);
482
+ }
483
+ return parseTextMatches(json);
484
+ },
390
485
  close() {
391
486
  if (closed)
392
487
  return;
@@ -22,8 +22,8 @@
22
22
  */
23
23
  import type { TakePixels } from './native-bridge.js';
24
24
  import type { PageRasterizer, RasterizerCapabilities } from '../types.js';
25
- export { TRANSPORT_PROBE_BYTES, bytesFromResult, decodeBase64, encodeBase64, lookupTakePixels, negotiateTransport, toPageGeometry, } from './native-bridge.js';
26
- export type { NativeOpenResult, NativePdfPage, NativeRenderRequest, NativeRenderResult, NativeSource, NativeTransport, PdfCanvasNativeModule, TakePixels, } from './native-bridge.js';
25
+ export { TRANSPORT_PROBE_BYTES, bytesFromResult, decodeBase64, encodeBase64, lookupTakePixels, negotiateTransport, parseTextMatches, toPageGeometry, } from './native-bridge.js';
26
+ export type { NativeOpenResult, NativePdfPage, NativeRenderRequest, NativeRenderResult, NativeSearchRequest, NativeSource, NativeTransport, PdfCanvasNativeModule, TakePixels, } from './native-bridge.js';
27
27
  export type NativePlatform = 'ios' | 'android';
28
28
  export declare const NATIVE_CAPABILITIES: RasterizerCapabilities;
29
29
  export interface NativeRasterizerOptions {
@@ -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
- * FALSE, AND A DECLARATION ABOUT THE PACKAGE, NOT THE ENGINE. PDFium has
60
- * text extraction, search and link enumeration, so all three could be true
61
- * the day a surface for them exists. It does not — see the non-goals — and a
62
- * capability that says "yes" about an API no consumer can reach is worse
63
- * than one that says "no".
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: false,
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
- * `text` / `search` / `links` are false because none of them is implemented.
67
- * PDFium can do all three (`FPDFText_*`, `FPDFLink_*`) and the seam has no place
68
- * to expose them yet; declaring them true would be claiming an API that does not
69
- * exist.
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: false,
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,8 +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
43
  import { isPageRotation, ROTATIONS, swapsExtents } from '../../rotation.js';
44
+ import { CONTEXT_CHARS, collapseWhitespace } from '../text-search.js';
44
45
  import { PdfError } from '../../types.js';
45
46
  /**
46
47
  * MEASURED, not inherited from the other backends. An antialiased opaque-white
@@ -65,6 +66,22 @@ const OPAQUE_WHITE = 0xffffffff;
65
66
  const TRANSPARENT = 0x00000000;
66
67
  /** PDFium's ints are 32-bit; a request past this cannot be expressed at all. */
67
68
  const MAX_INT32 = 0x7fffffff;
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.');