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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +57 -0
  3. package/PdfCanvas.podspec +68 -39
  4. package/README.md +213 -1316
  5. package/android/build.gradle +67 -54
  6. package/android/consumer-rules.pro +7 -0
  7. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +254 -0
  8. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +60 -468
  9. package/android/src/main/cpp/CMakeLists.txt +54 -0
  10. package/android/src/main/cpp/pdfcanvas-jni.cpp +262 -0
  11. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +167 -0
  12. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +14 -4
  13. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +10 -4
  14. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +160 -376
  15. package/android/tools/compile-gate.sh +81 -101
  16. package/dist/controller.d.ts +14 -0
  17. package/dist/controller.js +4 -3
  18. package/dist/rasterizer/index.native.d.ts +8 -12
  19. package/dist/rasterizer/index.native.js +15 -21
  20. package/dist/rasterizer/native-bridge.d.ts +51 -75
  21. package/dist/rasterizer/native-bridge.js +49 -33
  22. package/dist/rasterizer/native.d.ts +45 -0
  23. package/dist/rasterizer/native.js +123 -0
  24. package/dist/react/usePdfDocument.d.ts +5 -5
  25. package/dist/react/usePdfLayer.d.ts +10 -1
  26. package/dist/react/usePdfLayer.js +10 -2
  27. package/dist/testing/scenes.d.ts +3 -3
  28. package/dist/testing/scenes.js +4 -4
  29. package/dist/types.d.ts +36 -42
  30. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +234 -536
  31. package/native/.clangd +5 -0
  32. package/native/CMakeLists.txt +89 -0
  33. package/native/core/include/pdfcanvas/document.h +90 -0
  34. package/native/core/include/pdfcanvas/error.h +47 -0
  35. package/native/core/include/pdfcanvas/jsi.h +18 -0
  36. package/native/core/include/pdfcanvas/jsi_config.h +30 -0
  37. package/native/core/include/pdfcanvas/library.h +28 -0
  38. package/native/core/include/pdfcanvas/pixels.h +92 -0
  39. package/native/core/include/pdfcanvas/service.h +87 -0
  40. package/native/core/include/pdfcanvas/slots.h +113 -0
  41. package/native/core/include/pdfcanvas/types.h +110 -0
  42. package/native/core/pdfcanvas-core.cmake +22 -0
  43. package/native/core/src/document.cpp +516 -0
  44. package/native/core/src/error.cpp +29 -0
  45. package/native/core/src/jsi.cpp +66 -0
  46. package/native/core/src/library.cpp +52 -0
  47. package/native/core/src/pixels.cpp +45 -0
  48. package/native/core/src/service.cpp +156 -0
  49. package/native/core/src/slots.cpp +139 -0
  50. package/native/tests/fixtures.cpp +451 -0
  51. package/native/tests/fixtures.h +96 -0
  52. package/native/tests/harness.h +122 -0
  53. package/native/tests/main.cpp +64 -0
  54. package/native/tests/pixels_util.h +126 -0
  55. package/native/tests/test_document.cpp +485 -0
  56. package/native/tests/test_service.cpp +130 -0
  57. package/native/tests/test_slots.cpp +250 -0
  58. package/package.json +27 -7
  59. package/react-native.config.js +5 -2
  60. package/scripts/fetch-pdfium.mjs +487 -0
  61. package/scripts/pdfium-manifest.json +46 -0
  62. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +0 -226
  63. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +0 -239
  64. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +0 -251
  65. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +0 -221
  66. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +0 -783
  67. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +0 -1388
  68. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +0 -98
  69. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +0 -324
  70. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +0 -775
  71. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +0 -20
  72. package/android/src/jsi/cpp/CMakeLists.txt +0 -33
  73. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +0 -469
  74. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +0 -140
  75. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +0 -45
  76. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +0 -564
  77. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +0 -277
  78. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +0 -214
  79. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +0 -234
  80. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +0 -47
  81. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +0 -143
  82. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +0 -95
  83. package/dist/rasterizer/android.d.ts +0 -121
  84. package/dist/rasterizer/android.js +0 -126
  85. package/dist/rasterizer/ios.d.ts +0 -84
  86. package/dist/rasterizer/ios.js +0 -155
  87. package/ios/Package.swift +0 -58
  88. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +0 -38
  89. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +0 -389
  90. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +0 -57
  91. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +0 -44
  92. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +0 -82
  93. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +0 -37
  94. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +0 -158
  95. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +0 -80
  96. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +0 -65
  97. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +0 -548
  98. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +0 -48
  99. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +0 -26
  100. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +0 -122
  101. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +0 -80
  102. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +0 -79
  103. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +0 -129
  104. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +0 -100
  105. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +0 -65
  106. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +0 -111
  107. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +0 -85
  108. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +0 -163
  109. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +0 -297
  110. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +0 -140
  111. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +0 -358
  112. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +0 -262
  113. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +0 -173
  114. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +0 -114
  115. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +0 -158
  116. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +0 -544
  117. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +0 -287
  118. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +0 -396
  119. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +0 -639
  120. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +0 -179
  121. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +0 -283
