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

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 +77 -40
  4. package/README.md +218 -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 +559 -0
  61. package/scripts/pdfium-manifest.json +48 -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
@@ -1,775 +0,0 @@
1
- package tools.reekon.pdfcanvas;
2
-
3
- import static org.junit.Assert.assertEquals;
4
- import static org.junit.Assert.assertTrue;
5
- import static org.junit.Assert.fail;
6
-
7
- import android.content.Context;
8
- import android.graphics.Bitmap;
9
- import android.graphics.Color;
10
- import android.util.Log;
11
-
12
- import androidx.test.ext.junit.runners.AndroidJUnit4;
13
- import androidx.test.platform.app.InstrumentationRegistry;
14
-
15
- import org.junit.Test;
16
- import org.junit.runner.RunWith;
17
-
18
- import java.io.File;
19
- import java.io.FileInputStream;
20
- import java.io.IOException;
21
-
22
- /**
23
- * Geometry, registration, background and pixel layout for
24
- * {@link PdfCanvasRasterizer}.
25
- *
26
- * Every test here names the bug it catches. That is not decoration: a rasterizer
27
- * fails by producing a PLAUSIBLE WRONG IMAGE — sheared, offset, black where it
28
- * should be white, channels swapped — and none of those throw. A test that only
29
- * asserts "some bytes came back" passes for all of them.
30
- */
31
- @RunWith(AndroidJUnit4.class)
32
- public class PdfCanvasRasterizerTest {
33
-
34
- private static final String TAG = "PDFCANVAS";
35
-
36
- private static Context ctx() {
37
- return InstrumentationRegistry.getInstrumentation().getTargetContext();
38
- }
39
-
40
- private static RasterRequest req(
41
- int page, float x, float y, float w, float h, float scale, int background) {
42
- return new RasterRequest(page, x, y, w, h, scale, false, background);
43
- }
44
-
45
- private static RasterRequest white(
46
- int page, float x, float y, float w, float h, float scale) {
47
- return req(page, x, y, w, h, scale, RasterRequest.BACKGROUND_WHITE);
48
- }
49
-
50
- /* ================================================================ *
51
- * 1. The S1 property, now against THIS code
52
- * ================================================================ */
53
-
54
- /**
55
- * A tile must BE the corresponding region of a larger render at the same
56
- * scale.
57
- *
58
- * CATCHES: a sign error in {@code postTranslate} (the tile would show the
59
- * mirror-image offset), an x/y transposition (the origin here has x != y and
60
- * the region offset inside the reference has dx != dy, so neither can hide),
61
- * scale applied to the translate twice, and any inset/rounding of the doc rect
62
- * before it reaches the matrix. All four produce a perfectly valid-looking
63
- * image, and all four break the overlap registration that the detail rasters
64
- * and the cache both depend on.
65
- *
66
- * Note that BOTH renders here use a non-zero origin. Spike S1 compared a tile
67
- * against a full-page render, so its reference had a zero translate and could
68
- * not have caught a translate that is wrong only for a moving origin.
69
- */
70
- @Test
71
- public void tileIsPixelIdenticalToTheSameRegionOfALargerRender() throws Exception {
72
- File pdf = TestPdfs.pattern(ctx(), "s1-letter.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
73
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
74
- try {
75
- final float scale = 4f;
76
- // Reference region and tile, both interior, both with x != y.
77
- RasterPixels reference = r.render(white(0, 100f, 300f, 400f, 400f, scale), null);
78
- RasterPixels tile = r.render(white(0, 200f, 450f, 128f, 128f, scale), null);
79
-
80
- int dx = Math.round((200f - 100f) * scale);
81
- int dy = Math.round((450f - 300f) * scale);
82
-
83
- Diff diff = compareRegion(tile, reference, dx, dy);
84
- String summary = "tile 512^2 @" + scale + "x inside " + reference.width + "x"
85
- + reference.height + " at (" + dx + "," + dy + ") | " + diff;
86
- Log.i(TAG, "S1-property " + summary);
87
-
88
- // Same bar spike S1 used: anti-aliasing on a stroke edge can differ by a
89
- // hair, but a wrong ORIGIN or SCALE moves a large share of this
90
- // high-frequency pattern and cannot sneak under it.
91
- assertTrue(summary, diff.mismatchPercent < 0.5);
92
- assertTrue(summary, diff.maxDelta <= 64);
93
- } finally {
94
- r.close();
95
- }
96
- }
97
-
98
- /**
99
- * Two rasters asked for at the SAME scale must agree pixel-for-pixel wherever
100
- * they overlap, even when neither doc rect lands on a whole number of pixels.
101
- *
102
- * CATCHES: re-deriving the scale from the rounded pixel size
103
- * ({@code pixelWidth() / docWidth}) instead of using the requested
104
- * {@code scale} verbatim. That change is tempting — it makes each raster cover
105
- * its doc rect to the last sub-pixel — and it is invisible to
106
- * {@link #tileIsPixelIdenticalToTheSameRegionOfALargerRender}, whose rects are
107
- * 400 and 128 points at 4x and therefore land on exact pixel counts, so the
108
- * derived scale comes out to exactly 4 for both. {@code tileMatrix}'s comment
109
- * used to claim this property "rests on" that test. It did not, and nothing
110
- * pinned it until this.
111
- *
112
- * The rects here are chosen so the rounding goes OPPOSITE ways:
113
- * 128.125 x 4 = 512.5, which rounds UP to 513, and 128.1 x 4 = 512.4, which
114
- * rounds DOWN to 512. Under exact scale both rasters map pixel i to doc
115
- * {@code origin + i/4} and are identical over the 512-pixel overlap. Under a
116
- * derived scale one is drawn at 513/128.125 and the other at 512/128.1, and
117
- * the two drift apart by close to a full pixel by the far corner — which on
118
- * this high-frequency pattern moves a large share of the pixels.
119
- *
120
- * Both rects share an origin, so the overlap starts at (0, 0) in each and no
121
- * offset arithmetic can absorb the error.
122
- */
123
- @Test
124
- public void rastersAtTheSameScaleAgreeWhereTheyOverlap() throws Exception {
125
- File pdf = TestPdfs.pattern(ctx(), "scale-letter.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
126
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
127
- try {
128
- final float scale = 4f;
129
- final float originX = 100f;
130
- final float originY = 200f;
131
-
132
- RasterPixels roundsUp =
133
- r.render(white(0, originX, originY, 128.125f, 128.125f, scale), null);
134
- RasterPixels roundsDown =
135
- r.render(white(0, originX, originY, 128.1f, 128.1f, scale), null);
136
-
137
- // The rounding is the whole point of the fixture, so assert it happened.
138
- assertEquals("128.125pt @4x must round UP to 513px", 513, roundsUp.width);
139
- assertEquals("128.1pt @4x must round DOWN to 512px", 512, roundsDown.width);
140
-
141
- Diff diff = compareRegion(roundsDown, roundsUp, 0, 0);
142
- String summary = "overlap 512^2 inside 513^2 @" + scale + "x | " + diff;
143
- Log.i(TAG, "same-scale " + summary);
144
-
145
- assertTrue(summary, diff.mismatchPercent < 0.5);
146
- assertTrue(summary, diff.maxDelta <= 64);
147
- } finally {
148
- r.close();
149
- }
150
- }
151
-
152
- /* ================================================================ *
153
- * 2. Background — S1 finding 3
154
- * ================================================================ */
155
-
156
- /**
157
- * CATCHES: a missing pre-fill. Spike S1 measured that
158
- * {@code PdfRenderer.render} PRESERVES whatever was in the bitmap and never
159
- * clears it, so area the page does not cover keeps the allocation's zeros —
160
- * transparent black — and composites under the app's annotation layers as a
161
- * BLACK page rather than a blank one. This test samples a pixel beyond the
162
- * page edge, which the renderer provably never touches, so a white result
163
- * there can only have come from the pre-fill.
164
- */
165
- @Test
166
- public void whiteBackgroundFillsAreaThePageDoesNotCover() throws Exception {
167
- File pdf = TestPdfs.pattern(ctx(), "bg-letter.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
168
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
169
- try {
170
- // Straddles the page's bottom-right corner: doc x 500..700, y 700..900,
171
- // against a 612x792 page.
172
- RasterPixels px = r.render(white(0, 500f, 700f, 200f, 200f, 1f), null);
173
- assertEquals(200, px.width);
174
- assertEquals(200, px.height);
175
-
176
- // (190, 190) is doc (690, 890) — outside the page on both axes.
177
- int[] outside = rgba(px, 190, 190);
178
- assertEquals("R outside page: " + describe(outside), 255, outside[0]);
179
- assertEquals("G outside page: " + describe(outside), 255, outside[1]);
180
- assertEquals("B outside page: " + describe(outside), 255, outside[2]);
181
- assertEquals("A outside page: " + describe(outside), 255, outside[3]);
182
- } finally {
183
- r.close();
184
- }
185
- }
186
-
187
- /**
188
- * CATCHES: a backend that ignores {@code background} and always fills white.
189
- * That failure is invisible on an opaque page and only shows up when the host
190
- * composites the raster over something, at which point the "transparent"
191
- * raster hides the layer beneath it.
192
- *
193
- * Also catches the opposite mistake — a bitmap that was never rendered into at
194
- * all — because the in-page sample must be OPAQUE.
195
- */
196
- @Test
197
- public void transparentBackgroundLeavesUncoveredAreaTransparent() throws Exception {
198
- File pdf = TestPdfs.pattern(ctx(), "bg2-letter.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
199
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
200
- try {
201
- RasterPixels px =
202
- r.render(
203
- req(0, 500f, 700f, 200f, 200f, 1f, RasterRequest.BACKGROUND_TRANSPARENT), null);
204
-
205
- int[] outside = rgba(px, 190, 190);
206
- assertEquals("alpha outside page: " + describe(outside), 0, outside[3]);
207
-
208
- // (10, 10) is doc (510, 710) — well inside the page, which the pattern
209
- // paints opaque white.
210
- int[] inside = rgba(px, 10, 10);
211
- assertEquals("alpha inside page: " + describe(inside), 255, inside[3]);
212
- } finally {
213
- r.close();
214
- }
215
- }
216
-
217
- /* ================================================================ *
218
- * 3. Page geometry
219
- * ================================================================ */
220
-
221
- /**
222
- * CATCHES: probing page 0 and reporting its size for every page — which a
223
- * single-size document cannot catch, and which is the natural shape of a
224
- * "cache the first geometry" bug.
225
- */
226
- @Test
227
- public void pageGeometryIsPerPage() throws Exception {
228
- File pdf = TestPdfs.mixedSizes(ctx(), "mixed.pdf");
229
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
230
- try {
231
- assertEquals(3, r.pageCount());
232
- for (int p = 0; p < 3; p++) {
233
- int[] expected = TestPdfs.mixedSizeExpectation(p);
234
- PageGeometry g = r.pageGeometry(p);
235
- assertEquals("page " + p + " width", expected[0], g.widthPoints);
236
- assertEquals("page " + p + " height", expected[1], g.heightPoints);
237
- assertEquals("page " + p + " index", p, g.index);
238
- }
239
- } finally {
240
- r.close();
241
- }
242
- }
243
-
244
- /**
245
- * Page geometry is post-rotation on EVERY level tested, including the API 24
246
- * floor.
247
- *
248
- * CATCHES: reporting the MediaBox instead of the displayed box. This half is
249
- * unconditional — {@code getWidth()} forwards to PDFium's
250
- * {@code FPDF_GetPageWidth}, and {@code CPDF_Page::UpdateDimensions} swaps the
251
- * media box's axes for /Rotate 90 and 270 on every build measured.
252
- */
253
- @Test
254
- public void rotatedPageGeometryIsPostRotation() throws Exception {
255
- File pdf = TestPdfs.rotated90(ctx(), "rotated-geom.pdf");
256
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
257
- try {
258
- PageGeometry g = r.pageGeometry(0);
259
- Log.i(TAG, "rotation geometry " + g);
260
- assertEquals("width should be the 792pt long edge", TestPdfs.LETTER_H, g.widthPoints);
261
- assertEquals("height should be the 612pt short edge", TestPdfs.LETTER_W, g.heightPoints);
262
- assertEquals("residual rotation the caller must apply", 0, g.rotationDegrees);
263
- } finally {
264
- r.close();
265
- }
266
- }
267
-
268
- /**
269
- * Whether a rotated page is DRAWN upright is a platform capability, and this
270
- * asserts the backend's report of it against what the platform actually does.
271
- *
272
- * The fixture is a 612x792 page with {@code /Rotate 90} and two markers: red
273
- * at the unrotated top-left, blue at the unrotated bottom-left. A clockwise
274
- * quarter turn — which is what /Rotate means — puts red at the displayed
275
- * top-RIGHT and blue at the displayed top-LEFT. An unrotated render leaves red
276
- * top-left; a counter-clockwise render puts red bottom-left. All three are
277
- * distinguishable here, which a dimensions-only assertion could not do:
278
- * swapping width and height while drawing the content unrotated would pass
279
- * that, and is exactly what API 24 does.
280
- *
281
- * CATCHES, on a platform that rotates: any of those three wrong placements.
282
- * CATCHES, on a platform that does not: a {@link PdfPlatformQuirks} probe that
283
- * lies. The UNSUPPORTED branch asserts the MEASURED broken layout — content
284
- * drawn unrotated with the y-flip taken from the post-rotation height, so blue
285
- * lands at y = 612 - 100 and red is pushed off the top — rather than merely
286
- * skipping. A probe stuck on SUPPORTED fails on API 24 and one stuck on
287
- * UNSUPPORTED fails on API 31/33, so neither constant passes this.
288
- */
289
- @Test
290
- public void rotatedPageRendersUprightWhereThePlatformSupportsIt() throws Exception {
291
- PdfPlatformQuirks.RotationSupport support =
292
- PdfCanvasRasterizer.rotationSupport(ctx().getCacheDir());
293
- File pdf = TestPdfs.rotated90(ctx(), "rotated-render.pdf");
294
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
295
- try {
296
- PageGeometry g = r.pageGeometry(0);
297
- RasterPixels px = r.render(white(0, 0f, 0f, g.widthPoints, g.heightPoints, 1f), null);
298
- assertEquals(TestPdfs.LETTER_H, px.width);
299
- assertEquals(TestPdfs.LETTER_W, px.height);
300
-
301
- int[] topRight = rgba(px, px.width - 40, 40);
302
- int[] topLeft = rgba(px, 40, 40);
303
- int[] middle = rgba(px, px.width / 2, px.height / 2);
304
- Log.i(TAG, "rotation support=" + support + " samples tr=" + describe(topRight)
305
- + " tl=" + describe(topLeft) + " mid=" + describe(middle));
306
-
307
- if (support == PdfPlatformQuirks.RotationSupport.SUPPORTED) {
308
- assertTrue("top-right should be the red marker: " + describe(topRight), isRed(topRight));
309
- assertTrue("top-left should be the blue marker: " + describe(topLeft), isBlue(topLeft));
310
- assertTrue("middle should be blank page: " + describe(middle), isWhite(middle));
311
- } else {
312
- // The measured API 24 layout: content unrotated, y-flip using the
313
- // post-rotation height of 612, so blue (PDF y 0..100) lands at
314
- // 612-100..612 and red (PDF y 692..792) is translated off the top.
315
- assertTrue(
316
- "probe says " + support + ", so red must NOT be at the top-right: "
317
- + describe(topRight),
318
- !isRed(topRight));
319
- int[] blue = rgba(px, 40, 560);
320
- assertTrue(
321
- "probe says " + support + ", so blue must be at the measured broken "
322
- + "position (x40, y560): " + describe(blue),
323
- isBlue(blue));
324
- }
325
- } finally {
326
- r.close();
327
- }
328
- }
329
-
330
- /**
331
- * CATCHES: a lazy {@code pageGeometry} that opens the page on demand.
332
- *
333
- * The contract says the call is cheap and must not load a page, and the only
334
- * way to observe that from outside is to ask when loading a page is
335
- * impossible. After {@code close()} every PdfRenderer is shut; a lazy
336
- * implementation would throw or crash here, while a precomputed one returns
337
- * the same numbers it did before. Page 2 is read ONLY after close, so an
338
- * implementation that lazily cached page 0 during open cannot pass by
339
- * accident.
340
- */
341
- @Test
342
- public void pageGeometryIsPrecomputedAtOpenNotLoadedOnDemand() throws Exception {
343
- File pdf = TestPdfs.mixedSizes(ctx(), "mixed-lazy.pdf");
344
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
345
- PageGeometry beforeClose = r.pageGeometry(0);
346
- r.close();
347
-
348
- PageGeometry afterClose = r.pageGeometry(0);
349
- assertEquals(beforeClose.widthPoints, afterClose.widthPoints);
350
- assertEquals(beforeClose.heightPoints, afterClose.heightPoints);
351
-
352
- int[] expected = TestPdfs.mixedSizeExpectation(2);
353
- PageGeometry neverAskedBefore = r.pageGeometry(2);
354
- assertEquals(expected[0], neverAskedBefore.widthPoints);
355
- assertEquals(expected[1], neverAskedBefore.heightPoints);
356
- assertEquals(3, r.pageCount());
357
- }
358
-
359
- /* ================================================================ *
360
- * 4. Pixel layout — byte order and stride
361
- * ================================================================ */
362
-
363
- /**
364
- * CATCHES: reporting {@code bgra8888}, or emitting ARGB (the order the config
365
- * is NAMED for) while claiming RGBA.
366
- *
367
- * {@code Bitmap.Config.ARGB_8888} is named after the packed int that
368
- * {@code getPixel} returns, not after the in-memory byte order, which is
369
- * R,G,B,A. Getting this backwards does not fail — it swaps red and blue in
370
- * every raster, which looks like a colour-management problem and gets chased
371
- * in the wrong place for a day.
372
- */
373
- @Test
374
- public void pixelBytesAreRgbaNotBgraOrArgb() throws Exception {
375
- File pdf = TestPdfs.quadrants(ctx(), "quads.pdf", 200, 300);
376
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
377
- try {
378
- RasterPixels px = r.render(white(0, 0f, 0f, 200f, 300f, 1f), null);
379
- assertEquals(RasterPixels.FORMAT_RGBA_8888, px.format);
380
-
381
- int[] red = rgba(px, 50, 75);
382
- int[] green = rgba(px, 150, 75);
383
- int[] blue = rgba(px, 50, 225);
384
- int[] black = rgba(px, 150, 225);
385
- Log.i(TAG, "channel order r=" + describe(red) + " g=" + describe(green)
386
- + " b=" + describe(blue) + " k=" + describe(black));
387
-
388
- assertTrue("first byte of the red quadrant must be R: " + describe(red), isRed(red));
389
- assertTrue("green quadrant: " + describe(green), isGreen(green));
390
- assertTrue("blue quadrant: " + describe(blue), isBlue(blue));
391
- assertTrue("black quadrant: " + describe(black), isBlack(black));
392
- } finally {
393
- r.close();
394
- }
395
- }
396
-
397
- /**
398
- * The reported {@code rowBytes} must be the stride the PLATFORM chose for a
399
- * bitmap of these dimensions and config — not a number this backend derived.
400
- *
401
- * CATCHES: any stride the backend states that the platform did not use. The
402
- * reference is an independently allocated {@code Bitmap} of the same width,
403
- * height and {@code ARGB_8888} config, asked for its own
404
- * {@code getRowBytes()} / {@code getByteCount()}. That is the only claim that
405
- * can be checked from outside, and it is checked at seven widths including odd
406
- * and prime ones, which is where a platform would pad if it padded anywhere.
407
- *
408
- * WHAT IT CANNOT CATCH, stated plainly because the previous version of this
409
- * comment claimed otherwise. {@code Bitmap.createBitmap} goes to
410
- * {@code SkImageInfo::minRowBytes()}, which is {@code width * bytesPerPixel}
411
- * with no alignment, so on every Android build measured here the stride IS
412
- * {@code width * 4} at every width — 1, 3, 17, 33, 127, 200 and 201 all come
413
- * back tight. On such a platform an implementation that ASSUMED
414
- * {@code width * 4} is indistinguishable from one that read
415
- * {@code getRowBytes()}, by this test or by any other, because the two
416
- * quantities are equal. The old buffer-length assertion
417
- * ({@code rowBytes * height == bytes.length}) did not close that gap either:
418
- * both sides move together when the buffer is sized from
419
- * {@code getByteCount()}.
420
- *
421
- * So the honest statement of what is defended: the backend states a stride the
422
- * platform agrees with, and the moment a device DOES pad — a bitmap that came
423
- * from a hardware buffer, or a future allocator that aligns rows — the
424
- * assumption becomes wrong and this test is the thing that notices. The
425
- * padded-stride path itself is covered on the JS side, where the fake backend
426
- * can pad on demand (`reports the stride the backend gave, padding and all`).
427
- */
428
- @Test
429
- public void rowBytesIsReportedNotAssumed() throws Exception {
430
- File pdf = TestPdfs.quadrants(ctx(), "quads-stride.pdf", 200, 300);
431
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
432
- StringBuilder measured = new StringBuilder("rowBytes measured:");
433
- try {
434
- for (int targetWidth : new int[] {1, 3, 17, 33, 127, 200, 201}) {
435
- float scale = targetWidth / 200f;
436
- RasterPixels px = r.render(white(0, 0f, 0f, 200f, 300f, scale), null);
437
- assertEquals(targetWidth, px.width);
438
-
439
- // The platform's own answer for this exact allocation. Same call the
440
- // backend makes, on a bitmap this test owns, so the comparison is
441
- // against the platform rather than against the backend's arithmetic.
442
- Bitmap probe = Bitmap.createBitmap(px.width, px.height, Bitmap.Config.ARGB_8888);
443
- int platformRowBytes = probe.getRowBytes();
444
- int platformByteCount = probe.getByteCount();
445
- probe.recycle();
446
-
447
- measured
448
- .append(' ')
449
- .append(px.width)
450
- .append('x')
451
- .append(px.height)
452
- .append("->")
453
- .append(px.rowBytes)
454
- .append(px.rowBytes == px.width * 4 ? "(tight)" : "(PADDED)");
455
-
456
- assertEquals(
457
- "reported rowBytes must be the platform's own stride for a "
458
- + px.width + "x" + px.height + " ARGB_8888 bitmap",
459
- platformRowBytes,
460
- px.rowBytes);
461
- assertEquals(
462
- "buffer length must be the platform's own byteCount for a "
463
- + px.width + "x" + px.height + " ARGB_8888 bitmap",
464
- platformByteCount,
465
- px.bytes.length);
466
- assertTrue(
467
- "rowBytes " + px.rowBytes + " cannot hold " + px.width + " pixels",
468
- px.rowBytes >= px.width * 4);
469
- assertEquals(
470
- "buffer length must be rowBytes*height for width " + px.width,
471
- px.rowBytes * px.height,
472
- px.bytes.length);
473
-
474
- // Quadrant sampling only means anything once the raster is big enough
475
- // for a quadrant to have interior pixels.
476
- if (px.width >= 16 && px.height >= 16) {
477
- int qx = px.width / 4;
478
- int qy = px.height / 4;
479
- assertTrue("red quadrant at width " + px.width + ": "
480
- + describe(rgba(px, qx, qy)), isRed(rgba(px, qx, qy)));
481
- assertTrue("green quadrant at width " + px.width + ": "
482
- + describe(rgba(px, px.width - 1 - qx, qy)),
483
- isGreen(rgba(px, px.width - 1 - qx, qy)));
484
- assertTrue("blue quadrant at width " + px.width + ": "
485
- + describe(rgba(px, qx, px.height - 1 - qy)),
486
- isBlue(rgba(px, qx, px.height - 1 - qy)));
487
- }
488
- }
489
- } finally {
490
- r.close();
491
- }
492
- Log.i(TAG, measured.toString());
493
- }
494
-
495
- /* ================================================================ *
496
- * 5. Request plumbing
497
- * ================================================================ */
498
-
499
- /**
500
- * CATCHES: ignoring {@code RasterRequest.page} and rendering page 0 every
501
- * time. Identical-looking pages make that bug invisible, so each page here is
502
- * a different flat colour.
503
- */
504
- @Test
505
- public void eachRequestRendersItsOwnPage() throws Exception {
506
- int[] colors = {Color.RED, Color.GREEN, Color.BLUE};
507
- File pdf = TestPdfs.coloredPages(ctx(), "colored.pdf", colors, 100, 100);
508
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
509
- try {
510
- assertEquals(3, r.pageCount());
511
- assertTrue(isRed(rgba(r.render(white(0, 0f, 0f, 100f, 100f, 1f), null), 50, 50)));
512
- assertTrue(isGreen(rgba(r.render(white(1, 0f, 0f, 100f, 100f, 1f), null), 50, 50)));
513
- assertTrue(isBlue(rgba(r.render(white(2, 0f, 0f, 100f, 100f, 1f), null), 50, 50)));
514
- } finally {
515
- r.close();
516
- }
517
- }
518
-
519
- /**
520
- * CATCHES: a spool path that truncates, reorders, or reopens the wrong file.
521
- * Byte-for-byte equality against the same document opened from disk is the
522
- * only assertion that catches a spool which is merely CLOSE to right.
523
- */
524
- @Test
525
- public void openBytesMatchesOpenFile() throws Exception {
526
- File pdf = TestPdfs.quadrants(ctx(), "quads-bytes.pdf", 200, 300);
527
- byte[] data = readAll(pdf);
528
-
529
- RasterRequest request = white(0, 20f, 30f, 150f, 150f, 2f);
530
- PdfCanvasRasterizer fromFile = PdfCanvasRasterizer.openFile(pdf, null, 1);
531
- RasterPixels a;
532
- try {
533
- a = fromFile.render(request, null);
534
- } finally {
535
- fromFile.close();
536
- }
537
-
538
- PdfCanvasRasterizer fromBytes =
539
- PdfCanvasRasterizer.openBytes(ctx().getCacheDir(), data, null, 1);
540
- RasterPixels b;
541
- try {
542
- assertEquals(fromFile.pageCount(), fromBytes.pageCount());
543
- b = fromBytes.render(request, null);
544
- } finally {
545
- fromBytes.close();
546
- }
547
-
548
- assertEquals(a.width, b.width);
549
- assertEquals(a.height, b.height);
550
- assertEquals(a.rowBytes, b.rowBytes);
551
- Diff diff = compareRegion(b, a, 0, 0);
552
- assertEquals("openBytes must match openFile exactly: " + diff, 0, diff.mismatches);
553
- }
554
-
555
- /**
556
- * CATCHES: an {@code openBytes} spool file that outlives its rasterizer.
557
- *
558
- * Nothing asserted this before, and it is the kind of leak nobody ever
559
- * notices: a whole extra copy of every document opened from bytes, left in the
560
- * cache directory until the OS decides to reclaim it. On the ARCH-D drawings
561
- * this package exists for that is tens of megabytes per document, and the copy
562
- * is invisible — the app has a working rasterizer and no error anywhere.
563
- *
564
- * Both exits are checked, because they are different code paths: {@code close()}
565
- * deletes the spool it was handed, and the failure path inside
566
- * {@code openBytes} has to delete a spool no rasterizer ever took ownership of.
567
- * A private directory is used rather than the shared cache dir so the count is
568
- * an assertion rather than a guess about what else is in there.
569
- */
570
- @Test
571
- public void openBytesDeletesItsSpoolFile() throws Exception {
572
- File spoolDir = new File(ctx().getCacheDir(), "spool-lifecycle");
573
- emptyDirectory(spoolDir);
574
-
575
- byte[] good = readAll(TestPdfs.quadrants(ctx(), "spool-quads.pdf", 200, 300));
576
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openBytes(spoolDir, good, null, 1);
577
- assertEquals(
578
- "the document must be spooled to disk while it is open — PdfRenderer "
579
- + "needs a seekable descriptor",
580
- 1,
581
- countFiles(spoolDir));
582
- r.close();
583
- assertEquals("close() must delete the spool file", 0, countFiles(spoolDir));
584
-
585
- // A failed open: the spool exists, and nothing else will ever delete it.
586
- byte[] garbage = readAll(TestPdfs.notAPdf(ctx(), "spool-notes.txt"));
587
- try {
588
- PdfCanvasRasterizer.openBytes(spoolDir, garbage, null, 1).close();
589
- fail("garbage bytes should not have opened");
590
- } catch (PdfRasterException e) {
591
- assertEquals(PdfErrorCode.CORRUPT, e.code());
592
- }
593
- assertEquals("a failed openBytes must delete its spool file", 0, countFiles(spoolDir));
594
- }
595
-
596
- private static void emptyDirectory(File dir) {
597
- File[] existing = dir.listFiles();
598
- if (existing != null) {
599
- for (File f : existing) {
600
- //noinspection ResultOfMethodCallIgnored
601
- f.delete();
602
- }
603
- }
604
- if (!dir.exists() && !dir.mkdirs()) {
605
- fail("could not create " + dir);
606
- }
607
- assertEquals("the spool directory must start empty", 0, countFiles(dir));
608
- }
609
-
610
- private static int countFiles(File dir) {
611
- File[] files = dir.listFiles();
612
- return files == null ? 0 : files.length;
613
- }
614
-
615
- /* ================================================================ *
616
- * 6. Timings — reported, not asserted
617
- * ================================================================ */
618
-
619
- /**
620
- * No assertion beyond "it completed". These numbers exist to be READ: the
621
- * package's {@code minEpochIntervalMs} is meant to be the measured p95 raster
622
- * time, and an emulator can only give the SHAPE of that. Asserting a wall time
623
- * here would produce a test that fails on a loaded CI machine and tells nobody
624
- * anything.
625
- */
626
- @Test
627
- public void reportsTimings() throws Exception {
628
- File letter =
629
- TestPdfs.pattern(ctx(), "t-letter.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
630
- File arch = TestPdfs.pattern(ctx(), "t-archd.pdf", TestPdfs.ARCH_D_W, TestPdfs.ARCH_D_H);
631
-
632
- StringBuilder sb = new StringBuilder("timings (ms, best of 3):");
633
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(arch, null, 1);
634
- try {
635
- sb.append(" archD-tile512@1x=").append(best(r, white(0, 400f, 600f, 512f, 512f, 1f)));
636
- sb.append(" @4x=").append(best(r, white(0, 400f, 600f, 128f, 128f, 4f)));
637
- sb.append(" @8x=").append(best(r, white(0, 400f, 600f, 64f, 64f, 8f)));
638
- // The fit-scale whole-page base raster: 1170x1755 for an ARCH-D sheet.
639
- sb.append(" archD-base1170x1755=")
640
- .append(best(r, white(0, 0f, 0f, TestPdfs.ARCH_D_W, TestPdfs.ARCH_D_H, 0.677f)));
641
- } finally {
642
- r.close();
643
- }
644
-
645
- PdfCanvasRasterizer r2 = PdfCanvasRasterizer.openFile(letter, null, 1);
646
- try {
647
- sb.append(" letter-full@2x=")
648
- .append(best(r2, white(0, 0f, 0f, TestPdfs.LETTER_W, TestPdfs.LETTER_H, 2f)));
649
- long t0 = System.nanoTime();
650
- PdfCanvasRasterizer probe = PdfCanvasRasterizer.openFile(letter, null, 2);
651
- long openMs = (System.nanoTime() - t0) / 1_000_000;
652
- probe.close();
653
- sb.append(" open+geometry(1page,pool2)=").append(openMs);
654
- } finally {
655
- r2.close();
656
- }
657
- Log.i(TAG, sb.toString());
658
- }
659
-
660
- private static long best(PdfCanvasRasterizer r, RasterRequest request)
661
- throws PdfRasterException {
662
- long best = Long.MAX_VALUE;
663
- for (int i = 0; i < 3; i++) {
664
- long t = System.nanoTime();
665
- r.render(request, null);
666
- long ms = (System.nanoTime() - t) / 1_000_000;
667
- if (ms < best) {
668
- best = ms;
669
- }
670
- }
671
- return best;
672
- }
673
-
674
- /* ================================================================ *
675
- * Helpers
676
- * ================================================================ */
677
-
678
- /** RGBA at (x, y), read through the REPORTED stride. */
679
- static int[] rgba(RasterPixels px, int x, int y) {
680
- return new int[] {
681
- px.channel(x, y, 0), px.channel(x, y, 1), px.channel(x, y, 2), px.channel(x, y, 3)
682
- };
683
- }
684
-
685
- static String describe(int[] rgba) {
686
- return "rgba(" + rgba[0] + "," + rgba[1] + "," + rgba[2] + "," + rgba[3] + ")";
687
- }
688
-
689
- private static final int TOLERANCE = 6;
690
-
691
- private static boolean near(int actual, int expected) {
692
- return Math.abs(actual - expected) <= TOLERANCE;
693
- }
694
-
695
- static boolean isRed(int[] c) {
696
- return near(c[0], 255) && near(c[1], 0) && near(c[2], 0) && near(c[3], 255);
697
- }
698
-
699
- static boolean isGreen(int[] c) {
700
- return near(c[0], 0) && near(c[1], 255) && near(c[2], 0) && near(c[3], 255);
701
- }
702
-
703
- static boolean isBlue(int[] c) {
704
- return near(c[0], 0) && near(c[1], 0) && near(c[2], 255) && near(c[3], 255);
705
- }
706
-
707
- static boolean isBlack(int[] c) {
708
- return near(c[0], 0) && near(c[1], 0) && near(c[2], 0) && near(c[3], 255);
709
- }
710
-
711
- static boolean isWhite(int[] c) {
712
- return near(c[0], 255) && near(c[1], 255) && near(c[2], 255) && near(c[3], 255);
713
- }
714
-
715
- static final class Diff {
716
- final int mismatches;
717
- final int maxDelta;
718
- final int total;
719
- final double mismatchPercent;
720
-
721
- Diff(int mismatches, int maxDelta, int total) {
722
- this.mismatches = mismatches;
723
- this.maxDelta = maxDelta;
724
- this.total = total;
725
- this.mismatchPercent = total == 0 ? 0 : 100.0 * mismatches / total;
726
- }
727
-
728
- @Override
729
- public String toString() {
730
- return "mismatches=" + mismatches + "/" + total
731
- + String.format(" (%.4f%%)", mismatchPercent) + " maxDelta=" + maxDelta;
732
- }
733
- }
734
-
735
- /**
736
- * Compares `tile` against the region of `reference` starting at (dx, dy).
737
- * A per-channel delta of 2 or less is not counted as a mismatch — that is
738
- * anti-aliasing noise on a stroke edge, which S1 also measured (maxDelta 1).
739
- */
740
- static Diff compareRegion(RasterPixels tile, RasterPixels reference, int dx, int dy) {
741
- int mismatches = 0;
742
- int maxDelta = 0;
743
- int total = tile.width * tile.height;
744
- for (int y = 0; y < tile.height; y++) {
745
- for (int x = 0; x < tile.width; x++) {
746
- for (int c = 0; c < 4; c++) {
747
- int d = Math.abs(tile.channel(x, y, c) - reference.channel(x + dx, y + dy, c));
748
- if (d > maxDelta) {
749
- maxDelta = d;
750
- }
751
- if (d > 2) {
752
- mismatches++;
753
- break;
754
- }
755
- }
756
- }
757
- }
758
- return new Diff(mismatches, maxDelta, total);
759
- }
760
-
761
- private static byte[] readAll(File file) throws IOException {
762
- byte[] out = new byte[(int) file.length()];
763
- try (FileInputStream in = new FileInputStream(file)) {
764
- int read = 0;
765
- while (read < out.length) {
766
- int n = in.read(out, read, out.length - read);
767
- if (n < 0) {
768
- fail("Short read on " + file);
769
- }
770
- read += n;
771
- }
772
- }
773
- return out;
774
- }
775
- }