@reekon-tools/react-native-pdf-canvas 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +44 -6
  2. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +30 -0
  3. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +22 -3
  4. package/android/src/main/cpp/pdfcanvas-jni.cpp +43 -1
  5. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +26 -1
  6. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +69 -6
  7. package/android/tools/compile-gate.sh +6 -4
  8. package/dist/controller.js +5 -0
  9. package/dist/rasterizer/fake.d.ts +24 -2
  10. package/dist/rasterizer/fake.js +74 -1
  11. package/dist/rasterizer/native-bridge.d.ts +37 -1
  12. package/dist/rasterizer/native-bridge.js +95 -0
  13. package/dist/rasterizer/native.d.ts +2 -2
  14. package/dist/rasterizer/native.js +9 -7
  15. package/dist/rasterizer/text-search.d.ts +22 -0
  16. package/dist/rasterizer/text-search.js +28 -0
  17. package/dist/rasterizer/web/client.js +69 -49
  18. package/dist/rasterizer/web/engine.d.ts +3 -1
  19. package/dist/rasterizer/web/engine.js +199 -1
  20. package/dist/rasterizer/web/pdfium.d.ts +64 -2
  21. package/dist/rasterizer/web/pdfium.js +21 -0
  22. package/dist/rasterizer/web/protocol.d.ts +23 -6
  23. package/dist/rasterizer/web/protocol.js +4 -1
  24. package/dist/rasterizer/web/session.js +30 -13
  25. package/dist/react/PdfContentView.js +5 -0
  26. package/dist/react/usePdfDocument.d.ts +17 -2
  27. package/dist/react/usePdfDocument.js +44 -0
  28. package/dist/testing/index.d.ts +1 -1
  29. package/dist/testing/index.js +1 -1
  30. package/dist/types.d.ts +66 -0
  31. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +66 -2
  32. package/native/core/include/pdfcanvas/document.h +17 -0
  33. package/native/core/include/pdfcanvas/service.h +14 -1
  34. package/native/core/include/pdfcanvas/text_search.h +52 -0
  35. package/native/core/include/pdfcanvas/types.h +41 -0
  36. package/native/core/pdfcanvas-core.cmake +1 -0
  37. package/native/core/src/document.cpp +188 -5
  38. package/native/core/src/service.cpp +43 -28
  39. package/native/core/src/text_search.cpp +115 -0
  40. package/native/tests/fixtures.cpp +27 -0
  41. package/native/tests/fixtures.h +15 -0
  42. package/native/tests/test_document.cpp +308 -0
  43. package/native/tests/test_service.cpp +110 -0
  44. package/package.json +1 -1
@@ -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 {
@@ -19,4 +19,5 @@ set(PDFCANVAS_CORE_SOURCES
19
19
  "${PDFCANVAS_CORE_DIR}/src/pixels.cpp"
20
20
  "${PDFCANVAS_CORE_DIR}/src/service.cpp"
21
21
  "${PDFCANVAS_CORE_DIR}/src/slots.cpp"
22
+ "${PDFCANVAS_CORE_DIR}/src/text_search.cpp"
22
23
  )
@@ -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("Render cancelled (") + stage + ").");
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
- cache.push_back({index, page, ++useCounter});
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 != 0 && request.rotation != 90 && request.rotation != 180 &&
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
- RasterPixels Service::render(int handle, const RasterRequest& request, int64_t token,
50
- bool useSlot) {
51
- const int64_t key = signalKey(handle, token);
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
- std::lock_guard<std::mutex> lock(mutex_);
56
- auto found = signals_.find(key);
57
- if (found != signals_.end()) {
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 render runs is honoured.
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
- // Unregistered on EVERY path out, including a throw — otherwise a superseded
71
- // epoch, which produces a stream of cancelled renders, grows the map for the
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
- } unregister{this, key};
80
+ }
81
81
 
82
- if (document == nullptr) {
83
- throw PdfError(ErrorCode::BackendFailure,
84
- "No open document for handle " + std::to_string(handle) + ".");
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
  * ------------------------------------------------------------------ */
@@ -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