@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
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// stride shear, a y-flip, a page ignored, an annotation drawn when it was not
|
|
6
6
|
// asked for, a transparent page composited as black.
|
|
7
7
|
|
|
8
|
+
#include <algorithm>
|
|
9
|
+
#include <chrono>
|
|
10
|
+
#include <cmath>
|
|
8
11
|
#include <string>
|
|
9
12
|
#include <thread>
|
|
10
13
|
|
|
@@ -661,3 +664,308 @@ TEST(closeWhileRenderingIsSafe) {
|
|
|
661
664
|
CHECK(!failure);
|
|
662
665
|
CHECK(document->isClosed());
|
|
663
666
|
}
|
|
667
|
+
|
|
668
|
+
/* ------------------------------------------------------------------ *
|
|
669
|
+
* Text search
|
|
670
|
+
* ------------------------------------------------------------------ */
|
|
671
|
+
|
|
672
|
+
namespace {
|
|
673
|
+
|
|
674
|
+
TextSearchRequest searchFor(const std::u16string& query, int rotation = 0, bool matchCase = false,
|
|
675
|
+
bool wholeWord = false) {
|
|
676
|
+
TextSearchRequest request;
|
|
677
|
+
request.page = 0;
|
|
678
|
+
request.query = query;
|
|
679
|
+
request.rotation = rotation;
|
|
680
|
+
request.matchCase = matchCase;
|
|
681
|
+
request.wholeWord = wholeWord;
|
|
682
|
+
return request;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
std::string showRect(const DocRect& r) {
|
|
686
|
+
return "{" + std::to_string(r.x) + ", " + std::to_string(r.y) + ", " + std::to_string(r.width) + " x " +
|
|
687
|
+
std::to_string(r.height) + "}";
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/// THE GEOMETRY PROOF. Renders the doc-space neighbourhood of `rect` at scale
|
|
691
|
+
/// 2 through the real engine, with the SAME host rotation the search used, and
|
|
692
|
+
/// asks the pixels whether the rect is on the glyphs:
|
|
693
|
+
///
|
|
694
|
+
/// - a meaningful share of the pixels inside it are ink (measured 32% for
|
|
695
|
+
/// "measured" at 24pt: text is mostly counter and gap even in a tight box);
|
|
696
|
+
/// - the ink found within 3pt of it has its edges within 0.75pt of the
|
|
697
|
+
/// rect's. MEASURED worst edge: 0.357pt here, 0.525pt on the web engine —
|
|
698
|
+
/// pixel-centre sampling (±0.25pt at scale 2) plus up to a pixel of
|
|
699
|
+
/// antialiased fringe a rotated glyph throws past its unhinted box.
|
|
700
|
+
/// Sub-point precision is pinned by the metrics test, not here; this is the
|
|
701
|
+
/// contract check — the rect is on the glyphs of the raster it is drawn over;
|
|
702
|
+
/// - a probe 2pt outside the middle of each edge is paper. Along the line the
|
|
703
|
+
/// probes fall in the inter-word spaces (6.7pt at 24pt), across it on bare
|
|
704
|
+
/// page.
|
|
705
|
+
///
|
|
706
|
+
/// A rect left in PDF user space (y up), mapped without the host's turn, or
|
|
707
|
+
/// quantised to whole points fails at least one of these by a wide margin.
|
|
708
|
+
void checkRectIsOnInk(Document& document, const DocRect& rect, int rotation, const std::string& label) {
|
|
709
|
+
const double scale = 2;
|
|
710
|
+
const double pad = 12;
|
|
711
|
+
RasterRequest request = px::request(0, rect.x - pad, rect.y - pad, rect.width + 2 * pad, rect.height + 2 * pad, scale);
|
|
712
|
+
request.rotation = rotation;
|
|
713
|
+
auto raster = document.render(request, nullptr, nullptr);
|
|
714
|
+
|
|
715
|
+
const auto inked = [&](int x, int y) { return px::at(raster, x, y).r < 128; };
|
|
716
|
+
const auto paper = [&](double docX, double docY) {
|
|
717
|
+
const int x = static_cast<int>((docX - request.x) * scale);
|
|
718
|
+
const int y = static_cast<int>((docY - request.y) * scale);
|
|
719
|
+
return px::at(raster, x, y).near(kWhite, 6);
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
int inside = 0;
|
|
723
|
+
int ink = 0;
|
|
724
|
+
double minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
|
|
725
|
+
for (int y = 0; y < raster.height; y++) {
|
|
726
|
+
for (int x = 0; x < raster.width; x++) {
|
|
727
|
+
const double docX = request.x + (x + 0.5) / scale;
|
|
728
|
+
const double docY = request.y + (y + 0.5) / scale;
|
|
729
|
+
const bool inRect = docX >= rect.x && docX <= rect.x + rect.width && docY >= rect.y &&
|
|
730
|
+
docY <= rect.y + rect.height;
|
|
731
|
+
const bool nearRect = docX >= rect.x - 3 && docX <= rect.x + rect.width + 3 && docY >= rect.y - 3 &&
|
|
732
|
+
docY <= rect.y + rect.height + 3;
|
|
733
|
+
if (inRect) {
|
|
734
|
+
inside++;
|
|
735
|
+
if (inked(x, y)) ink++;
|
|
736
|
+
}
|
|
737
|
+
if (nearRect && px::at(raster, x, y).r < 250) {
|
|
738
|
+
minX = (std::min)(minX, docX);
|
|
739
|
+
maxX = (std::max)(maxX, docX);
|
|
740
|
+
minY = (std::min)(minY, docY);
|
|
741
|
+
maxY = (std::max)(maxY, docY);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const std::string where = label + " rect " + showRect(rect) + "\n" + px::map(raster, 32, 12);
|
|
746
|
+
REQUIRE(inside > 0);
|
|
747
|
+
CHECK_MSG(ink * 100 > inside * 15, where + " only " + std::to_string(ink) + "/" + std::to_string(inside) + " inked");
|
|
748
|
+
CHECK_MSG(std::fabs(minX - rect.x) < 0.75, where + " ink left " + std::to_string(minX));
|
|
749
|
+
CHECK_MSG(std::fabs(minY - rect.y) < 0.75, where + " ink top " + std::to_string(minY));
|
|
750
|
+
CHECK_MSG(std::fabs(maxX - (rect.x + rect.width)) < 0.75, where + " ink right " + std::to_string(maxX));
|
|
751
|
+
CHECK_MSG(std::fabs(maxY - (rect.y + rect.height)) < 0.75, where + " ink bottom " + std::to_string(maxY));
|
|
752
|
+
const double midX = rect.x + rect.width / 2;
|
|
753
|
+
const double midY = rect.y + rect.height / 2;
|
|
754
|
+
CHECK_MSG(paper(rect.x - 2, midY), where + " not paper left of the rect");
|
|
755
|
+
CHECK_MSG(paper(rect.x + rect.width + 2, midY), where + " not paper right of the rect");
|
|
756
|
+
CHECK_MSG(paper(midX, rect.y - 2), where + " not paper above the rect");
|
|
757
|
+
CHECK_MSG(paper(midX, rect.y + rect.height + 2), where + " not paper below the rect");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/// `rect` on a W x H page, turned a quarter clockwise `quarters` times: the
|
|
761
|
+
/// doc-space turn every raster of a rotated page is drawn in.
|
|
762
|
+
DocRect turnRect(DocRect rect, double width, double height, int quarters) {
|
|
763
|
+
for (int i = 0; i < quarters; i++) {
|
|
764
|
+
rect = DocRect{height - (rect.y + rect.height), rect.x, rect.height, rect.width};
|
|
765
|
+
std::swap(width, height);
|
|
766
|
+
}
|
|
767
|
+
return rect;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
void checkSameRect(const DocRect& actual, const DocRect& expected, const std::string& label) {
|
|
771
|
+
// 1/1024 pt is the mapping's resolution; two of them is the round-trip.
|
|
772
|
+
const double tolerance = 2.0 / 1024;
|
|
773
|
+
CHECK_MSG(std::fabs(actual.x - expected.x) <= tolerance && std::fabs(actual.y - expected.y) <= tolerance &&
|
|
774
|
+
std::fabs(actual.width - expected.width) <= tolerance &&
|
|
775
|
+
std::fabs(actual.height - expected.height) <= tolerance,
|
|
776
|
+
label + ": " + showRect(actual) + " vs " + showRect(expected));
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
} // namespace
|
|
780
|
+
|
|
781
|
+
/// CATCHES: a char index counted in bytes or from 1, a count that includes the
|
|
782
|
+
/// terminator, a context that is not the page's own text.
|
|
783
|
+
TEST(searchFindsAWordWithItsCharIndexAndContext) {
|
|
784
|
+
auto document = openFixture(fixtures::textPage(), "search-basic.pdf");
|
|
785
|
+
auto matches = document->searchText(searchFor(u"measured"), nullptr);
|
|
786
|
+
REQUIRE(matches.size() == 1);
|
|
787
|
+
CHECK_EQ(matches[0].page, 0);
|
|
788
|
+
CHECK_EQ(matches[0].charIndex, 6);
|
|
789
|
+
CHECK_EQ(matches[0].charCount, 8);
|
|
790
|
+
CHECK(!matches[0].rects.empty());
|
|
791
|
+
CHECK(matches[0].context == u"Hello measured world");
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/// CATCHES: the two flags swapped or dropped on the way to FPDFText_FindStart.
|
|
795
|
+
TEST(searchHonoursMatchCaseAndWholeWord) {
|
|
796
|
+
auto document = openFixture(fixtures::textPage(), "search-flags.pdf");
|
|
797
|
+
CHECK_EQ(document->searchText(searchFor(u"MEASURED"), nullptr).size(), size_t{1});
|
|
798
|
+
CHECK_EQ(document->searchText(searchFor(u"MEASURED", 0, true), nullptr).size(), size_t{0});
|
|
799
|
+
CHECK_EQ(document->searchText(searchFor(u"measured", 0, true), nullptr).size(), size_t{1});
|
|
800
|
+
CHECK_EQ(document->searchText(searchFor(u"meas"), nullptr).size(), size_t{1});
|
|
801
|
+
CHECK_EQ(document->searchText(searchFor(u"meas", 0, false, true), nullptr).size(), size_t{0});
|
|
802
|
+
CHECK_EQ(document->searchText(searchFor(u"measured", 0, false, true), nullptr).size(), size_t{1});
|
|
803
|
+
// Every occurrence, in reading order: "Hell[o] measured w[o]rld".
|
|
804
|
+
auto os = document->searchText(searchFor(u"o"), nullptr);
|
|
805
|
+
REQUIRE(os.size() == 2);
|
|
806
|
+
CHECK_EQ(os[0].charIndex, 4);
|
|
807
|
+
CHECK_EQ(os[1].charIndex, 16);
|
|
808
|
+
CHECK(document->searchText(searchFor(u"absent"), nullptr).empty());
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/// CATCHES: the context read raw. PDFium's text page carries a generated line
|
|
812
|
+
/// break between the two lines; a "find" list wants one line per hit.
|
|
813
|
+
TEST(aContextCollapsesTheLineBreak) {
|
|
814
|
+
auto document = openFixture(fixtures::twoLineTextPage(), "search-two-lines.pdf");
|
|
815
|
+
auto matches = document->searchText(searchFor(u"second"), nullptr);
|
|
816
|
+
REQUIRE(matches.size() == 1);
|
|
817
|
+
CHECK(matches[0].context == u"Hello measured world on a second line");
|
|
818
|
+
const DocRect first = document->searchText(searchFor(u"measured"), nullptr).at(0).rects.at(0);
|
|
819
|
+
CHECK(matches[0].rects.at(0).y > first.y + first.height);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
TEST(anEmptyQueryFindsNothing) {
|
|
823
|
+
auto document = openFixture(fixtures::textPage(), "search-empty.pdf");
|
|
824
|
+
CHECK(document->searchText(searchFor(u""), nullptr).empty());
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/// THE CONTRACT: a rect is where the glyphs are in the page's doc space — the
|
|
828
|
+
/// space its rasters use — proven two independent ways.
|
|
829
|
+
///
|
|
830
|
+
/// ABSOLUTE PLACEMENT from the font's own metrics, to a tenth of a point.
|
|
831
|
+
/// Helvetica's AFM: "Hello " advances H 722 + e 556 + l 222 + l 222 + o 556 +
|
|
832
|
+
/// space 278 = 2556 units, `m`'s left bearing is 65, "measured" advances 4446
|
|
833
|
+
/// with `d` ending its box at 499 of 556, `d` rises to 718 and the round
|
|
834
|
+
/// letters dip to -15. At 24pt from (72, 700), y-down on a 792pt page:
|
|
835
|
+
/// left 134.904, right 238.680, top 74.768, bottom 92.360. The tolerance is
|
|
836
|
+
/// 0.5pt because THE TWO ENGINES' FONTS DIFFER, measured: each PDFium build
|
|
837
|
+
/// substitutes its own built-in face for an unembedded Helvetica. This build
|
|
838
|
+
/// is 0.024 / 0.048 / 0.36 / 0.096pt off (left / top / right / bottom — its
|
|
839
|
+
/// `d` ends 15 font units earlier); the web engine's is within 0.001pt on
|
|
840
|
+
/// three edges. Each engine's rects match ITS OWN raster, which is the
|
|
841
|
+
/// contract. Catches user space left unflipped and a CropBox origin not
|
|
842
|
+
/// subtracted.
|
|
843
|
+
///
|
|
844
|
+
/// THE RESOLUTION, font-independent: every edge is an exact multiple of
|
|
845
|
+
/// 1/1024pt (`kSearchUnitsPerPoint`) and, for a word at an arbitrary
|
|
846
|
+
/// position, not a whole point — which catches the page box handed to
|
|
847
|
+
/// `FPDF_PageToDevice` in points, a quantisation the scale-2 pixel proof is
|
|
848
|
+
/// too coarse to see.
|
|
849
|
+
///
|
|
850
|
+
/// And against the RASTER — see `checkRectIsOnInk`.
|
|
851
|
+
TEST(searchRectsLandOnTheGlyphs) {
|
|
852
|
+
auto document = openFixture(fixtures::textPage(), "search-geometry.pdf");
|
|
853
|
+
auto matches = document->searchText(searchFor(u"measured"), nullptr);
|
|
854
|
+
REQUIRE(matches.size() == 1);
|
|
855
|
+
REQUIRE(matches[0].rects.size() == 1);
|
|
856
|
+
const DocRect rect = matches[0].rects[0];
|
|
857
|
+
CHECK_MSG(std::fabs(rect.x - 134.904) < 0.5, showRect(rect));
|
|
858
|
+
CHECK_MSG(std::fabs(rect.x + rect.width - 238.68) < 0.5, showRect(rect));
|
|
859
|
+
CHECK_MSG(std::fabs(rect.y - 74.768) < 0.5, showRect(rect));
|
|
860
|
+
CHECK_MSG(std::fabs(rect.y + rect.height - 92.36) < 0.5, showRect(rect));
|
|
861
|
+
|
|
862
|
+
bool allWhole = true;
|
|
863
|
+
for (const double value : {rect.x, rect.y, rect.width, rect.height}) {
|
|
864
|
+
CHECK_MSG(std::fabs(value * 1024 - std::round(value * 1024)) < 1e-6, showRect(rect));
|
|
865
|
+
allWhole = allWhole && value == std::round(value);
|
|
866
|
+
}
|
|
867
|
+
CHECK_MSG(!allWhole, "quantised to whole points: " + showRect(rect));
|
|
868
|
+
|
|
869
|
+
checkRectIsOnInk(*document, rect, 0, "upright");
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/// A phrase spanning a space is ONE rect on its one line: the union of its
|
|
873
|
+
/// glyphs' boxes, starting at its first glyph rather than at the line.
|
|
874
|
+
TEST(aPhraseAcrossAWordBoundaryIsTheUnionOfItsGlyphs) {
|
|
875
|
+
auto document = openFixture(fixtures::textPage(), "search-phrase.pdf");
|
|
876
|
+
const DocRect phrase = document->searchText(searchFor(u"lo me"), nullptr).at(0).rects.at(0);
|
|
877
|
+
const DocRect lo = document->searchText(searchFor(u"lo"), nullptr).at(0).rects.at(0);
|
|
878
|
+
const DocRect me = document->searchText(searchFor(u"me"), nullptr).at(0).rects.at(0);
|
|
879
|
+
const double top = (std::min)(lo.y, me.y);
|
|
880
|
+
const double bottom = (std::max)(lo.y + lo.height, me.y + me.height);
|
|
881
|
+
checkSameRect(phrase, DocRect{lo.x, top, me.x + me.width - lo.x, bottom - top}, "\"lo me\"");
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/// CATCHES: the host's rotation ignored by the mapping (rects stay upright
|
|
885
|
+
/// while the raster turns), applied anticlockwise, or applied without the
|
|
886
|
+
/// swapped box. Every quarter is also the upright rect turned in doc space.
|
|
887
|
+
TEST(searchRectsTurnWithTheHostRotation) {
|
|
888
|
+
auto document = openFixture(fixtures::textPage(), "search-host-rotate.pdf");
|
|
889
|
+
const DocRect upright = document->searchText(searchFor(u"measured"), nullptr).at(0).rects.at(0);
|
|
890
|
+
for (int rotation : {90, 180, 270}) {
|
|
891
|
+
auto matches = document->searchText(searchFor(u"measured", rotation), nullptr);
|
|
892
|
+
REQUIRE(matches.size() == 1);
|
|
893
|
+
REQUIRE(matches[0].rects.size() == 1);
|
|
894
|
+
const DocRect rect = matches[0].rects[0];
|
|
895
|
+
checkSameRect(rect, turnRect(upright, fixtures::kLetterW, fixtures::kLetterH, rotation / 90),
|
|
896
|
+
"turned " + std::to_string(rotation));
|
|
897
|
+
checkRectIsOnInk(*document, rect, rotation, "host " + std::to_string(rotation));
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/// CATCHES: the file's own /Rotate left out of the mapping — the display
|
|
902
|
+
/// matrix is what folds it in, which is why the core goes through
|
|
903
|
+
/// FPDF_PageToDevice rather than restating the arithmetic. A page saved at
|
|
904
|
+
/// /Rotate 90 reads exactly like an upright page turned 90 by the host, and
|
|
905
|
+
/// turning it a further 270 unwinds it to the upright page's rects.
|
|
906
|
+
TEST(searchRectsFollowTheDocumentsRotate) {
|
|
907
|
+
auto saved = openFixture(fixtures::textPage(90), "search-saved-rotate.pdf");
|
|
908
|
+
auto plain = openFixture(fixtures::textPage(), "search-plain.pdf");
|
|
909
|
+
CHECK_NEAR(saved->geometry(0).width, 792, 0.01);
|
|
910
|
+
const DocRect rect = saved->searchText(searchFor(u"measured"), nullptr).at(0).rects.at(0);
|
|
911
|
+
checkRectIsOnInk(*saved, rect, 0, "/Rotate 90");
|
|
912
|
+
checkSameRect(rect, plain->searchText(searchFor(u"measured", 90), nullptr).at(0).rects.at(0),
|
|
913
|
+
"/Rotate 90 vs host 90");
|
|
914
|
+
const DocRect unwound = saved->searchText(searchFor(u"measured", 270), nullptr).at(0).rects.at(0);
|
|
915
|
+
checkSameRect(unwound, plain->searchText(searchFor(u"measured"), nullptr).at(0).rects.at(0),
|
|
916
|
+
"/Rotate 90 + host 270 vs upright");
|
|
917
|
+
checkRectIsOnInk(*saved, unwound, 270, "/Rotate 90 + host 270");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/// Refused, as a render refuses it — and the document stays usable.
|
|
921
|
+
TEST(searchRefusesWhatARenderRefuses) {
|
|
922
|
+
auto document = openFixture(fixtures::textPage(), "search-refuse.pdf");
|
|
923
|
+
for (int bad : {45, -90, 360}) {
|
|
924
|
+
EXPECT_ERROR(document->searchText(searchFor(u"measured", bad), nullptr), ErrorCode::BackendFailure);
|
|
925
|
+
}
|
|
926
|
+
TextSearchRequest missing = searchFor(u"measured");
|
|
927
|
+
missing.page = 3;
|
|
928
|
+
EXPECT_ERROR(document->searchText(missing, nullptr), ErrorCode::NotFound);
|
|
929
|
+
CHECK_EQ(document->searchText(searchFor(u"measured"), nullptr).size(), size_t{1});
|
|
930
|
+
document->close();
|
|
931
|
+
EXPECT_ERROR(document->searchText(searchFor(u"measured"), nullptr), ErrorCode::BackendFailure);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/// A cancel before the lock, and one that lands BETWEEN matches — the
|
|
935
|
+
/// affordance flips the flag on the second poll, which happens after the
|
|
936
|
+
/// first "o" was collected and before the second is. Either way the search
|
|
937
|
+
/// throws `cancelled` and the page is searchable afterwards.
|
|
938
|
+
TEST(aCancelStopsASearchBetweenMatches) {
|
|
939
|
+
auto document = openFixture(fixtures::textPage(), "search-cancel.pdf");
|
|
940
|
+
Cancellation before;
|
|
941
|
+
before.cancel();
|
|
942
|
+
EXPECT_ERROR(document->searchText(searchFor(u"o"), &before), ErrorCode::Cancelled);
|
|
943
|
+
|
|
944
|
+
Cancellation between;
|
|
945
|
+
between.cancelAfterPolls(2);
|
|
946
|
+
EXPECT_ERROR(document->searchText(searchFor(u"o"), &between), ErrorCode::Cancelled);
|
|
947
|
+
CHECK(between.isCancelled());
|
|
948
|
+
|
|
949
|
+
Cancellation live;
|
|
950
|
+
CHECK_EQ(document->searchText(searchFor(u"o"), &live).size(), size_t{2});
|
|
951
|
+
CHECK(!live.isCancelled());
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/// The text page lives and dies with its cached page: searching more pages
|
|
955
|
+
/// than the cache holds evicts pages that CARRY a text page, and PDFium
|
|
956
|
+
/// requires the text page closed before its page. Then render and search the
|
|
957
|
+
/// survivors again — a wrong unload order is a use-after-free here.
|
|
958
|
+
TEST(searchedPagesAreEvictedCleanly) {
|
|
959
|
+
auto document = openFixture(
|
|
960
|
+
fixtures::coloredPages({{1, 0, 0}, {0, 1, 0}, {0, 0, 1}, {0, 0, 0}, {1, 1, 1}}, 40, 40),
|
|
961
|
+
"search-evict.pdf");
|
|
962
|
+
for (int round = 0; round < 2; round++) {
|
|
963
|
+
for (int page = 0; page < 5; page++) {
|
|
964
|
+
TextSearchRequest request = searchFor(u"x");
|
|
965
|
+
request.page = page;
|
|
966
|
+
CHECK(document->searchText(request, nullptr).empty());
|
|
967
|
+
CHECK_EQ(document->render(px::request(page, 0, 0, 40, 40, 1), nullptr, nullptr).width, 40);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
CHECK_PIXEL(document->render(px::request(0, 0, 0, 40, 40, 1), nullptr, nullptr), 20, 20, kRed);
|
|
971
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// The facade both bindings marshal to.
|
|
2
2
|
|
|
3
|
+
#include <clocale>
|
|
4
|
+
#include <cmath>
|
|
5
|
+
#include <limits>
|
|
6
|
+
#include <string>
|
|
3
7
|
#include <thread>
|
|
4
8
|
|
|
5
9
|
#include "fixtures.h"
|
|
6
10
|
#include "harness.h"
|
|
7
11
|
#include "pdfcanvas/service.h"
|
|
8
12
|
#include "pdfcanvas/slots.h"
|
|
13
|
+
#include "pdfcanvas/text_search.h"
|
|
9
14
|
#include "pixels_util.h"
|
|
10
15
|
|
|
11
16
|
using namespace pdfcanvas;
|
|
@@ -128,3 +133,108 @@ TEST(openErrorsCarryTheirCodeThroughTheService) {
|
|
|
128
133
|
EXPECT_ERROR(service.open(locked), ErrorCode::PasswordRequired);
|
|
129
134
|
CHECK_EQ(service.openCount(), size_t{0});
|
|
130
135
|
}
|
|
136
|
+
|
|
137
|
+
/* ------------------------------------------------------------------ *
|
|
138
|
+
* Text search: the wire form
|
|
139
|
+
* ------------------------------------------------------------------ */
|
|
140
|
+
|
|
141
|
+
namespace {
|
|
142
|
+
|
|
143
|
+
TextSearchRequest measured(int page = 0) {
|
|
144
|
+
TextSearchRequest request;
|
|
145
|
+
request.page = page;
|
|
146
|
+
request.query = u"measured";
|
|
147
|
+
return request;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
} // namespace
|
|
151
|
+
|
|
152
|
+
/// The JSON both bindings hand to JS, end to end through the service. The
|
|
153
|
+
/// exact prefix pins the key names and order `parseTextMatches` reads; the
|
|
154
|
+
/// context pins that the string crossed as itself.
|
|
155
|
+
TEST(serviceSearchReturnsTheWireJson) {
|
|
156
|
+
Service service;
|
|
157
|
+
const auto opened = service.open(fileSource(fixtures::textPage(), "svc-search.pdf"));
|
|
158
|
+
const std::string json = service.searchJson(opened.handle, measured(), 1);
|
|
159
|
+
CHECK_MSG(json.rfind("[{\"page\":0,\"charIndex\":6,\"charCount\":8,\"rects\":[{\"x\":", 0) == 0, json);
|
|
160
|
+
CHECK_MSG(json.find(",\"context\":\"Hello measured world\"}]") != std::string::npos, json);
|
|
161
|
+
for (const char c : json) CHECK_MSG(static_cast<unsigned char>(c) < 0x80, json);
|
|
162
|
+
|
|
163
|
+
TextSearchRequest none = measured();
|
|
164
|
+
none.query = u"absent";
|
|
165
|
+
CHECK_EQ(service.searchJson(opened.handle, none, 2), std::string("[]"));
|
|
166
|
+
EXPECT_ERROR(service.searchJson(999, measured(), 3), ErrorCode::BackendFailure);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// The same (handle, token) registry as a render: a registered, cancelled
|
|
170
|
+
/// token stops the search, and the signal is consumed so the token is reusable.
|
|
171
|
+
TEST(aRegisteredCancellationStopsASearch) {
|
|
172
|
+
Service service;
|
|
173
|
+
const auto opened = service.open(fileSource(fixtures::textPage(), "svc-search-cancel.pdf"));
|
|
174
|
+
service.registerCancellation(opened.handle, 4);
|
|
175
|
+
service.cancel(opened.handle, 4);
|
|
176
|
+
EXPECT_ERROR(service.searchJson(opened.handle, measured(), 4), ErrorCode::Cancelled);
|
|
177
|
+
CHECK(service.searchJson(opened.handle, measured(), 4) != "[]");
|
|
178
|
+
// Invalidation closes the document under a search as under a render.
|
|
179
|
+
service.invalidate();
|
|
180
|
+
EXPECT_ERROR(service.searchJson(opened.handle, measured(), 5), ErrorCode::BackendFailure);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// CATCHES: a raw non-ASCII byte in the output (JNI's NewStringUTF reads
|
|
184
|
+
/// "modified UTF-8", which only agrees with UTF-8 on ASCII), an unescaped
|
|
185
|
+
/// quote or backslash, a control character, a surrogate pair split wrongly.
|
|
186
|
+
TEST(theWireJsonIsPureAsciiAndEscapesEverythingElse) {
|
|
187
|
+
TextMatch match;
|
|
188
|
+
match.page = 2;
|
|
189
|
+
match.charIndex = 10;
|
|
190
|
+
match.charCount = 3;
|
|
191
|
+
match.rects.push_back(DocRect{72.5, 0.1, 100, 17.568359375});
|
|
192
|
+
match.context = u"a\"b\\c\n\u00e9\U0001F600";
|
|
193
|
+
const std::string json = textMatchesToJson({match});
|
|
194
|
+
CHECK_EQ(json, std::string("[{\"page\":2,\"charIndex\":10,\"charCount\":3,\"rects\":[{\"x\":72.5,"
|
|
195
|
+
"\"y\":0.10000000000000001,\"width\":100,\"height\":17.568359375}],"
|
|
196
|
+
"\"context\":\"a\\u0022b\\u005cc\\u000a\\u00e9\\ud83d\\ude00\"}]"));
|
|
197
|
+
CHECK_EQ(textMatchesToJson({}), std::string("[]"));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// CATCHES: `nan` / `inf` written as tokens JSON does not have, and a comma
|
|
201
|
+
/// decimal separator from a host that has called setlocale(LC_NUMERIC, ...).
|
|
202
|
+
TEST(theWireJsonNumbersAreLocaleProofAndFinite) {
|
|
203
|
+
TextMatch match;
|
|
204
|
+
match.rects.push_back(DocRect{std::nan(""), std::numeric_limits<double>::infinity(), -1.25, 1e21});
|
|
205
|
+
CHECK_EQ(textMatchesToJson({match}),
|
|
206
|
+
std::string("[{\"page\":0,\"charIndex\":0,\"charCount\":0,\"rects\":[{\"x\":0,\"y\":0,"
|
|
207
|
+
"\"width\":-1.25,\"height\":1e+21}],\"context\":\"\"}]"));
|
|
208
|
+
|
|
209
|
+
const std::string previous = std::setlocale(LC_NUMERIC, nullptr);
|
|
210
|
+
const char* candidates[] = {"de_DE.UTF-8", "de_DE.utf8", "fr_FR.UTF-8", "fr_FR.utf8", "de_DE"};
|
|
211
|
+
bool switched = false;
|
|
212
|
+
for (const char* name : candidates) {
|
|
213
|
+
if (std::setlocale(LC_NUMERIC, name) != nullptr) {
|
|
214
|
+
switched = true;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (switched) {
|
|
219
|
+
char probe[16];
|
|
220
|
+
std::snprintf(probe, sizeof(probe), "%.1f", 0.5);
|
|
221
|
+
TextMatch comma;
|
|
222
|
+
comma.rects.push_back(DocRect{72.5, 0, 0, 0});
|
|
223
|
+
const std::string json = textMatchesToJson({comma});
|
|
224
|
+
std::setlocale(LC_NUMERIC, previous.c_str());
|
|
225
|
+
// Only meaningful where the locale really does use a comma.
|
|
226
|
+
if (std::string(probe) == "0,5") CHECK_MSG(json.find("\"x\":72.5,") != std::string::npos, json);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/// The context rule, pinned to ECMAScript's `\s` — the rule the web engine and
|
|
231
|
+
/// the fake apply through `collapseWhitespace` in `text-search.ts`.
|
|
232
|
+
TEST(collapseWhitespaceMatchesTheJavaScriptRule) {
|
|
233
|
+
CHECK(collapseWhitespace(u" Hello\r\n\tmeasured\u00a0\u2003 world \ufeff") == u"Hello measured world");
|
|
234
|
+
CHECK(collapseWhitespace(u"") == u"");
|
|
235
|
+
CHECK(collapseWhitespace(u" \r\n ") == u"");
|
|
236
|
+
// Not whitespace to JavaScript, so not collapsed here either.
|
|
237
|
+
CHECK(collapseWhitespace(u"a\u200bb\u0085c") == u"a\u200bb\u0085c");
|
|
238
|
+
CHECK(isJsWhitespace(u'\u3000'));
|
|
239
|
+
CHECK(!isJsWhitespace(u'x'));
|
|
240
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reekon-tools/react-native-pdf-canvas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "PDF pages as Skia images, drawn at exact doc-space rects inside somebody else's transform. One PDFium engine on iOS, Android and web. Owns no canvas, no gestures, no viewport state.",
|
|
5
5
|
"author": "REEKON Tools",
|
|
6
6
|
"license": "Apache-2.0",
|