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