@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
@@ -1,783 +0,0 @@
1
- package tools.reekon.pdfcanvas;
2
-
3
- import static org.junit.Assert.assertArrayEquals;
4
- import static org.junit.Assert.assertEquals;
5
- import static org.junit.Assert.assertFalse;
6
- import static org.junit.Assert.assertNotNull;
7
- import static org.junit.Assert.assertNull;
8
- import static org.junit.Assert.assertTrue;
9
-
10
- import android.content.Context;
11
- import android.util.Base64;
12
- import android.util.Log;
13
-
14
- import androidx.test.ext.junit.runners.AndroidJUnit4;
15
- import androidx.test.platform.app.InstrumentationRegistry;
16
-
17
- import org.junit.Before;
18
- import org.junit.Test;
19
- import org.junit.runner.RunWith;
20
-
21
- import java.io.File;
22
- import java.util.ArrayList;
23
- import java.util.List;
24
- import java.util.concurrent.Callable;
25
- import java.util.concurrent.CountDownLatch;
26
- import java.util.concurrent.ExecutorService;
27
- import java.util.concurrent.Executors;
28
- import java.util.concurrent.Future;
29
- import java.util.concurrent.TimeUnit;
30
- import java.util.concurrent.atomic.AtomicReference;
31
-
32
- import tools.reekon.pdfcanvas.jsi.PdfCanvasPixelBridge;
33
-
34
- /**
35
- * The raster transport's byte fidelity, on device.
36
- *
37
- * <h3>What this can and cannot reach</h3>
38
- *
39
- * This is the STANDALONE build: no React Native, no Hermes, no libjsi.so — see
40
- * {@code android/build.gradle}. So the JS half of the transport is out of reach
41
- * here and always will be. What IS in reach, and is the only part that can
42
- * corrupt a raster, is the JNI copy: {@code nativeStore} reads a Java
43
- * {@code byte[]} into the C++ buffer that the ArrayBuffer will later wrap
44
- * WITHOUT COPYING. Everything downstream of that buffer is a pointer hand-off;
45
- * everything upstream is the rasterizer, which the rest of this suite proves.
46
- *
47
- * So the question worth asking on device is exactly one: are the bytes that
48
- * enter the transport the same bytes the base64 path would have delivered? A
49
- * transport that is fast and wrong is worthless, and it would fail silently —
50
- * a stride slip shears, a channel swap discolours, a signed-byte widening
51
- * mangles every value above 0x7F. None of those throw.
52
- *
53
- * <h3>TWO fills now, and the second one is the shipped one</h3>
54
- *
55
- * {@code nativeStore} reads a Java {@code byte[]}. {@code nativeStoreFromBitmap}
56
- * reads the {@code Bitmap}'s own pixels through
57
- * {@code AndroidBitmap_lockPixels} and is what a production render actually
58
- * uses — it deletes the {@code byte[]} entirely. They are different memcpys
59
- * from different sources, so proving one says nothing about the other, and
60
- * section 5 asks the same fidelity question of the second.
61
- *
62
- * <p>That section is also the ONLY place the direct fill can be checked at all.
63
- * The install-time probe that gates the whole transport
64
- * ({@code PdfCanvasModule.installJsiTransport}) round-trips
65
- * {@link PdfCanvasPixelBridge#PROBE_BYTES} through {@code nativeStore}, because
66
- * a probe needs bytes both sides already agree on and there is no Bitmap at
67
- * install time. What the probe proves is the JS half — the host function is
68
- * installed, Hermes will wrap a {@code MutableBuffer}, byte values survive the
69
- * ArrayBuffer view. It does NOT exercise the fill that every subsequent raster
70
- * uses. This file is what covers that, which is why it ships in the standalone
71
- * build rather than living behind an app.
72
- *
73
- * <h3>Why the comparison is against base64 rather than against the bitmap</h3>
74
- *
75
- * Because base64 is the thing being replaced. {@code Base64.encodeToString} then
76
- * {@code Base64.decode} is the round trip the shipped transport performs, so
77
- * asserting the new path equals it is asserting the replacement is a
78
- * replacement. Comparing against {@code pixels.bytes} directly would only prove
79
- * the copy copied — true of a path that had never been through base64 either.
80
- */
81
- @RunWith(AndroidJUnit4.class)
82
- public class PdfCanvasJsiTransportTest {
83
-
84
- private static final String TAG = "PDFCANVAS";
85
-
86
- private static Context ctx() {
87
- return InstrumentationRegistry.getInstrumentation().getTargetContext();
88
- }
89
-
90
- /** Slot ids are the caller's to choose; this suite owns a range of its own. */
91
- private static long slot = 1_000_000L;
92
-
93
- private static long nextSlot() {
94
- return slot++;
95
- }
96
-
97
- @Before
98
- public void drainSlots() {
99
- // Each test starts from a known slot count, so the leak assertions below
100
- // measure this test rather than the one before it.
101
- PdfCanvasPixelBridge.nativeReleaseAll();
102
- assertEquals(0, PdfCanvasPixelBridge.nativeSlotCount());
103
- }
104
-
105
- /* ================================================================ *
106
- * 1. The library loaded at all
107
- * ================================================================ */
108
-
109
- /**
110
- * CATCHES: the native build silently not running.
111
- *
112
- * {@code PdfCanvasPixelBridge.isLoaded()} is false-tolerant by design — every
113
- * caller falls back to base64 — which means a CMake misconfiguration, a
114
- * missing ABI or a dropped source set produces a working app that is simply
115
- * never fast. There is no error anywhere. This is the assertion that turns
116
- * that into a red test.
117
- */
118
- @Test
119
- public void nativeTransportLibraryIsPresent() {
120
- assertTrue(
121
- "libpdfcanvasjsi.so did not load. Every render will fall back to base64.",
122
- PdfCanvasPixelBridge.isLoaded());
123
- }
124
-
125
- /**
126
- * The other half of the two-build split, asserted rather than assumed.
127
- *
128
- * This build compiles the transport with {@code PDFCANVAS_WITH_JSI} undefined,
129
- * so there is no {@code jsi/jsi.h} and no host function to install. Install
130
- * must report false rather than crash — a JNI method that is declared but has
131
- * no definition throws {@code UnsatisfiedLinkError} at CALL time, and that is
132
- * exactly the shape of failure this asserts is absent.
133
- */
134
- @Test
135
- public void installReportsFalseInABuildWithoutJsi() {
136
- assertFalse(PdfCanvasPixelBridge.nativeInstall(0L));
137
- // A non-null pointer must be refused just as calmly: there is no runtime
138
- // here for it to point at.
139
- assertFalse(PdfCanvasPixelBridge.nativeInstall(1L));
140
- }
141
-
142
- /* ================================================================ *
143
- * 2. Byte fidelity against the transport being replaced
144
- * ================================================================ */
145
-
146
- /**
147
- * THE TEST THIS FILE EXISTS FOR.
148
- *
149
- * A real raster of a four-colour page, moved through the transport, must be
150
- * byte-identical to the same raster moved through base64.
151
- *
152
- * CATCHES: a channel swap (R and B differ per quadrant), a stride slip (the
153
- * quadrant boundary would drift one pixel per row), a truncated tail (the last
154
- * row is BLACK here and its bytes are 0x00/0xFF, so a short copy shows up as a
155
- * length mismatch rather than as plausible content), and a signed-byte
156
- * widening — {@code Color.RED} is 0xFF in one channel, above Java's
157
- * {@code byte} range, and {@code GetByteArrayRegion} is the one call in this
158
- * path that could get it wrong.
159
- */
160
- @Test
161
- public void transportBytesEqualBase64BytesForARealRaster() throws Exception {
162
- File pdf = TestPdfs.quadrants(ctx(), "jsi-quadrants.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
163
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
164
- try {
165
- RasterPixels pixels =
166
- r.render(
167
- new RasterRequest(
168
- 0, 0f, 0f, TestPdfs.LETTER_W, TestPdfs.LETTER_H, 2f, false,
169
- RasterRequest.BACKGROUND_WHITE),
170
- null);
171
-
172
- // What the shipped transport delivers to JS, produced by the shipped calls.
173
- byte[] viaBase64 =
174
- Base64.decode(Base64.encodeToString(pixels.bytes, Base64.NO_WRAP), Base64.DEFAULT);
175
-
176
- long id = nextSlot();
177
- PdfCanvasPixelBridge.nativeStore(id, pixels.bytes);
178
- byte[] viaTransport = PdfCanvasPixelBridge.nativeTakeForTest(id);
179
-
180
- assertNotNull("slot came back empty", viaTransport);
181
- assertEquals(
182
- "length differs: " + viaBase64.length + " vs " + viaTransport.length,
183
- viaBase64.length,
184
- viaTransport.length);
185
- assertArrayEquals(viaBase64, viaTransport);
186
-
187
- // And the reference is itself right — otherwise the assertion above is two
188
- // wrong things agreeing. Top-left is RED in an RGBA_8888 buffer.
189
- assertEquals(255, pixels.channel(4, 4, 0));
190
- assertEquals(0, pixels.channel(4, 4, 1));
191
- assertEquals(0, pixels.channel(4, 4, 2));
192
-
193
- Log.i(
194
- TAG,
195
- "PDFCANVAS-TRANSPORT identical bytes="
196
- + viaTransport.length
197
- + " ("
198
- + pixels.width
199
- + "x"
200
- + pixels.height
201
- + " rowBytes="
202
- + pixels.rowBytes
203
- + ")");
204
- } finally {
205
- r.close();
206
- }
207
- }
208
-
209
- /**
210
- * The same claim at the FIELD'S tile size, because a 4 MiB copy is a different
211
- * call from a 40 KiB one.
212
- *
213
- * CATCHES: an {@code int} overflow or a truncating cast in the JNI length
214
- * handling, and any per-chunk copy that loses a boundary. 1024x1024 at 131.25
215
- * devpx/pt is the exact geometry the split measurement used, so this is also
216
- * the payload the speed numbers are quoted against.
217
- */
218
- @Test
219
- public void transportBytesEqualBase64BytesForAFieldSizedTile() throws Exception {
220
- File pdf = TestPdfs.pattern(ctx(), "jsi-tile.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
221
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
222
- try {
223
- final float scale = 131.25f;
224
- final float points = 1024f / scale;
225
- RasterPixels pixels =
226
- r.render(
227
- new RasterRequest(0, 0f, 0f, points, points, scale, false,
228
- RasterRequest.BACKGROUND_WHITE),
229
- null);
230
- assertEquals(1024, pixels.width);
231
- assertEquals(1024, pixels.height);
232
- assertEquals(1024 * 1024 * 4, pixels.bytes.length);
233
-
234
- byte[] viaBase64 =
235
- Base64.decode(Base64.encodeToString(pixels.bytes, Base64.NO_WRAP), Base64.DEFAULT);
236
-
237
- long id = nextSlot();
238
- PdfCanvasPixelBridge.nativeStore(id, pixels.bytes);
239
- byte[] viaTransport = PdfCanvasPixelBridge.nativeTakeForTest(id);
240
-
241
- assertNotNull(viaTransport);
242
- assertArrayEquals(viaBase64, viaTransport);
243
- } finally {
244
- r.close();
245
- }
246
- }
247
-
248
- /**
249
- * Every byte value, so the sign question is settled outright rather than by
250
- * whichever values a rasterized page happened to contain.
251
- *
252
- * CATCHES: {@code GetByteArrayRegion} into a {@code uint8_t*} going through a
253
- * sign-extending conversion. Values 0x80-0xFF are the entire failure set and
254
- * a white-background raster is mostly 0xFF, which would HIDE the bug rather
255
- * than expose it — a page that is all 0xFF looks identical whether or not the
256
- * copy is correct for 0x80.
257
- */
258
- @Test
259
- public void everyByteValueSurvivesTheCopy() {
260
- byte[] all = new byte[256 * 7];
261
- for (int i = 0; i < all.length; i++) {
262
- all[i] = (byte) (i & 0xFF);
263
- }
264
- long id = nextSlot();
265
- PdfCanvasPixelBridge.nativeStore(id, all);
266
- assertArrayEquals(all, PdfCanvasPixelBridge.nativeTakeForTest(id));
267
- }
268
-
269
- /**
270
- * The probe payload the JS negotiation compares against, round-tripped here.
271
- *
272
- * CATCHES: the two restatements of the probe drifting apart. Java's copy is
273
- * {@code PdfCanvasPixelBridge.PROBE_BYTES} and TypeScript's is
274
- * {@code TRANSPORT_PROBE_BYTES} in {@code src/rasterizer/android.ts}; they are
275
- * restated rather than derived, on purpose, so that a divergence disables the
276
- * fast path instead of shipping a shear. This asserts the Java half at least
277
- * survives its own transport — the values are checked literally so that
278
- * editing the constant without editing the TypeScript is a red test HERE,
279
- * where the reason is written down, rather than a silent fallback in the field.
280
- */
281
- @Test
282
- public void probeBytesRoundTrip() {
283
- assertArrayEquals(
284
- new byte[] {0x00, 0x01, 0x02, (byte) 0xFD, (byte) 0xFE, (byte) 0xFF, 0x2A, 0x07},
285
- PdfCanvasPixelBridge.PROBE_BYTES);
286
-
287
- long id = nextSlot();
288
- PdfCanvasPixelBridge.nativeStore(id, PdfCanvasPixelBridge.PROBE_BYTES);
289
- assertArrayEquals(
290
- PdfCanvasPixelBridge.PROBE_BYTES, PdfCanvasPixelBridge.nativeTakeForTest(id));
291
- }
292
-
293
- /* ================================================================ *
294
- * 3. Slot lifecycle — where a raster-sized leak would live
295
- * ================================================================ */
296
-
297
- /**
298
- * CATCHES: a slot that survives being taken.
299
- *
300
- * The ArrayBuffer handed to JS owns the buffer from the moment the take
301
- * succeeds. If the map kept a reference too, every raster would stay resident
302
- * for the life of the process — tens of megabytes per epoch — and nothing
303
- * would report it.
304
- */
305
- @Test
306
- public void takingASlotEmptiesIt() {
307
- long id = nextSlot();
308
- PdfCanvasPixelBridge.nativeStore(id, new byte[] {1, 2, 3, 4});
309
- assertEquals(1, PdfCanvasPixelBridge.nativeSlotCount());
310
-
311
- assertArrayEquals(new byte[] {1, 2, 3, 4}, PdfCanvasPixelBridge.nativeTakeForTest(id));
312
- assertEquals(0, PdfCanvasPixelBridge.nativeSlotCount());
313
-
314
- // Second take is empty rather than a stale buffer. On the JS side this is
315
- // what becomes a named throw instead of a black tile.
316
- assertNull(PdfCanvasPixelBridge.nativeTakeForTest(id));
317
- }
318
-
319
- /** CATCHES: release doing nothing, so a cancelled raster is held forever. */
320
- @Test
321
- public void releaseDropsASlotWithoutReadingIt() {
322
- long id = nextSlot();
323
- PdfCanvasPixelBridge.nativeStore(id, new byte[1024]);
324
- PdfCanvasPixelBridge.nativeRelease(id);
325
- assertEquals(0, PdfCanvasPixelBridge.nativeSlotCount());
326
- assertNull(PdfCanvasPixelBridge.nativeTakeForTest(id));
327
-
328
- // Releasing an absent slot is a no-op, not a crash: `invalidate()` runs it
329
- // over slots that may already have been taken.
330
- PdfCanvasPixelBridge.nativeRelease(id);
331
- }
332
-
333
- /**
334
- * CATCHES: {@code invalidate()} leaving stranded rasters behind.
335
- *
336
- * A Fast Refresh drops the JS side between a promise resolving and its
337
- * continuation running, so slots stored for those renders can never be taken.
338
- * Dozens of reloads a day at several megabytes each is the whole budget.
339
- */
340
- @Test
341
- public void releaseAllDrainsEverything() {
342
- for (int i = 0; i < 16; i++) {
343
- PdfCanvasPixelBridge.nativeStore(nextSlot(), new byte[64 * 1024]);
344
- }
345
- assertEquals(16, PdfCanvasPixelBridge.nativeSlotCount());
346
- PdfCanvasPixelBridge.nativeReleaseAll();
347
- assertEquals(0, PdfCanvasPixelBridge.nativeSlotCount());
348
- }
349
-
350
- /* ================================================================ *
351
- * 4. Threading
352
- * ================================================================ */
353
-
354
- /**
355
- * CATCHES: an unsynchronised slot map.
356
- *
357
- * In production the stores happen on the render executor — up to
358
- * {@code MAX_POOL_SIZE} threads at once, one per lane — while the takes happen
359
- * on the JS thread. An unlocked {@code std::unordered_map} under that pattern
360
- * does not usually throw; it corrupts a bucket chain and either loses a slot
361
- * or returns the wrong one, which on the JS side is a tile drawn with another
362
- * tile's pixels. Four writers and a concurrent reader for 200 rounds is enough
363
- * to make an unguarded map fail reliably.
364
- */
365
- @Test
366
- public void concurrentStoresAndTakesKeepTheirOwnBytes() throws Exception {
367
- final int threads = 4;
368
- final int rounds = 200;
369
- ExecutorService pool = Executors.newFixedThreadPool(threads);
370
- try {
371
- List<Future<Boolean>> futures = new ArrayList<>();
372
- for (int t = 0; t < threads; t++) {
373
- final int lane = t;
374
- futures.add(
375
- pool.submit(
376
- (Callable<Boolean>)
377
- () -> {
378
- for (int i = 0; i < rounds; i++) {
379
- byte[] payload = new byte[512];
380
- // Lane-stamped, so a slot returned to the wrong caller is
381
- // detectable rather than merely "some bytes".
382
- for (int b = 0; b < payload.length; b++) {
383
- payload[b] = (byte) ((lane * 37 + i * 11 + b) & 0xFF);
384
- }
385
- long id;
386
- synchronized (PdfCanvasJsiTransportTest.class) {
387
- id = nextSlot();
388
- }
389
- PdfCanvasPixelBridge.nativeStore(id, payload);
390
- byte[] back = PdfCanvasPixelBridge.nativeTakeForTest(id);
391
- if (back == null || !java.util.Arrays.equals(payload, back)) {
392
- return false;
393
- }
394
- }
395
- return true;
396
- }));
397
- }
398
- for (Future<Boolean> future : futures) {
399
- assertTrue("a lane got back bytes it did not store", future.get(60, TimeUnit.SECONDS));
400
- }
401
- assertEquals(0, PdfCanvasPixelBridge.nativeSlotCount());
402
- } finally {
403
- pool.shutdownNow();
404
- }
405
- }
406
-
407
- /**
408
- * MEASUREMENT, not verification — the Java-side cost the new transport ADDS.
409
- *
410
- * The base64 encode it replaces was 19.88 ms p50 for this payload. This is the
411
- * number that has to be smaller, and it is the only new cost on the native
412
- * side. Logged rather than asserted: a threshold here would fail on a slower
413
- * device for a reason that is not a bug.
414
- */
415
- @Test
416
- public void measuresTheStoreCostAgainstTheBase64EncodeItReplaces() {
417
- byte[] tile = new byte[1024 * 1024 * 4];
418
- for (int i = 0; i < tile.length; i++) tile[i] = (byte) (i & 0xFF);
419
-
420
- final int iterations = 7;
421
- long[] store = new long[iterations];
422
- long[] encode = new long[iterations];
423
- for (int i = 0; i < iterations; i++) {
424
- long id = nextSlot();
425
- long t0 = System.nanoTime();
426
- PdfCanvasPixelBridge.nativeStore(id, tile);
427
- store[i] = System.nanoTime() - t0;
428
- PdfCanvasPixelBridge.nativeRelease(id);
429
-
430
- long t1 = System.nanoTime();
431
- String encoded = Base64.encodeToString(tile, Base64.NO_WRAP);
432
- encode[i] = System.nanoTime() - t1;
433
- assertTrue(encoded.length() > 0);
434
- }
435
- java.util.Arrays.sort(store);
436
- java.util.Arrays.sort(encode);
437
- Log.i(
438
- TAG,
439
- "PDFCANVAS-TRANSPORT-COST bytes="
440
- + tile.length
441
- + " storeMs="
442
- + (store[iterations / 2] / 1e6)
443
- + " base64EncodeMs="
444
- + (encode[iterations / 2] / 1e6));
445
- }
446
-
447
- /* ================================================================ *
448
- * 5. The ONE-COPY fill: Bitmap -> native memory, no Java heap
449
- * ================================================================ */
450
-
451
- /**
452
- * THE TEST THE ONE-COPY PATH EXISTS TO BE HELD TO.
453
- *
454
- * {@code renderToNativeSlot} never materialises a {@code byte[]}: the raster
455
- * goes from the {@code Bitmap}'s pixels into the slot in one memcpy. So the
456
- * only way to know it moved the RIGHT bytes is to render the same request
457
- * twice — once down the heap path, once down the direct path — and compare.
458
- *
459
- * CATCHES everything the heap path is already checked for, because the direct
460
- * fill re-derives all of it from {@code AndroidBitmap_getInfo} instead of from
461
- * {@code Bitmap.getRowBytes}: a stride disagreement between the NDK and the
462
- * Java API (the quadrant boundary would drift one pixel per row), a format
463
- * mismatch reading ARGB as something else (R and B swap), and a short copy
464
- * (the two arrays would differ in length). It also catches the fill silently
465
- * refusing on every device, which would leave the transport correct and
466
- * permanently slow with nothing anywhere to say so — hence the assert that
467
- * the raster really did come back in a slot.
468
- */
469
- @Test
470
- public void directFillBytesEqualTheHeapPathForARealRaster() throws Exception {
471
- File pdf =
472
- TestPdfs.quadrants(ctx(), "jsi-direct-quadrants.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
473
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
474
- try {
475
- RasterRequest request =
476
- new RasterRequest(
477
- 0, 0f, 0f, TestPdfs.LETTER_W, TestPdfs.LETTER_H, 2f, false,
478
- RasterRequest.BACKGROUND_WHITE);
479
-
480
- RasterPixels viaHeap = r.render(request, null);
481
- byte[] expected =
482
- Base64.decode(Base64.encodeToString(viaHeap.bytes, Base64.NO_WRAP), Base64.DEFAULT);
483
-
484
- long id = nextSlot();
485
- RasterPixels viaDirect = r.renderToNativeSlot(request, null, id);
486
- assertTrue(
487
- "the direct fill refused, so this ran on the heap path and proved nothing "
488
- + "about the fill under test",
489
- viaDirect.isInNativeSlot());
490
- assertEquals(id, viaDirect.slot);
491
- assertNull("a slot-backed raster must not also hold a byte[]", viaDirect.bytes);
492
- // The geometry has to agree too: it is read from the Bitmap on one path
493
- // and cross-checked against the NDK's view of the same Bitmap on the
494
- // other, and a disagreement there is what would shear the image.
495
- assertEquals(viaHeap.width, viaDirect.width);
496
- assertEquals(viaHeap.height, viaDirect.height);
497
- assertEquals(viaHeap.rowBytes, viaDirect.rowBytes);
498
- assertEquals(viaHeap.format, viaDirect.format);
499
-
500
- byte[] actual = PdfCanvasPixelBridge.nativeTakeForTest(id);
501
- assertNotNull("the slot came back empty", actual);
502
- assertEquals(
503
- "length differs: " + expected.length + " vs " + actual.length,
504
- expected.length,
505
- actual.length);
506
- assertArrayEquals(expected, actual);
507
- assertEquals(
508
- "the take must have emptied the slot", 0, PdfCanvasPixelBridge.nativeSlotCount());
509
-
510
- Log.i(
511
- TAG,
512
- "PDFCANVAS-DIRECT identical bytes="
513
- + actual.length
514
- + " ("
515
- + viaDirect.width
516
- + "x"
517
- + viaDirect.height
518
- + " rowBytes="
519
- + viaDirect.rowBytes
520
- + ")");
521
- } finally {
522
- r.close();
523
- }
524
- }
525
-
526
- /**
527
- * The same claim at the FIELD'S tile size, for the same reason the heap path
528
- * is checked twice: a 4 MiB memcpy is a different call from a 40 KiB one.
529
- *
530
- * CATCHES an {@code int} overflow or a truncating cast in the byteCount
531
- * cross-check — {@code stride * height} is computed in 64-bit on the native
532
- * side precisely so a 32-bit product cannot wrap into agreement.
533
- */
534
- @Test
535
- public void directFillBytesEqualTheHeapPathForAFieldSizedTile() throws Exception {
536
- File pdf =
537
- TestPdfs.pattern(ctx(), "jsi-direct-tile.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
538
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
539
- try {
540
- final float scale = 131.25f;
541
- final float points = 1024f / scale;
542
- RasterRequest request =
543
- new RasterRequest(
544
- 0, 0f, 0f, points, points, scale, false, RasterRequest.BACKGROUND_WHITE);
545
-
546
- RasterPixels viaHeap = r.render(request, null);
547
- assertEquals(1024, viaHeap.width);
548
- assertEquals(1024 * 1024 * 4, viaHeap.bytes.length);
549
-
550
- long id = nextSlot();
551
- RasterPixels viaDirect = r.renderToNativeSlot(request, null, id);
552
- assertTrue("the direct fill refused", viaDirect.isInNativeSlot());
553
-
554
- byte[] actual = PdfCanvasPixelBridge.nativeTakeForTest(id);
555
- assertNotNull(actual);
556
- assertArrayEquals(viaHeap.bytes, actual);
557
- } finally {
558
- r.close();
559
- }
560
- }
561
-
562
- /**
563
- * CATCHES: a slot-backed raster pretending its pixels are readable.
564
- *
565
- * {@code RasterPixels.bytes} is null on this path, so every pixel accessor is
566
- * a latent NPE from somewhere deep in a caller. They throw by name instead,
567
- * and that has to stay true — the base64 fallback reads {@code bytes}
568
- * directly and the module branches on {@code isInNativeSlot()} to avoid it.
569
- */
570
- @Test
571
- public void aDirectFilledRasterRefusesToBeReadFromJava() throws Exception {
572
- File pdf = TestPdfs.quadrants(ctx(), "jsi-direct-read.pdf", 200, 300);
573
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
574
- try {
575
- long id = nextSlot();
576
- RasterPixels px =
577
- r.renderToNativeSlot(
578
- new RasterRequest(0, 0f, 0f, 200f, 300f, 1f, false,
579
- RasterRequest.BACKGROUND_WHITE),
580
- null,
581
- id);
582
- assertTrue(px.isInNativeSlot());
583
- try {
584
- px.channel(1, 1, 0);
585
- throw new AssertionError("channel() read a raster that is not on the Java heap");
586
- } catch (IllegalStateException expected) {
587
- assertTrue(
588
- "the message must name the slot: " + expected.getMessage(),
589
- expected.getMessage().contains(String.valueOf(id)));
590
- }
591
-
592
- // And release() is what the abandon paths call, so it has to actually free.
593
- assertEquals(1, PdfCanvasPixelBridge.nativeSlotCount());
594
- px.release();
595
- assertEquals(0, PdfCanvasPixelBridge.nativeSlotCount());
596
- // Idempotent: PdfCanvasModule's finally can run after the rasterizer has
597
- // already released the same raster.
598
- px.release();
599
- assertEquals(0, PdfCanvasPixelBridge.nativeSlotCount());
600
- } finally {
601
- r.close();
602
- }
603
- }
604
-
605
- /**
606
- * CATCHES: THE LEAK. A cancel observed AFTER the draw finished must not strand
607
- * the raster it already filled.
608
- *
609
- * This is the one abandon path inside the rasterizer, and it is not
610
- * hypothetical: a superseded epoch produces a stream of late cancels, so a
611
- * miss here is megabytes of native memory per gesture, freed only by the next
612
- * reload. On the heap path the dropped {@code byte[]} was simply collected and
613
- * there was nothing to get wrong — which is exactly why this needs its own
614
- * test now.
615
- *
616
- * The cancel has to land inside the render window to reach the post-draw
617
- * check, and the window is device-dependent, so the delay is adapted the same
618
- * way {@code PdfCanvasCancellationTest} adapts it: a cancel reported at any
619
- * other stage is retried rather than accepted. Reaching the window is
620
- * ASSERTED — a run that never got there has not tested the leak.
621
- */
622
- @Test
623
- public void cancellingAfterTheDrawLeavesNoSlotBehind() throws Exception {
624
- File pdf =
625
- TestPdfs.pattern(ctx(), "jsi-direct-cancel.pdf", TestPdfs.ARCH_D_W, TestPdfs.ARCH_D_H);
626
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
627
- try {
628
- // Big destination, because S1 finding 2 says cost tracks bitmap area.
629
- RasterRequest heavy =
630
- new RasterRequest(
631
- 0, 200f, 300f, 800f, 800f, 2f, false, RasterRequest.BACKGROUND_WHITE);
632
-
633
- long baselineMs = Long.MAX_VALUE;
634
- for (int i = 0; i < 3; i++) {
635
- long t = System.nanoTime();
636
- r.render(heavy, null);
637
- baselineMs = Math.min(baselineMs, (System.nanoTime() - t) / 1_000_000);
638
- }
639
-
640
- long delayMs = Math.max(5, baselineMs / 2);
641
- boolean reachedPostDraw = false;
642
-
643
- for (int attempt = 0; attempt < 8 && !reachedPostDraw; attempt++) {
644
- PdfCanvasPixelBridge.nativeReleaseAll();
645
- final long id = nextSlot();
646
- final RasterCancellation signal = new RasterCancellation();
647
- final CountDownLatch started = new CountDownLatch(1);
648
- final AtomicReference<PdfRasterException> thrown = new AtomicReference<>();
649
- final AtomicReference<RasterPixels> returned = new AtomicReference<>();
650
-
651
- Thread worker =
652
- new Thread(
653
- () -> {
654
- started.countDown();
655
- try {
656
- returned.set(r.renderToNativeSlot(heavy, signal, id));
657
- } catch (PdfRasterException e) {
658
- thrown.set(e);
659
- }
660
- });
661
- worker.start();
662
- assertTrue(started.await(10, TimeUnit.SECONDS));
663
- Thread.sleep(delayMs);
664
- signal.cancel();
665
- worker.join(120_000);
666
-
667
- PdfRasterException e = thrown.get();
668
- if (e == null) {
669
- // Landed after the render returned: the raster is legitimately parked.
670
- assertNotNull(returned.get());
671
- returned.get().release();
672
- delayMs = Math.max(2, delayMs / 2);
673
- continue;
674
- }
675
- assertEquals(PdfErrorCode.CANCELLED, e.code());
676
- if (!e.getMessage().contains("after render")) {
677
- // Landed before the draw. Nothing was filled, so nothing can leak —
678
- // but assert that too, since a slot allocated early would show here.
679
- assertEquals(
680
- "a cancel before the draw must not have filled a slot",
681
- 0,
682
- PdfCanvasPixelBridge.nativeSlotCount());
683
- delayMs = Math.max(delayMs * 2, delayMs + 5);
684
- continue;
685
- }
686
-
687
- reachedPostDraw = true;
688
- assertEquals(
689
- "a raster cancelled AFTER it was drawn left its slot behind — nothing "
690
- + "will ever take it",
691
- 0,
692
- PdfCanvasPixelBridge.nativeSlotCount());
693
- assertNull(PdfCanvasPixelBridge.nativeTakeForTest(id));
694
- }
695
-
696
- assertTrue(
697
- "never managed to cancel inside the render window, so the post-draw abandon "
698
- + "path was not exercised; baseline=" + baselineMs + "ms",
699
- reachedPostDraw);
700
- } finally {
701
- r.close();
702
- }
703
- }
704
-
705
- /**
706
- * MEASUREMENT, not verification — what the one-copy fill is worth per tile.
707
- *
708
- * Three numbers on the same payload and the same thread: the direct fill, the
709
- * two-copy store it replaces (allocate a {@code byte[]},
710
- * {@code copyPixelsToBuffer} into it, then {@code nativeStore} it), and the
711
- * base64 encode both replace. The DIFFERENCE between the first two is the whole
712
- * claim of this change, and it is a difference of one full-raster ART
713
- * allocation plus one full-raster memcpy.
714
- *
715
- * Logged, never asserted: a threshold would fail on a slower device for a
716
- * reason that is not a bug. See {@code PdfCanvasPhaseTimingTest} for the number
717
- * that actually decides whether this was worth shipping — per-tile cost inside
718
- * the lane schedule, where the render executor is the serialised bottleneck.
719
- */
720
- @Test
721
- public void measuresTheDirectFillAgainstTheTwoCopyStoreItReplaces() throws Exception {
722
- File pdf =
723
- TestPdfs.pattern(ctx(), "jsi-direct-cost.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H);
724
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
725
- try {
726
- final float scale = 131.25f;
727
- final float points = 1024f / scale;
728
- RasterRequest request =
729
- new RasterRequest(
730
- 0, 0f, 0f, points, points, scale, false, RasterRequest.BACKGROUND_WHITE);
731
-
732
- final int iterations = 7;
733
- long[] direct = new long[iterations];
734
- long[] twoCopy = new long[iterations];
735
- long[] encode = new long[iterations];
736
-
737
- // Warm both paths so the first sample is not paying for class init.
738
- r.render(request, null);
739
- long warmId = nextSlot();
740
- r.renderToNativeSlot(request, null, warmId).release();
741
-
742
- for (int i = 0; i < iterations; i++) {
743
- // The whole render is inside the clock for both, because the phase this
744
- // change touches cannot be separated from it: the fill happens while the
745
- // Bitmap is still alive, inside the rasterizer.
746
- long id = nextSlot();
747
- long t0 = System.nanoTime();
748
- RasterPixels viaDirect = r.renderToNativeSlot(request, null, id);
749
- direct[i] = System.nanoTime() - t0;
750
- assertTrue(viaDirect.isInNativeSlot());
751
- viaDirect.release();
752
-
753
- long id2 = nextSlot();
754
- long t1 = System.nanoTime();
755
- RasterPixels viaHeap = r.render(request, null);
756
- PdfCanvasPixelBridge.nativeStore(id2, viaHeap.bytes);
757
- twoCopy[i] = System.nanoTime() - t1;
758
- PdfCanvasPixelBridge.nativeRelease(id2);
759
-
760
- long t2 = System.nanoTime();
761
- String encoded = Base64.encodeToString(viaHeap.bytes, Base64.NO_WRAP);
762
- encode[i] = System.nanoTime() - t2;
763
- assertTrue(encoded.length() > 0);
764
- }
765
-
766
- java.util.Arrays.sort(direct);
767
- java.util.Arrays.sort(twoCopy);
768
- java.util.Arrays.sort(encode);
769
- Log.i(
770
- TAG,
771
- String.format(
772
- "PDFCANVAS-DIRECT-COST 1024x1024 render+directFill=%.2f ms"
773
- + " render+heap+nativeStore=%.2f ms saved=%.2f ms"
774
- + " (base64 encode alone=%.2f ms)",
775
- direct[iterations / 2] / 1e6,
776
- twoCopy[iterations / 2] / 1e6,
777
- (twoCopy[iterations / 2] - direct[iterations / 2]) / 1e6,
778
- encode[iterations / 2] / 1e6));
779
- } finally {
780
- r.close();
781
- }
782
- }
783
- }