@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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The two text-search rules that are not PDFium calls: how a match's context
|
|
2
|
+
// reads, and how a page's matches cross the bridge.
|
|
3
|
+
//
|
|
4
|
+
// PURE — no PDFium, no lock, no document — so both are provable on their own,
|
|
5
|
+
// and both have a TypeScript twin that must agree with them:
|
|
6
|
+
// `src/rasterizer/text-search.ts` (the context rule, used by the web engine
|
|
7
|
+
// and the fake) and `parseTextMatches` in `src/rasterizer/native-bridge.ts`
|
|
8
|
+
// (the reader of the JSON written here).
|
|
9
|
+
#pragma once
|
|
10
|
+
|
|
11
|
+
#include <string>
|
|
12
|
+
#include <vector>
|
|
13
|
+
|
|
14
|
+
#include "pdfcanvas/types.h"
|
|
15
|
+
|
|
16
|
+
namespace pdfcanvas {
|
|
17
|
+
|
|
18
|
+
/// Characters of page text a match carries either side of itself. Mirrors
|
|
19
|
+
/// `CONTEXT_CHARS` in `src/rasterizer/text-search.ts`.
|
|
20
|
+
constexpr int kContextChars = 40;
|
|
21
|
+
|
|
22
|
+
/// Whether a UTF-16 code unit is whitespace as ECMAScript's `\s` defines it —
|
|
23
|
+
/// the exact set `collapseWhitespace` in `text-search.ts` collapses, so a
|
|
24
|
+
/// context reads the same from every engine.
|
|
25
|
+
bool isJsWhitespace(char16_t unit) noexcept;
|
|
26
|
+
|
|
27
|
+
/// Runs of whitespace collapsed to one U+0020, both ends trimmed. PDFium's
|
|
28
|
+
/// text page carries `\r\n` at every line break and a "find" list wants one
|
|
29
|
+
/// line per hit.
|
|
30
|
+
std::u16string collapseWhitespace(const std::u16string& text);
|
|
31
|
+
|
|
32
|
+
/// `TextMatch[]` as ONE JSON string, the shape `parseTextMatches` reads:
|
|
33
|
+
///
|
|
34
|
+
/// [{"page":0,"charIndex":6,"charCount":8,
|
|
35
|
+
/// "rects":[{"x":..,"y":..,"width":..,"height":..}],"context":".."}]
|
|
36
|
+
///
|
|
37
|
+
/// THE OUTPUT IS PURE ASCII: every string character outside printable ASCII —
|
|
38
|
+
/// and `"` and `\` — is written as a `\uXXXX` escape of its UTF-16 unit, which
|
|
39
|
+
/// `JSON.parse` reassembles, surrogate pairs included. That is what lets both
|
|
40
|
+
/// bindings hand it over without a transcoding step: JNI's `NewStringUTF`
|
|
41
|
+
/// takes "modified UTF-8", which is identical to ASCII and to nothing else,
|
|
42
|
+
/// and `+[NSString stringWithUTF8String:]` reads ASCII as itself.
|
|
43
|
+
///
|
|
44
|
+
/// NUMBERS ARE LOCALE-PROOF. `%.17g` round-trips a double exactly, but printf
|
|
45
|
+
/// honours `LC_NUMERIC`, and a host that has called `setlocale` for a
|
|
46
|
+
/// comma-decimal locale would get `72,5` — which is not JSON. The one
|
|
47
|
+
/// separator `%g` can emit is replaced back. A non-finite value (which the
|
|
48
|
+
/// search never produces) is written as 0 rather than as the invalid token
|
|
49
|
+
/// `nan`.
|
|
50
|
+
std::string textMatchesToJson(const std::vector<TextMatch>& matches);
|
|
51
|
+
|
|
52
|
+
} // namespace pdfcanvas
|
|
@@ -67,6 +67,47 @@ struct RasterRequest {
|
|
|
67
67
|
int pixelHeight() const;
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
/// A rect in page-local doc space: PDF points, y-down, origin at the DISPLAYED
|
|
71
|
+
/// page's top-left. `DocRect` in `src/types.ts`.
|
|
72
|
+
struct DocRect {
|
|
73
|
+
double x = 0;
|
|
74
|
+
double y = 0;
|
|
75
|
+
double width = 0;
|
|
76
|
+
double height = 0;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/// One page's search, mirroring `TextSearchRequest` in `src/types.ts`: every
|
|
80
|
+
/// option already resolved by `PdfDocument.searchPage`, so no default here can
|
|
81
|
+
/// drift from the TypeScript one.
|
|
82
|
+
struct TextSearchRequest {
|
|
83
|
+
int page = 0;
|
|
84
|
+
/// UTF-16 code units, exactly as JavaScript holds them. Neither binding
|
|
85
|
+
/// transcodes — JNI's `GetStringChars` and `-[NSString getCharacters:]` both
|
|
86
|
+
/// hand over UTF-16 — and PDFium's `FPDF_WIDESTRING` is UTF-16LE, so the
|
|
87
|
+
/// query reaches `FPDFText_FindStart` with no UTF-8 step anywhere that could
|
|
88
|
+
/// mangle a supplementary character (JNI's "modified UTF-8" would).
|
|
89
|
+
std::u16string query;
|
|
90
|
+
bool matchCase = false;
|
|
91
|
+
bool wholeWord = false;
|
|
92
|
+
/// The HOST's rotation, exactly as `RasterRequest::rotation`: 0/90/180/270,
|
|
93
|
+
/// anything else refused. Rects come back in the TURNED page's doc space —
|
|
94
|
+
/// the space that page's rasters are drawn in.
|
|
95
|
+
int rotation = 0;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/// One hit, mirroring `TextMatch` in `src/types.ts`.
|
|
99
|
+
struct TextMatch {
|
|
100
|
+
int page = 0;
|
|
101
|
+
/// PDFium's character index of the first matched character, and the count.
|
|
102
|
+
int charIndex = 0;
|
|
103
|
+
int charCount = 0;
|
|
104
|
+
/// One per text line the match spans, in the request's doc space.
|
|
105
|
+
std::vector<DocRect> rects;
|
|
106
|
+
/// `kContextChars` of page text either side, whitespace collapsed. UTF-16,
|
|
107
|
+
/// for the same reason the query is.
|
|
108
|
+
std::u16string context;
|
|
109
|
+
};
|
|
110
|
+
|
|
70
111
|
/// Where a document's bytes come from. Exactly one of `path` / `bytes` is used;
|
|
71
112
|
/// a non-empty path wins.
|
|
72
113
|
struct Source {
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
#include "fpdf_formfill.h"
|
|
14
14
|
#include "fpdf_progressive.h"
|
|
15
|
+
#include "fpdf_text.h"
|
|
15
16
|
#include "fpdfview.h"
|
|
16
17
|
#include "pdfcanvas/library.h"
|
|
17
18
|
#include "pdfcanvas/slots.h"
|
|
19
|
+
#include "pdfcanvas/text_search.h"
|
|
18
20
|
|
|
19
21
|
namespace pdfcanvas {
|
|
20
22
|
|
|
@@ -33,6 +35,19 @@ constexpr FPDF_DWORD kTransparent = 0x00000000u;
|
|
|
33
35
|
/// PDFium's ints are 32-bit; a request past this cannot be expressed at all.
|
|
34
36
|
constexpr long long kMaxInt32 = (std::numeric_limits<int>::max)();
|
|
35
37
|
|
|
38
|
+
/// The device box a search rect is mapped through, in units per PDF point.
|
|
39
|
+
///
|
|
40
|
+
/// `FPDF_PageToDevice` is the display matrix the rasters use — `/Rotate`, the
|
|
41
|
+
/// CropBox origin and the host's `rotate` all folded in by PDFium itself, which
|
|
42
|
+
/// is the whole reason to go through it rather than restate the arithmetic —
|
|
43
|
+
/// but its outputs are `int*`: it ROUNDS TO WHOLE DEVICE UNITS. Handed the page
|
|
44
|
+
/// box in points, a rect would come back quantised to whole points, 4 px off
|
|
45
|
+
/// the glyphs at scale 4. So the box is handed over at this many units per
|
|
46
|
+
/// point and the answer divided back down: 1/1024 pt. The matrix is linear in
|
|
47
|
+
/// the box, so this is exactly the scale-1 mapping with more digits. Mirrors
|
|
48
|
+
/// `SEARCH_UNITS_PER_POINT` in `src/rasterizer/web/engine.ts`.
|
|
49
|
+
constexpr double kSearchUnitsPerPoint = 1024;
|
|
50
|
+
|
|
36
51
|
/* ------------------------------------------------------------------ *
|
|
37
52
|
* Errors
|
|
38
53
|
* ------------------------------------------------------------------ */
|
|
@@ -103,12 +118,16 @@ void preflightPath(const std::string& path) {
|
|
|
103
118
|
}
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
void checkCancelled(Cancellation* signal, const char* stage) {
|
|
121
|
+
void checkCancelled(Cancellation* signal, const char* stage, const char* what = "Render") {
|
|
107
122
|
if (signal != nullptr && signal->isCancelled()) {
|
|
108
|
-
throw PdfError(ErrorCode::Cancelled, std::string("
|
|
123
|
+
throw PdfError(ErrorCode::Cancelled, std::string(what) + " cancelled (" + stage + ").");
|
|
109
124
|
}
|
|
110
125
|
}
|
|
111
126
|
|
|
127
|
+
bool isQuarterTurn(int rotation) {
|
|
128
|
+
return rotation == 0 || rotation == 90 || rotation == 180 || rotation == 270;
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
/* ------------------------------------------------------------------ *
|
|
113
132
|
* Geometry
|
|
114
133
|
* ------------------------------------------------------------------ */
|
|
@@ -192,6 +211,10 @@ struct Document::Impl {
|
|
|
192
211
|
struct LoadedPage {
|
|
193
212
|
int index = -1;
|
|
194
213
|
FPDF_PAGE page = nullptr;
|
|
214
|
+
/// The page's TEXT, parsed on its first search and then held for as long
|
|
215
|
+
/// as the page is: a find box re-searches on every keystroke. Null until
|
|
216
|
+
/// then — a page that is only ever rendered never pays for it.
|
|
217
|
+
FPDF_TEXTPAGE text = nullptr;
|
|
195
218
|
uint64_t lastUse = 0;
|
|
196
219
|
};
|
|
197
220
|
std::vector<LoadedPage> cache;
|
|
@@ -230,12 +253,40 @@ struct Document::Impl {
|
|
|
230
253
|
// FORM_OnBeforeClosePage in unloadPage.
|
|
231
254
|
FORM_OnAfterLoadPage(page, form);
|
|
232
255
|
}
|
|
233
|
-
|
|
256
|
+
LoadedPage entry;
|
|
257
|
+
entry.index = index;
|
|
258
|
+
entry.page = page;
|
|
259
|
+
entry.lastUse = ++useCounter;
|
|
260
|
+
cache.push_back(entry);
|
|
234
261
|
return page;
|
|
235
262
|
}
|
|
236
263
|
|
|
264
|
+
/// Requires the library lock. The text page of `index`, loading the page
|
|
265
|
+
/// (and making it the most recently used) first.
|
|
266
|
+
FPDF_TEXTPAGE loadTextPage(int index) {
|
|
267
|
+
loadPage(index);
|
|
268
|
+
for (auto& entry : cache) {
|
|
269
|
+
if (entry.index != index) continue;
|
|
270
|
+
if (entry.text == nullptr) {
|
|
271
|
+
entry.text = FPDFText_LoadPage(entry.page);
|
|
272
|
+
if (entry.text == nullptr) {
|
|
273
|
+
throw PdfError(ErrorCode::Corrupt,
|
|
274
|
+
"PDFium could not read the text of page " + std::to_string(index) + ".");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return entry.text;
|
|
278
|
+
}
|
|
279
|
+
// Unreachable: loadPage either put the entry there or threw.
|
|
280
|
+
throw PdfError(ErrorCode::BackendFailure, "Page " + std::to_string(index) + " left the cache.");
|
|
281
|
+
}
|
|
282
|
+
|
|
237
283
|
void unloadPage(LoadedPage& entry) {
|
|
238
284
|
if (entry.page == nullptr) return;
|
|
285
|
+
// The text page FIRST: PDFium requires it closed before its page.
|
|
286
|
+
if (entry.text != nullptr) {
|
|
287
|
+
FPDFText_ClosePage(entry.text);
|
|
288
|
+
entry.text = nullptr;
|
|
289
|
+
}
|
|
239
290
|
if (form != nullptr) FORM_OnBeforeClosePage(entry.page, form);
|
|
240
291
|
FPDF_ClosePage(entry.page);
|
|
241
292
|
entry.page = nullptr;
|
|
@@ -365,8 +416,7 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
|
|
|
365
416
|
" at (" + std::to_string(request.x) + ", " + std::to_string(request.y) + ").");
|
|
366
417
|
}
|
|
367
418
|
|
|
368
|
-
if (request.rotation
|
|
369
|
-
request.rotation != 270) {
|
|
419
|
+
if (!isQuarterTurn(request.rotation)) {
|
|
370
420
|
// Refused rather than folded: `45 / 90` is 0 in integer arithmetic, and a
|
|
371
421
|
// request that asked for a turn must not quietly come back upright.
|
|
372
422
|
throw PdfError(ErrorCode::BackendFailure,
|
|
@@ -533,4 +583,137 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
|
|
|
533
583
|
return out;
|
|
534
584
|
}
|
|
535
585
|
|
|
586
|
+
/* ------------------------------------------------------------------ *
|
|
587
|
+
* Text search
|
|
588
|
+
* ------------------------------------------------------------------ */
|
|
589
|
+
|
|
590
|
+
namespace {
|
|
591
|
+
|
|
592
|
+
/// `kContextChars` either side of `[charIndex, charIndex + charCount)`, out of
|
|
593
|
+
/// the text page, collapsed. The window is clamped to the page's text, so a
|
|
594
|
+
/// match near an edge carries less on that side rather than padding. Exactly
|
|
595
|
+
/// `readContext` in `src/rasterizer/web/engine.ts`.
|
|
596
|
+
std::u16string readContext(FPDF_TEXTPAGE text, int totalChars, int charIndex, int charCount) {
|
|
597
|
+
const int start = (std::max)(0, charIndex - kContextChars);
|
|
598
|
+
const int end = (std::min)(totalChars, charIndex + charCount + kContextChars);
|
|
599
|
+
const int count = end - start;
|
|
600
|
+
if (count <= 0) return {};
|
|
601
|
+
// `count + 1` units: PDFium writes the terminator, and its return value
|
|
602
|
+
// counts it.
|
|
603
|
+
std::vector<unsigned short> buffer(static_cast<size_t>(count) + 1, 0);
|
|
604
|
+
const int written = FPDFText_GetText(text, start, count, buffer.data());
|
|
605
|
+
std::u16string raw;
|
|
606
|
+
for (int i = 0; i + 1 < written; i++) raw += static_cast<char16_t>(buffer[static_cast<size_t>(i)]);
|
|
607
|
+
return collapseWhitespace(raw);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
} // namespace
|
|
611
|
+
|
|
612
|
+
std::vector<TextMatch> Document::searchText(const TextSearchRequest& request, Cancellation* signal) {
|
|
613
|
+
checkCancelled(signal, "before start", "Search");
|
|
614
|
+
if (impl_->closed) {
|
|
615
|
+
throw PdfError(ErrorCode::BackendFailure, "Document handle is closed.");
|
|
616
|
+
}
|
|
617
|
+
const PageGeometry& geometry = this->geometry(request.page);
|
|
618
|
+
if (!isQuarterTurn(request.rotation)) {
|
|
619
|
+
// Refused, as a render refuses it: rects for a turn nobody draws would be
|
|
620
|
+
// rects in no space at all.
|
|
621
|
+
throw PdfError(ErrorCode::BackendFailure,
|
|
622
|
+
"Rotation must be 0, 90, 180 or 270, got " + std::to_string(request.rotation) + ".");
|
|
623
|
+
}
|
|
624
|
+
if (request.query.empty()) return {};
|
|
625
|
+
|
|
626
|
+
// THE DISPLAYED PAGE'S BOX, as the renderer hands it to PDFium: the
|
|
627
|
+
// post-/Rotate extents, swapped for a host quarter turn — then scaled up,
|
|
628
|
+
// because `FPDF_PageToDevice` rounds to whole units.
|
|
629
|
+
const bool turned = request.rotation == 90 || request.rotation == 270;
|
|
630
|
+
const double displayedWidth = turned ? geometry.height : geometry.width;
|
|
631
|
+
const double displayedHeight = turned ? geometry.width : geometry.height;
|
|
632
|
+
const long long sizeX = std::llround(displayedWidth * kSearchUnitsPerPoint);
|
|
633
|
+
const long long sizeY = std::llround(displayedHeight * kSearchUnitsPerPoint);
|
|
634
|
+
if (sizeX <= 0 || sizeY <= 0 || sizeX > kMaxInt32 || sizeY > kMaxInt32) {
|
|
635
|
+
throw PdfError(ErrorCode::BackendFailure,
|
|
636
|
+
"Page " + std::to_string(request.page) + " is too large to map search rects on.");
|
|
637
|
+
}
|
|
638
|
+
// Per axis, from the ROUNDED box, so the division undoes exactly the scale
|
|
639
|
+
// PDFium applied rather than the one that was asked for.
|
|
640
|
+
const double pointsPerUnitX = displayedWidth / static_cast<double>(sizeX);
|
|
641
|
+
const double pointsPerUnitY = displayedHeight / static_cast<double>(sizeY);
|
|
642
|
+
const int rotate = request.rotation / 90;
|
|
643
|
+
const unsigned long flags = (request.matchCase ? FPDF_MATCHCASE : 0u) |
|
|
644
|
+
(request.wholeWord ? FPDF_MATCHWHOLEWORD : 0u);
|
|
645
|
+
|
|
646
|
+
std::lock_guard<std::mutex> lock(Library::mutex());
|
|
647
|
+
checkCancelled(signal, "after lock", "Search");
|
|
648
|
+
if (impl_->closed) {
|
|
649
|
+
throw PdfError(ErrorCode::BackendFailure, "Document handle is closed.");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// The text page first: it loads (or re-uses) the page it belongs to, and a
|
|
653
|
+
// page loaded afterwards could evict it.
|
|
654
|
+
FPDF_TEXTPAGE text = impl_->loadTextPage(request.page);
|
|
655
|
+
FPDF_PAGE page = impl_->loadPage(request.page);
|
|
656
|
+
const int totalChars = FPDFText_CountChars(text);
|
|
657
|
+
|
|
658
|
+
// `c_str()` is terminated, which is what an FPDF_WIDESTRING must be.
|
|
659
|
+
// `char16_t` and `unsigned short` are the same 16 bits on every target.
|
|
660
|
+
static_assert(sizeof(char16_t) == sizeof(FPDF_WCHAR), "UTF-16 unit size");
|
|
661
|
+
FPDF_SCHHANDLE find =
|
|
662
|
+
FPDFText_FindStart(text, reinterpret_cast<FPDF_WIDESTRING>(request.query.c_str()), flags, 0);
|
|
663
|
+
if (find == nullptr) return {};
|
|
664
|
+
struct CloseFind {
|
|
665
|
+
FPDF_SCHHANDLE handle;
|
|
666
|
+
~CloseFind() { FPDFText_FindClose(handle); }
|
|
667
|
+
} closeFind{find};
|
|
668
|
+
|
|
669
|
+
const auto toDevice = [&](double pageX, double pageY, int& x, int& y) {
|
|
670
|
+
if (!FPDF_PageToDevice(page, 0, 0, static_cast<int>(sizeX), static_cast<int>(sizeY), rotate,
|
|
671
|
+
pageX, pageY, &x, &y)) {
|
|
672
|
+
throw PdfError(ErrorCode::BackendFailure,
|
|
673
|
+
"PDFium could not map a search rect on page " + std::to_string(request.page) +
|
|
674
|
+
" to device space.");
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
std::vector<TextMatch> matches;
|
|
679
|
+
while (FPDFText_FindNext(find)) {
|
|
680
|
+
// Between matches, through `poll()` so the test affordance can land a
|
|
681
|
+
// cancel INSIDE a search. A superseded "find" on a dense drawing stops
|
|
682
|
+
// here instead of collecting hundreds of hits nobody will see.
|
|
683
|
+
if (signal != nullptr && signal->poll()) {
|
|
684
|
+
throw PdfError(ErrorCode::Cancelled, "Search cancelled (between matches).");
|
|
685
|
+
}
|
|
686
|
+
const int charIndex = FPDFText_GetSchResultIndex(find);
|
|
687
|
+
const int charCount = FPDFText_GetSchCount(find);
|
|
688
|
+
// Cannot happen for a non-empty query; the guard is against looping
|
|
689
|
+
// forever if PDFium ever reported one.
|
|
690
|
+
if (charCount <= 0) break;
|
|
691
|
+
|
|
692
|
+
TextMatch match;
|
|
693
|
+
match.page = request.page;
|
|
694
|
+
match.charIndex = charIndex;
|
|
695
|
+
match.charCount = charCount;
|
|
696
|
+
const int rectCount = FPDFText_CountRects(text, charIndex, charCount);
|
|
697
|
+
for (int i = 0; i < rectCount; i++) {
|
|
698
|
+
double left = 0, top = 0, right = 0, bottom = 0;
|
|
699
|
+
if (!FPDFText_GetRect(text, i, &left, &top, &right, &bottom)) continue;
|
|
700
|
+
// User space, y up (top > bottom). Both corners through the display
|
|
701
|
+
// matrix; a quarter turn can swap which one is the top-left, so the
|
|
702
|
+
// rect is min/max of the two.
|
|
703
|
+
int x1 = 0, y1 = 0, x2 = 0, y2 = 0;
|
|
704
|
+
toDevice(left, top, x1, y1);
|
|
705
|
+
toDevice(right, bottom, x2, y2);
|
|
706
|
+
DocRect rect;
|
|
707
|
+
rect.x = (std::min)(x1, x2) * pointsPerUnitX;
|
|
708
|
+
rect.y = (std::min)(y1, y2) * pointsPerUnitY;
|
|
709
|
+
rect.width = std::abs(x2 - x1) * pointsPerUnitX;
|
|
710
|
+
rect.height = std::abs(y2 - y1) * pointsPerUnitY;
|
|
711
|
+
match.rects.push_back(rect);
|
|
712
|
+
}
|
|
713
|
+
match.context = readContext(text, totalChars, charIndex, charCount);
|
|
714
|
+
matches.push_back(std::move(match));
|
|
715
|
+
}
|
|
716
|
+
return matches;
|
|
717
|
+
}
|
|
718
|
+
|
|
536
719
|
} // namespace pdfcanvas
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
#include <string>
|
|
4
4
|
|
|
5
5
|
#include "pdfcanvas/slots.h"
|
|
6
|
+
#include "pdfcanvas/text_search.h"
|
|
6
7
|
|
|
7
8
|
namespace pdfcanvas {
|
|
8
9
|
|
|
@@ -46,52 +47,66 @@ void Service::registerCancellation(int handle, int64_t token) {
|
|
|
46
47
|
signals_[signalKey(handle, token)] = std::make_shared<Cancellation>();
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
/// Looks up (or plants) the job's signal and its document under the service
|
|
51
|
+
/// mutex, and unregisters the signal on EVERY path out, including a throw —
|
|
52
|
+
/// otherwise a superseded epoch, which produces a stream of cancelled jobs,
|
|
53
|
+
/// grows the map for the life of the module.
|
|
54
|
+
struct Service::Lease {
|
|
55
|
+
Service& service;
|
|
56
|
+
int64_t key;
|
|
52
57
|
std::shared_ptr<Cancellation> signal;
|
|
53
58
|
std::shared_ptr<Document> document;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
|
|
60
|
+
Lease(Service& owner, int handle, int64_t token)
|
|
61
|
+
: service(owner), key(signalKey(handle, token)) {
|
|
62
|
+
std::lock_guard<std::mutex> lock(service.mutex_);
|
|
63
|
+
auto found = service.signals_.find(key);
|
|
64
|
+
if (found != service.signals_.end()) {
|
|
58
65
|
signal = found->second;
|
|
59
66
|
} else {
|
|
60
67
|
// Not registered ahead of time: a caller that dispatched without the
|
|
61
68
|
// register step. It still gets a signal, so a cancel that arrives while
|
|
62
|
-
// this
|
|
69
|
+
// this job runs is honoured.
|
|
63
70
|
signal = std::make_shared<Cancellation>();
|
|
64
|
-
signals_.emplace(key, signal);
|
|
71
|
+
service.signals_.emplace(key, signal);
|
|
65
72
|
}
|
|
66
|
-
auto doc = documents_.find(handle);
|
|
67
|
-
document = doc == documents_.end() ? nullptr : doc->second;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// life of the module.
|
|
73
|
-
struct Unregister {
|
|
74
|
-
Service* service;
|
|
75
|
-
int64_t key;
|
|
76
|
-
~Unregister() {
|
|
77
|
-
std::lock_guard<std::mutex> lock(service->mutex_);
|
|
78
|
-
service->signals_.erase(key);
|
|
73
|
+
auto doc = service.documents_.find(handle);
|
|
74
|
+
document = doc == service.documents_.end() ? nullptr : doc->second;
|
|
75
|
+
if (document == nullptr) {
|
|
76
|
+
service.signals_.erase(key);
|
|
77
|
+
throw PdfError(ErrorCode::BackendFailure,
|
|
78
|
+
"No open document for handle " + std::to_string(handle) + ".");
|
|
79
79
|
}
|
|
80
|
-
}
|
|
80
|
+
}
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
~Lease() {
|
|
83
|
+
std::lock_guard<std::mutex> lock(service.mutex_);
|
|
84
|
+
service.signals_.erase(key);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
Lease(const Lease&) = delete;
|
|
88
|
+
Lease& operator=(const Lease&) = delete;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
RasterPixels Service::render(int handle, const RasterRequest& request, int64_t token,
|
|
92
|
+
bool useSlot) {
|
|
93
|
+
Lease lease(*this, handle, token);
|
|
94
|
+
|
|
87
95
|
// Read ONCE per render: whether to reach for a slot. The sink may still
|
|
88
96
|
// refuse, in which case the raster comes back heap-backed and the caller
|
|
89
97
|
// branches on where the pixels ARE, not on what was asked for.
|
|
90
98
|
if (useSlot) {
|
|
91
99
|
SlotSink sink;
|
|
92
|
-
return document->render(request, signal.get(), &sink);
|
|
100
|
+
return lease.document->render(request, lease.signal.get(), &sink);
|
|
93
101
|
}
|
|
94
|
-
return document->render(request, signal.get(), nullptr);
|
|
102
|
+
return lease.document->render(request, lease.signal.get(), nullptr);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
std::string Service::searchJson(int handle, const TextSearchRequest& request, int64_t token) {
|
|
106
|
+
Lease lease(*this, handle, token);
|
|
107
|
+
// Serialised HERE rather than in either binding, so the two cannot
|
|
108
|
+
// disagree about the wire form and the JSON is proven on a laptop.
|
|
109
|
+
return textMatchesToJson(lease.document->searchText(request, lease.signal.get()));
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
void Service::cancel(int handle, int64_t token) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#include "pdfcanvas/text_search.h"
|
|
2
|
+
|
|
3
|
+
#include <cmath>
|
|
4
|
+
#include <cstdio>
|
|
5
|
+
#include <string>
|
|
6
|
+
|
|
7
|
+
namespace pdfcanvas {
|
|
8
|
+
|
|
9
|
+
namespace {
|
|
10
|
+
|
|
11
|
+
void appendNumber(std::string& out, double value) {
|
|
12
|
+
if (!std::isfinite(value)) {
|
|
13
|
+
out += '0';
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
char buffer[40];
|
|
17
|
+
const int written = std::snprintf(buffer, sizeof(buffer), "%.17g", value);
|
|
18
|
+
if (written <= 0) {
|
|
19
|
+
out += '0';
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (int i = 0; i < written && buffer[i] != '\0'; i++) {
|
|
23
|
+
// `%g` emits digits, a sign, an exponent and ONE locale decimal separator.
|
|
24
|
+
// Anything that is not one of the first three is that separator.
|
|
25
|
+
const char c = buffer[i];
|
|
26
|
+
const bool keep = (c >= '0' && c <= '9') || c == '-' || c == '+' || c == 'e' || c == 'E';
|
|
27
|
+
out += keep ? c : '.';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
void appendString(std::string& out, const std::u16string& text) {
|
|
32
|
+
static const char kHex[] = "0123456789abcdef";
|
|
33
|
+
out += '"';
|
|
34
|
+
for (const char16_t unit : text) {
|
|
35
|
+
if (unit >= 0x20 && unit < 0x7F && unit != u'"' && unit != u'\\') {
|
|
36
|
+
out += static_cast<char>(unit);
|
|
37
|
+
} else {
|
|
38
|
+
out += "\\u";
|
|
39
|
+
out += kHex[(unit >> 12) & 0xF];
|
|
40
|
+
out += kHex[(unit >> 8) & 0xF];
|
|
41
|
+
out += kHex[(unit >> 4) & 0xF];
|
|
42
|
+
out += kHex[unit & 0xF];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
out += '"';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
} // namespace
|
|
49
|
+
|
|
50
|
+
bool isJsWhitespace(char16_t unit) noexcept {
|
|
51
|
+
// ECMAScript WhiteSpace + LineTerminator: TAB VT FF SP NBSP ZWNBSP, the
|
|
52
|
+
// Unicode Space_Separator category, LF CR LS PS.
|
|
53
|
+
switch (unit) {
|
|
54
|
+
case 0x0009: case 0x000A: case 0x000B: case 0x000C: case 0x000D:
|
|
55
|
+
case 0x0020: case 0x00A0: case 0x1680:
|
|
56
|
+
case 0x2028: case 0x2029: case 0x202F: case 0x205F: case 0x3000:
|
|
57
|
+
case 0xFEFF:
|
|
58
|
+
return true;
|
|
59
|
+
default:
|
|
60
|
+
return unit >= 0x2000 && unit <= 0x200A;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
std::u16string collapseWhitespace(const std::u16string& text) {
|
|
65
|
+
std::u16string out;
|
|
66
|
+
out.reserve(text.size());
|
|
67
|
+
bool pendingSpace = false;
|
|
68
|
+
for (const char16_t unit : text) {
|
|
69
|
+
if (isJsWhitespace(unit)) {
|
|
70
|
+
pendingSpace = !out.empty(); // leading whitespace is dropped
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (pendingSpace) out += u' ';
|
|
74
|
+
pendingSpace = false;
|
|
75
|
+
out += unit;
|
|
76
|
+
}
|
|
77
|
+
return out; // trailing whitespace never emitted its space
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
std::string textMatchesToJson(const std::vector<TextMatch>& matches) {
|
|
81
|
+
std::string out;
|
|
82
|
+
out.reserve(64 + matches.size() * 200);
|
|
83
|
+
out += '[';
|
|
84
|
+
for (size_t i = 0; i < matches.size(); i++) {
|
|
85
|
+
const TextMatch& match = matches[i];
|
|
86
|
+
if (i > 0) out += ',';
|
|
87
|
+
out += "{\"page\":";
|
|
88
|
+
out += std::to_string(match.page);
|
|
89
|
+
out += ",\"charIndex\":";
|
|
90
|
+
out += std::to_string(match.charIndex);
|
|
91
|
+
out += ",\"charCount\":";
|
|
92
|
+
out += std::to_string(match.charCount);
|
|
93
|
+
out += ",\"rects\":[";
|
|
94
|
+
for (size_t j = 0; j < match.rects.size(); j++) {
|
|
95
|
+
const DocRect& rect = match.rects[j];
|
|
96
|
+
if (j > 0) out += ',';
|
|
97
|
+
out += "{\"x\":";
|
|
98
|
+
appendNumber(out, rect.x);
|
|
99
|
+
out += ",\"y\":";
|
|
100
|
+
appendNumber(out, rect.y);
|
|
101
|
+
out += ",\"width\":";
|
|
102
|
+
appendNumber(out, rect.width);
|
|
103
|
+
out += ",\"height\":";
|
|
104
|
+
appendNumber(out, rect.height);
|
|
105
|
+
out += '}';
|
|
106
|
+
}
|
|
107
|
+
out += "],\"context\":";
|
|
108
|
+
appendString(out, match.context);
|
|
109
|
+
out += '}';
|
|
110
|
+
}
|
|
111
|
+
out += ']';
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
} // namespace pdfcanvas
|
|
@@ -204,6 +204,33 @@ Bytes formFieldPage() {
|
|
|
204
204
|
return w.finish(catalog);
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
namespace {
|
|
208
|
+
|
|
209
|
+
Bytes helveticaPage(const std::string& content, int rotate) {
|
|
210
|
+
Writer w;
|
|
211
|
+
const int contents = w.add(streamObject(content + "\n"));
|
|
212
|
+
// Indirect, as the spec prefers for a font resource; written before the page
|
|
213
|
+
// so /Resources can name the number it actually got.
|
|
214
|
+
const int font = w.add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
|
|
215
|
+
const int pagesNumber = w.count() + 2;
|
|
216
|
+
std::string dict = "<< /Type /Page /Parent " + std::to_string(pagesNumber) + " 0 R /MediaBox [0 0 " +
|
|
217
|
+
std::to_string(kLetterW) + " " + std::to_string(kLetterH) + "]";
|
|
218
|
+
if (rotate != 0) dict += " /Rotate " + std::to_string(rotate);
|
|
219
|
+
dict += " /Contents " + std::to_string(contents) + " 0 R /Resources << /Font << /F1 " +
|
|
220
|
+
std::to_string(font) + " 0 R >> >> >>";
|
|
221
|
+
const int pageObj = w.add(dict);
|
|
222
|
+
const int pages = w.add("<< /Type /Pages /Kids [" + std::to_string(pageObj) + " 0 R] /Count 1 >>");
|
|
223
|
+
const int catalog = w.add("<< /Type /Catalog /Pages " + std::to_string(pages) + " 0 R >>");
|
|
224
|
+
if (pages != pagesNumber) throw std::logic_error("pages object number drifted");
|
|
225
|
+
return w.finish(catalog);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
} // namespace
|
|
229
|
+
|
|
230
|
+
Bytes textPage(int rotate) { return helveticaPage(kTextContent, rotate); }
|
|
231
|
+
|
|
232
|
+
Bytes twoLineTextPage() { return helveticaPage(kTwoLineTextContent, 0); }
|
|
233
|
+
|
|
207
234
|
/* ------------------------------------------------------------------ *
|
|
208
235
|
* Multi-page fixtures
|
|
209
236
|
* ------------------------------------------------------------------ */
|
package/native/tests/fixtures.h
CHANGED
|
@@ -61,6 +61,21 @@ Bytes annotatedPage();
|
|
|
61
61
|
/// through the document's AcroForm. Content: white page with a red marker.
|
|
62
62
|
Bytes formFieldPage();
|
|
63
63
|
|
|
64
|
+
/// ONE LINE OF REAL TEXT: "Hello measured world", 24pt Helvetica, baseline at
|
|
65
|
+
/// user-space (72, 700) on a 612x792 page carrying `/Rotate rotate`. The font is
|
|
66
|
+
/// NOT embedded — Helvetica is one of the standard 14, which PDFium substitutes
|
|
67
|
+
/// from its built-in fonts, so it draws and extracts with no font file. The same
|
|
68
|
+
/// bytes as `textPdf` in `src/__tests__/support/pdfium.ts`, so both engines are
|
|
69
|
+
/// asked the same question.
|
|
70
|
+
constexpr const char* kTextContent = "BT /F1 24 Tf 72 700 Td (Hello measured world) Tj ET";
|
|
71
|
+
Bytes textPage(int rotate = 0);
|
|
72
|
+
/// The same line plus "on a second line" 30pt below it, so PDFium's text page
|
|
73
|
+
/// carries a generated line break a match's context must collapse. The same
|
|
74
|
+
/// bytes as `twoLineTextPdf` in `src/__tests__/support/pdfium.ts`.
|
|
75
|
+
constexpr const char* kTwoLineTextContent =
|
|
76
|
+
"BT /F1 24 Tf 72 700 Td (Hello measured world) Tj 0 -30 Td (on a second line) Tj ET";
|
|
77
|
+
Bytes twoLineTextPage();
|
|
78
|
+
|
|
64
79
|
/// A single page with explicit boxes, rotation and content.
|
|
65
80
|
Bytes page(Box mediaBox, const Box* cropBox, int rotate, const std::string& content);
|
|
66
81
|
|