@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
package/README.md CHANGED
@@ -50,15 +50,16 @@ and drawing review, and form overlays.
50
50
 
51
51
  The package supplies no viewer chrome — no scroll view, page controls or navigation — and
52
52
  deliberately requires the host to provide the canvas, the gesture handling and the page layout.
53
- It does not extract text, perform search, or modify documents; its function is rasterization.
53
+ It does not extract text or modify documents. Beyond finding text on a page (see "Text search"),
54
+ its function is rasterization.
54
55
 
55
56
  ## Project status
56
57
 
57
- Version 0.2.0 is a pre-release. The core, the cadence controller, the React layer and a
58
- deterministic fake backend are implemented and tested. All three platform backends render with
59
- PDFium, so annotations, form fields, passwords and page rotation behave identically on each. The
60
- native core is covered by a host-machine C++ suite, and the web backend by 45 tests driving real
61
- PDFium WebAssembly under Node.
58
+ Version 0.4.0 is a pre-release. The core, the cadence controller, the React layer and a
59
+ deterministic fake backend are implemented and tested. All three platform backends render and
60
+ search with PDFium, so annotations, form fields, passwords, page rotation and text search behave
61
+ identically on each. The native core is covered by a host-machine C++ suite, and the web backend
62
+ by suites driving real PDFium WebAssembly under Node.
62
63
 
63
64
  Two areas remain unverified: the web backend has not yet been exercised in a browser, and the
64
65
  iOS binding requires a device pass.
@@ -267,6 +268,43 @@ Doc space for a turned page is the turned page's: a 612x792 page at 90 is laid o
267
268
  positioned in the same space. Changing a page's rotation rebuilds the layer's raster cache,
268
269
  exactly as changing `annotations` does.
269
270
 
