@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
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.
@@ -250,6 +251,60 @@ usePdfLayer({document, policy: {sharpnessBand: 1.2, quietZoomMs: 400}});
250
251
  observed p95 raster time. A value set too low permits a user adjusting the zoom to supersede
251
252
  each render before it completes.
252
253
 
254
+ ### Page rotation
255
+
256
+ A host may show a page turned a quarter, half or three-quarter turn clockwise on top of the
257
+ document's own `/Rotate`, per page:
258
+
259
+ ```ts
260
+ // Page 0 shown turned 90° clockwise; pages without an entry stay upright.
261
+ usePdfLayer({document, rotations: [90]});
262
+ // A matching thumbnail. `widthPx` is the width of the TURNED raster.
263
+ document.renderPage(0, {widthPx: 160, rotation: 90});
264
+ ```
265
+
266
+ Doc space for a turned page is the turned page's: a 612x792 page at 90 is laid out as 792x612,
267
+ `content.pages[i].pageRect` reports that rect, and anything the host draws over the page is
268
+ positioned in the same space. Changing a page's rotation rebuilds the layer's raster cache,
269
+ exactly as changing `annotations` does.
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
+
253
308
  ## Platform support
254
309
 
255
310
  | Platform | Engine | Backend identifier |
@@ -102,7 +102,7 @@ public class PdfCanvasNativeTest {
102
102
 
103
103
  long[] shape = new long[4];
104
104
  byte[] bytes = PdfCanvasNative.render(
105
- service, handle, 0, 0, 0, 200, 100, 2, false, false, 1, false, shape);
105
+ service, handle, 0, 0, 0, 200, 100, 2, 0, false, false, 1, false, shape);
106
106
  assertNotNull("a heap render returns bytes", bytes);
107
107
  assertEquals(0L, shape[0]);
108
108
  assertEquals(400L, shape[1]);
