@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.
Files changed (50) hide show
  1. package/README.md +61 -6
  2. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +64 -10
  3. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +22 -3
  4. package/android/src/main/cpp/pdfcanvas-jni.cpp +47 -3
  5. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +31 -1
  6. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +79 -9
  7. package/android/tools/compile-gate.sh +6 -4
  8. package/dist/controller.d.ts +23 -1
  9. package/dist/controller.js +33 -16
  10. package/dist/rasterizer/fake.d.ts +38 -4
  11. package/dist/rasterizer/fake.js +167 -37
  12. package/dist/rasterizer/native-bridge.d.ts +48 -1
  13. package/dist/rasterizer/native-bridge.js +96 -0
  14. package/dist/rasterizer/native.d.ts +2 -2
  15. package/dist/rasterizer/native.js +9 -7
  16. package/dist/rasterizer/text-search.d.ts +22 -0
  17. package/dist/rasterizer/text-search.js +28 -0
  18. package/dist/rasterizer/web/client.js +69 -49
  19. package/dist/rasterizer/web/engine.d.ts +3 -1
  20. package/dist/rasterizer/web/engine.js +225 -8
  21. package/dist/rasterizer/web/pdfium.d.ts +64 -2
  22. package/dist/rasterizer/web/pdfium.js +21 -0
  23. package/dist/rasterizer/web/protocol.d.ts +23 -6
  24. package/dist/rasterizer/web/protocol.js +4 -1
  25. package/dist/rasterizer/web/session.js +30 -13
  26. package/dist/react/PdfContentView.js +5 -0
  27. package/dist/react/usePdfDocument.d.ts +27 -2
  28. package/dist/react/usePdfDocument.js +65 -12
  29. package/dist/react/usePdfLayer.d.ts +19 -2
  30. package/dist/react/usePdfLayer.js +51 -8
  31. package/dist/rotation.d.ts +59 -0
  32. package/dist/rotation.js +85 -0
  33. package/dist/testing/index.d.ts +1 -1
  34. package/dist/testing/index.js +1 -1
  35. package/dist/types.d.ts +86 -0
  36. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +71 -2
  37. package/native/CMakeLists.txt +7 -0
  38. package/native/core/include/pdfcanvas/document.h +17 -0
  39. package/native/core/include/pdfcanvas/service.h +14 -1
  40. package/native/core/include/pdfcanvas/text_search.h +52 -0
  41. package/native/core/include/pdfcanvas/types.h +49 -0
  42. package/native/core/pdfcanvas-core.cmake +1 -0
  43. package/native/core/src/document.cpp +211 -8
  44. package/native/core/src/service.cpp +43 -28
  45. package/native/core/src/text_search.cpp +115 -0
  46. package/native/tests/fixtures.cpp +27 -0
  47. package/native/tests/fixtures.h +15 -0
  48. package/native/tests/test_document.cpp +486 -0
  49. package/native/tests/test_service.cpp +110 -0
  50. 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
- * 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,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
- const ROTATIONS = [0, 90, 180, 270];
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
- const pageWidth = Math.round(geometry.width * scale);
227
- const pageHeight = Math.round(geometry.height * scale);
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
- // Device rotation, NOT the page's `/Rotate`. PDFium applies the
259
- // page's own rotation itself, and `geometry` is already post-rotation
260
- // — passing anything but 0 here would rotate a second time.
261
- 0, FPDF_REVERSE_BYTE_ORDER | (request.annotations ? FPDF_ANNOT : 0));
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
- * text, search, links and form filling are all absent because none of them is
102
- * implemented, and `RasterizerCapabilities` says so.
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`.