@reekon-tools/react-native-pdf-canvas 0.1.1 → 0.2.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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +57 -0
  3. package/PdfCanvas.podspec +68 -39
  4. package/README.md +213 -1316
  5. package/android/build.gradle +67 -54
  6. package/android/consumer-rules.pro +7 -0
  7. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +254 -0
  8. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +60 -468
  9. package/android/src/main/cpp/CMakeLists.txt +54 -0
  10. package/android/src/main/cpp/pdfcanvas-jni.cpp +262 -0
  11. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +167 -0
  12. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +14 -4
  13. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +10 -4
  14. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +160 -376
  15. package/android/tools/compile-gate.sh +81 -101
  16. package/dist/controller.d.ts +14 -0
  17. package/dist/controller.js +4 -3
  18. package/dist/rasterizer/index.native.d.ts +8 -12
  19. package/dist/rasterizer/index.native.js +15 -21
  20. package/dist/rasterizer/native-bridge.d.ts +51 -75
  21. package/dist/rasterizer/native-bridge.js +49 -33
  22. package/dist/rasterizer/native.d.ts +45 -0
  23. package/dist/rasterizer/native.js +123 -0
  24. package/dist/react/usePdfDocument.d.ts +5 -5
  25. package/dist/react/usePdfLayer.d.ts +10 -1
  26. package/dist/react/usePdfLayer.js +10 -2
  27. package/dist/testing/scenes.d.ts +3 -3
  28. package/dist/testing/scenes.js +4 -4
  29. package/dist/types.d.ts +36 -42
  30. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +234 -536
  31. package/native/.clangd +5 -0
  32. package/native/CMakeLists.txt +89 -0
  33. package/native/core/include/pdfcanvas/document.h +90 -0
  34. package/native/core/include/pdfcanvas/error.h +47 -0
  35. package/native/core/include/pdfcanvas/jsi.h +18 -0
  36. package/native/core/include/pdfcanvas/jsi_config.h +30 -0
  37. package/native/core/include/pdfcanvas/library.h +28 -0
  38. package/native/core/include/pdfcanvas/pixels.h +92 -0
  39. package/native/core/include/pdfcanvas/service.h +87 -0
  40. package/native/core/include/pdfcanvas/slots.h +113 -0
  41. package/native/core/include/pdfcanvas/types.h +110 -0
  42. package/native/core/pdfcanvas-core.cmake +22 -0
  43. package/native/core/src/document.cpp +516 -0
  44. package/native/core/src/error.cpp +29 -0
  45. package/native/core/src/jsi.cpp +66 -0
  46. package/native/core/src/library.cpp +52 -0
  47. package/native/core/src/pixels.cpp +45 -0
  48. package/native/core/src/service.cpp +156 -0
  49. package/native/core/src/slots.cpp +139 -0
  50. package/native/tests/fixtures.cpp +451 -0
  51. package/native/tests/fixtures.h +96 -0
  52. package/native/tests/harness.h +122 -0
  53. package/native/tests/main.cpp +64 -0
  54. package/native/tests/pixels_util.h +126 -0
  55. package/native/tests/test_document.cpp +485 -0
  56. package/native/tests/test_service.cpp +130 -0
  57. package/native/tests/test_slots.cpp +250 -0
  58. package/package.json +27 -7
  59. package/react-native.config.js +5 -2
  60. package/scripts/fetch-pdfium.mjs +487 -0
  61. package/scripts/pdfium-manifest.json +46 -0
  62. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +0 -226
  63. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +0 -239
  64. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +0 -251
  65. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +0 -221
  66. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +0 -783
  67. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +0 -1388
  68. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +0 -98
  69. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +0 -324
  70. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +0 -775
  71. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +0 -20
  72. package/android/src/jsi/cpp/CMakeLists.txt +0 -33
  73. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +0 -469
  74. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +0 -140
  75. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +0 -45
  76. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +0 -564
  77. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +0 -277
  78. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +0 -214
  79. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +0 -234
  80. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +0 -47
  81. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +0 -143
  82. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +0 -95
  83. package/dist/rasterizer/android.d.ts +0 -121
  84. package/dist/rasterizer/android.js +0 -126
  85. package/dist/rasterizer/ios.d.ts +0 -84
  86. package/dist/rasterizer/ios.js +0 -155
  87. package/ios/Package.swift +0 -58
  88. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +0 -38
  89. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +0 -389
  90. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +0 -57
  91. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +0 -44
  92. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +0 -82
  93. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +0 -37
  94. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +0 -158
  95. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +0 -80
  96. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +0 -65
  97. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +0 -548
  98. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +0 -48
  99. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +0 -26
  100. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +0 -122
  101. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +0 -80
  102. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +0 -79
  103. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +0 -129
  104. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +0 -100
  105. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +0 -65
  106. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +0 -111
  107. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +0 -85
  108. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +0 -163
  109. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +0 -297
  110. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +0 -140
  111. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +0 -358
  112. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +0 -262
  113. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +0 -173
  114. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +0 -114
  115. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +0 -158
  116. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +0 -544
  117. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +0 -287
  118. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +0 -396
  119. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +0 -639
  120. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +0 -179
  121. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +0 -283
