@reekon-tools/react-native-pdf-canvas 0.1.0 → 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 (129) 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/index.native.d.ts +5 -0
  19. package/dist/index.native.js +5 -0
  20. package/dist/index.web.d.ts +10 -0
  21. package/dist/index.web.js +10 -0
  22. package/dist/rasterizer/index.native.d.ts +8 -12
  23. package/dist/rasterizer/index.native.js +15 -21
  24. package/dist/rasterizer/native-bridge.d.ts +51 -75
  25. package/dist/rasterizer/native-bridge.js +49 -33
  26. package/dist/rasterizer/native.d.ts +45 -0
  27. package/dist/rasterizer/native.js +123 -0
  28. package/dist/react/usePdfDocument.d.ts +5 -5
  29. package/dist/react/usePdfLayer.d.ts +10 -1
  30. package/dist/react/usePdfLayer.js +10 -2
  31. package/dist/skia/index.native.d.ts +7 -3
  32. package/dist/skia/index.native.js +7 -3
  33. package/dist/skia/index.web.d.ts +4 -2
  34. package/dist/skia/index.web.js +4 -2
  35. package/dist/testing/scenes.d.ts +3 -3
  36. package/dist/testing/scenes.js +4 -4
  37. package/dist/types.d.ts +36 -42
  38. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +234 -536
  39. package/native/.clangd +5 -0
  40. package/native/CMakeLists.txt +89 -0
  41. package/native/core/include/pdfcanvas/document.h +90 -0
  42. package/native/core/include/pdfcanvas/error.h +47 -0
  43. package/native/core/include/pdfcanvas/jsi.h +18 -0
  44. package/native/core/include/pdfcanvas/jsi_config.h +30 -0
  45. package/native/core/include/pdfcanvas/library.h +28 -0
  46. package/native/core/include/pdfcanvas/pixels.h +92 -0
  47. package/native/core/include/pdfcanvas/service.h +87 -0
  48. package/native/core/include/pdfcanvas/slots.h +113 -0
  49. package/native/core/include/pdfcanvas/types.h +110 -0
  50. package/native/core/pdfcanvas-core.cmake +22 -0
  51. package/native/core/src/document.cpp +516 -0
  52. package/native/core/src/error.cpp +29 -0
  53. package/native/core/src/jsi.cpp +66 -0
  54. package/native/core/src/library.cpp +52 -0
  55. package/native/core/src/pixels.cpp +45 -0
  56. package/native/core/src/service.cpp +156 -0
  57. package/native/core/src/slots.cpp +139 -0
  58. package/native/tests/fixtures.cpp +451 -0
  59. package/native/tests/fixtures.h +96 -0
  60. package/native/tests/harness.h +122 -0
  61. package/native/tests/main.cpp +64 -0
  62. package/native/tests/pixels_util.h +126 -0
  63. package/native/tests/test_document.cpp +485 -0
  64. package/native/tests/test_service.cpp +130 -0
  65. package/native/tests/test_slots.cpp +250 -0
  66. package/package.json +38 -8
  67. package/react-native.config.js +5 -2
  68. package/scripts/fetch-pdfium.mjs +487 -0
  69. package/scripts/pdfium-manifest.json +46 -0
  70. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +0 -226
  71. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +0 -239
  72. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +0 -251
  73. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +0 -221
  74. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +0 -783
  75. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +0 -1388
  76. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +0 -98
  77. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +0 -324
  78. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +0 -775
  79. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +0 -20
  80. package/android/src/jsi/cpp/CMakeLists.txt +0 -33
  81. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +0 -469
  82. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +0 -140
  83. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +0 -45
  84. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +0 -564
  85. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +0 -277
  86. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +0 -214
  87. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +0 -234
  88. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +0 -47
  89. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +0 -143
  90. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +0 -95
  91. package/dist/rasterizer/android.d.ts +0 -121
  92. package/dist/rasterizer/android.js +0 -126
  93. package/dist/rasterizer/ios.d.ts +0 -84
  94. package/dist/rasterizer/ios.js +0 -155
  95. package/ios/Package.swift +0 -58
  96. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +0 -38
  97. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +0 -389
  98. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +0 -57
  99. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +0 -44
  100. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +0 -82
  101. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +0 -37
  102. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +0 -158
  103. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +0 -80
  104. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +0 -65
  105. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +0 -548
  106. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +0 -48
  107. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +0 -26
  108. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +0 -122
  109. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +0 -80
  110. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +0 -79
  111. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +0 -129
  112. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +0 -100
  113. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +0 -65
  114. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +0 -111
  115. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +0 -85
  116. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +0 -163
  117. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +0 -297
  118. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +0 -140
  119. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +0 -358
  120. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +0 -262
  121. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +0 -173
  122. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +0 -114
  123. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +0 -158
  124. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +0 -544
  125. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +0 -287
  126. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +0 -396
  127. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +0 -639
  128. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +0 -179
  129. 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
+ }