@@ -122,14 +122,68 @@ public class PdfCanvasNativeTest {
122
122
  public void annotationsFlagReachesTheCore() throws Exception {
123
123
  int handle = open(TestPdfs.annotated(ctx(), "jni-annotated.pdf"));
124
124
  long[] shape = new long[4];
125
- byte[] with = PdfCanvasNative.render(service, handle, 0, 0, 0, 200, 200, 1, true, false, 1, false, shape);
126
- byte[] without = PdfCanvasNative.render(service, handle, 0, 0, 0, 200, 200, 1, false, false, 2, false, shape);
125
+ byte[] with = PdfCanvasNative.render(service, handle, 0, 0, 0, 200, 200, 1, 0, true, false, 1, false, shape);
126
+ byte[] without = PdfCanvasNative.render(service, handle, 0, 0, 0, 200, 200, 1, 0, false, false, 2, false, shape);
127
127
  int rowBytes = (int) shape[3];
128
128
  assertPixel(with, rowBytes, 100, 100, 0, 255, 0);
129
129
  assertPixel(without, rowBytes, 100, 100, 255, 255, 255);
130
130
  PdfCanvasNative.close(service, handle);
131
131
  }
132
132
 
133
+ /**
134
+ * CATCHES: the rotation not surviving the JNI int hop, or landing in the
135
+ * wrong argument slot (a swapped {@code annotations} would still compile).
136
+ * Doc top-left is blue and bottom-left red; a quarter turn clockwise puts
137
+ * the bottom-left at the top-left and the top-left at the top-right. The
138
+ * rect is the TURNED page's: 100x200 for a 200x100 page.
139
+ */
140
+ @Test
141
+ public void rotationReachesTheCore() throws Exception {
142
+ int handle = open(TestPdfs.quadrants(ctx(), "jni-rotated.pdf", 200, 100));
143
+ long[] shape = new long[4];
144
+ byte[] bytes =
145
+ PdfCanvasNative.render(service, handle, 0, 0, 0, 100, 200, 2, 90, false, false, 1, false, shape);
146
+ assertNotNull(bytes);
147
+ assertEquals(200L, shape[1]);
148
+ assertEquals(400L, shape[2]);
149
+ int rowBytes = (int) shape[3];
150
+ assertPixel(bytes, rowBytes, 20, 20, 255, 0, 0); // red: doc bottom-left, now top-left
151
+ assertPixel(bytes, rowBytes, 180, 20, 0, 0, 255); // blue: doc top-left, now top-right
152
+ assertPixel(bytes, rowBytes, 180, 380, 0, 0, 0); // black: doc top-right, now bottom-right
153
+ assertPixel(bytes, rowBytes, 20, 380, 0, 255, 0); // green: doc bottom-right, now bottom-left
154
+ PdfCanvasNative.close(service, handle);
155
+ }
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
+
133
187
  /* ================================================================ *
134
188
  * 2. The transport
135
189
  * ================================================================ */
@@ -144,11 +198,11 @@ public class PdfCanvasNativeTest {
144
198
  public void slotBytesEqualHeapBytesForARealRaster() throws Exception {
145
199
  int handle = open(TestPdfs.quadrants(ctx(), "jni-slot.pdf", 300, 200));
146
200
  long[] heapShape = new long[4];
147
- byte[] heap = PdfCanvasNative.render(service, handle, 0, 37, 23, 64, 41, 4, false, false, 1, false, heapShape);
201
+ byte[] heap = PdfCanvasNative.render(service, handle, 0, 37, 23, 64, 41, 4, 0, false, false, 1, false, heapShape);
148
202
  assertNotNull(heap);
149
203
 
150
204
  long[] slotShape = new long[4];
151
- byte[] none = PdfCanvasNative.render(service, handle, 0, 37, 23, 64, 41, 4, false, false, 2, true, slotShape);
205
+ byte[] none = PdfCanvasNative.render(service, handle, 0, 37, 23, 64, 41, 4, 0, false, false, 2, true, slotShape);
152
206
  assertNull("a slot render returns no bytes", none);
153
207
  assertTrue("a slot render names a slot", slotShape[0] > 0);
154
208
  assertEquals(heapShape[1], slotShape[1]);
@@ -215,7 +269,7 @@ public class PdfCanvasNativeTest {
215
269
  }
216
270
  int handle = open(TestPdfs.quadrants(ctx(), "jni-range.pdf", 40, 40));
217
271
  try {
218
- PdfCanvasNative.render(service, handle, 7, 0, 0, 10, 10, 1, false, false, 1, false, new long[4]);
272
+ PdfCanvasNative.render(service, handle, 7, 0, 0, 10, 10, 1, 0, false, false, 1, false, new long[4]);
219
273
  fail("an out-of-range page must throw");
220
274
  } catch (PdfRasterException e) {
221
275
  assertEquals(PdfErrorCode.NOT_FOUND, e.code());
@@ -228,24 +282,24 @@ public class PdfCanvasNativeTest {
228
282
  PdfCanvasNative.registerCancellation(service, handle, 5);
229
283
  PdfCanvasNative.cancel(service, handle, 5);
230
284
  try {
231
- PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, false, false, 5, false, new long[4]);
285
+ PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, 0, false, false, 5, false, new long[4]);
232
286
  fail("a cancelled render must throw");
233
287
  } catch (PdfRasterException e) {
234
288
  assertEquals(PdfErrorCode.CANCELLED, e.code());
235
289
  }
236
290
  // The signal was consumed: the same token renders fine afterwards.
237
- assertNotNull(PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, false, false, 5, false, new long[4]));
291
+ assertNotNull(PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, 0, false, false, 5, false, new long[4]));
238
292
  }
239
293
 
240
294
  @Test
241
295
  public void invalidateClosesEverythingAndRefusesAfterwards() throws Exception {
242
296
  int handle = open(TestPdfs.quadrants(ctx(), "jni-invalidate.pdf", 40, 40));
243
- PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, false, false, 1, true, new long[4]);
297
+ PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, 0, false, false, 1, true, new long[4]);
244
298
  assertEquals(1, PdfCanvasNative.slotCount());
245
299
  PdfCanvasNative.invalidateService(service);
246
300
  assertEquals("invalidate drains parked slots", 0, PdfCanvasNative.slotCount());
