@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
@@ -137,6 +137,7 @@ export function createPdfController(options) {
137
137
  page,
138
138
  pageRect,
139
139
  layoutScale: Number.isFinite(raw) && raw > 0 ? raw : 1,
140
+ rotation: options.rotations?.[page] ?? 0,
140
141
  };
141
142
  });
142
143
  const listeners = new Set();
@@ -147,6 +148,9 @@ export function createPdfController(options) {
147
148
  pages: Object.freeze(pageInfos.map(info => Object.freeze({
148
149
  page: info.page,
149
150
  pageRect: info.pageRect,
151
+ // Fixed for the controller's lifetime: the allow-list is a
152
+ // construction option, and a change to it rebuilds the controller.
153
+ drawable: allowedPages === undefined || allowedPages.has(info.page),
150
154
  base: EMPTY_RASTERS,
151
155
  detail: EMPTY_RASTERS,
152
156
  retiring: EMPTY_RASTERS,
@@ -405,24 +409,35 @@ export function createPdfController(options) {
405
409
  };
406
410
  const renderOne = async (item, slot, info) => {
407
411
  const startedAt = now();
412
+ const request = {
413
+ page: item.page,
414
+ // Page-LOCAL rect: the planner works in LAYOUT space, but a backend
415
+ // renders a page and knows nothing about where the layout put it.
416
+ // For a page the host has turned this is the ROTATED page's space:
417
+ // `pageRects` and `pages` both carry the swapped extents, which is
418
+ // what keeps `layoutScale` at 1 and lets the backend slide the whole
419
+ // turned page exactly as it slides an upright one.
420
+ docRect: {
421
+ x: (item.docRect.x - info.pageRect.x) / info.layoutScale,
422
+ y: (item.docRect.y - info.pageRect.y) / info.layoutScale,
423
+ width: item.docRect.width / info.layoutScale,
424
+ height: item.docRect.height / info.layoutScale,
425
+ },
426
+ scale: item.scale * info.layoutScale,
427
+ // See `PdfControllerOptions.annotations`: true unless the host
428
+ // draws the PDF's annotations itself.
429
+ annotations: drawAnnotations,
430
+ background: 'white',
431
+ };
432
+ // STAMPED ONLY WHEN IT IS NOT THE DEFAULT. A host that never rotates
433
+ // hands its backend the exact request it always did, key for key — a
434
+ // host-injected backend that compares requests structurally sees no
435
+ // change at all — and every shipped backend reads `rotation ?? 0`.
436
+ if (info.rotation !== 0)
437
+ request.rotation = info.rotation;
408
438
  let pixels;
409
439
  try {
410
- pixels = await handle.render({
411
- page: item.page,
412
- // Page-LOCAL rect: the planner works in LAYOUT space, but a backend
413
- // renders a page and knows nothing about where the layout put it.
414
- docRect: {
415
- x: (item.docRect.x - info.pageRect.x) / info.layoutScale,
416
- y: (item.docRect.y - info.pageRect.y) / info.layoutScale,
417
- width: item.docRect.width / info.layoutScale,
418
- height: item.docRect.height / info.layoutScale,
419
- },
420
- scale: item.scale * info.layoutScale,
421
- // See `PdfControllerOptions.annotations`: true unless the host
422
- // draws the PDF's annotations itself.
423
- annotations: drawAnnotations,
424
- background: 'white',
425
- }, state.abort.signal);
440
+ pixels = await handle.render(request, state.abort.signal);
426
441
  }
427
442
  catch (error) {
428
443
  missing.push(item.key);
@@ -819,6 +834,7 @@ export function createPdfController(options) {
819
834
  return Object.freeze({
820
835
  page: previous.page,
821
836
  pageRect: previous.pageRect,
837
+ drawable: previous.drawable,
822
838
  base,
823
839
  detail,
824
840
  retiring,
@@ -987,6 +1003,7 @@ export function createPdfController(options) {
987
1003
  pages: Object.freeze(content.pages.map(page => Object.freeze({
988
1004
  page: page.page,
989
1005
  pageRect: page.pageRect,
1006
+ drawable: page.drawable,
990
1007
  base: EMPTY_RASTERS,
991
1008
  detail: EMPTY_RASTERS,
992
1009
  retiring: EMPTY_RASTERS,
@@ -14,13 +14,19 @@
14
14
  * checks and an accidental clip is visible as a blank margin;
15
15
  * - each piece carries a 1px border, so a piece that has been inset or
16
16
  * stretched shows its border in the wrong place;
17
- * - each piece encodes its own doc rect into a corner swatch, so a test can
18
- * read back what a raster BELIEVES it is without any Skia in the loop.
17
+ * - each piece encodes its own doc rect AND its rotation into a corner
18
+ * swatch, so a test can read back what a raster BELIEVES it is without any
19
+ * Skia in the loop;
20
+ * - the checker is drawn in the PAGE's own space and turned by the request's
21
+ * `rotation`, the way a real engine turns its page — so a raster at 90 is
22
+ * the raster at 0 turned a quarter clockwise, pixel for pixel outside the
23
+ * border and swatch, and a pipeline that rotated its geometry but forgot
24
+ * to stamp the request shows an unturned board in a turned frame.
19
25
  *
20
26
  * Everything here is a pure function of the request, so the same request always
21
27
  * produces byte-identical output.
22
28
  */
23
- import type { DocRect, DocSize, PageRasterizer, PageRotation, PdfErrorCode, PixelFormat, PixelSize, RasterPixels, RasterRequest } from '../types.js';
29
+ import type { DocRect, DocSize, PageRasterizer, PageRotation, PdfErrorCode, PixelFormat, PixelSize, RasterPixels, RasterRequest, TextMatch, TextSearchRequest } from '../types.js';
24
30
  export declare const LETTER: DocSize;
25
31
  /** ARCH D, 24x36in — the sheet the base-raster sizing rule is calibrated on. */
26
32
  export declare const ARCH_D: DocSize;
@@ -47,10 +53,18 @@ export interface FakeRasterizerOptions {
47
53
  * height are already post-rotation, so the fake does not swap them.
48
54
  */
49
55
  pageRotations?: readonly PageRotation[];
56
+ /**
57
+ * The text `searchText` searches, one entry per page (`text[page]`). NOT
58
+ * cycled: a page with no entry has no text and every search of it answers
59
+ * `[]`, which is also the default. The pixels never show it — the fake
60
+ * draws a checkerboard — so this is what lets a controller or host test
61
+ * drive "find" without an engine.
62
+ */
63
+ text?: readonly string[];
50
64
  /** Checker cell size in DOC units. */
51
65
  checkerDocSize?: number;
52
66
  format?: PixelFormat;
53
- /** Artificial delay on `render()` only; `open()` always resolves promptly. */
67
+ /** Artificial delay on `render()` and `searchText()`; `open()` always resolves promptly. */
54
68
  latencyMs?: number;
55
69
  /** When set, `open()` enforces it against `source.password`. */
56
70
  password?: string;
@@ -89,4 +103,24 @@ export declare function fakePageColor(page: number): [number, number, number];
89
103
  * was too small to carry a swatch.
90
104
  */
91
105
  export declare function decodeFakeDocRect(pixels: RasterPixels): DocRect | null;
106
+ /**
107
+ * Read the rotation a fake raster was rendered at, or null when the raster was
108
+ * too small to carry a swatch. What a pipeline test reads to prove the
109
+ * controller's stamp reached the backend.
110
+ */
111
+ export declare function decodeFakeRotation(pixels: RasterPixels): PageRotation | null;
112
+ /** Where the fake puts the i-th match's rect: a 10x10 box at (10 + 20i, 10). */
113
+ export declare const FAKE_MATCH_RECT_SIZE = 10;
114
+ export declare const FAKE_MATCH_RECT_ORIGIN = 10;
115
+ export declare const FAKE_MATCH_RECT_STRIDE = 20;
116
+ /**
117
+ * Every occurrence of `query` in `text`, PDFium-shaped: non-overlapping,
118
+ * left to right, case-folded unless `matchCase`, and — under `wholeWord` —
119
+ * only where neither neighbour is a word character. Rects are DETERMINISTIC
120
+ * and say nothing about where the text would be drawn (the fake draws no
121
+ * text), which is exactly what a pipeline test wants: the i-th match of any
122
+ * query on any page is at the same place, so a test can assert a hit was
123
+ * drawn there without a raster in the loop.
124
+ */
125
+ export declare function searchFakeText(text: string, request: TextSearchRequest): TextMatch[];
92
126
  export declare function createFakeRasterizer(options?: FakeRasterizerOptions): PageRasterizer;
@@ -14,12 +14,20 @@
14
14
  * checks and an accidental clip is visible as a blank margin;
15
15
  * - each piece carries a 1px border, so a piece that has been inset or
16
16
  * stretched shows its border in the wrong place;
17
- * - each piece encodes its own doc rect into a corner swatch, so a test can
18
- * read back what a raster BELIEVES it is without any Skia in the loop.
17
+ * - each piece encodes its own doc rect AND its rotation into a corner
18
+ * swatch, so a test can read back what a raster BELIEVES it is without any
19
+ * Skia in the loop;
20
+ * - the checker is drawn in the PAGE's own space and turned by the request's
21
+ * `rotation`, the way a real engine turns its page — so a raster at 90 is
22
+ * the raster at 0 turned a quarter clockwise, pixel for pixel outside the
23
+ * border and swatch, and a pipeline that rotated its geometry but forgot
24
+ * to stamp the request shows an unturned board in a turned frame.
19
25
  *
20
26
  * Everything here is a pure function of the request, so the same request always
21
27
  * produces byte-identical output.
22
28
  */
29
+ import { isPageRotation } from '../rotation.js';
30
+ import { contextAround } from './text-search.js';
23
31
  import { PdfError } from '../types.js';
24
32
  /* ------------------------------------------------------------------ *
25
33
  * Page sizes, in PDF points (1pt = 1/72in)
@@ -55,6 +63,7 @@ function resolveConfig(options) {
55
63
  return {
56
64
  id: options.id ?? 'fake',
57
65
  pages,
66
+ text: options.text ?? [],
58
67
  checkerDocSize: options.checkerDocSize ?? DEFAULT_CHECKER_DOC_SIZE,
59
68
  format: options.format ?? 'rgba8888',
60
69
  latencyMs: options.latencyMs ?? 0,
@@ -171,9 +180,10 @@ function writePixel(out, offset, r, g, b, a, format) {
171
180
  * The corner swatch
172
181
  * ------------------------------------------------------------------ */
173
182
  /**
174
- * The swatch is RAW BYTES, not a colour: four pixels, each carrying one doc
175
- * rect component as a 24-bit fixed-point value in channels 0..2 with channel 3
176
- * held at 255. Because it is written without the format swizzle, the decoder is
183
+ * The swatch is RAW BYTES, not a colour: five pixels, each carrying one value
184
+ * as a 24-bit fixed-point number in channels 0..2 with channel 3 held at 255 —
185
+ * the four doc rect components, then the request's rotation in degrees.
186
+ * Because it is written without the format swizzle, the decoder is
177
187
  * format-independent — it reads back the same numbers from an RGBA and a BGRA
178
188
  * raster.
179
189
  */
@@ -182,7 +192,9 @@ const SWATCH_FIXED_POINT = 16;
182
192
  const SWATCH_BIAS = 0x800000;
183
193
  const SWATCH_ORIGIN_X = 1;
184
194
  const SWATCH_ROW = 1;
185
- const SWATCH_PIXELS = 4;
195
+ const SWATCH_PIXELS = 5;
196
+ /** Index of the rotation value within the swatch, after x, y, width, height. */
197
+ const SWATCH_ROTATION = 4;
186
198
  /** Smallest raster that has room for the swatch inside its border. */
187
199
  const SWATCH_MIN_WIDTH = SWATCH_ORIGIN_X + SWATCH_PIXELS + 1;
188
200
  const SWATCH_MIN_HEIGHT = SWATCH_ROW + 2;
@@ -194,7 +206,7 @@ function encodeSwatchValue(out, offset, value) {
194
206
  out[offset + 2] = clamped & 0xff;
195
207
  out[offset + 3] = 255;
196
208
  }
197
- function encodeSwatch(out, rowBytes, width, height, docRect) {
209
+ function encodeSwatch(out, rowBytes, width, height, docRect, rotation) {
198
210
  if (width < SWATCH_MIN_WIDTH || height < SWATCH_MIN_HEIGHT) {
199
211
  return;
200
212
  }
@@ -203,39 +215,81 @@ function encodeSwatch(out, rowBytes, width, height, docRect) {
203
215
  encodeSwatchValue(out, base + BYTES_PER_PIXEL, docRect.y);
204
216
  encodeSwatchValue(out, base + BYTES_PER_PIXEL * 2, docRect.width);
205
217
  encodeSwatchValue(out, base + BYTES_PER_PIXEL * 3, docRect.height);
218
+ encodeSwatchValue(out, base + BYTES_PER_PIXEL * SWATCH_ROTATION, rotation);
219
+ }
220
+ /** One swatch value back out, or null when the raster is too small to carry a
221
+ * swatch at all. `component` is the pixel index within the swatch. */
222
+ function decodeSwatchValue(pixels, component) {
223
+ const { bytes, width, height, rowBytes } = pixels;
224
+ if (width < SWATCH_MIN_WIDTH || height < SWATCH_MIN_HEIGHT) {
225
+ return null;
226
+ }
227
+ const o = SWATCH_ROW * rowBytes + (SWATCH_ORIGIN_X + component) * BYTES_PER_PIXEL;
228
+ // In range by the size guard above; `?? 0` satisfies
229
+ // noUncheckedIndexedAccess on TypedArray indexing.
230
+ const raw = ((bytes[o] ?? 0) << 16) | ((bytes[o + 1] ?? 0) << 8) | (bytes[o + 2] ?? 0);
231
+ return (raw - SWATCH_BIAS) / SWATCH_FIXED_POINT;
206
232
  }
207
233
  /**
208
234
  * Read the doc rect a fake raster believes it covers, or null when the raster
209
235
  * was too small to carry a swatch.
210
236
  */
211
237
  export function decodeFakeDocRect(pixels) {
212
- const { bytes, width, height, rowBytes } = pixels;
213
- if (width < SWATCH_MIN_WIDTH || height < SWATCH_MIN_HEIGHT) {
238
+ const x = decodeSwatchValue(pixels, 0);
239
+ const y = decodeSwatchValue(pixels, 1);
240
+ const width = decodeSwatchValue(pixels, 2);
241
+ const height = decodeSwatchValue(pixels, 3);
242
+ if (x === null || y === null || width === null || height === null) {
214
243
  return null;
215
244
  }
216
- const base = SWATCH_ROW * rowBytes + SWATCH_ORIGIN_X * BYTES_PER_PIXEL;
217
- const read = (component) => {
218
- const o = base + component * BYTES_PER_PIXEL;
219
- // In range by the size guard above; `?? 0` satisfies
220
- // noUncheckedIndexedAccess on TypedArray indexing.
221
- const raw = ((bytes[o] ?? 0) << 16) |
222
- ((bytes[o + 1] ?? 0) << 8) |
223
- (bytes[o + 2] ?? 0);
224
- return (raw - SWATCH_BIAS) / SWATCH_FIXED_POINT;
225
- };
226
- return {
227
- x: read(0),
228
- y: read(1),
229
- width: read(2),
230
- height: read(3),
231
- };
245
+ return { x, y, width, height };
246
+ }
247
+ /**
248
+ * Read the rotation a fake raster was rendered at, or null when the raster was
249
+ * too small to carry a swatch. What a pipeline test reads to prove the
250
+ * controller's stamp reached the backend.
251
+ */
252
+ export function decodeFakeRotation(pixels) {
253
+ const rotation = decodeSwatchValue(pixels, SWATCH_ROTATION);
254
+ if (rotation === null)
255
+ return null;
256
+ return isPageRotation(rotation) ? rotation : null;
232
257
  }
233
258
  /* ------------------------------------------------------------------ *
234
259
  * Rendering
235
260
  * ------------------------------------------------------------------ */
236
- function renderPixels(request, cfg) {
261
+ /**
262
+ * The inverse of the request's rotation: a point in the TURNED page's doc space
263
+ * back to the page's own. `pageWidth` / `pageHeight` are the page's intrinsic
264
+ * (unturned) extents.
265
+ *
266
+ * These are the inverses of PDFium's `CPDF_Page::GetDisplayMatrix` for rotate
267
+ * 1..3, restated in y-down doc units — the same mapping the real backends'
268
+ * tests hold a turned render to, so the fake and the engine agree on what "90
269
+ * clockwise" means.
270
+ */
271
+ function unrotate(rotation, pageWidth, pageHeight) {
272
+ switch (rotation) {
273
+ case 90:
274
+ return (x, y) => [y, pageHeight - x];
275
+ case 180:
276
+ return (x, y) => [pageWidth - x, pageHeight - y];
277
+ case 270:
278
+ return (x, y) => [pageWidth - y, x];
279
+ default:
280
+ return (x, y) => [x, y];
281
+ }
282
+ }
283
+ function renderPixels(request, cfg, geometry) {
237
284
  const { docRect, scale, page, background } = request;
238
285
  const { width, height } = fakePixelSize(request);
286
+ // Read as `?? 0` and then VALIDATED, as every real backend does: the fake
287
+ // stands in for them in the controller suite, so it must refuse what they
288
+ // refuse rather than quietly draw an upright page.
289
+ const rotation = request.rotation ?? 0;
290
+ if (!isPageRotation(rotation)) {
291
+ throw new PdfError('backend-failure', `Fake rasterizer: rotation must be 0, 90, 180 or 270, got ${String(rotation)}.`);
292
+ }
239
293
  if (width * height > cfg.maxPixels) {
240
294
  throw new PdfError('out-of-memory', `Fake rasterizer refused a ${width}x${height} raster ` +
241
295
  `(${width * height} pixels > maxPixels ${cfg.maxPixels}).`);
@@ -256,18 +310,23 @@ function renderPixels(request, cfg) {
256
310
  writePixel(bytes, o, tintR, tintG, tintB, 255, cfg.format);
257
311
  }
258
312
  }
259
- // Dark checker cells, in DOC space. Pixel CENTRES are converted to doc
260
- // coordinates — a corner-based conversion puts the sample exactly on a cell
261
- // boundary whenever the piece origin happens to align with one, and the
262
- // parity then flips on a floating-point tie.
313
+ // Dark checker cells, in the PAGE's DOC space. Pixel CENTRES are converted
314
+ // to doc coordinates — a corner-based conversion puts the sample exactly on
315
+ // a cell boundary whenever the piece origin happens to align with one, and
316
+ // the parity then flips on a floating-point tie. The request's rect is in
317
+ // the TURNED page's space, so each centre is mapped back through the inverse
318
+ // rotation before its cell is looked up: that is what makes the board turn
319
+ // with the request, the way a real engine's page does.
263
320
  const cell = cfg.checkerDocSize;
321
+ const toPage = unrotate(rotation, geometry.width, geometry.height);
264
322
  for (let py = 0; py < height; py++) {
265
- const docY = docRect.y + (py + 0.5) / scale;
266
- const cellY = Math.floor(docY / cell);
323
+ const turnedY = docRect.y + (py + 0.5) / scale;
267
324
  let offset = py * rowBytes;
268
325
  for (let px = 0; px < width; px++, offset += BYTES_PER_PIXEL) {
269
- const docX = docRect.x + (px + 0.5) / scale;
326
+ const turnedX = docRect.x + (px + 0.5) / scale;
327
+ const [docX, docY] = toPage(turnedX, turnedY);
270
328
  const cellX = Math.floor(docX / cell);
329
+ const cellY = Math.floor(docY / cell);
271
330
  if (((cellX + cellY) & 1) === 1) {
272
331
  writePixel(bytes, offset, r, g, b, 255, cfg.format);
273
332
  }
@@ -288,7 +347,7 @@ function renderPixels(request, cfg) {
288
347
  writePixel(bytes, row + lastCol, 0, 0, 0, 255, cfg.format);
289
348
  }
290
349
  }
291
- encodeSwatch(bytes, rowBytes, width, height, docRect);
350
+ encodeSwatch(bytes, rowBytes, width, height, docRect, rotation);
292
351
  return {
293
352
  bytes,
294
353
  width,
@@ -298,6 +357,58 @@ function renderPixels(request, cfg) {
298
357
  alpha: FAKE_ALPHA,
299
358
  };
300
359
  }
360
+ /* ------------------------------------------------------------------ *
361
+ * Text search
362
+ * ------------------------------------------------------------------ */
363
+ /** Where the fake puts the i-th match's rect: a 10x10 box at (10 + 20i, 10). */
364
+ export const FAKE_MATCH_RECT_SIZE = 10;
365
+ export const FAKE_MATCH_RECT_ORIGIN = 10;
366
+ export const FAKE_MATCH_RECT_STRIDE = 20;
367
+ const isWordChar = (ch) => ch !== undefined && /[\p{L}\p{N}_]/u.test(ch);
368
+ /**
369
+ * Every occurrence of `query` in `text`, PDFium-shaped: non-overlapping,
370
+ * left to right, case-folded unless `matchCase`, and — under `wholeWord` —
371
+ * only where neither neighbour is a word character. Rects are DETERMINISTIC
372
+ * and say nothing about where the text would be drawn (the fake draws no
373
+ * text), which is exactly what a pipeline test wants: the i-th match of any
374
+ * query on any page is at the same place, so a test can assert a hit was
375
+ * drawn there without a raster in the loop.
376
+ */
377
+ export function searchFakeText(text, request) {
378
+ const query = request.query;
379
+ if (query.length === 0 || text.length === 0)
380
+ return [];
381
+ const haystack = request.matchCase ? text : text.toLowerCase();
382
+ const needle = request.matchCase ? query : query.toLowerCase();
383
+ const matches = [];
384
+ let from = 0;
385
+ for (;;) {
386
+ const at = haystack.indexOf(needle, from);
387
+ if (at < 0)
388
+ break;
389
+ from = at + needle.length;
390
+ if (request.wholeWord &&
391
+ (isWordChar(text[at - 1]) || isWordChar(text[at + needle.length]))) {
392
+ continue;
393
+ }
394
+ const i = matches.length;
395
+ matches.push({
396
+ page: request.page,
397
+ charIndex: at,
398
+ charCount: needle.length,
399
+ rects: [
400
+ {
401
+ x: FAKE_MATCH_RECT_ORIGIN + FAKE_MATCH_RECT_STRIDE * i,
402
+ y: FAKE_MATCH_RECT_ORIGIN,
403
+ width: FAKE_MATCH_RECT_SIZE,
404
+ height: FAKE_MATCH_RECT_SIZE,
405
+ },
406
+ ],
407
+ context: contextAround(text, at, needle.length),
408
+ });
409
+ }
410
+ return matches;
411
+ }
301
412
  /* ------------------------------------------------------------------ *
302
413
  * Cancellation
303
414
  * ------------------------------------------------------------------ */
@@ -384,7 +495,7 @@ function createHandle(cfg) {
384
495
  async render(request, signal) {
385
496
  throwIfAborted(signal);
386
497
  requireOpen();
387
- requirePage(request.page);
498
+ const geometry = requirePage(request.page);
388
499
  if (cfg.latencyMs > 0) {
389
500
  await delay(cfg.latencyMs, signal);
390
501
  }
@@ -396,7 +507,24 @@ function createHandle(cfg) {
396
507
  if (failure) {
397
508
  throw new PdfError(failure, `Fake rasterizer failed page ${request.page} by configuration.`);
398
509
  }
399
- return renderPixels(request, cfg);
510
+ return renderPixels(request, cfg, geometry);
511
+ },
512
+ async searchText(request, signal) {
513
+ throwIfAborted(signal);
514
+ requireOpen();
515
+ requirePage(request.page);
516
+ // Validated as every real backend validates it, even though the fake's
517
+ // rects do not turn: a request that asked for a bad turn must not
518
+ // quietly answer as if it were upright.
519
+ if (!isPageRotation(request.rotation)) {
520
+ throw new PdfError('backend-failure', `Fake rasterizer: rotation must be 0, 90, 180 or 270, got ${String(request.rotation)}.`);
521
+ }
522
+ if (cfg.latencyMs > 0) {
523
+ await delay(cfg.latencyMs, signal);
524
+ }
525
+ throwIfAborted(signal);
526
+ requireOpen();
527
+ return searchFakeText(cfg.text[request.page] ?? '', request);
400
528
  },
401
529
  close() {
402
530
  closed = true;
@@ -413,7 +541,9 @@ export function createFakeRasterizer(options = {}) {
413
541
  // which is to say, between requests only.
414
542
  interruptibleRender: cfg.latencyMs > 0,
415
543
  text: false,
416
- search: false,
544
+ // True even with no `text` configured: the seam is implemented, and a
545
+ // page with nothing on it genuinely has no matches.
546
+ search: true,
417
547
  links: false,
418
548
  maxConcurrentRenders: cfg.maxConcurrentRenders,
419
549
  };
@@ -23,7 +23,7 @@
23
23
  * carries on.
24
24
  */
25
25
  import { PdfError } from '../types.js';
26
- import type { AlphaEncoding, PageGeometry, PageRasterizer, PdfSource, PixelFormat, RasterizerCapabilities, RasterizerHandle, RasterTransport } from '../types.js';
26
+ import type { AlphaEncoding, PageGeometry, PageRasterizer, PdfSource, PixelFormat, RasterizerCapabilities, RasterizerHandle, RasterTransport, TextMatch } from '../types.js';
27
27
  export interface NativePdfPage {
28
28
  index: number;
29
29
  /** Post-rotation, in PDF points. A double: A4 is 595.276 wide. */
@@ -81,15 +81,52 @@ export interface NativeRenderRequest {
81
81
  scale: number;
82
82
  annotations: boolean;
83
83
  background: 'white' | 'transparent';
84
+ /**
85
+ * Degrees clockwise, 0 / 90 / 180 / 270, on top of the page's `/Rotate`.
86
+ *
87
+ * ALWAYS PRESENT on the wire — 0 when the `RasterRequest` carried none —
88
+ * while both native modules read it as optional-defaulting-to-0. That pair
89
+ * is what makes a version skew safe in both directions: a JS bundle newer
90
+ * than its binary sends a key the binary never looks for, and a binary newer
91
+ * than its bundle reads 0 for a key that never arrives. Neither can turn a
92
+ * page by accident.
93
+ */
94
+ rotation: number;
84
95
  }
85
96
  export interface NativeSource {
86
97
  uri?: string;
87
98
  base64?: string;
88
99
  password?: string;
89
100
  }
101
+ /**
102
+ * `TextSearchRequest` as it crosses the bridge — the same five fields, every
103
+ * one present, so neither binding has a default that could drift from
104
+ * `PdfDocument.searchPage`'s. `rotation` is degrees clockwise like the render
105
+ * request's, and the core refuses anything but the four values.
106
+ */
107
+ export interface NativeSearchRequest {
108
+ page: number;
109
+ query: string;
110
+ matchCase: boolean;
111
+ wholeWord: boolean;
112
+ rotation: number;
113
+ }
90
114
  export interface PdfCanvasNativeModule {
91
115
  open(source: NativeSource): Promise<NativeOpenResult>;
92
116
  render(handle: number, request: NativeRenderRequest, token: number): Promise<NativeRenderResult>;
117
+ /**
118
+ * Resolves with ONE JSON string — `TextMatch[]`, built in the C++ core —
119
+ * rather than a bridge array of maps. A find-in-document pass on a dense
120
+ * page is hundreds of matches with several rects each, and one string is
121
+ * one bridge value where the map form is thousands; `parseTextMatches`
122
+ * checks the shape on the way in. The same (handle, token) registry as
123
+ * `render`, so `cancel` stops a search between matches.
124
+ *
125
+ * OPTIONAL IN THIS TYPE because it is optional in the wild: a JS bundle
126
+ * shipped over the air can be newer than the binary under it, and a
127
+ * 0.3.x binary has no such method. `searchText` checks for it and says so.
128
+ */
129
+ search?(handle: number, request: NativeSearchRequest, token: number): Promise<string>;
93
130
  /**
94
131
  * Sets the signal the render polls — including MID-RENDER, through PDFium's
95
132
  * progressive API — so a superseded tile stops within one batch of page
@@ -200,6 +237,16 @@ export interface NativeTransport {
200
237
  * do this fast renders exactly as it did before.
201
238
  */
202
239
  export declare function negotiateTransport(native: PdfCanvasNativeModule, lookup?: () => TakePixels | null): NativeTransport;
240
+ /**
241
+ * The core's JSON, checked field by field into `TextMatch[]`.
242
+ *
243
+ * Checked rather than cast because the string was built by hand in C++
244
+ * (`native/core/src/text_search.cpp`) with no JSON library on either side of
245
+ * it, and a host drawing hits from a `rects` entry that is `undefined` would
246
+ * fail inside its own draw code with no mention of this package. Every
247
+ * failure here is `backend-failure` naming what was wrong.
248
+ */
249
+ export declare function parseTextMatches(json: string): TextMatch[];
203
250
  export declare function toPageGeometry(page: NativePdfPage): PageGeometry;
204
251
  /**
205
252
  * Turns one native render result into bytes, on whichever transport produced it.
@@ -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
  * ------------------------------------------------------------------ */
@@ -343,6 +400,7 @@ export function createNativeHandle(native, opened, transport, alpha, label) {
343
400
  scale: request.scale,
344
401
  annotations: request.annotations,
345
402
  background: request.background,
403
+ rotation: request.rotation ?? 0,
346
404
  }, token);
347
405
  }
348
406
  catch (error) {
@@ -386,6 +444,44 @@ export function createNativeHandle(native, opened, transport, alpha, label) {
386
444
  },
387
445
  };
388
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
+ },
389
485
  close() {
390
486
  if (closed)
391
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 {