@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.
- package/README.md +44 -6
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +30 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +22 -3
- package/android/src/main/cpp/pdfcanvas-jni.cpp +43 -1
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +26 -1
- package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +69 -6
- package/android/tools/compile-gate.sh +6 -4
- package/dist/controller.js +5 -0
- package/dist/rasterizer/fake.d.ts +24 -2
- package/dist/rasterizer/fake.js +74 -1
- package/dist/rasterizer/native-bridge.d.ts +37 -1
- package/dist/rasterizer/native-bridge.js +95 -0
- package/dist/rasterizer/native.d.ts +2 -2
- package/dist/rasterizer/native.js +9 -7
- package/dist/rasterizer/text-search.d.ts +22 -0
- package/dist/rasterizer/text-search.js +28 -0
- package/dist/rasterizer/web/client.js +69 -49
- package/dist/rasterizer/web/engine.d.ts +3 -1
- package/dist/rasterizer/web/engine.js +199 -1
- package/dist/rasterizer/web/pdfium.d.ts +64 -2
- package/dist/rasterizer/web/pdfium.js +21 -0
- package/dist/rasterizer/web/protocol.d.ts +23 -6
- package/dist/rasterizer/web/protocol.js +4 -1
- package/dist/rasterizer/web/session.js +30 -13
- package/dist/react/PdfContentView.js +5 -0
- package/dist/react/usePdfDocument.d.ts +17 -2
- package/dist/react/usePdfDocument.js +44 -0
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/types.d.ts +66 -0
- package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +66 -2
- package/native/core/include/pdfcanvas/document.h +17 -0
- package/native/core/include/pdfcanvas/service.h +14 -1
- package/native/core/include/pdfcanvas/text_search.h +52 -0
- package/native/core/include/pdfcanvas/types.h +41 -0
- package/native/core/pdfcanvas-core.cmake +1 -0
- package/native/core/src/document.cpp +188 -5
- package/native/core/src/service.cpp +43 -28
- package/native/core/src/text_search.cpp +115 -0
- package/native/tests/fixtures.cpp +27 -0
- package/native/tests/fixtures.h +15 -0
- package/native/tests/test_document.cpp +308 -0
- package/native/tests/test_service.cpp +110 -0
- 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
|
|
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.
|
|
58
|
-
deterministic fake backend are implemented and tested. All three platform backends render
|
|
59
|
-
PDFium, so annotations, form fields, passwords
|
|
60
|
-
native core is covered by a host-machine C++ suite, and the web backend
|
|
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
|
-
*
|
|
22
|
-
* channel swap or a stride shear cannot hide)
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
64
|
-
* One, because PDFium is not thread-safe and the core serialises every
|
|
65
|
-
* behind a process-wide lock anyway; a second worker would only queue
|
|
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
|
|
385
|
-
* for a token whose
|
|
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
|
-
|
|
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 >=
|
|
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
|
|
253
|
-
echo "FAIL the JNI gate found only $n declared native methods (expected >=
|
|
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
|
package/dist/controller.js
CHANGED
|
@@ -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()`
|
|
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;
|
package/dist/rasterizer/fake.js
CHANGED
|
@@ -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
|
-
|
|
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.
|