247
301
  try {
248
- PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, false, false, 2, false, new long[4]);
302
+ PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, 0, false, false, 2, false, new long[4]);
249
303
  fail("a render after invalidate must throw");
250
304
  } catch (PdfRasterException e) {
251
305
  assertEquals(PdfErrorCode.BACKEND_FAILURE, e.code());
@@ -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);
@@ -172,8 +187,8 @@ JNIEXPORT void JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_registerCance
172
187
 
173
188
  JNIEXPORT jbyteArray JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_render(
174
189
  JNIEnv* env, jclass, jlong service, jint handle, jint page, jdouble x, jdouble y, jdouble width,
175
- jdouble height, jdouble scale, jboolean annotations, jboolean transparent, jlong token,
176
- jboolean useSlot, jlongArray out) {
190
+ jdouble height, jdouble scale, jint rotation, jboolean annotations, jboolean transparent,
191
+ jlong token, jboolean useSlot, jlongArray out) {
177
192
  return guarded<jbyteArray>(env, nullptr, [&]() -> jbyteArray {
178
193
  Service* s = serviceOf(service);
179
194
  if (s == nullptr) throw PdfError(ErrorCode::BackendFailure, "The native service is gone.");
@@ -188,6 +203,8 @@ JNIEXPORT jbyteArray JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_render(
188
203
  request.width = width;
189
204
  request.height = height;
190
205
  request.scale = scale;
206
+ // Degrees clockwise on top of the page's /Rotate; the core validates it.
207
+ request.rotation = rotation;
191
208
  request.annotations = annotations == JNI_TRUE;
192
209
  request.background = transparent == JNI_TRUE ? pdfcanvas::Background::Transparent
193
210
  : pdfcanvas::Background::White;
@@ -207,6 +224,33 @@ JNIEXPORT jbyteArray JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_render(
207
224
  });
208
225
  }
209
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
+
210
254
  JNIEXPORT void JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_cancel(JNIEnv* env, jclass, jlong service,
211
255
  jint handle, jlong token) {
212
256
  guarded<int>(env, 0, [&] {
@@ -107,6 +107,10 @@ public final class PdfCanvasNative {
107
107
  /**
108
108
  * Renders one raster on the calling thread.
109
109
  *
110
+ * @param rotation the HOST's rotation on top of the page's {@code /Rotate},
111
+ * in degrees clockwise: 0, 90, 180 or 270. The rect is in the TURNED
112
+ * page's doc space — extents swapped for a quarter turn. The core refuses
113
+ * anything else.
110
114
  * @param useSlot ask for the zero-copy transport. The store may still refuse,
111
115
  * so the result says where the pixels ARE: a non-null return is heap
112
116
  * bytes, a null return means {@code out[0]} names a slot.
@@ -122,6 +126,7 @@ public final class PdfCanvasNative {
122
126
  double width,
123
127
  double height,
124
128
  double scale,
129
+ int rotation,
125
130
  boolean annotations,
126
131
  boolean transparent,
127
132
  long token,
@@ -129,7 +134,32 @@ public final class PdfCanvasNative {
129
134
  long[] out)
130
135
  throws PdfRasterException;
131
136
 
132
- /** 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. */
133
163
  public static native void cancel(long service, int handle, long token);
134
164
 
135
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
@@ -294,7 +294,7 @@ public class PdfCanvasModule extends ReactContextBaseJavaModule {
294
294
  return;
295
295
  }
296
296
  // The whole request is turned into plain values HERE, on the bridge thread.
297
- final int page;
297
+ final int page, rotation;
298
298
  final double x, y, width, height, scale;
299
299
  final boolean annotations, transparent;
300
300
  try {
@@ -304,6 +304,13 @@ public class PdfCanvasModule extends ReactContextBaseJavaModule {
304
304
  width = request.getDouble("width");
305
305
  height = request.getDouble("height");
306
306
  scale = request.getDouble("scale");
307
+ // OPTIONAL, DEFAULTING TO 0: a JS bundle older than this binary never
308
+ // sends it, and a page must not turn because a key is missing. The core
309
+ // validates the value; this only decides what "absent" means.
310
+ rotation =
311
+ request.hasKey("rotation") && !request.isNull("rotation")
312
+ ? request.getInt("rotation")
313
+ : 0;
307
314
  annotations = !request.hasKey("annotations") || request.getBoolean("annotations");
308
315
  transparent = "transparent".equals(request.getString("background"));
309
316
  } catch (RuntimeException e) {
@@ -329,8 +336,8 @@ public class PdfCanvasModule extends ReactContextBaseJavaModule {
329
336
  final long[] shape = new long[4];
330
337
  final byte[] bytes =
331
338
  PdfCanvasNative.render(
332
- service, handle, page, x, y, width, height, scale, annotations, transparent,
333
- token, fast, shape);
339
+ service, handle, page, x, y, width, height, scale, rotation, annotations,
340
+ transparent, token, fast, shape);
334
341
  slot = shape[0];
335
342
 
336
343
  WritableMap result = Arguments.createMap();
@@ -371,11 +378,74 @@ public class PdfCanvasModule extends ReactContextBaseJavaModule {
371
378
  }
372
379
  }
373
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
+
374
443
  /**
375
444
  * Genuinely mid-render now, not best-effort: the core polls the signal from
376
445
  * PDFium's progressive renderer between batches of page objects, so a
377
- * superseded tile stops drawing within one batch. A pure lookup — a cancel
378
- * 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.
379
449
  */
380
450
  @ReactMethod
381
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
@@ -37,7 +37,7 @@
37
37
  * which key comes from `./planner.js`. A private copy of either is how the two
38
38
  * silently drift until the tested one is testing nothing that runs.
39
39
  */
40
- import type { DocRect, PageGeometry, PdfContent, PdfController, PdfDiagnostic, PdfRaster, RasterizerHandle, RasterPixels, RasterPolicy, RasterRole } from './types.js';
40
+ import type { DocRect, PageGeometry, PageRotation, PdfContent, PdfController, PdfDiagnostic, PdfRaster, RasterizerHandle, RasterPixels, RasterPolicy, RasterRole } from './types.js';
41
41
  import type { RasterCache } from './cache.js';
42
42
  export interface ScheduledHandle {
43
43
  cancel(): void;
@@ -62,6 +62,28 @@ export interface PdfControllerOptions {
62
62
  pageRects: DocRect[];
63
63
  /** Parallel to `pageRects`. Every page, in page order. */
64
64
  pages: PageGeometry[];
65
+ /**
66
+ * The HOST's per-page rotation on top of the document's `/Rotate`, indexed
67
+ * by page like `pageRects`; a missing or undefined entry is 0. Stamped onto
68
+ * every `RasterRequest` for that page as `rotation` — and only that. The
69
+ * controller does no rotation arithmetic of its own.
70
+ *
71
+ * WHY A PARALLEL ARRAY AND NOT A FIELD ON `PageGeometry`. `PageGeometry` is
72
+ * the backend's statement of a page's intrinsic size, and its `rotation` is
73
+ * already taken: it is the document's own `/Rotate`. A host preference on
74
+ * the same object would give the type two rotations with different meanings,
75
+ * and would ride into every backend that constructs one (the fake, the web
76
+ * engine, the native bridge) for a value none of them produce. This
77
+ * controller already takes its per-page inputs as parallel arrays, so a
78
+ * third costs nothing new and touches nothing outside this file.
79
+ *
80
+ * THE GEOMETRY MUST ALREADY BE ROTATED. For a page turned 90 or 270, `pages`
81
+ * and `pageRects` must both carry the SWAPPED extents — see `../rotation.ts`
82
+ * — because `layoutScale` is `pageRect.width / geometry.width` and is only
83
+ * right when both sides describe the same orientation. `usePdfLayer` does
84
+ * the swap; a host driving this directly must too.
85
+ */
86
+ rotations?: readonly PageRotation[];
65
87
  policy: RasterPolicy;
66
88
  ingest: (pixels: RasterPixels, meta: RasterIngestMeta) => PdfRaster;
67
89
  cache: RasterCache;