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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +61 -6
  2. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +64 -10
  3. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +22 -3
  4. package/android/src/main/cpp/pdfcanvas-jni.cpp +47 -3
  5. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +31 -1
  6. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +79 -9
  7. package/android/tools/compile-gate.sh +6 -4
  8. package/dist/controller.d.ts +23 -1
  9. package/dist/controller.js +33 -16
  10. package/dist/rasterizer/fake.d.ts +38 -4
  11. package/dist/rasterizer/fake.js +167 -37
  12. package/dist/rasterizer/native-bridge.d.ts +48 -1
  13. package/dist/rasterizer/native-bridge.js +96 -0
  14. package/dist/rasterizer/native.d.ts +2 -2
  15. package/dist/rasterizer/native.js +9 -7
  16. package/dist/rasterizer/text-search.d.ts +22 -0
  17. package/dist/rasterizer/text-search.js +28 -0
  18. package/dist/rasterizer/web/client.js +69 -49
  19. package/dist/rasterizer/web/engine.d.ts +3 -1
  20. package/dist/rasterizer/web/engine.js +225 -8
  21. package/dist/rasterizer/web/pdfium.d.ts +64 -2
  22. package/dist/rasterizer/web/pdfium.js +21 -0
  23. package/dist/rasterizer/web/protocol.d.ts +23 -6
  24. package/dist/rasterizer/web/protocol.js +4 -1
  25. package/dist/rasterizer/web/session.js +30 -13
  26. package/dist/react/PdfContentView.js +5 -0
  27. package/dist/react/usePdfDocument.d.ts +27 -2
  28. package/dist/react/usePdfDocument.js +65 -12
  29. package/dist/react/usePdfLayer.d.ts +19 -2
  30. package/dist/react/usePdfLayer.js +51 -8
  31. package/dist/rotation.d.ts +59 -0
  32. package/dist/rotation.js +85 -0
  33. package/dist/testing/index.d.ts +1 -1
  34. package/dist/testing/index.js +1 -1
  35. package/dist/types.d.ts +86 -0
  36. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +71 -2
  37. package/native/CMakeLists.txt +7 -0
  38. package/native/core/include/pdfcanvas/document.h +17 -0
  39. package/native/core/include/pdfcanvas/service.h +14 -1
  40. package/native/core/include/pdfcanvas/text_search.h +52 -0
  41. package/native/core/include/pdfcanvas/types.h +49 -0
  42. package/native/core/pdfcanvas-core.cmake +1 -0
  43. package/native/core/src/document.cpp +211 -8
  44. package/native/core/src/service.cpp +43 -28
  45. package/native/core/src/text_search.cpp +115 -0
  46. package/native/tests/fixtures.cpp +27 -0
  47. package/native/tests/fixtures.h +15 -0
  48. package/native/tests/test_document.cpp +486 -0
  49. package/native/tests/test_service.cpp +110 -0
  50. package/package.json +1 -1
@@ -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,6 +416,13 @@ 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
 
419
+ if (!isQuarterTurn(request.rotation)) {
420
+ // Refused rather than folded: `45 / 90` is 0 in integer arithmetic, and a
421
+ // request that asked for a turn must not quietly come back upright.
422
+ throw PdfError(ErrorCode::BackendFailure,
423
+ "Rotation must be 0, 90, 180 or 270, got " + std::to_string(request.rotation) + ".");
424
+ }
425
+
368
426
  const int pxW = request.pixelWidth();
369
427
  const int pxH = request.pixelHeight();
370
428
  const long long pixels = static_cast<long long>(pxW) * pxH;
@@ -377,8 +435,16 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
377
435
  // byte-identical to the matrix form on every rect it tried.
378
436
  const long long startX = -std::llround(request.x * request.scale);
379
437
  const long long startY = -std::llround(request.y * request.scale);
380
- const long long pageW = std::llround(geometry.width * request.scale);
381
- const long long pageH = std::llround(geometry.height * request.scale);
438
+ // THE PAGE BOX HANDED TO PDFIUM IS THE DISPLAYED ONE. `size_x` / `size_y`
439
+ // describe the box the page is drawn INTO after `rotate` is applied —
440
+ // `CPDF_Page::GetDisplayMatrix` maps the page's HEIGHT onto `size_x` for
441
+ // rotate 1 and 3 — so a quarter turn swaps the intrinsic extents here. The
442
+ // request's rect is already in that space (see `RasterRequest::rotation`), so
443
+ // the start offsets above need nothing: they slide the whole turned page
444
+ // exactly as they slide an upright one.
445
+ const bool turned = request.rotation == 90 || request.rotation == 270;
446
+ const long long pageW = std::llround((turned ? geometry.height : geometry.width) * request.scale);
447
+ const long long pageH = std::llround((turned ? geometry.width : geometry.height) * request.scale);
382
448
  if (pixels > kMaxRasterPixels) {
383
449
  throw PdfError(ErrorCode::OutOfMemory,
384
450
  "Refused a " + std::to_string(pxW) + "x" + std::to_string(pxH) + " raster (" +
@@ -451,6 +517,11 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
451
517
  const int sy = static_cast<int>(startY);
452
518
  const int pw = static_cast<int>(pageW);
453
519
  const int ph = static_cast<int>(pageH);
520
+ // The HOST's rotation in quarter turns clockwise — never the page's own
521
+ // `/Rotate`, which PDFium has already folded into `geometry` and into the
522
+ // render. Passed to BOTH passes below, so a form field lands where the turned
523
+ // content says and not where the upright page had it.
524
+ const int rotate = request.rotation / 90;
454
525
 
455
526
  auto abandonWith = [&](PdfError error) -> PdfError {
456
527
  FPDFBitmap_Destroy(bitmap);
@@ -466,8 +537,7 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
466
537
  // TOBECONTINUED when the pause adapter asked to stop, which it does only
467
538
  // once the signal is set; a live render runs to DONE in one call.
468
539
  PauseAdapter pause(signal);
469
- int status = FPDF_RenderPageBitmap_Start(bitmap, page, sx, sy, pw, ph, /*rotate=*/0, flags,
470
- &pause.pause);
540
+ int status = FPDF_RenderPageBitmap_Start(bitmap, page, sx, sy, pw, ph, rotate, flags, &pause.pause);
471
541
  while (status == FPDF_RENDER_TOBECONTINUED) {
472
542
  if (signal != nullptr && signal->isCancelled()) {
473
543
  FPDF_RenderPage_Close(page);
@@ -487,7 +557,7 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
487
557
  // state — with the same offsets, so they land exactly where the page put
488
558
  // them.
489
559
  if (request.annotations && impl_->form != nullptr) {
490
- FPDF_FFLDraw(impl_->form, bitmap, page, sx, sy, pw, ph, /*rotate=*/0, flags);
560
+ FPDF_FFLDraw(impl_->form, bitmap, page, sx, sy, pw, ph, rotate, flags);
491
561
  }
492
562
 
493
563
  FPDFBitmap_Destroy(bitmap);
@@ -513,4 +583,137 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
513
583
  return out;
514
584
  }
515
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
+
516
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