@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
@@ -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`.
@@ -48,6 +48,9 @@ import { PdfError } from '../../types.js';
48
48
  export const FPDF_BITMAP_BGRA = 4;
49
49
  /** Draw PDF-embedded annotations. Answers `RasterRequest.annotations`. */
50
50
  export const FPDF_ANNOT = 0x01;
51
+ /** `FPDFText_FindStart` flags. Answer `TextSearchRequest.matchCase` / `.wholeWord`. */
52
+ export const FPDF_MATCHCASE = 0x01;
53
+ export const FPDF_MATCHWHOLEWORD = 0x02;
51
54
  /**
52
55
  * Write RGBA instead of BGRA.
53
56
  *
@@ -96,6 +99,8 @@ export function bindPdfium(module) {
96
99
  malloc: byteLength => pdfium.wasmExports.malloc(byteLength),
97
100
  free: pointer => pdfium.wasmExports.free(pointer),
98
101
  getFloat: pointer => Number(pdfium.getValue(pointer, 'float')),
102
+ getDouble: pointer => Number(pdfium.getValue(pointer, 'double')),
103
+ getInt32: pointer => Number(pdfium.getValue(pointer, 'i32')),
99
104
  FPDF_LoadMemDocument: (pointer, byteLength, password) => module.FPDF_LoadMemDocument(pointer, byteLength, password),
100
105
  FPDF_CloseDocument: document => {
101
106
  module.FPDF_CloseDocument(document);
@@ -118,6 +123,22 @@ export function bindPdfium(module) {
118
123
  FPDF_RenderPageBitmap: (bitmap, page, startX, startY, sizeX, sizeY, rotate, flags) => {
119
124
  module.FPDF_RenderPageBitmap(bitmap, page, startX, startY, sizeX, sizeY, rotate, flags);
120
125
  },
126
+ FPDF_PageToDevice: (page, startX, startY, sizeX, sizeY, rotate, pageX, pageY, deviceXOut, deviceYOut) => module.FPDF_PageToDevice(page, startX, startY, sizeX, sizeY, rotate, pageX, pageY, deviceXOut, deviceYOut),
127
+ FPDFText_LoadPage: page => module.FPDFText_LoadPage(page),
128
+ FPDFText_ClosePage: textPage => {
129
+ module.FPDFText_ClosePage(textPage);
130
+ },
131
+ FPDFText_CountChars: textPage => module.FPDFText_CountChars(textPage),
132
+ FPDFText_GetText: (textPage, startIndex, count, buffer) => module.FPDFText_GetText(textPage, startIndex, count, buffer),
133
+ FPDFText_FindStart: (textPage, findWhat, flags, startIndex) => module.FPDFText_FindStart(textPage, findWhat, flags, startIndex),
134
+ FPDFText_FindNext: handle => module.FPDFText_FindNext(handle),
135
+ FPDFText_FindClose: handle => {
136
+ module.FPDFText_FindClose(handle);
137
+ },
138
+ FPDFText_GetSchResultIndex: handle => module.FPDFText_GetSchResultIndex(handle),
139
+ FPDFText_GetSchCount: handle => module.FPDFText_GetSchCount(handle),
140
+ FPDFText_CountRects: (textPage, startIndex, count) => module.FPDFText_CountRects(textPage, startIndex, count),
141
+ FPDFText_GetRect: (textPage, rectIndex, leftOut, topOut, rightOut, bottomOut) => module.FPDFText_GetRect(textPage, rectIndex, leftOut, topOut, rightOut, bottomOut),
121
142
  };
122
143
  }
123
144
  /* ------------------------------------------------------------------ *
@@ -21,7 +21,7 @@
21
21
  * exists but has not been told where its wasm is" — that could otherwise be
22
22
  * observed by a `render` arriving first.
23
23
  */
24
- import type { AlphaEncoding, PageGeometry, PdfErrorCode, PixelFormat, RasterRequest } from '../../types.js';
24
+ import type { AlphaEncoding, PageGeometry, PdfErrorCode, PixelFormat, RasterRequest, TextMatch, TextSearchRequest } from '../../types.js';
25
25
  /**
26
26
  * Where the worker gets `pdfium.wasm`.
27
27
  *
@@ -65,11 +65,22 @@ export interface RenderRequest {
65
65
  request: RasterRequest;
66
66
  }
67
67
  /**
68
- * Drop a render that has not started yet.
68
+ * Find text on one page. Routed exactly as a render is — the same queue, the
69
+ * same yield, the same `cancel` by id — so a search never overtakes a render
70
+ * that was posted before it and a superseded search can still be dropped
71
+ * before it starts.
72
+ */
73
+ export interface SearchRequest {
74
+ kind: 'search';
75
+ id: number;
76
+ request: TextSearchRequest;
77
+ }
78
+ /**
79
+ * Drop a render or a search that has not started yet.
69
80
  *
70
81
  * BEST EFFORT AND HONEST ABOUT IT. The worker renders synchronously, so a
71
- * `cancel` can only ever be delivered BETWEEN renders — never into one. What it
72
- * genuinely does is stop a QUEUED render from starting, which is why the worker
82
+ * `cancel` can only ever be delivered BETWEEN jobs — never into one. What it
83
+ * genuinely does is stop a QUEUED job from starting, which is why the worker
73
84
  * yields to its event loop between jobs (see `./session.ts`): without that
74
85
  * yield, messages queued behind a burst of renders would not be read until the
75
86
  * burst had finished and this message would be worthless.
@@ -94,7 +105,7 @@ export interface CancelRequest {
94
105
  export interface CloseRequest {
95
106
  kind: 'close';
96
107
  }
97
- export type WorkerRequest = OpenRequest | RenderRequest | CancelRequest | CloseRequest;
108
+ export type WorkerRequest = OpenRequest | RenderRequest | SearchRequest | CancelRequest | CloseRequest;
98
109
  export interface OpenedResponse {
99
110
  kind: 'opened';
100
111
  id: number;
@@ -119,6 +130,12 @@ export interface RenderedResponse {
119
130
  */
120
131
  alpha: AlphaEncoding;
121
132
  }
133
+ /** Plain data, structured-cloned whole: `DocRect`s and strings. */
134
+ export interface SearchedResponse {
135
+ kind: 'searched';
136
+ id: number;
137
+ matches: TextMatch[];
138
+ }
122
139
  export interface ErrorResponse {
123
140
  kind: 'error';
124
141
  /** Null for a failure that belongs to no single request. */
@@ -126,7 +143,7 @@ export interface ErrorResponse {
126
143
  code: PdfErrorCode;
127
144
  message: string;
128
145
  }
129
- export type WorkerResponse = OpenedResponse | RenderedResponse | ErrorResponse;
146
+ export type WorkerResponse = OpenedResponse | RenderedResponse | SearchedResponse | ErrorResponse;
130
147
  /**
131
148
  * Is this a message from our worker?
132
149
  *
@@ -33,5 +33,8 @@ export function isWorkerResponse(value) {
33
33
  if (typeof value !== 'object' || value === null)
34
34
  return false;
35
35
  const kind = value.kind;
36
- return kind === 'opened' || kind === 'rendered' || kind === 'error';
36
+ return (kind === 'opened' ||
37
+ kind === 'rendered' ||
38
+ kind === 'searched' ||
39
+ kind === 'error');
37
40
  }
@@ -180,6 +180,13 @@ export function createWorkerSession(deps) {
180
180
  let opening = null;
181
181
  let document = null;
182
182
  let disposed = false;
183
+ /**
184
+ * Renders AND searches, one queue. A search is a synchronous PDFium call
185
+ * like a render, wants the same between-jobs cancel window, and must not
186
+ * overtake a render posted before it — a page's text is parsed from the
187
+ * same loaded page the render uses, so keeping them in order keeps the
188
+ * engine's one-page cache hot for both.
189
+ */
183
190
  const queue = [];
184
191
  /**
185
192
  * Ids the main thread has given up on.
@@ -286,18 +293,27 @@ export function createWorkerSession(deps) {
286
293
  if (cancelled.delete(job.id))
287
294
  continue;
288
295
  try {
289
- const raster = target.render(job.request);
290
- const buffer = raster.bytes.buffer;
291
- deps.post({
292
- kind: 'rendered',
293
- id: job.id,
294
- bytes: buffer,
295
- width: raster.width,
296
- height: raster.height,
297
- rowBytes: raster.rowBytes,
298
- format: raster.format,
299
- alpha: raster.alpha,
300
- }, [buffer]);
296
+ if (job.kind === 'search') {
297
+ deps.post({
298
+ kind: 'searched',
299
+ id: job.id,
300
+ matches: target.search(job.request),
301
+ });
302
+ }
303
+ else {
304
+ const raster = target.render(job.request);
305
+ const buffer = raster.bytes.buffer;
306
+ deps.post({
307
+ kind: 'rendered',
308
+ id: job.id,
309
+ bytes: buffer,
310
+ width: raster.width,
311
+ height: raster.height,
312
+ rowBytes: raster.rowBytes,
313
+ format: raster.format,
314
+ alpha: raster.alpha,
315
+ }, [buffer]);
316
+ }
301
317
  }
302
318
  catch (error) {
303
319
  postError(job.id, error);
@@ -387,7 +403,8 @@ export function createWorkerSession(deps) {
387
403
  });
388
404
  return;
389
405
  }
390
- case 'render': {
406
+ case 'render':
407
+ case 'search': {
391
408
  queue.push(request);
392
409
  void drain();
393
410
  return;
@@ -158,6 +158,11 @@ export function PdfContentView({ content, zIndex, sampling = 'linear', paperColo
158
158
  const out = [];
159
159
  if (paperColor != null) {
160
160
  for (const page of content.pages) {
161
+ // A page outside the allow-list holds nothing and gets no paper: in a
162
+ // stacked layout its sheet would sit at the origin beside the one page
163
+ // that is meant to show (see PdfPageContent.drawable).
164
+ if (!page.drawable)
165
+ continue;
161
166
  const { pageRect } = page;
162
167
  out.push(_jsx(Rect, { x: pageRect.x, y: pageRect.y, width: pageRect.width, height: pageRect.height, color: paperColor }, `paper:${page.page}`));
163
168
  }
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { SkImage } from '@shopify/react-native-skia';
18
18
  import { PdfError } from '../types.js';
19
- import type { DocRect, DocSize, PageGeometry, PageRasterizer, PageRotation, PdfSource, PixelSize, RasterPixels, RasterizerCapabilities, RasterizerHandle } from '../types.js';
19
+ import type { DocRect, DocSize, PageGeometry, PageRasterizer, PageRotation, PdfSource, PixelSize, RasterPixels, RasterizerCapabilities, RasterizerHandle, TextMatch, TextSearchOptions } from '../types.js';
20
20
  /** Install the rasterizer used when `usePdfDocument` / `openPdfDocument` are not
21
21
  * given one explicitly. Returns the previous value so a test can restore it. */
22
22
  export declare function setDefaultRasterizer(rasterizer: PageRasterizer | null): PageRasterizer | null;
@@ -83,7 +83,22 @@ export interface PdfDocument {
83
83
  /** Intrinsic sizes of every page, in the order a `PdfPageLayout` expects. */
84
84
  pageSizes(): DocSize[];
85
85
  renderPage(index: number, options?: PdfRenderOptions): Promise<PdfPageRender>;
86
- /** True once `close()` has run. Every further call rejects with `cancelled`. */
86
+ /**
87
+ * Every occurrence of `query` on one page, in reading order.
88
+ *
89
+ * `TextMatch.rects` are in the page's doc space — PDF points, origin at the
90
+ * DISPLAYED page's top-left, y down — with `options.rotation` applied, so
91
+ * pass the same rotation the page's layer or `renderPage` uses and a hit
92
+ * drawn at a rect lands on the glyphs. An empty or whitespace-only query
93
+ * resolves `[]` without asking the backend. Rejects `backend-failure` for a
94
+ * page index out of range or a closed document, `cancelled` when
95
+ * `options.signal` aborts.
96
+ */
97
+ searchPage(page: number, query: string, options?: TextSearchOptions): Promise<TextMatch[]>;
98
+ /**
99
+ * True once `close()` has run. Every further `renderPage` rejects with
100
+ * `cancelled`, every further `searchPage` with `backend-failure`.
101
+ */
87
102
  readonly closed: boolean;
88
103
  /**
89
104
  * Release the underlying file handle. `usePdfDocument` owns this for documents
@@ -210,6 +210,50 @@ export async function openPdfDocument(source, rasterizer) {
210
210
  },
211
211
  };
212
212
  },
213
+ async searchPage(page, query, options) {
214
+ // `backend-failure`, not `renderPage`'s `cancelled`: nothing was
215
+ // superseded — the caller asked a closed document a question, which is
216
+ // a bug in the caller, and "cancelled" is the one code a host is
217
+ // expected to swallow silently.
218
+ if (closed) {
219
+ throw new PdfError('backend-failure', 'searchPage called on a closed PdfDocument');
220
+ }
221
+ if (!Number.isInteger(page) || page < 0 || page >= handle.pageCount) {
222
+ throw new PdfError('backend-failure', `page index ${page} is out of range (pageCount ${handle.pageCount})`);
223
+ }
224
+ // Nothing to find, and every engine would either refuse the query or
225
+ // answer it with nothing — so neither is asked. This is also what makes
226
+ // a find box that clears itself free.
227
+ if (query.trim().length === 0) {
228
+ return [];
229
+ }
230
+ if (isAborted(options?.signal)) {
231
+ throw new PdfError('cancelled', 'searchPage aborted before it started');
232
+ }
233
+ // RESOLVED HERE, once, for every backend: a backend is handed the three
234
+ // options with no defaults of its own left to drift.
235
+ const request = {
236
+ page,
237
+ query,
238
+ matchCase: options?.matchCase ?? false,
239
+ wholeWord: options?.wholeWord ?? false,
240
+ rotation: options?.rotation ?? 0,
241
+ };
242
+ let matches;
243
+ try {
244
+ matches = await handle.searchText(request, options?.signal);
245
+ }
246
+ catch (cause) {
247
+ throw toPdfError(cause, `Failed to search page ${page}`);
248
+ }
249
+ // A backend that answered anyway — the web worker cannot interrupt a
250
+ // search it has started — must not hand hits to a caller that already
251
+ // moved on to the next keystroke.
252
+ if (isAborted(options?.signal)) {
253
+ throw new PdfError('cancelled', 'searchPage aborted');
254
+ }
255
+ return matches;
256
+ },
213
257
  close() {
214
258
  if (closed) {
215
259
  return;
@@ -8,7 +8,7 @@
8
8
  * directly and never `../rasterizer/index.js`, which pulls in the Skia ingest
9
9
  * path.
10
10
  */
11
- export { ARCH_D, ARCH_E, LETTER, createFakeRasterizer, decodeFakeDocRect, fakePageColor, fakePixelSize, } from '../rasterizer/fake.js';
11
+ export { ARCH_D, ARCH_E, FAKE_MATCH_RECT_ORIGIN, FAKE_MATCH_RECT_SIZE, FAKE_MATCH_RECT_STRIDE, LETTER, createFakeRasterizer, decodeFakeDocRect, decodeFakeRotation, fakePageColor, fakePixelSize, searchFakeText, } from '../rasterizer/fake.js';
12
12
  export type { FakeFailureModes, FakeRasterizerOptions, } from '../rasterizer/fake.js';
13
13
  /**
14
14
  * The reference scenes BUILD STEP 1 produced: the composition contract as data,
@@ -8,7 +8,7 @@
8
8
  * directly and never `../rasterizer/index.js`, which pulls in the Skia ingest
9
9
  * path.
10
10
  */
11
- export { ARCH_D, ARCH_E, LETTER, createFakeRasterizer, decodeFakeDocRect, fakePageColor, fakePixelSize, } from '../rasterizer/fake.js';
11
+ export { ARCH_D, ARCH_E, FAKE_MATCH_RECT_ORIGIN, FAKE_MATCH_RECT_SIZE, FAKE_MATCH_RECT_STRIDE, LETTER, createFakeRasterizer, decodeFakeDocRect, decodeFakeRotation, fakePageColor, fakePixelSize, searchFakeText, } from '../rasterizer/fake.js';
12
12
  /**
13
13
  * The reference scenes BUILD STEP 1 produced: the composition contract as data,
14
14
  * for a backend suite to replay against its own rasterizer. Pure — it reaches
package/dist/types.d.ts CHANGED
@@ -247,6 +247,13 @@ export interface RasterizerCapabilities {
247
247
  /** Honours the abort signal mid-render, not merely between requests. */
248
248
  interruptibleRender: boolean;
249
249
  text: boolean;
250
+ /**
251
+ * `RasterizerHandle.searchText` finds text and returns glyph rects. TRUE for
252
+ * every shipped backend since 0.4.0 — they are all PDFium, and its text API
253
+ * is what implements it — and for the fake, which searches a declared
254
+ * per-page text. False only for the self-describing missing-backend
255
+ * placeholder, whose `open()` never succeeds anyway.
256
+ */
250
257
  search: boolean;
251
258
  links: boolean;
252
259
  /**
@@ -298,8 +305,57 @@ export interface RasterizerHandle {
298
305
  /** Cheap: must not load a page. */
299
306
  pageGeometry(index: number): PageGeometry;
300
307
  render(request: RasterRequest, signal?: AbortSignal): Promise<RasterPixels>;
308
+ /**
309
+ * Find every occurrence of `request.query` on one page.
310
+ *
311
+ * REQUIRED ON THE SEAM, and every shipped backend implements it (the fake
312
+ * included), so a host can build "find in document" on any of them. The
313
+ * rects come back in the same doc space the page's rasters use — see
314
+ * `TextMatch.rects` for the contract — which is what lets a host draw a hit
315
+ * over the page with the transform it already has. An empty query is the
316
+ * document's business to short-circuit (`PdfDocument.searchPage` does);
317
+ * a backend handed one may answer `[]` or search for nothing, both of which
318
+ * are `[]`.
319
+ */
320
+ searchText(request: TextSearchRequest, signal?: AbortSignal): Promise<TextMatch[]>;
301
321
  close(): void;
302
322
  }
323
+ export interface TextSearchOptions {
324
+ /** Case-sensitive match. Default false. */
325
+ matchCase?: boolean;
326
+ /** Whole-word match. Default false. */
327
+ wholeWord?: boolean;
328
+ /**
329
+ * The HOST's rotation for this page (see RasterRequest.rotation), so rects
330
+ * come back in the TURNED page's doc space — the space its rasters use.
331
+ * Default 0.
332
+ */
333
+ rotation?: PageRotation;
334
+ signal?: AbortSignal;
335
+ }
336
+ export interface TextMatch {
337
+ page: number;
338
+ /** PDFium character index of the match start on the page, and its length. */
339
+ charIndex: number;
340
+ charCount: number;
341
+ /**
342
+ * Bounding boxes of the matched glyphs, one per text line the match spans,
343
+ * in the page's doc space: PDF points, origin at the DISPLAYED page's
344
+ * top-left, y down, host rotation applied. Empty only for a match PDFium
345
+ * reports no rects for.
346
+ */
347
+ rects: DocRect[];
348
+ /** The page text around the match, roughly 40 chars each side, whitespace collapsed. */
349
+ context: string;
350
+ }
351
+ /**
352
+ * What a backend is handed: the options RESOLVED, so no backend has a default
353
+ * of its own that could drift from `PdfDocument.searchPage`'s.
354
+ */
355
+ export interface TextSearchRequest extends Required<Pick<TextSearchOptions, 'matchCase' | 'wholeWord' | 'rotation'>> {
356
+ page: number;
357
+ query: string;
358
+ }
303
359
  export type RasterRole = 'base' | 'detail';
304
360
  /**
305
361
  * One rasterized piece, ready to draw.
@@ -337,6 +393,16 @@ export interface PdfPageContent {
337
393
  readonly page: number;
338
394
  /** Where this page sits in doc space, per the layout function. */
339
395
  readonly pageRect: DocRect;
396
+ /**
397
+ * Whether this page may be painted at all — false for a page outside the
398
+ * controller's `allowedPages`. Such a page never holds a raster, and
399
+ * `PdfContentView` paints NO PAPER for it either: under a stacked layout
400
+ * (`singlePage()`) every page's rect sits at the origin, so paper for an
401
+ * unselected page of different extents (a page the host turned a quarter)
402
+ * would show through beside the selected one. The entry stays in `pages`
403
+ * so the array remains index-aligned with page numbers.
404
+ */
405
+ readonly drawable: boolean;
340
406
  /**
341
407
  * The whole page at fit scale, as one or more pieces whose union is exactly
342
408
  * `pageRect`.
@@ -397,11 +397,75 @@ RCT_EXPORT_METHOD(render
397
397
  }];
398
398
  }
399
399
 
400
+ /* ---------------------------------------------------------------- *
401
+ * search
402
+ * ---------------------------------------------------------------- */
403
+
404
+ /**
405
+ * Finds text on one page and resolves with the core's JSON — ONE string, however
406
+ * many hits, so a find pass over a dense drawing is one bridge value rather than
407
+ * thousands of dictionaries. `native-bridge.ts` parses and checks it.
408
+ *
409
+ * THE SAME QUEUE AS RENDER, deliberately: PDFium is not thread-safe and the core
410
+ * serialises everything behind one lock anyway. The same (handle, token) registry
411
+ * too, so `cancel` stops a superseded search between matches.
412
+ */
413
+ RCT_EXPORT_METHOD(search
414
+ : (double)handle request
415
+ : (NSDictionary *)request token
416
+ : (double)token resolve
417
+ : (RCTPromiseResolveBlock)resolve reject
418
+ : (RCTPromiseRejectBlock)reject) {
419
+ pdfcanvas::TextSearchRequest req;
420
+ req.page = [request[@"page"] intValue];
421
+ // UTF-16 straight out of the NSString, which already is UTF-16 — no UTF-8 step
422
+ // that could mangle a supplementary character on the way to PDFium.
423
+ NSString *query = [request[@"query"] isKindOfClass:NSString.class] ? request[@"query"] : @"";
424
+ req.query.resize(query.length);
425
+ if (query.length > 0) {
426
+ static_assert(sizeof(unichar) == sizeof(char16_t), "unichar is a UTF-16 unit");
427
+ [query getCharacters:reinterpret_cast<unichar *>(&req.query[0]) range:NSMakeRange(0, query.length)];
428
+ }
429
+ req.matchCase = [request[@"matchCase"] isKindOfClass:NSNumber.class] && [request[@"matchCase"] boolValue];
430
+ req.wholeWord = [request[@"wholeWord"] isKindOfClass:NSNumber.class] && [request[@"wholeWord"] boolValue];
431
+ // OPTIONAL, DEFAULTING TO 0, for the reason render's is.
432
+ req.rotation = [request[@"rotation"] isKindOfClass:NSNumber.class] ? [request[@"rotation"] intValue] : 0;
433
+ const int nativeHandle = static_cast<int>(handle);
434
+ const int64_t nativeToken = static_cast<int64_t>(token);
435
+
436
+ // Registered BEFORE the operation runs — see render.
437
+ _service->registerCancellation(nativeHandle, nativeToken);
438
+
439
+ std::shared_ptr<pdfcanvas::Service> service = _service;
440
+ [_renderQueue addOperationWithBlock:^{
441
+ @try {
442
+ std::string json;
443
+ try {
444
+ json = service->searchJson(nativeHandle, req, nativeToken);
445
+ } catch (const pdfcanvas::PdfError &error) {
446
+ RejectWithPdfError(reject, error);
447
+ return;
448
+ } catch (const std::exception &error) {
449
+ RejectWith(reject, pdfcanvas::ErrorCode::BackendFailure,
450
+ [NSString stringWithUTF8String:error.what()]);
451
+ return;
452
+ }
453
+ // PURE ASCII by construction (`textMatchesToJson`), so UTF-8 decoding it is
454
+ // the identity.
455
+ resolve([NSString stringWithUTF8String:json.c_str()]);
456
+ } @catch (NSException *exception) {
457
+ RejectWith(reject, pdfcanvas::ErrorCode::BackendFailure,
458
+ [NSString stringWithFormat:@"%@: %@", exception.name, exception.reason]);
459
+ }
460
+ }];
461
+ }
462
+
400
463
  /**
401
464
  * Genuinely mid-render now, not best-effort: the core polls the signal from
402
465
  * PDFium's progressive renderer between batches of page objects, so a superseded
403
- * tile stops drawing within one batch. A pure lookup — a cancel for a token whose
404
- * render already finished finds nothing and does nothing.
466
+ * tile stops drawing within one batch — and a search between matches. A pure
467
+ * lookup — a cancel for a token whose job already finished finds nothing and does
468
+ * nothing.
405
469
  */
406
470
  RCT_EXPORT_METHOD(cancel : (double)handle token : (double)token) {
407
471
  _service->cancel(static_cast<int>(handle), static_cast<int64_t>(token));
@@ -74,6 +74,23 @@ class Document {
74
74
  RasterPixels render(const RasterRequest& request, Cancellation* signal,
75
75
  PixelSink* sink);
76
76
 
77
+ /// Every occurrence of `request.query` on one page, in reading order.
78
+ ///
79
+ /// PDFium's own search (`FPDFText_FindStart` / `FindNext`), on a text page
80
+ /// parsed once and cached with the page it belongs to — a find box searches
81
+ /// again on every keystroke, and `FPDFText_LoadPage` is the call that walks
82
+ /// the content stream. Each match's rects are mapped through the SAME
83
+ /// display matrix the rasters use (`FPDF_PageToDevice` with the displayed
84
+ /// box and the host's rotate), so a hit drawn at a rect lands on the glyphs.
85
+ ///
86
+ /// `signal` may be null. It is observed before the lock, after it, and
87
+ /// between matches — a superseded search holds the lock for at most one
88
+ /// more match. An empty query answers `{}` without touching the page.
89
+ /// Throws `not-found` for a missing page, `backend-failure` for a closed
90
+ /// document or a rotation that is not a quarter turn, `corrupt` when PDFium
91
+ /// cannot read the page's text, `cancelled`.
92
+ std::vector<TextMatch> searchText(const TextSearchRequest& request, Cancellation* signal);
93
+
77
94
  /// Releases every PDFium object. Blocks until an in-flight render on this
78
95
  /// document finishes (it holds the library lock), and is therefore free of
79
96
  /// the use-after-free window the Android pool's close had. Idempotent.
@@ -16,6 +16,7 @@
16
16
  #include <cstdint>
17
17
  #include <memory>
18
18
  #include <mutex>
19
+ #include <string>
19
20
  #include <unordered_map>
20
21
  #include <vector>
21
22
 
@@ -55,7 +56,15 @@ class Service {
55
56
  RasterPixels render(int handle, const RasterRequest& request, int64_t token,
56
57
  bool useSlot);
57
58
 
58
- /// Best-effort, and genuinely mid-render: sets the signal a render polls.
59
+ /// Searches one page on the calling thread and returns the WIRE FORM — the
60
+ /// JSON `textMatchesToJson` writes, pure ASCII, one bridge value however
61
+ /// many hits there are. The SAME (handle, token) registry as `render`:
62
+ /// register first, and `cancel` stops the search between matches. The
63
+ /// signal is consumed and unregistered on every path out.
64
+ std::string searchJson(int handle, const TextSearchRequest& request, int64_t token);
65
+
66
+ /// Best-effort, and genuinely mid-render: sets the signal a render (or a
67
+ /// search) polls.
59
68
  void cancel(int handle, int64_t token);
60
69
 
61
70
  /// Closes one document. Blocks until its in-flight render finishes.
@@ -74,6 +83,10 @@ class Service {
74
83
  size_t openCount() const;
75
84
 
76
85
  private:
86
+ /// One job's (signal, document) pair, unregistering the signal on
87
+ /// destruction. Shared by `render` and `searchJson`.
88
+ struct Lease;
89
+
77
90
  static int64_t signalKey(int handle, int64_t token) noexcept;
78
91
  std::shared_ptr<Document> documentFor(int handle);
79
92