@@ -1,287 +0,0 @@
1
- import Foundation
2
- import XCTest
3
-
4
- @testable import PdfCanvasCore
5
-
6
- /**
7
- A recording sink, standing in for the JSI slot store.
8
-
9
- The real one lives in `ios/Sources/PdfCanvasBridge/PdfCanvasSlots.mm`, which
10
- SwiftPM cannot build — it needs `<jsi/jsi.h>` from a pod. What CAN be proven here
11
- is the CONTRACT: reserve once, before the draw; publish last; abandon on every path
12
- that drops a raster. Those are the three properties whose violation is a leak or a
13
- use-after-free rather than a wrong pixel, so they are worth pinning without a
14
- device.
15
- */
16
- class RecordingSink: NSObject, PdfCanvasPixelSink {
17
- /// Set to refuse the reservation, which is the fallback-to-heap path.
18
- var refuse = false
19
- var reserveCount = 0
20
- var publishCount = 0
21
- var abandonCount = 0
22
- /// The identifier `publish` hands back. Non-zero means "in a native slot".
23
- var slot: Int64 = 4242
24
-
25
- private var storage: UnsafeMutableRawPointer?
26
- private var length = 0
27
- private(set) var published = false
28
-
29
- /// The reserved bytes, so a test can check what CoreGraphics wrote into them.
30
- func bytes() -> [UInt8] {
31
- guard let storage else { return [] }
32
- return Array(UnsafeRawBufferPointer(start: storage, count: length))
33
- }
34
-
35
- @objc dynamic func pdfCanvasReserveBytes(_ length: UInt) -> UnsafeMutableRawPointer? {
36
- reserveCount += 1
37
- if refuse { return nil }
38
- self.length = Int(length)
39
- // `calloc`, because ZEROED IS PART OF THE SINK CONTRACT — it is what makes
40
- // `background: transparent` free and what stops a published buffer ever
41
- // handing out memory nobody wrote.
42
- storage = calloc(Int(length), 1)
43
- return storage
44
- }
45
-
46
- func pdfCanvasPublish() -> Int64 {
47
- publishCount += 1
48
- published = true
49
- return slot
50
- }
51
-
52
- func pdfCanvasAbandon() {
53
- abandonCount += 1
54
- if let storage {
55
- free(storage)
56
- self.storage = nil
57
- length = 0
58
- }
59
- }
60
-
61
- deinit {
62
- if let storage { free(storage) }
63
- }
64
- }
65
-
66
- final class SinkTests: XCTestCase {
67
-
68
- private func rasterizer() throws -> PdfCanvasRasterizer {
69
- try openFixture(TestPdfs.quadrants(width: 200, height: 100), name: "sink.pdf")
70
- }
71
-
72
- /**
73
- A sink gets exactly one reservation, one publish, and no abandon.
74
-
75
- The counts matter more than they look: a second reservation would mean the
76
- rasterizer had drawn twice, and an abandon on the happy path would free the
77
- buffer the caller is about to redeem.
78
- */
79
- func testTheHappyPathReservesOnceAndPublishesOnce() throws {
80
- let r = try rasterizer()
81
- let sink = RecordingSink()
82
- let pixels = try r.render(
83
- request(width: 200, height: 100, scale: 2), cancellation: nil, sink: sink)
84
-
85
- XCTAssertEqual(sink.reserveCount, 1)
86
- XCTAssertEqual(sink.publishCount, 1)
87
- XCTAssertEqual(sink.abandonCount, 0)
88
- XCTAssertTrue(pixels.isInNativeSlot)
89
- XCTAssertEqual(pixels.slot, 4242)
90
- XCTAssertNil(pixels.bytes, "a slot-backed raster has no heap bytes")
91
- r.close()
92
- }
93
-
94
- /**
95
- The bytes CoreGraphics wrote are the sink's own buffer — no copy anywhere.
96
-
97
- This is the whole point of the sink protocol: `CGBitmapContextCreate` takes the
98
- destination pointer, so the raster is written once, by the rasterizer, into
99
- memory the transport already owns. Android's fast path costs one full-raster
100
- memcpy and cannot do better, because `PdfRenderer` owns its `Bitmap`'s pixels.
101
- */
102
- func testCoreGraphicsWritesStraightIntoTheSinksBuffer() throws {
103
- let r = try rasterizer()
104
- let sink = RecordingSink()
105
- let pixels = try r.render(
106
- request(width: 200, height: 100, scale: 1), cancellation: nil, sink: sink)
107
- XCTAssertTrue(pixels.isInNativeSlot)
108
-
109
- let bytes = sink.bytes()
110
- XCTAssertEqual(bytes.count, 200 * 100 * 4)
111
- // Doc top-left is the PDF's top-left, which the fixture paints blue.
112
- XCTAssertEqual([bytes[0], bytes[1], bytes[2], bytes[3]], [0, 0, 255, 255])
113
- // And the same raster rendered to the heap agrees byte for byte.
114
- let heap = try r.render(
115
- request(width: 200, height: 100, scale: 1), cancellation: nil, sink: nil)
116
- XCTAssertEqual(bytes, Array(heap.bytes!))
117
- r.close()
118
- }
119
-
120
- /**
121
- A REFUSED reservation falls through to the heap, and the tile still goes out.
122
-
123
- The refusal happens before anything is drawn, so it costs nothing — which is the
124
- structural advantage over Android, where `nativeStoreFromBitmap` can only refuse
125
- after the raster exists.
126
- */
127
- func testARefusedReservationFallsBackToTheHeap() throws {
128
- let r = try rasterizer()
129
- let sink = RecordingSink()
130
- sink.refuse = true
131
- let pixels = try r.render(
132
- request(width: 200, height: 100, scale: 1), cancellation: nil, sink: sink)
133
-
134
- XCTAssertEqual(sink.reserveCount, 1, "asked once")
135
- XCTAssertEqual(sink.publishCount, 0, "and never published to")
136
- XCTAssertFalse(pixels.isInNativeSlot, "the result must be heap-backed")
137
- XCTAssertEqual(pixels.slot, 0)
138
- XCTAssertNotNil(pixels.bytes)
139
- // And it is a correct raster, not a degraded one.
140
- assertPixel(pixels, 10, 10, .blue)
141
- assertPixel(pixels, 190, 90, .green)
142
- r.close()
143
- }
144
-
145
- /// A nil sink is the heap, which is what every pixel-reading caller uses.
146
- func testANilSinkProducesAHeapRaster() throws {
147
- let r = try rasterizer()
148
- let pixels = try r.render(
149
- request(width: 200, height: 100, scale: 1), cancellation: nil, sink: nil)
150
- XCTAssertFalse(pixels.isInNativeSlot)
151
- XCTAssertEqual(pixels.slot, 0)
152
- XCTAssertNotNil(pixels.bytes)
153
- r.close()
154
- }
155
-
156
- /**
157
- A raster ABANDONED after the draw releases its sink.
158
-
159
- The one path the rasterizer owns: a cancellation observed after the pixels are
160
- already finished. Without the abandon, a superseded epoch strands one whole
161
- raster per tile in a process-wide map — megabytes per gesture, freed only by the
162
- next teardown.
163
- */
164
- func testACancellationAfterTheDrawAbandonsTheSink() throws {
165
- let r = try rasterizer()
166
- let sink = RecordingSink()
167
- let signal = PdfCanvasCancellation()
168
- // `PdfCanvasCancellation` is checked before start, after the pool wait, and
169
- // after the draw. Cancelling up front would be caught by the first check and
170
- // would never reach the sink, so this test needs the signal to fire only once
171
- // the reservation has happened — which is what a subclass that cancels inside
172
- // `reserveBytes` gives it.
173
- final class CancelOnReserve: RecordingSink {
174
- var signal: PdfCanvasCancellation?
175
- override func pdfCanvasReserveBytes(_ length: UInt) -> UnsafeMutableRawPointer? {
176
- let pointer = super.pdfCanvasReserveBytes(length)
177
- signal?.cancel()
178
- return pointer
179
- }
180
- }
181
- let cancelling = CancelOnReserve()
182
- cancelling.signal = signal
183
-
184
- XCTAssertThrowsError(
185
- try r.render(
186
- request(width: 200, height: 100, scale: 1), cancellation: signal,
187
- sink: cancelling)
188
- ) { error in
189
- XCTAssertEqual(PdfCanvasErrorCodeOf(error as NSError), .cancelled)
190
- XCTAssertTrue(
191
- (error as NSError).localizedDescription.contains("after render"),
192
- "the stage should say the draw had already finished: "
193
- + (error as NSError).localizedDescription)
194
- }
195
- XCTAssertEqual(cancelling.reserveCount, 1)
196
- XCTAssertEqual(cancelling.publishCount, 0, "never published")
197
- XCTAssertEqual(cancelling.abandonCount, 1, "and released")
198
- _ = sink
199
- r.close()
200
- }
201
-
202
- /**
203
- A refusal is NOT an error, which is the half of the fallback worth stating.
204
-
205
- `out-of-memory` is reserved for a request neither destination can serve, and
206
- that case is not reachable deterministically from here: the heap path allocates
207
- through `NSMutableData dataWithLength:`, and the only size that reliably fails is
208
- one the 64 Mpx backstop rejects first — which is a different error, covered by
209
- `testAnAbsurdRasterIsRefused`. So what this pins is the boundary: a refusing sink
210
- produces a raster, not a failure.
211
- */
212
- func testARefusalIsNotAnError() throws {
213
- let r = try rasterizer()
214
- let sink = RecordingSink()
215
- sink.refuse = true
216
- let pixels = try r.render(
217
- request(width: 10, height: 10, scale: 1), cancellation: nil, sink: sink)
218
- XCTAssertFalse(pixels.isInNativeSlot)
219
- XCTAssertEqual(pixels.width, 10)
220
- r.close()
221
- }
222
-
223
- /**
224
- `releaseSlot` is idempotent and a no-op for a heap raster.
225
-
226
- Called from every abandon path in the binding above it, some of which overlap —
227
- a `@finally` that releases unless the slot was handed to the promise can
228
- legitimately run after a take.
229
- */
230
- func testReleaseSlotIsIdempotent() throws {
231
- let r = try rasterizer()
232
- let sink = RecordingSink()
233
- let pixels = try r.render(
234
- request(width: 20, height: 20, scale: 1), cancellation: nil, sink: sink)
235
- XCTAssertEqual(sink.abandonCount, 0)
236
- pixels.releaseSlot()
237
- XCTAssertEqual(sink.abandonCount, 1)
238
- pixels.releaseSlot()
239
- pixels.releaseSlot()
240
- XCTAssertEqual(sink.abandonCount, 1, "idempotent")
241
-
242
- let heap = try r.render(
243
- request(width: 20, height: 20, scale: 1), cancellation: nil, sink: nil)
244
- heap.releaseSlot() // must not crash and must do nothing
245
- XCTAssertNotNil(heap.bytes)
246
- r.close()
247
- }
248
-
249
- /// Reading pixels off a slot-backed raster fails by name rather than by NPE.
250
- func testReadingASlotBackedRasterRaises() throws {
251
- let r = try rasterizer()
252
- let sink = RecordingSink()
253
- let pixels = try r.render(
254
- request(width: 20, height: 20, scale: 1), cancellation: nil, sink: sink)
255
- // `NSException` from Objective-C is not a Swift error, so this is checked
256
- // through the property that is safe to read instead. The message is what a
257
- // developer sees, and it is asserted in the ObjC unit of the binding suite.
258
- XCTAssertTrue(pixels.isInNativeSlot)
259
- XCTAssertNil(pixels.bytes)
260
- r.close()
261
- }
262
-
263
- /**
264
- The heap sink zeroes its buffer, which is the contract `transparent` relies on.
265
-
266
- Checked directly rather than through a render, because a render with a white
267
- background would overwrite the evidence.
268
- */
269
- func testTheHeapSinkReservesZeroedMemory() {
270
- let sink = PdfCanvasHeapSink()
271
- let pointer = sink.pdfCanvasReserveBytes(1024)
272
- XCTAssertNotNil(pointer)
273
- let bytes = Array(UnsafeRawBufferPointer(start: pointer!, count: 1024))
274
- XCTAssertTrue(bytes.allSatisfy { $0 == 0 }, "reserveBytes must zero")
275
- XCTAssertEqual(sink.pdfCanvasPublish(), 0, "the heap sink has no identifier")
276
- XCTAssertNotNil(sink.data)
277
- sink.pdfCanvasAbandon()
278
- XCTAssertNil(sink.data)
279
- }
280
-
281
- /// A second reservation on one sink is refused, so a double draw cannot happen quietly.
282
- func testTheHeapSinkRefusesASecondReservation() {
283
- let sink = PdfCanvasHeapSink()
284
- XCTAssertNotNil(sink.pdfCanvasReserveBytes(64))
285
- XCTAssertNil(sink.pdfCanvasReserveBytes(64))
286
- }
287
- }
@@ -1,396 +0,0 @@
1
- import Foundation
2
- import XCTest
3
-
4
- @testable import PdfCanvasCore
5
- @testable import PdfCanvasSlots
6
-
7
- /**
8
- The REAL slot store — the same storage the device runs, not a double.
9
-
10
- `SinkTests.swift` covers the sink CONTRACT with a recording double, which is the right
11
- tool for "does the rasterizer reserve once and abandon on the cancel path". This file
12
- covers the storage itself, and the reason it can is an `#if __has_include(<jsi/jsi.h>)`
13
- in `PdfCanvasSlots.mm`: with the header absent, `PdfCanvasSlotsInstall` reports NO and
14
- everything else — allocate, fill, publish, take, release — compiles and runs on a
15
- laptop. Android draws the identical split for the identical reason.
16
-
17
- WHY IT IS WORTH THE IFDEF. Every failure mode here is a leak or a use-after-free rather
18
- than a wrong pixel, so a device session does not show any of them: 48 tiles rendered
19
- correctly on an iPhone with the store leaking every superseded raster would look exactly
20
- like 48 tiles rendered correctly. The one thing the ifdef does not cover is the JSI call
21
- itself, and that is proven on device by the negotiation probe.
22
- */
23
- final class SlotsTests: XCTestCase {
24
-
25
- override func setUp() {
26
- super.setUp()
27
- // The store is process-wide, and these tests assert on its size. Draining first
28
- // makes each one independent of whatever the last left behind — including a
29
- // deliberate leak a test was checking for.
30
- PdfCanvasSlotsReleaseAll()
31
- }
32
-
33
- override func tearDown() {
34
- PdfCanvasSlotsReleaseAll()
35
- super.tearDown()
36
- }
37
-
38
- // ---------------------------------------------------------------- //
39
- // The probe
40
- // ---------------------------------------------------------------- //
41
-
42
- /**
43
- The probe bytes are the ones the TypeScript side and Android both expect.
44
-
45
- Three copies of this list exist — here, `TRANSPORT_PROBE_BYTES` in
46
- `native-bridge.ts`, and `PdfCanvasPixelBridge.PROBE_BYTES` in Java — and they are
47
- restated rather than derived on purpose: they are the only place the transport's byte
48
- fidelity is asserted, so a mismatch has to make the probe FAIL and the platform fall
49
- back to base64, not silently pass with different bytes.
50
- */
51
- func testProbeBytesMatchTheSharedList() {
52
- let expected: [UInt8] = [0x00, 0x01, 0x02, 0xFD, 0xFE, 0xFF, 0x2A, 0x07]
53
- XCTAssertEqual(Array(PdfCanvasSlotsProbeBytes()), expected)
54
- }
55
-
56
- /// The probe round-trips through the real store, which is what `negotiateTransport` does.
57
- func testTheProbeRoundTrips() {
58
- let slot = PdfCanvasSlotsStoreProbe()
59
- XCTAssertGreaterThan(slot, 0, "a published slot id is never 0 — that is the heap sentinel")
60
- XCTAssertEqual(PdfCanvasSlotsCount(), 1)
61
- let taken = PdfCanvasSlotsTakeForTest(slot)
62
- XCTAssertEqual(taken.map { Array($0) }, Array(PdfCanvasSlotsProbeBytes()))
63
- XCTAssertEqual(PdfCanvasSlotsCount(), 0, "a take removes the slot")
64
- }
65
-
66
- // ---------------------------------------------------------------- //
67
- // Taken exactly once
68
- // ---------------------------------------------------------------- //
69
-
70
- /**
71
- A slot is taken EXACTLY once, and a second take finds nothing.
72
-
73
- The host function reports "nothing" by throwing, and that is deliberate: an empty
74
- `ArrayBuffer` would reach Skia and draw a black tile. So the storage has to actually
75
- erase, not merely hand out a copy.
76
- */
77
- func testASlotIsTakenExactlyOnce() {
78
- let slot = PdfCanvasSlotsStoreProbe()
79
- XCTAssertNotNil(PdfCanvasSlotsTakeForTest(slot))
80
- XCTAssertNil(PdfCanvasSlotsTakeForTest(slot), "the second take must find nothing")
81
- XCTAssertNil(PdfCanvasSlotsTakeForTest(slot))
82
- }
83
-
84
- /// Slot ids are monotonic per process, which is what makes a collision unrepresentable.
85
- func testSlotIdsAreMonotonic() {
86
- var ids: [Int64] = []
87
- for _ in 0..<8 { ids.append(PdfCanvasSlotsStoreProbe()) }
88
- XCTAssertEqual(ids, ids.sorted(), "ids must increase")
89
- XCTAssertEqual(Set(ids).count, ids.count, "and never repeat")
90
- XCTAssertEqual(PdfCanvasSlotsCount(), 8)
91
- }
92
-
93
- /**
94
- Ids are NOT reused after a take.
95
-
96
- Android's C++ has to REFUSE a colliding id, because its ids are handed in from Java
97
- and React Native builds a fresh module on every reload while the old one may still
98
- have rasters parked — an overwrite there would hand one module another module's
99
- raster, which is a wrong image rather than an error. Allocating the id inside the
100
- store, next to the map, removes that whole failure class; this pins it.
101
- */
102
- func testIdsAreNotReusedAfterATake() {
103
- let first = PdfCanvasSlotsStoreProbe()
104
- _ = PdfCanvasSlotsTakeForTest(first)
105
- let second = PdfCanvasSlotsStoreProbe()
106
- XCTAssertGreaterThan(second, first, "a freed id must not come back")
107
- }
108
-
109
- // ---------------------------------------------------------------- //
110
- // Release
111
- // ---------------------------------------------------------------- //
112
-
113
- func testReleaseDropsOneSlotAndIsIdempotent() {
114
- let a = PdfCanvasSlotsStoreProbe()
115
- let b = PdfCanvasSlotsStoreProbe()
116
- XCTAssertEqual(PdfCanvasSlotsCount(), 2)
117
- PdfCanvasSlotsRelease(a)
118
- XCTAssertEqual(PdfCanvasSlotsCount(), 1)
119
- PdfCanvasSlotsRelease(a)
120
- PdfCanvasSlotsRelease(a)
121
- XCTAssertEqual(PdfCanvasSlotsCount(), 1, "releasing twice is a no-op")
122
- XCTAssertNil(PdfCanvasSlotsTakeForTest(a))
123
- XCTAssertNotNil(PdfCanvasSlotsTakeForTest(b), "the other slot is untouched")
124
- }
125
-
126
- /**
127
- `releaseAll` drains everything, which is what a Fast Refresh depends on.
128
-
129
- A reload drops the JS side without calling `close`, so every slot whose promise
130
- resolved but whose continuation never ran is a whole raster — megabytes — held in C++
131
- with nothing left that could take it. `PdfCanvasModule.invalidate` calls this.
132
- */
133
- func testReleaseAllDrainsTheStore() {
134
- for _ in 0..<16 { _ = PdfCanvasSlotsStoreProbe() }
135
- XCTAssertEqual(PdfCanvasSlotsCount(), 16)
136
- PdfCanvasSlotsReleaseAll()
137
- XCTAssertEqual(PdfCanvasSlotsCount(), 0)
138
- PdfCanvasSlotsReleaseAll()
139
- XCTAssertEqual(PdfCanvasSlotsCount(), 0, "draining an empty store is a no-op")
140
- }
141
-
142
- /// Releasing a slot id that never existed does nothing, rather than corrupting the map.
143
- func testReleasingAnUnknownSlotIsHarmless() {
144
- let real = PdfCanvasSlotsStoreProbe()
145
- PdfCanvasSlotsRelease(0)
146
- PdfCanvasSlotsRelease(-1)
147
- PdfCanvasSlotsRelease(real + 100_000)
148
- XCTAssertEqual(PdfCanvasSlotsCount(), 1)
149
- XCTAssertNotNil(PdfCanvasSlotsTakeForTest(real))
150
- }
151
-
152
- // ---------------------------------------------------------------- //
153
- // The real sink, through the real rasterizer
154
- // ---------------------------------------------------------------- //
155
-
156
- /**
157
- THE HEADLINE: the bytes taken out of a slot are byte-identical to the same raster
158
- rendered to the heap.
159
-
160
- This is the transport's whole promise, and it is the one thing that cannot be checked
161
- by looking at the code: CoreGraphics is handed a pointer into a `SlotBuffer` and
162
- writes the tile in place, so if the buffer, the stride or the publish were wrong the
163
- result would be a plausible-looking wrong image rather than an error. Its Android
164
- counterpart is `directFillBytesEqualTheHeapPathForARealRaster`, which has to run on a
165
- device; this one runs on a laptop.
166
- */
167
- func testSlotBytesAreIdenticalToTheHeapPathForARealRaster() throws {
168
- let rasterizer = try openFixture(
169
- TestPdfs.grid(width: 400, height: 300), name: "slots-fidelity.pdf")
170
-
171
- let req = request(x: 37, y: 23, width: 64, height: 41, scale: 4)
172
- let heap = try rasterizer.render(req, cancellation: nil, sink: nil)
173
- XCTAssertNotNil(heap.bytes)
174
-
175
- let sink = PdfCanvasSlotSink()
176
- let slotted = try rasterizer.render(req, cancellation: nil, sink: sink)
177
- XCTAssertTrue(slotted.isInNativeSlot, "the real sink must produce a slot")
178
- XCTAssertNil(slotted.bytes, "and no heap bytes")
179
- XCTAssertEqual(slotted.width, heap.width)
180
- XCTAssertEqual(slotted.height, heap.height)
181
- XCTAssertEqual(slotted.rowBytes, heap.rowBytes)
182
-
183
- let taken = PdfCanvasSlotsTakeForTest(slotted.slot)
184
- XCTAssertNotNil(taken, "the slot must still be there before anyone takes it")
185
- XCTAssertEqual(
186
- Array(taken!), Array(heap.bytes!),
187
- "the slot path and the heap path must produce identical bytes")
188
- XCTAssertEqual(taken!.count, heap.rowBytes * heap.height)
189
- rasterizer.close()
190
- }
191
-
192
- /// And on a transparent background, where the untouched bytes are the reserved zeroes.
193
- func testSlotBytesMatchTheHeapPathOnATransparentBackground() throws {
194
- let rasterizer = try openFixture(
195
- TestPdfs.page(mediaBox: (0, 0, 100, 100), content: "1 0 0 rg 0 0 40 40 re f\n"),
196
- name: "slots-transparent.pdf")
197
- let req = request(width: 100, height: 100, scale: 2, background: .transparent)
198
- let heap = try rasterizer.render(req, cancellation: nil, sink: nil)
199
- let sink = PdfCanvasSlotSink()
200
- let slotted = try rasterizer.render(req, cancellation: nil, sink: sink)
201
- let taken = PdfCanvasSlotsTakeForTest(slotted.slot)
202
- XCTAssertEqual(Array(taken!), Array(heap.bytes!))
203
- // And the reserved memory really was zeroed — otherwise the untouched area would
204
- // carry whatever the allocator had, which is the "tile of somebody else's heap"
205
- // failure the value-initialised buffer exists to prevent.
206
- let bytes = Array(taken!)
207
- let cornerOffset = 10 * heap.rowBytes + 190 * 4
208
- XCTAssertEqual(
209
- Array(bytes[cornerOffset..<(cornerOffset + 4)]), [0, 0, 0, 0],
210
- "unpainted area on a transparent background must be exactly zero")
211
- rasterizer.close()
212
- }
213
-
214
- /**
215
- A raster the rasterizer ABANDONS leaves nothing in the store.
216
-
217
- The path is a cancellation observed after the draw finished. Without the abandon a
218
- superseded epoch strands one whole raster per tile — megabytes per gesture, freed only
219
- by the next reload — and nothing about the rendered output would look wrong.
220
- */
221
- func testAnAbandonedRasterLeavesNoSlotBehind() throws {
222
- let rasterizer = try openFixture(
223
- TestPdfs.grid(width: 200, height: 200), name: "slots-abandon.pdf")
224
- let signal = PdfCanvasCancellation()
225
-
226
- // A sink that cancels once the buffer is reserved, so the cancel lands after the
227
- // draw rather than before it.
228
- final class CancelOnReserve: PdfCanvasSlotSink {
229
- var signal: PdfCanvasCancellation?
230
- override func pdfCanvasReserveBytes(_ length: UInt) -> UnsafeMutableRawPointer? {
231
- let pointer = super.pdfCanvasReserveBytes(length)
232
- signal?.cancel()
233
- return pointer
234
- }
235
- }
236
- let sink = CancelOnReserve()
237
- sink.signal = signal
238
-
239
- XCTAssertEqual(PdfCanvasSlotsCount(), 0)
240
- XCTAssertThrowsError(
241
- try rasterizer.render(
242
- request(width: 200, height: 200, scale: 2), cancellation: signal, sink: sink)
243
- ) { error in
244
- XCTAssertEqual(PdfCanvasErrorCodeOf(error as NSError), .cancelled)
245
- }
246
- XCTAssertEqual(
247
- PdfCanvasSlotsCount(), 0,
248
- "the abandoned raster must not be parked in the store")
249
- rasterizer.close()
250
- }
251
-
252
- /// `releaseSlot` on a published raster takes it back out of the store.
253
- func testReleaseSlotOnAPublishedRasterDrainsIt() throws {
254
- let rasterizer = try openFixture(
255
- TestPdfs.grid(width: 100, height: 100), name: "slots-release.pdf")
256
- let pixels = try rasterizer.render(
257
- request(width: 100, height: 100, scale: 1), cancellation: nil,
258
- sink: PdfCanvasSlotSink())
259
- XCTAssertEqual(PdfCanvasSlotsCount(), 1)
260
- pixels.releaseSlot()
261
- XCTAssertEqual(PdfCanvasSlotsCount(), 0)
262
- pixels.releaseSlot()
263
- XCTAssertEqual(PdfCanvasSlotsCount(), 0, "idempotent")
264
- XCTAssertNil(PdfCanvasSlotsTakeForTest(pixels.slot))
265
- rasterizer.close()
266
- }
267
-
268
- /// A `releaseSlot` AFTER a successful take is harmless — the normal ordering in the binding.
269
- func testReleaseSlotAfterATakeIsHarmless() throws {
270
- let rasterizer = try openFixture(
271
- TestPdfs.grid(width: 100, height: 100), name: "slots-late-release.pdf")
272
- let pixels = try rasterizer.render(
273
- request(width: 100, height: 100, scale: 1), cancellation: nil,
274
- sink: PdfCanvasSlotSink())
275
- XCTAssertNotNil(PdfCanvasSlotsTakeForTest(pixels.slot))
276
- XCTAssertEqual(PdfCanvasSlotsCount(), 0)
277
- pixels.releaseSlot() // must not crash, must not free anything else
278
- XCTAssertEqual(PdfCanvasSlotsCount(), 0)
279
- rasterizer.close()
280
- }
281
-
282
- /**
283
- A sink is single-use: a second reservation is refused.
284
-
285
- Which is what makes "reserve once, before the draw" enforceable rather than merely
286
- documented — a rasterizer that drew twice into one sink would otherwise silently
287
- publish the second draw.
288
- */
289
- func testASinkRefusesASecondReservation() {
290
- let sink = PdfCanvasSlotSink()
291
- XCTAssertNotNil(sink.pdfCanvasReserveBytes(1024))
292
- XCTAssertNil(sink.pdfCanvasReserveBytes(1024))
293
- XCTAssertNil(sink.pdfCanvasReserveBytes(8))
294
- }
295
-
296
- /// A zero-length reservation is refused rather than publishing an empty buffer.
297
- func testAZeroLengthReservationIsRefused() {
298
- let sink = PdfCanvasSlotSink()
299
- XCTAssertNil(sink.pdfCanvasReserveBytes(0))
300
- XCTAssertEqual(sink.pdfCanvasPublish(), 0, "nothing to publish")
301
- XCTAssertEqual(PdfCanvasSlotsCount(), 0)
302
- }
303
-
304
- /**
305
- RESERVE DOES NOT PUBLISH. The buffer is unreachable from the store until `publish`.
306
-
307
- THE SAFETY PROPERTY OF THE WHOLE FILE. A buffer reachable from the process-wide map
308
- before it is filled can be freed by `PdfCanvasSlotsReleaseAll` — which runs on
309
- another thread, on every Fast Refresh — while CoreGraphics is still writing into it.
310
- The Android JSI file calls that outcome "silent heap corruption, weeks from the change
311
- that caused it".
312
- */
313
- func testAReservedBufferIsNotInTheStoreUntilPublished() {
314
- let sink = PdfCanvasSlotSink()
315
- let pointer = sink.pdfCanvasReserveBytes(4096)
316
- XCTAssertNotNil(pointer)
317
- XCTAssertEqual(
318
- PdfCanvasSlotsCount(), 0,
319
- "a reserved-but-unpublished buffer must be unreachable from the store")
320
- // Draining now must not touch it — this is the reload-lands-mid-draw case.
321
- PdfCanvasSlotsReleaseAll()
322
- // Still writable. If releaseAll had freed it, this is where ASan would fire.
323
- memset(pointer!, 0xAB, 4096)
324
- let slot = sink.pdfCanvasPublish()
325
- XCTAssertGreaterThan(slot, 0)
326
- XCTAssertEqual(PdfCanvasSlotsCount(), 1)
327
- let taken = PdfCanvasSlotsTakeForTest(slot)
328
- XCTAssertEqual(taken?.count, 4096)
329
- XCTAssertEqual(Array(taken!).first, 0xAB, "the bytes written after the drain survived")
330
- }
331
-
332
- /// Reserved memory is zeroed, which is the contract `background: transparent` rests on.
333
- func testReservedMemoryIsZeroed() {
334
- let sink = PdfCanvasSlotSink()
335
- let pointer = sink.pdfCanvasReserveBytes(8192)
336
- XCTAssertNotNil(pointer)
337
- let bytes = Array(UnsafeRawBufferPointer(start: pointer!, count: 8192))
338
- XCTAssertTrue(bytes.allSatisfy { $0 == 0 })
339
- sink.pdfCanvasAbandon()
340
- }
341
-
342
- /**
343
- The store is safe to publish into and take from concurrently.
344
-
345
- The real schedule: several render lanes publishing while the JS thread takes. A map
346
- without a lock would corrupt here rather than merely race.
347
- */
348
- func testConcurrentPublishAndTake() {
349
- let lock = NSLock()
350
- var slots: [Int64] = []
351
- DispatchQueue.concurrentPerform(iterations: 64) { index in
352
- let sink = PdfCanvasSlotSink()
353
- guard let pointer = sink.pdfCanvasReserveBytes(1024) else {
354
- XCTFail("reservation \(index) failed")
355
- return
356
- }
357
- memset(pointer, Int32(index & 0xFF), 1024)
358
- let slot = sink.pdfCanvasPublish()
359
- lock.lock()
360
- slots.append(slot)
361
- lock.unlock()
362
- }
363
- XCTAssertEqual(slots.count, 64)
364
- XCTAssertEqual(Set(slots).count, 64, "every publish got its own id")
365
- XCTAssertEqual(PdfCanvasSlotsCount(), 64)
366
-
367
- let taken = NSLock()
368
- var lengths: [Int] = []
369
- DispatchQueue.concurrentPerform(iterations: slots.count) { index in
370
- if let data = PdfCanvasSlotsTakeForTest(slots[index]) {
371
- taken.lock()
372
- lengths.append(data.count)
373
- taken.unlock()
374
- }
375
- }
376
- XCTAssertEqual(lengths.count, 64, "every slot was taken exactly once")
377
- XCTAssertTrue(lengths.allSatisfy { $0 == 1024 })
378
- XCTAssertEqual(PdfCanvasSlotsCount(), 0)
379
- }
380
-
381
- /**
382
- `PdfCanvasSlotsInstall` reports NO in this build, honestly.
383
-
384
- Not a stub for its own sake: this is the configuration that lets every test above
385
- run, and the value it returns is what a build with no JSI genuinely has to say. A
386
- `YES` here would be a lie that made the JS side arm a transport it cannot redeem.
387
- */
388
- func testInstallDeclinesWithoutAJsiRuntime() {
389
- XCTAssertFalse(PdfCanvasSlotsInstall(nil))
390
- var dummy = 0
391
- XCTAssertFalse(
392
- PdfCanvasSlotsInstall(&dummy),
393
- "the SwiftPM build has no jsi.h, so install must decline rather than "
394
- + "dereference whatever it was handed")
395
- }
396
- }