@@ -0,0 +1,64 @@
1
+ #include <chrono>
2
+ #include <cstdio>
3
+ #include <cstring>
4
+ #include <exception>
5
+ #include <string>
6
+
7
+ #include "harness.h"
8
+
9
+ namespace harness {
10
+
11
+ Context& current() {
12
+ static Context context;
13
+ return context;
14
+ }
15
+
16
+ std::vector<Case>& cases() {
17
+ static std::vector<Case> all;
18
+ return all;
19
+ }
20
+
21
+ } // namespace harness
22
+
23
+ int main(int argc, char** argv) {
24
+ const char* filter = nullptr;
25
+ for (int i = 1; i < argc; i++) {
26
+ if (std::strcmp(argv[i], "--filter") == 0 && i + 1 < argc) filter = argv[++i];
27
+ }
28
+
29
+ int ran = 0;
30
+ int failed = 0;
31
+ for (const auto& test : harness::cases()) {
32
+ if (filter != nullptr && std::strstr(test.name, filter) == nullptr) continue;
33
+ ran++;
34
+ harness::current().failures.clear();
35
+ const auto started = std::chrono::steady_clock::now();
36
+ try {
37
+ test.body();
38
+ } catch (const harness::Abort&) {
39
+ // Already recorded.
40
+ } catch (const pdfcanvas::PdfError& error) {
41
+ harness::current().fail("<runner>", 0,
42
+ std::string("unexpected PdfError ") +
43
+ pdfcanvas::wireName(error.code()) + ": " + error.message());
44
+ } catch (const std::exception& error) {
45
+ harness::current().fail("<runner>", 0,
46
+ std::string("unexpected exception: ") + error.what());
47
+ }
48
+ const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
49
+ std::chrono::steady_clock::now() - started)
50
+ .count();
51
+ if (harness::current().failures.empty()) {
52
+ std::printf("PASS %-64s %5lld ms\n", test.name, static_cast<long long>(ms));
53
+ } else {
54
+ failed++;
55
+ std::printf("FAIL %s\n", test.name);
56
+ for (const auto& failure : harness::current().failures) {
57
+ std::printf(" %s:%d: %s\n", failure.file.c_str(), failure.line,
58
+ failure.message.c_str());
59
+ }
60
+ }
61
+ }
62
+ std::printf("---\n%d cases, %d failed\n", ran, failed);
63
+ return failed == 0 && ran > 0 ? 0 : 1;
64
+ }
@@ -0,0 +1,126 @@
1
+ // Reading pixels back, and the vocabulary every rendering test is written in.
2
+ //
3
+ // `RasterPixels` is indexed from the TOP-LEFT, because that is what doc space
4
+ // is. The fixtures are y-UP in PDF space, so a quadrant that is "bottom-left"
5
+ // in the PDF reads at the bottom-left of the raster too — the flip is PDFium's,
6
+ // not the test's, and a test that reasoned in PDF coordinates would be testing
7
+ // its own arithmetic against the library's.
8
+ #pragma once
9
+
10
+ #include <algorithm>
11
+ #include <cstdlib>
12
+ #include <string>
13
+
14
+ #include "harness.h"
15
+ #include "pdfcanvas/document.h"
16
+
17
+ namespace px {
18
+
19
+ struct Pixel {
20
+ int r, g, b, a;
21
+ bool operator==(const Pixel& o) const { return r == o.r && g == o.g && b == o.b && a == o.a; }
22
+ /// Within `tolerance` on every channel. Antialiasing makes exact equality
23
+ /// wrong near an edge.
24
+ bool near(const Pixel& o, int tolerance = 4) const {
25
+ return std::abs(r - o.r) <= tolerance && std::abs(g - o.g) <= tolerance &&
26
+ std::abs(b - o.b) <= tolerance && std::abs(a - o.a) <= tolerance;
27
+ }
28
+ std::string str() const {
29
+ return "[" + std::to_string(r) + "," + std::to_string(g) + "," + std::to_string(b) + "," +
30
+ std::to_string(a) + "]";
31
+ }
32
+ };
33
+
34
+ inline const Pixel kRed{255, 0, 0, 255};
35
+ inline const Pixel kGreen{0, 255, 0, 255};
36
+ inline const Pixel kBlue{0, 0, 255, 255};
37
+ inline const Pixel kBlack{0, 0, 0, 255};
38
+ inline const Pixel kWhite{255, 255, 255, 255};
39
+ inline const Pixel kClear{0, 0, 0, 0};
40
+ inline const Pixel kGrey{128, 128, 128, 255};
41
+
42
+ inline Pixel at(const pdfcanvas::RasterPixels& raster, int x, int y) {
43
+ return {raster.channel(x, y, 0), raster.channel(x, y, 1), raster.channel(x, y, 2),
44
+ raster.channel(x, y, 3)};
45
+ }
46
+
47
+ /// A coarse letter map, so a failure in the log says what happened.
48
+ inline std::string map(const pdfcanvas::RasterPixels& raster, int cols = 16, int rows = 16) {
49
+ std::string out;
50
+ for (int row = 0; row < rows; row++) {
51
+ for (int col = 0; col < cols; col++) {
52
+ const int x = std::min(raster.width - 1, (col * raster.width) / cols + raster.width / (cols * 2));
53
+ const int y = std::min(raster.height - 1, (row * raster.height) / rows + raster.height / (rows * 2));
54
+ const Pixel p = at(raster, x, y);
55
+ if (p.a == 0) out += '_';
56
+ else if (p.r > 200 && p.g > 200 && p.b > 200) out += 'W';
57
+ else if (p.r > 200 && p.g < 60 && p.b < 60) out += 'r';
58
+ else if (p.g > 200 && p.r < 60 && p.b < 60) out += 'g';
59
+ else if (p.b > 200 && p.r < 60 && p.g < 60) out += 'b';
60
+ else if (p.r < 60 && p.g < 60 && p.b < 60) out += 'k';
61
+ else out += '.';
62
+ }
63
+ out += '\n';
64
+ }
65
+ return out;
66
+ }
67
+
68
+ struct Diff {
69
+ int differing = 0;
70
+ int maxDelta = 0;
71
+ int total = 0;
72
+ };
73
+
74
+ /// Compares `a` against the region of `b` starting at (offsetX, offsetY).
75
+ inline Diff compare(const pdfcanvas::RasterPixels& a, const pdfcanvas::RasterPixels& b,
76
+ int offsetX = 0, int offsetY = 0) {
77
+ Diff diff;
78
+ for (int y = 0; y < a.height; y++) {
79
+ for (int x = 0; x < a.width; x++) {
80
+ const Pixel pa = at(a, x, y);
81
+ const Pixel pb = at(b, x + offsetX, y + offsetY);
82
+ const int d = std::max({std::abs(pa.r - pb.r), std::abs(pa.g - pb.g), std::abs(pa.b - pb.b),
83
+ std::abs(pa.a - pb.a)});
84
+ if (d != 0) diff.differing++;
85
+ diff.maxDelta = std::max(diff.maxDelta, d);
86
+ }
87
+ }
88
+ diff.total = a.width * a.height;
89
+ return diff;
90
+ }
91
+
92
+ inline pdfcanvas::RasterRequest request(int page, double x, double y, double width, double height,
93
+ double scale, bool annotations = false,
94
+ pdfcanvas::Background background = pdfcanvas::Background::White) {
95
+ pdfcanvas::RasterRequest r;
96
+ r.page = page;
97
+ r.x = x;
98
+ r.y = y;
99
+ r.width = width;
100
+ r.height = height;
101
+ r.scale = scale;
102
+ r.annotations = annotations;
103
+ r.background = background;
104
+ return r;
105
+ }
106
+
107
+ /// Renders the whole of `page` at `scale` to the heap.
108
+ inline pdfcanvas::RasterPixels renderWhole(pdfcanvas::Document& document, int page = 0,
109
+ double scale = 1, bool annotations = false,
110
+ pdfcanvas::Background background = pdfcanvas::Background::White) {
111
+ const auto& geometry = document.geometry(page);
112
+ return document.render(request(page, 0, 0, geometry.width, geometry.height, scale, annotations, background),
113
+ nullptr, nullptr);
114
+ }
115
+
116
+ } // namespace px
117
+
118
+ #define CHECK_PIXEL(raster, x, y, expected) \
119
+ do { \
120
+ const ::px::Pixel _actual = ::px::at((raster), (x), (y)); \
121
+ if (!_actual.near(expected)) \
122
+ ::harness::current().fail(__FILE__, __LINE__, \
123
+ "(" + std::to_string(x) + "," + std::to_string(y) + ") is " + \
124
+ _actual.str() + ", expected " + (expected).str() + "\n" + \
125
+ ::px::map(raster)); \
126
+ } while (0)
@@ -0,0 +1,485 @@
1
+ // What the core actually draws.
2
+ //
3
+ // Every test here names the bug it catches, because every one of these failure
4
+ // modes produces a plausible image rather than an error: a channel swap, a
5
+ // stride shear, a y-flip, a page ignored, an annotation drawn when it was not
6
+ // asked for, a transparent page composited as black.
7
+
8
+ #include <string>
9
+ #include <thread>
10
+
11
+ #include "fixtures.h"
12
+ #include "harness.h"
13
+ #include "pdfcanvas/document.h"
14
+ #include "pixels_util.h"
15
+
16
+ using namespace pdfcanvas;
17
+ using px::kBlack;
18
+ using px::kBlue;
19
+ using px::kClear;
20
+ using px::kGreen;
21
+ using px::kGrey;
22
+ using px::kRed;
23
+ using px::kWhite;
24
+
25
+ namespace {
26
+
27
+ std::unique_ptr<Document> openFixture(const fixtures::Bytes& bytes, const std::string& name,
28
+ const char* password = nullptr) {
29
+ Source source;
30
+ source.path = fixtures::writeTemp(bytes, name);
31
+ if (password != nullptr) {
32
+ source.hasPassword = true;
33
+ source.password = password;
34
+ }
35
+ return Document::open(source);
36
+ }
37
+
38
+ } // namespace
39
+
40
+ /* ------------------------------------------------------------------ *
41
+ * The doc-space contract
42
+ * ------------------------------------------------------------------ */
43
+
44
+ /// The four quadrants land where doc space says, which pins the y-flip, the
45
+ /// channel order (RGBA, via FPDF_REVERSE_BYTE_ORDER) and the stride at once.
46
+ TEST(quadrantsLandWhereDocSpaceSaysTheyShould) {
47
+ auto document = openFixture(fixtures::quadrants(200, 100), "quadrants.pdf");
48
+ auto raster = px::renderWhole(*document, 0, 2);
49
+ CHECK_EQ(raster.width, 400);
50
+ CHECK_EQ(raster.height, 200);
51
+ CHECK_EQ(raster.rowBytes, 1600);
52
+ CHECK_PIXEL(raster, 20, 20, kBlue);
53
+ CHECK_PIXEL(raster, 380, 20, kBlack);
54
+ CHECK_PIXEL(raster, 20, 180, kRed);
55
+ CHECK_PIXEL(raster, 380, 180, kGreen);
56
+ }
57
+
58
+ /// A tile is the same region cropped from a whole-page render — THE PROPERTY
59
+ /// THE ENTIRE DETAIL LAYER RESTS ON. The tile's origin and size are
60
+ /// deliberately not round numbers.
61
+ ///
62
+ /// `maxDelta <= 1`, not 0: PDFium's AGG rasterizer accumulates antialiased
63
+ /// coverage in device space, so a shallow diagonal edge can come out one level
64
+ /// different when the bitmap origin moves — the web backend measured the same
65
+ /// (24 of 9,600 pixels, always by exactly one level) and spike S1 measured it
66
+ /// on Android's PdfRenderer, which is the same engine. A registration error
67
+ /// moves whole pixels of this high-frequency pattern and cannot hide under it.
68
+ TEST(aTileIsIdenticalToThatRegionOfAWholePageRender) {
69
+ auto document = openFixture(fixtures::grid(200, 100), "grid-tile.pdf");
70
+ const double scale = 4.0;
71
+ auto whole = px::renderWhole(*document, 0, scale);
72
+ auto tile = document->render(px::request(0, 37, 23, 64, 41, scale), nullptr, nullptr);
73
+ CHECK_EQ(tile.width, 256);
74
+ CHECK_EQ(tile.height, 164);
75
+ const auto diff = px::compare(tile, whole, static_cast<int>(37 * scale), static_cast<int>(23 * scale));
76
+ const std::string summary = std::to_string(diff.differing) + "/" + std::to_string(diff.total) +
77
+ " pixels differ, maxDelta " + std::to_string(diff.maxDelta);
78
+ CHECK_MSG(diff.maxDelta <= 1, summary);
79
+ CHECK_MSG(diff.differing * 100 < diff.total * 2, summary);
80
+ }
81
+
82
+ /// Two tiles at the same scale agree over their overlap, with heights that
83
+ /// round in OPPOSITE directions.
84
+ TEST(rastersAtTheSameScaleAgreeWhereTheyOverlap) {
85
+ auto document = openFixture(fixtures::grid(200, 200), "grid-overlap.pdf");
86
+ const double scale = 4.0;
87
+ auto a = document->render(px::request(0, 10, 10, 50.1, 25.1, scale), nullptr, nullptr);
88
+ auto b = document->render(px::request(0, 10, 20, 50.1, 25.4, scale), nullptr, nullptr);
89
+ CHECK(a.height != b.height);
90
+ const int overlapRows = static_cast<int>((35.1 - 20) * scale) - 1;
91
+ int differing = 0;
92
+ for (int row = 0; row < overlapRows; row++) {
93
+ for (int x = 0; x < a.width; x++) {
94
+ if (!(px::at(a, x, row + static_cast<int>(10 * scale)) == px::at(b, x, row))) differing++;
95
+ }
96
+ }
97
+ CHECK_EQ(differing, 0);
98
+ }
99
+
100
+ /// The rounding rule is `round`, at least 1 — the planner's rule.
101
+ TEST(pixelExtentsRoundAndFloorAtOne) {
102
+ CHECK_EQ(px::request(0, 0, 0, 128.125, 128.1, 4).pixelWidth(), 513);
103
+ CHECK_EQ(px::request(0, 0, 0, 128.125, 128.1, 4).pixelHeight(), 512);
104
+ CHECK_EQ(px::request(0, 0, 0, 0.01, 0.01, 1).pixelWidth(), 1);
105
+ CHECK_EQ(px::request(0, 0, 0, 0.01, 0.01, 1).pixelHeight(), 1);
106
+ }
107
+
108
+ /// Cost is flat in scale: the engine clips rather than rasterizing the whole
109
+ /// page and discarding it. Not a threshold in milliseconds — a shape assertion.
110
+ TEST(costIsFlatInScale) {
111
+ auto document = openFixture(fixtures::grid(fixtures::kArchDW, fixtures::kArchDH), "grid-archd.pdf");
112
+ const double side = 256;
113
+ auto best = [&](double scale) {
114
+ double best = 1e9;
115
+ for (int i = 0; i < 5; i++) {
116
+ const auto started = std::chrono::steady_clock::now();
117
+ (void)document->render(px::request(0, 100, 100, side / scale, side / scale, scale), nullptr, nullptr);
118
+ const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - started).count();
119
+ best = std::min(best, ms);
120
+ }
121
+ return best;
122
+ };
123
+ (void)best(1);
124
+ const double one = best(1);
125
+ const double eight = best(8);
126
+ CHECK_MSG(eight < one * 8 + 10,
127
+ "256px at 8x took " + std::to_string(eight) + "ms against " + std::to_string(one) + "ms at 1x");
128
+ }
129
+
130
+ /* ------------------------------------------------------------------ *
131
+ * Backgrounds and alpha
132
+ * ------------------------------------------------------------------ */
133
+
134
+ TEST(whiteBackgroundFillsEverythingTheContentDoesNotPaint) {
135
+ auto document = openFixture(fixtures::page({0, 0, 100, 100}, nullptr, 0, "1 0 0 rg 0 0 40 40 re f\n"),
136
+ "sparse.pdf");
137
+ auto raster = px::renderWhole(*document, 0, 1, false, Background::White);
138
+ CHECK_PIXEL(raster, 90, 10, kWhite);
139
+ CHECK_PIXEL(raster, 50, 50, kWhite);
140
+ CHECK_PIXEL(raster, 10, 90, kRed);
141
+ }
142
+
143
+ TEST(transparentBackgroundLeavesUnpaintedAreaClear) {
144
+ auto document = openFixture(fixtures::page({0, 0, 100, 100}, nullptr, 0, "1 0 0 rg 0 0 40 40 re f\n"),
145
+ "sparse-clear.pdf");
146
+ auto raster = px::renderWhole(*document, 0, 1, false, Background::Transparent);
147
+ CHECK_PIXEL(raster, 90, 10, kClear);
148
+ CHECK_PIXEL(raster, 10, 90, kRed);
149
+ }
150
+
151
+ /// ALPHA IS STRAIGHT, measured off the bytes. A partially covered opaque-white
152
+ /// edge reads `[255,255,255,a]`; premultiplied would be `[a,a,a,a]`. The TS
153
+ /// adapter declares `'straight'` for both native platforms on the strength of
154
+ /// this — the retired cores were both premultiplied, and getting it wrong is
155
+ /// invisible under a white background and wrong under a transparent one.
156
+ TEST(alphaIsStraight) {
157
+ auto document = openFixture(fixtures::antialiasedWhiteEdge(), "aa-edge.pdf");
158
+ auto raster = px::renderWhole(*document, 0, 1, false, Background::Transparent);
159
+ int partial = 0;
160
+ int straight = 0;
161
+ for (int y = 0; y < raster.height; y++) {
162
+ for (int x = 0; x < raster.width; x++) {
163
+ const px::Pixel p = px::at(raster, x, y);
164
+ if (p.a > 10 && p.a < 245) {
165
+ partial++;
166
+ if (p.r >= 250 && p.g >= 250 && p.b >= 250) straight++;
167
+ }
168
+ }
169
+ }
170
+ CHECK_MSG(partial > 20, "an antialiased circle must have partially covered edge pixels; got " +
171
+ std::to_string(partial));
172
+ CHECK_EQ(partial, straight);
173
+ }
174
+
175
+ /* ------------------------------------------------------------------ *
176
+ * Annotations and forms
177
+ * ------------------------------------------------------------------ */
178
+
179
+ /// The green is INSIDE an annotation's appearance stream and nowhere else, so
180
+ /// green in the raster can only have come from compositing the annotation.
181
+ TEST(annotationsAreDrawnOnlyWhenAsked) {
182
+ auto document = openFixture(fixtures::annotatedPage(), "annotated.pdf");
183
+ auto with = px::renderWhole(*document, 0, 1, true);
184
+ auto without = px::renderWhole(*document, 0, 1, false);
185
+ // The annotation covers PDF (50,50)-(150,150), i.e. doc (50,50)-(150,150).
186
+ CHECK_PIXEL(with, 100, 100, kGreen);
187
+ CHECK_PIXEL(without, 100, 100, kWhite);
188
+ // The content stream is unaffected by the flag.
189
+ CHECK_PIXEL(with, 25, 175, kRed);
190
+ CHECK_PIXEL(without, 25, 175, kRed);
191
+ }
192
+
193
+ /// FORM FIELDS ARE WIDGET ANNOTATIONS, and `FPDF_RenderPageBitmap` skips them
194
+ /// even with FPDF_ANNOT — they are drawn by `FPDF_FFLDraw` through the form-fill
195
+ /// environment. Without that second pass a filled-in form renders as a blank
196
+ /// form, which is exactly the "annotations are missing" report this rewrite
197
+ /// exists to close.
198
+ TEST(formFieldsAreDrawnWithAnnotations) {
199
+ auto document = openFixture(fixtures::formFieldPage(), "form.pdf");
200
+ auto with = px::renderWhole(*document, 0, 1, true);
201
+ auto without = px::renderWhole(*document, 0, 1, false);
202
+ // The widget covers PDF (50,50)-(150,100): doc y 100..150.
203
+ const px::Pixel inside = px::at(with, 100, 125);
204
+ CHECK_MSG(!inside.near(kWhite), "the widget must paint something at its centre; got " + inside.str() +
205
+ "\n" + px::map(with));
206
+ CHECK_PIXEL(without, 100, 125, kWhite);
207
+ CHECK_PIXEL(with, 25, 175, kRed);
208
+ }
209
+
210
+ /// Annotations tile exactly like content: a tile of the annotated region equals
211
+ /// that region of the whole-page render.
212
+ TEST(annotationTilesRegisterLikeContent) {
213
+ auto document = openFixture(fixtures::annotatedPage(), "annotated-tile.pdf");
214
+ auto whole = px::renderWhole(*document, 0, 3, true);
215
+ auto tile = document->render(px::request(0, 40, 40, 70, 55, 3, true), nullptr, nullptr);
216
+ const auto diff = px::compare(tile, whole, 120, 120);
217
+ CHECK_EQ(diff.differing, 0);
218
+ }
219
+
220
+ /* ------------------------------------------------------------------ *
221
+ * Geometry
222
+ * ------------------------------------------------------------------ */
223
+
224
+ TEST(pageGeometryIsPostRotation) {
225
+ auto document = openFixture(fixtures::rotated(90), "rotated-90.pdf");
226
+ CHECK_EQ(document->pageCount(), 1);
227
+ CHECK_NEAR(document->geometry(0).width, 792, 0.01);
228
+ CHECK_NEAR(document->geometry(0).height, 612, 0.01);
229
+ CHECK_EQ(document->geometry(0).rotation, 0);
230
+ }
231
+
232
+ /// `/Rotate 90` is clockwise: the unrotated top-left marker (red) lands at the
233
+ /// displayed TOP-RIGHT and the unrotated bottom-left (blue) at the top-left.
234
+ TEST(rotatedPagesDrawUpright) {
235
+ {
236
+ auto document = openFixture(fixtures::rotated(90), "rotated-90-draw.pdf");
237
+ auto raster = px::renderWhole(*document, 0, 0.5);
238
+ CHECK_EQ(raster.width, 396);
239
+ CHECK_EQ(raster.height, 306);
240
+ CHECK_PIXEL(raster, 380, 20, kRed);
241
+ CHECK_PIXEL(raster, 20, 20, kBlue);
242
+ CHECK_PIXEL(raster, 200, 200, kWhite);
243
+ }
244
+ {
245
+ auto document = openFixture(fixtures::rotated(180), "rotated-180-draw.pdf");
246
+ auto raster = px::renderWhole(*document, 0, 0.5);
247
+ CHECK_EQ(raster.width, 306);
248
+ CHECK_EQ(raster.height, 396);
249
+ // Red (unrotated top-left) is now bottom-right; blue (bottom-left) is top-right.
250
+ CHECK_PIXEL(raster, 290, 380, kRed);
251
+ CHECK_PIXEL(raster, 290, 20, kBlue);
252
+ }
253
+ {
254
+ auto document = openFixture(fixtures::rotated(270), "rotated-270-draw.pdf");
255
+ auto raster = px::renderWhole(*document, 0, 0.5);
256
+ CHECK_EQ(raster.width, 396);
257
+ CHECK_PIXEL(raster, 20, 290, kRed);
258
+ CHECK_PIXEL(raster, 380, 290, kBlue);
259
+ }
260
+ }
261
+
262
+ TEST(rotationIsReportedPerPage) {
263
+ auto document = openFixture(
264
+ fixtures::multiPage({{200, 100, 0, "1 0 0 rg 0 0 40 40 re f\n"},
265
+ {200, 100, 90, "1 0 0 rg 0 0 40 40 re f\n"},
266
+ {200, 100, 270, "1 0 0 rg 0 0 40 40 re f\n"}}),
267
+ "rotated-pages.pdf");
268
+ CHECK_NEAR(document->geometry(0).width, 200, 0.01);
269
+ CHECK_NEAR(document->geometry(1).width, 100, 0.01);
270
+ CHECK_NEAR(document->geometry(1).height, 200, 0.01);
271
+ CHECK_NEAR(document->geometry(2).width, 100, 0.01);
272
+ }
273
+
274
+ /// The crop box origin is subtracted: with MediaBox 0 0 400 400 and CropBox
275
+ /// 100 150 300 350, the page is 200x200 and the red marker sits at doc (0,0).
276
+ TEST(cropBoxOriginIsSubtracted) {
277
+ auto document = openFixture(fixtures::offsetCropBox(), "cropbox.pdf");
278
+ CHECK_NEAR(document->geometry(0).width, 200, 0.01);
279
+ CHECK_NEAR(document->geometry(0).height, 200, 0.01);
280
+ auto raster = px::renderWhole(*document, 0, 1);
281
+ CHECK_PIXEL(raster, 20, 20, kRed);
282
+ CHECK_PIXEL(raster, 180, 180, kBlue);
283
+ CHECK_PIXEL(raster, 100, 100, kGrey);
284
+ }
285
+
286
+ TEST(cropBoxIsIntersectedWithMediaBox) {
287
+ auto document = openFixture(fixtures::cropBoxOverhangingMediaBox(), "cropbox-overhang.pdf");
288
+ CHECK_NEAR(document->geometry(0).width, 150, 0.01);
289
+ CHECK_NEAR(document->geometry(0).height, 150, 0.01);
290
+ }
291
+
292
+ TEST(eachPageReportsItsOwnSize) {
293
+ auto document = openFixture(fixtures::mixedSizeDocument(), "mixed.pdf");
294
+ CHECK_EQ(document->pageCount(), 3);
295
+ CHECK_NEAR(document->geometry(0).width, fixtures::kLetterW, 0.01);
296
+ CHECK_NEAR(document->geometry(1).width, fixtures::kArchDW, 0.01);
297
+ CHECK_NEAR(document->geometry(1).height, fixtures::kArchDH, 0.01);
298
+ CHECK_NEAR(document->geometry(2).width, 200, 0.01);
299
+ CHECK_NEAR(document->geometry(2).height, 900, 0.01);
300
+ }
301
+
302
+ TEST(eachPageRendersItsOwnContent) {
303
+ auto document = openFixture(fixtures::coloredPages({{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}, 100, 100),
304
+ "colored.pdf");
305
+ CHECK_PIXEL(px::renderWhole(*document, 0), 50, 50, kRed);
306
+ CHECK_PIXEL(px::renderWhole(*document, 1), 50, 50, kGreen);
307
+ CHECK_PIXEL(px::renderWhole(*document, 2), 50, 50, kBlue);
308
+ // Back to the first, which the page cache (capacity 3) still holds; and
309
+ // then a fourth pattern that forces an eviction and a reload.
310
+ CHECK_PIXEL(px::renderWhole(*document, 0), 50, 50, kRed);
311
+ auto four = openFixture(fixtures::coloredPages({{1, 0, 0}, {0, 1, 0}, {0, 0, 1}, {0, 0, 0}}, 50, 50),
312
+ "colored-4.pdf");
313
+ for (int round = 0; round < 3; round++) {
314
+ CHECK_PIXEL(px::renderWhole(*four, 0), 25, 25, kRed);
315
+ CHECK_PIXEL(px::renderWhole(*four, 1), 25, 25, kGreen);
316
+ CHECK_PIXEL(px::renderWhole(*four, 2), 25, 25, kBlue);
317
+ CHECK_PIXEL(px::renderWhole(*four, 3), 25, 25, kBlack);
318
+ }
319
+ }
320
+
321
+ TEST(openBytesMatchesOpenFile) {
322
+ const auto bytes = fixtures::grid(120, 80);
323
+ auto fromFile = openFixture(bytes, "bytes-vs-file.pdf");
324
+ Source memory;
325
+ memory.bytes = bytes;
326
+ auto fromMemory = Document::open(memory);
327
+ auto a = px::renderWhole(*fromFile, 0, 2);
328
+ auto b = px::renderWhole(*fromMemory, 0, 2);
329
+ CHECK_EQ(px::compare(a, b).differing, 0);
330
+ }
331
+
332
+ /* ------------------------------------------------------------------ *
333
+ * Failures
334
+ * ------------------------------------------------------------------ */
335
+
336
+ TEST(openFailuresAreDistinguishable) {
337
+ Source missing;
338
+ missing.path = fixtures::tempDir() + "/does-not-exist.pdf";
339
+ EXPECT_ERROR(Document::open(missing), ErrorCode::NotFound);
340
+
341
+ Source directory;
342
+ directory.path = fixtures::tempDir();
343
+ EXPECT_ERROR(Document::open(directory), ErrorCode::NotFound);
344
+
345
+ Source empty;
346
+ empty.path = fixtures::writeTemp({}, "empty.pdf");
347
+ EXPECT_ERROR(Document::open(empty), ErrorCode::Corrupt);
348
+
349
+ Source emptyBytes;
350
+ EXPECT_ERROR(Document::open(emptyBytes), ErrorCode::Corrupt);
351
+
352
+ Source notPdf;
353
+ notPdf.path = fixtures::writeTemp(fixtures::notAPdfAtAll(), "not-a-pdf.pdf");
354
+ EXPECT_ERROR(Document::open(notPdf), ErrorCode::Corrupt);
355
+
356
+ Source garbage;
357
+ garbage.path = fixtures::writeTemp(fixtures::headerOnlyGarbage(), "header-only.pdf");
358
+ EXPECT_ERROR(Document::open(garbage), ErrorCode::Corrupt);
359
+
360
+ // And a good document still opens afterwards: nothing poisons the process,
361
+ // which is the API-24 `PdfRenderer` behaviour the old preflight existed for.
362
+ auto fine = openFixture(fixtures::quadrants(40, 40), "after-garbage.pdf");
363
+ CHECK_EQ(fine->pageCount(), 1);
364
+ }
365
+
366
+ TEST(encryptedDocuments) {
367
+ const auto bytes = fixtures::encrypted();
368
+ Source locked;
369
+ locked.path = fixtures::writeTemp(bytes, "encrypted.pdf");
370
+ EXPECT_ERROR(Document::open(locked), ErrorCode::PasswordRequired);
371
+
372
+ Source wrong = locked;
373
+ wrong.hasPassword = true;
374
+ wrong.password = "nope";
375
+ EXPECT_ERROR(Document::open(wrong), ErrorCode::PasswordIncorrect);
376
+
377
+ Source right = locked;
378
+ right.hasPassword = true;
379
+ right.password = fixtures::kUserPassword;
380
+ auto document = Document::open(right);
381
+ auto raster = px::renderWhole(*document, 0, 0.5);
382
+ CHECK_PIXEL(raster, 50, 50, kRed);
383
+
384
+ // The owner password opens it too — that is what an owner password is for.
385
+ Source owner = locked;
386
+ owner.hasPassword = true;
387
+ owner.password = fixtures::kOwnerPassword;
388
+ CHECK_EQ(Document::open(owner)->pageCount(), 1);
389
+ }
390
+
391
+ TEST(outOfRangePageIsNotFound) {
392
+ auto document = openFixture(fixtures::quadrants(40, 40), "range.pdf");
393
+ EXPECT_ERROR(document->geometry(1), ErrorCode::NotFound);
394
+ EXPECT_ERROR(document->geometry(-1), ErrorCode::NotFound);
395
+ EXPECT_ERROR(document->render(px::request(1, 0, 0, 10, 10, 1), nullptr, nullptr), ErrorCode::NotFound);
396
+ }
397
+
398
+ TEST(degenerateRequestsAreRefused) {
399
+ auto document = openFixture(fixtures::quadrants(40, 40), "degenerate.pdf");
400
+ EXPECT_ERROR(document->render(px::request(0, 0, 0, 0, 10, 1), nullptr, nullptr), ErrorCode::BackendFailure);
401
+ EXPECT_ERROR(document->render(px::request(0, 0, 0, 10, -1, 1), nullptr, nullptr), ErrorCode::BackendFailure);
402
+ EXPECT_ERROR(document->render(px::request(0, 0, 0, 10, 10, 0), nullptr, nullptr), ErrorCode::BackendFailure);
403
+ EXPECT_ERROR(document->render(px::request(0, 0, 0, 10, 10, -2), nullptr, nullptr), ErrorCode::BackendFailure);
404
+ EXPECT_ERROR(document->render(px::request(0, 1.0 / 0.0, 0, 10, 10, 1), nullptr, nullptr), ErrorCode::BackendFailure);
405
+ }
406
+
407
+ TEST(anAbsurdRasterIsRefused) {
408
+ auto document = openFixture(fixtures::quadrants(40, 40), "absurd.pdf");
409
+ EXPECT_ERROR(document->render(px::request(0, 0, 0, 40, 40, 300), nullptr, nullptr), ErrorCode::OutOfMemory);
410
+ EXPECT_ERROR(document->render(px::request(0, 0, 0, 1, 1, 1e12), nullptr, nullptr), ErrorCode::OutOfMemory);
411
+ }
412
+
413
+ TEST(renderingAfterCloseFails) {
414
+ auto document = openFixture(fixtures::quadrants(40, 40), "closed.pdf");
415
+ document->close();
416
+ document->close();
417
+ CHECK(document->isClosed());
418
+ EXPECT_ERROR(document->render(px::request(0, 0, 0, 10, 10, 1), nullptr, nullptr), ErrorCode::BackendFailure);
419
+ // Geometry is precomputed and still readable — a closed handle can still
420
+ // describe its pages, which is what a layout holding on to sizes needs.
421
+ CHECK_NEAR(document->geometry(0).width, 40, 0.01);
422
+ }
423
+
424
+ /* ------------------------------------------------------------------ *
425
+ * Cancellation
426
+ * ------------------------------------------------------------------ */
427
+
428
+ TEST(aCancelBeforeStartStopsTheRender) {
429
+ auto document = openFixture(fixtures::quadrants(40, 40), "cancel-before.pdf");
430
+ Cancellation signal;
431
+ signal.cancel();
432
+ EXPECT_ERROR(document->render(px::request(0, 0, 0, 40, 40, 1), &signal, nullptr), ErrorCode::Cancelled);
433
+ }
434
+
435
+ /// A cancel that lands DURING the draw stops it. The affordance flips the flag
436
+ /// on the second time PDFium asks whether to pause, which is inside the render
437
+ /// and nowhere else — the property neither retired core could offer.
438
+ TEST(aCancelMidRenderStopsTheDrawAndLeavesThePageUsable) {
439
+ auto document = openFixture(fixtures::grid(fixtures::kArchDW, fixtures::kArchDH, 4), "cancel-mid.pdf");
440
+ Cancellation signal;
441
+ signal.cancelAfterPolls(2);
442
+ EXPECT_ERROR(document->render(px::request(0, 0, 0, fixtures::kArchDW, fixtures::kArchDH, 1), &signal, nullptr),
443
+ ErrorCode::Cancelled);
444
+ CHECK(signal.isCancelled());
445
+ // The progressive render was closed properly: the same page renders again.
446
+ // (6,2) sits between the 4pt grid lines and off both red diagonals — one of
447
+ // which passes through the doc-space origin, so (2,2) would be red.
448
+ auto raster = document->render(px::request(0, 0, 0, 100, 100, 1), nullptr, nullptr);
449
+ CHECK_EQ(raster.width, 100);
450
+ CHECK_PIXEL(raster, 6, 2, kWhite);
451
+ // On the antialiased edge of the 2pt red diagonal: reddish, not exactly red.
452
+ const px::Pixel onDiagonal = px::at(raster, 2, 2);
453
+ CHECK_MSG(onDiagonal.r > 200 && onDiagonal.g < 120, "expected the red diagonal at (2,2), got " + onDiagonal.str());
454
+ }
455
+
456
+ TEST(anUncancelledSignalIsTransparent) {
457
+ auto document = openFixture(fixtures::quadrants(40, 40), "cancel-none.pdf");
458
+ Cancellation signal;
459
+ auto raster = document->render(px::request(0, 0, 0, 40, 40, 1), &signal, nullptr);
460
+ CHECK_EQ(raster.width, 40);
461
+ CHECK(!signal.isCancelled());
462
+ }
463
+
464
+ /// Closing while a render is in flight on another thread is safe: close waits
465
+ /// for the library lock, and the render completes or cancels first.
466
+ TEST(closeWhileRenderingIsSafe) {
467
+ auto document = openFixture(fixtures::grid(fixtures::kArchDW, fixtures::kArchDH, 4), "close-inflight.pdf");
468
+ Cancellation signal;
469
+ std::exception_ptr failure;
470
+ std::thread worker([&] {
471
+ try {
472
+ (void)document->render(px::request(0, 0, 0, fixtures::kArchDW, fixtures::kArchDH, 2), &signal, nullptr);
473
+ } catch (const PdfError&) {
474
+ // Cancelled or closed under us: both are correct outcomes.
475
+ } catch (...) {
476
+ failure = std::current_exception();
477
+ }
478
+ });
479
+ std::this_thread::sleep_for(std::chrono::milliseconds(5));
480
+ signal.cancel();
481
+ document->close();
482
+ worker.join();
483
+ CHECK(!failure);
484
+ CHECK(document->isClosed());
485
+ }