271
+ ### Text search
272
+
273
+ `PdfDocument.searchPage` finds every occurrence of a string on one page, in reading order:
274
+
275
+ ```tsx
276
+ const controller = new AbortController(); // abort it on the next keystroke
277
+ const hits = await document.searchPage(pageIndex, query, {
278
+ wholeWord: true, // default false; `matchCase` likewise
279
+ rotation: 90, // the SAME rotation the page's layer uses; default 0
280
+ signal: controller.signal,
281
+ });
282
+
283
+ // Inside the same <Group transform={worldTransform}> as the PdfContentView.
284
+ // Rects are PAGE-LOCAL: offset them by the page's rect in the layout.
285
+ const {pageRect} = content.pages[pageIndex];
286
+ hits
287
+ .flatMap(hit => hit.rects)
288
+ .map((r, i) => (
289
+ <Rect
290
+ key={i}
291
+ x={pageRect.x + r.x}
292
+ y={pageRect.y + r.y}
293
+ width={r.width}
294
+ height={r.height}
295
+ color="rgba(255, 200, 0, 0.35)"
296
+ />
297
+ ));
298
+ ```
299
+
300
+ Each `TextMatch` carries `charIndex` / `charCount` (PDFium's character offsets on the page),
301
+ `context` (about 40 characters either side, whitespace collapsed, for a results list) and `rects`:
302
+ one per text line the match spans, in PDF points with the origin at the **displayed** page's
303
+ top-left and y pointing down, with the host rotation applied. That is the space the page's own
304
+ rasters are drawn in, so a highlight lands on the glyphs with no conversion. An empty or
305
+ whitespace-only query resolves `[]` without asking the backend. Every shipped backend declares
306
+ `capabilities.search`, and the fake searches the per-page `text` it is given.
307
+
270
308
  ## Platform support
271
309
 
272
310
  | Platform | Engine | Backend identifier |
@@ -154,6 +154,36 @@ public class PdfCanvasNativeTest {
154
154
  PdfCanvasNative.close(service, handle);
155
155
  }
156
156
 
157
+ /**
158
+ * CATCHES: the query mangled crossing JNI (it goes as UTF-16 via
159
+ * {@code GetStringRegion}, never modified UTF-8), a flag or the rotation in
160
+ * the wrong argument slot, and the JSON not surviving {@code NewStringUTF}.
161
+ * The match itself — index, rects, geometry — is proven by {@code native/tests};
162
+ * this checks the values cross.
163
+ */
164
+ @Test
165
+ public void searchCrossesJni() throws Exception {
166
+ int handle = open(TestPdfs.text(ctx(), "jni-text.pdf"));
167
+ PdfCanvasNative.registerCancellation(service, handle, 1);
168
+ String json = PdfCanvasNative.search(service, handle, 0, "MEASURED", false, false, 0, 1);
169
+ assertTrue(json, json.startsWith("[{\"page\":0,\"charIndex\":6,\"charCount\":8,\"rects\":[{"));
170
+ assertTrue(json, json.contains("\"context\":\"Hello measured world\""));
171
+ // matchCase lands in its own slot: the same query now finds nothing.
172
+ assertEquals("[]", PdfCanvasNative.search(service, handle, 0, "MEASURED", true, false, 0, 2));
173
+ // wholeWord likewise.
174
+ assertEquals("[]", PdfCanvasNative.search(service, handle, 0, "meas", false, true, 0, 3));
175
+ // A supplementary character crosses intact and simply matches nothing.
176
+ assertEquals("[]", PdfCanvasNative.search(service, handle, 0, "\uD83D\uDE00", false, false, 0, 4));
177
+ // A refused rotation comes back as a typed error, not a crash.
178
+ try {
179
+ PdfCanvasNative.search(service, handle, 0, "measured", false, false, 45, 5);
180
+ fail("a 45-degree rotation must be refused");
181
+ } catch (PdfRasterException e) {
182
+ assertEquals(PdfErrorCode.BACKEND_FAILURE, e.code());
183
+ }
184
+ PdfCanvasNative.close(service, handle);
185
+ }
186
+
157
187
  /* ================================================================ *
158
188
  * 2. The transport
159
189
  * ================================================================ */
@@ -18,9 +18,9 @@ import java.util.List;
18
18
  * laptop by {@code native/tests} against the same PDFium build, with a much
19
19
  * richer fixture set. What is left to prove ON DEVICE is that the bytes cross
20
20
  * JNI intact and the transport's slot store hands back what PDFium wrote, and
21
- * two fixtures are enough for that: one whose channels are all saturated (a
22
- * channel swap or a stride shear cannot hide) and one whose green exists only
23
- * inside an annotation.
21
+ * three fixtures are enough for that: one whose channels are all saturated (a
22
+ * channel swap or a stride shear cannot hide), one whose green exists only
23
+ * inside an annotation, and one line of text for the search seam.
24
24
  */
25
25
  final class TestPdfs {
26
26
 
@@ -124,6 +124,25 @@ final class TestPdfs {
124
124
  return writeFile(ctx, name, wr.finish(catalog));
125
125
  }
126
126
 
127
+ /**
128
+ * "Hello measured world" in unembedded 24pt Helvetica at (72, 700) on a
129
+ * 612x792 page — the same bytes {@code native/tests} searches.
130
+ */
131
+ static File text(Context ctx, String name) throws IOException {
132
+ Writer wr = new Writer();
133
+ int contents = wr.add(stream("BT /F1 24 Tf 72 700 Td (Hello measured world) Tj ET\n"));
134
+ int font = wr.add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
135
+ int pagesNumber = wr.count() + 2;
136
+ int page = wr.add("<< /Type /Page /Parent " + pagesNumber + " 0 R /MediaBox [0 0 612 792] /Contents "
137
+ + contents + " 0 R /Resources << /Font << /F1 " + font + " 0 R >> >> >>");
138
+ int pages = wr.add("<< /Type /Pages /Kids [" + page + " 0 R] /Count 1 >>");
139
+ int catalog = wr.add("<< /Type /Catalog /Pages " + pages + " 0 R >>");
140
+ if (pages != pagesNumber) {
141
+ throw new IllegalStateException("Pages object is " + pages + ", not " + pagesNumber);
142
+ }
143
+ return writeFile(ctx, name, wr.finish(catalog));
144
+ }
145
+
127
146
  /** No %PDF header and no recoverable objects. */
128
147
  static File notAPdf(Context ctx, String name) throws IOException {
129
148
  StringBuilder sb = new StringBuilder();
@@ -13,7 +13,7 @@
13
13
  * `PdfError` into a `PdfRasterException` carrying the same wire code, and
14
14
  * anything else into `backend-failure`.
15
15
  *
16
- * PLAIN JNI, NO FBJNI. The whole surface is a dozen static methods on one
16
+ * PLAIN JNI, NO FBJNI. The whole surface is fourteen static methods on one
17
17
  * class; fbjni would add a dependency and a registration dance to express
18
18
  * `SetByteArrayRegion`.
19
19
  */
@@ -94,6 +94,21 @@ std::vector<uint8_t> toBytes(JNIEnv* env, jbyteArray value) {
94
94
  return out;
95
95
  }
96
96
 
97
+ /// UTF-16 straight out of the Java string, with no transcoding. NOT
98
+ /// `GetStringUTFChars`: that is "modified UTF-8", which encodes a
99
+ /// supplementary character as two three-byte surrogates and U+0000 as two
100
+ /// bytes, so a query containing either would reach PDFium as something else.
101
+ std::u16string toUtf16(JNIEnv* env, jstring value) {
102
+ if (value == nullptr) return {};
103
+ const jsize length = env->GetStringLength(value);
104
+ std::u16string out(static_cast<size_t>(length), u'\0');
105
+ if (length > 0) {
106
+ static_assert(sizeof(jchar) == sizeof(char16_t), "jchar is a UTF-16 unit");
107
+ env->GetStringRegion(value, 0, length, reinterpret_cast<jchar*>(&out[0]));
108
+ }
109
+ return out;
110
+ }
111
+
97
112
  jbyteArray toJavaBytes(JNIEnv* env, const std::vector<uint8_t>& bytes) {
98
113
  const jsize length = static_cast<jsize>(bytes.size());
99
114
  jbyteArray out = env->NewByteArray(length);
@@ -209,6 +224,33 @@ JNIEXPORT jbyteArray JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_render(
209
224
  });
210
225
  }
211
226
 
227
+ JNIEXPORT jstring JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_search(
228
+ JNIEnv* env, jclass, jlong service, jint handle, jint page, jstring query, jboolean matchCase,
229
+ jboolean wholeWord, jint rotation, jlong token) {
230
+ return guarded<jstring>(env, nullptr, [&]() -> jstring {
231
+ Service* s = serviceOf(service);
232
+ if (s == nullptr) throw PdfError(ErrorCode::BackendFailure, "The native service is gone.");
233
+
234
+ pdfcanvas::TextSearchRequest request;
235
+ request.page = page;
236
+ request.query = toUtf16(env, query);
237
+ request.matchCase = matchCase == JNI_TRUE;
238
+ request.wholeWord = wholeWord == JNI_TRUE;
239
+ // Degrees clockwise, as for render; the core validates it.
240
+ request.rotation = rotation;
241
+
242
+ // PURE ASCII by construction (`textMatchesToJson`), which is the one input
243
+ // on which NewStringUTF's "modified UTF-8" and real UTF-8 agree.
244
+ const std::string json = s->searchJson(handle, request, token);
245
+ jstring out = env->NewStringUTF(json.c_str());
246
+ if (out == nullptr) {
247
+ throw PdfError(ErrorCode::OutOfMemory, "Could not allocate a Java string for " +
248
+ std::to_string(json.size()) + " bytes of search results.");
249
+ }
250
+ return out;
251
+ });
252
+ }
253
+
212
254
  JNIEXPORT void JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_cancel(JNIEnv* env, jclass, jlong service,
213
255
  jint handle, jlong token) {
214
256
  guarded<int>(env, 0, [&] {
@@ -134,7 +134,32 @@ public final class PdfCanvasNative {
134
134
  long[] out)
135
135
  throws PdfRasterException;
136
136
 
137
- /** Sets the signal a render polls, including mid-draw. A pure lookup. */
137
+ /**
138
+ * Finds every occurrence of {@code query} on one page, on the calling thread.
139
+ * Register the token with {@link #registerCancellation} first, exactly as for
140
+ * a render: {@link #cancel} then stops the search between matches.
141
+ *
142
+ * @param query crosses JNI as UTF-16, untranscoded.
143
+ * @param rotation the HOST's rotation, as for {@link #render}: the rects come
144
+ * back in the TURNED page's doc space. The core refuses anything but 0,
145
+ * 90, 180 or 270.
146
+ * @return {@code TextMatch[]} as ONE JSON string, pure ASCII, written by the
147
+ * core ({@code textMatchesToJson}) — handed to JS as it is, and checked
148
+ * there by {@code parseTextMatches}.
149
+ * @throws PdfRasterException with the failure's {@link PdfErrorCode}.
150
+ */
151
+ public static native String search(
152
+ long service,
153
+ int handle,
154
+ int page,
155
+ String query,
156
+ boolean matchCase,
157
+ boolean wholeWord,
158
+ int rotation,
159
+ long token)
160
+ throws PdfRasterException;
161
+
162
+ /** Sets the signal a render (or a search) polls. A pure lookup. */
138
163
  public static native void cancel(long service, int handle, long token);
139
164
 
140
165
  /** Closes one document. Idempotent. */
@@ -60,10 +60,10 @@ import tools.reekon.pdfcanvas.PdfRasterException;
60
60
  * <h3>Threading</h3>
61
61
  *
62
62
  * {@code @ReactMethod} runs on the bridge's native-modules thread and a render
63
- * is a long synchronous call, so every open and render is handed to ONE worker.
64
- * One, because PDFium is not thread-safe and the core serialises every call
65
- * behind a process-wide lock anyway; a second worker would only queue behind
66
- * the first while holding a promise open.
63
+ * is a long synchronous call, so every open, render and search is handed to ONE
64
+ * worker. One, because PDFium is not thread-safe and the core serialises every
65
+ * call behind a process-wide lock anyway; a second worker would only queue
66
+ * behind the first while holding a promise open.
67
67
  *
68
68
  * <p>EVERY {@code ReadableMap} IS DRAINED ON THE CALLING THREAD, before the
69
69
  * worker is handed anything: on the legacy bridge a {@code ReadableNativeMap} is
@@ -378,11 +378,74 @@ public class PdfCanvasModule extends ReactContextBaseJavaModule {
378
378
  }
379
379
  }
380
380
 
381
+ /* ---------------------------------------------------------------- *
382
+ * search
383
+ * ---------------------------------------------------------------- */
384
+
385
+ /**
386
+ * Finds text on one page and resolves with the core's JSON — ONE string,
387
+ * however many hits, so a find pass over a dense drawing is one bridge value
388
+ * rather than thousands of maps. {@code native-bridge.ts} parses and checks
389
+ * it.
390
+ *
391
+ * <p>THE SAME WORKER AS RENDER, deliberately: PDFium is not thread-safe and
392
+ * the core serialises everything behind one lock anyway, and a search that
393
+ * jumped the queue would only wait on that lock while holding the text page
394
+ * a render is about to want. The same (handle, token) registry too, so
395
+ * {@link #cancel} stops a superseded search between matches.
396
+ */
397
+ @ReactMethod
398
+ public void search(int handle, ReadableMap request, int token, Promise promise) {
399
+ if (!requireService(promise)) {
400
+ return;
401
+ }
402
+ // Drained HERE, on the bridge thread, like render's request.
403
+ final int page, rotation;
404
+ final String query;
405
+ final boolean matchCase, wholeWord;
406
+ try {
407
+ page = request.getInt("page");
408
+ query = request.hasKey("query") && !request.isNull("query") ? request.getString("query") : "";
409
+ matchCase = request.hasKey("matchCase") && request.getBoolean("matchCase");
410
+ wholeWord = request.hasKey("wholeWord") && request.getBoolean("wholeWord");
411
+ // OPTIONAL, DEFAULTING TO 0, for the reason render's is.
412
+ rotation =
413
+ request.hasKey("rotation") && !request.isNull("rotation")
414
+ ? request.getInt("rotation")
415
+ : 0;
416
+ } catch (RuntimeException e) {
417
+ reject(promise, PdfErrorCode.BACKEND_FAILURE, e);
418
+ return;
419
+ }
420
+
421
+ // Registered BEFORE the worker runs — see render.
422
+ PdfCanvasNative.registerCancellation(service, handle, token);
423
+
424
+ try {
425
+ worker.execute(
426
+ () -> {
427
+ try {
428
+ promise.resolve(
429
+ PdfCanvasNative.search(
430
+ service, handle, page, query, matchCase, wholeWord, rotation, token));
431
+ } catch (PdfRasterException e) {
432
+ reject(promise, e);
433
+ } catch (Throwable t) {
434
+ reject(promise, PdfErrorCode.BACKEND_FAILURE, t);
435
+ }
436
+ });
437
+ } catch (RejectedExecutionException e) {
438
+ // invalidate() shut the worker down between the register and here.
439
+ reject(promise, PdfErrorCode.BACKEND_FAILURE, e);
440
+ }
441
+ }
442
+
381
443
  /**
382
444
  * Genuinely mid-render now, not best-effort: the core polls the signal from
383
445
  * PDFium's progressive renderer between batches of page objects, so a
384
- * superseded tile stops drawing within one batch. A pure lookup — a cancel
385
- * for a token whose render already finished finds nothing and does nothing.
446
+ * superseded tile stops drawing within one batch — and a search between
447
+ * matches. A pure lookup — a cancel for a token whose job already finished
448
+ * finds nothing and does nothing.
386
449
  */
387
450
  @ReactMethod
388
451
  public void cancel(int handle, int token) {
@@ -177,10 +177,12 @@ if [ -n "$CLANG" ] && [ -f "$PDFIUM/include/fpdfview.h" ]; then
177
177
  if [ -n "$NM" ] && [ -f "$OUT/lib-aarch64-linux-android24.so" ]; then
178
178
  exported=$("$NM" -D --defined-only "$OUT/lib-aarch64-linux-android24.so" 2>/dev/null \
179
179
  | grep -c 'Java_tools_reekon_pdfcanvas_PdfCanvasNative_')
180
- if [ "${exported:-0}" -ge 13 ]; then
180
+ # 14 since 0.4.0 (`search`). A floor, not an equality: the prototype gate
181
+ # below is what pins declarations and definitions to each other.
182
+ if [ "${exported:-0}" -ge 14 ]; then
181
183
  echo "PASS the linked .so exports $exported Java_tools_reekon_pdfcanvas_PdfCanvasNative_* symbols"
182
184
  else
183
- echo "FAIL the linked .so exports only ${exported:-0} JNI symbols (expected >= 13)"; fail=1
185
+ echo "FAIL the linked .so exports only ${exported:-0} JNI symbols (expected >= 14)"; fail=1
184
186
  fi
185
187
  else
186
188
  say_skip "exported JNI symbols" "no llvm-nm, or nothing linked"
@@ -249,8 +251,8 @@ if [ -n "$JAVAC" ] && [ -n "$AJAR" ] && [ -n "$CLANG" ] && [ -f "$PDFIUM/include
249
251
  n=$((n + 1))
250
252
  grep -q "${m}(" "$JNI_SRC" || missing="$missing $m"
251
253
  done
252
- if [ "$n" -lt 13 ]; then
253
- echo "FAIL the JNI gate found only $n declared native methods (expected >= 13)"; fail=1
254
+ if [ "$n" -lt 14 ]; then
255
+ echo "FAIL the JNI gate found only $n declared native methods (expected >= 14)"; fail=1
254
256
  elif [ -n "$missing" ]; then
255
257
  echo "FAIL declared native in Java, undefined in C++:$missing"; fail=1
256
258
  else
@@ -148,6 +148,9 @@ export function createPdfController(options) {
148
148
  pages: Object.freeze(pageInfos.map(info => Object.freeze({
149
149
  page: info.page,
150
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),
151
154
  base: EMPTY_RASTERS,
152
155
  detail: EMPTY_RASTERS,
153
156
  retiring: EMPTY_RASTERS,
@@ -831,6 +834,7 @@ export function createPdfController(options) {
831
834
  return Object.freeze({
832
835
  page: previous.page,
833
836
  pageRect: previous.pageRect,
837
+ drawable: previous.drawable,
834
838
  base,
835
839
  detail,
836
840
  retiring,
@@ -999,6 +1003,7 @@ export function createPdfController(options) {
999
1003
  pages: Object.freeze(content.pages.map(page => Object.freeze({
1000
1004
  page: page.page,
1001
1005
  pageRect: page.pageRect,
1006
+ drawable: page.drawable,
1002
1007
  base: EMPTY_RASTERS,
1003
1008
  detail: EMPTY_RASTERS,
1004
1009
  retiring: EMPTY_RASTERS,
@@ -26,7 +26,7 @@
26
26
  * Everything here is a pure function of the request, so the same request always
27
27
  * produces byte-identical output.
28
28
  */
29
- 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';
30
30
  export declare const LETTER: DocSize;
31
31
  /** ARCH D, 24x36in — the sheet the base-raster sizing rule is calibrated on. */
32
32
  export declare const ARCH_D: DocSize;
@@ -53,10 +53,18 @@ export interface FakeRasterizerOptions {
53
53
  * height are already post-rotation, so the fake does not swap them.
54
54
  */
55
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[];
56
64
  /** Checker cell size in DOC units. */
57
65
  checkerDocSize?: number;
58
66
  format?: PixelFormat;
59
- /** Artificial delay on `render()` only; `open()` always resolves promptly. */
67
+ /** Artificial delay on `render()` and `searchText()`; `open()` always resolves promptly. */
60
68
  latencyMs?: number;
61
69
  /** When set, `open()` enforces it against `source.password`. */
62
70
  password?: string;
@@ -101,4 +109,18 @@ export declare function decodeFakeDocRect(pixels: RasterPixels): DocRect | null;
101
109
  * controller's stamp reached the backend.
102
110
  */
103
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[];
104
126
  export declare function createFakeRasterizer(options?: FakeRasterizerOptions): PageRasterizer;
@@ -27,6 +27,7 @@
27
27
  * produces byte-identical output.
28
28
  */
29
29
  import { isPageRotation } from '../rotation.js';
30
+ import { contextAround } from './text-search.js';
30
31
  import { PdfError } from '../types.js';
31
32
  /* ------------------------------------------------------------------ *
32
33
  * Page sizes, in PDF points (1pt = 1/72in)
@@ -62,6 +63,7 @@ function resolveConfig(options) {
62
63
  return {
63
64
  id: options.id ?? 'fake',
64
65
  pages,
66
+ text: options.text ?? [],
65
67
  checkerDocSize: options.checkerDocSize ?? DEFAULT_CHECKER_DOC_SIZE,
66
68
  format: options.format ?? 'rgba8888',
67
69
  latencyMs: options.latencyMs ?? 0,
@@ -355,6 +357,58 @@ function renderPixels(request, cfg, geometry) {
355
357
  alpha: FAKE_ALPHA,
356
358
  };
357
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
+ }
358
412
  /* ------------------------------------------------------------------ *
359
413
  * Cancellation
360
414
  * ------------------------------------------------------------------ */
@@ -455,6 +509,23 @@ function createHandle(cfg) {
455
509
  }
456
510
  return renderPixels(request, cfg, geometry);
457
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);
528
+ },
458
529
  close() {
459
530
  closed = true;
460
531
  },
@@ -470,7 +541,9 @@ export function createFakeRasterizer(options = {}) {
470
541
  // which is to say, between requests only.
471
542
  interruptibleRender: cfg.latencyMs > 0,
472
543
  text: false,
473
- 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,
474
547
  links: false,
475
548
  maxConcurrentRenders: cfg.maxConcurrentRenders,
476
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. */
@@ -98,9 +98,35 @@ export interface NativeSource {
98
98
  base64?: string;
99
99
  password?: string;
100
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
+ }
101
114
  export interface PdfCanvasNativeModule {
102
115
  open(source: NativeSource): Promise<NativeOpenResult>;
103
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>;
104
130
  /**
105
131
  * Sets the signal the render polls — including MID-RENDER, through PDFium's
106
132
  * progressive API — so a superseded tile stops within one batch of page
@@ -211,6 +237,16 @@ export interface NativeTransport {
211
237
  * do this fast renders exactly as it did before.
212
238
  */
213
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[];
214
250
  export declare function toPageGeometry(page: NativePdfPage): PageGeometry;
215
251
  /**
216
252
  * Turns one native render result into bytes, on whichever transport produced it.