@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,140 +0,0 @@
1
- #import <Foundation/Foundation.h>
2
-
3
- #import <PdfCanvasPixelSink.h>
4
-
5
- NS_ASSUME_NONNULL_BEGIN
6
-
7
- /**
8
- * The raster transport: CoreGraphics writes a tile, and JS gets an `ArrayBuffer`
9
- * over those exact bytes with no base64 and no copy anywhere.
10
- *
11
- * ------------------------------------------------------------------------
12
- * WHY THIS FILE EXISTS
13
- * ------------------------------------------------------------------------
14
- *
15
- * Neither the legacy bridge nor new-architecture codegen has a binary type, and
16
- * that is not an Android quirk — MEASURED on iOS by reading RN 0.86.2's own
17
- * source: `TurboModuleConvertUtils::convertObjCObjectToJSIValue` handles
18
- * `NSString`, `NSNumber`, `NSDictionary`, `NSArray` and `kCFNull`, and returns
19
- * `jsi::Value::undefined()` for anything else. An `NSData` in a resolved promise
20
- * arrives in JS as `undefined`.
21
- *
22
- * So the fallback is base64, exactly as on Android, and the cost is the same
23
- * cliff: the Android measurement for one 1024x1024 tile was 19.88 ms to encode,
24
- * ~72.6 ms of string marshalling and 1005.46 ms of `decodeBase64` in Hermes —
25
- * 90.6% of the tile, spent undoing an encode that finished a moment earlier. iOS
26
- * runs the same Hermes.
27
- *
28
- * ------------------------------------------------------------------------
29
- * HOW MANY COPIES: ZERO, AND THAT IS BETTER THAN ANDROID CAN DO
30
- * ------------------------------------------------------------------------
31
- *
32
- * Android's fast path costs ONE full-raster `memcpy` and cannot do better: a
33
- * `Bitmap` owns its own pixel memory, `PdfRenderer.render` writes into that, and
34
- * `nativeStoreFromBitmap`'s job is to copy it out. There is no way to tell
35
- * `PdfRenderer` where to put the pixels.
36
- *
37
- * `CGBitmapContextCreate` takes the destination pointer as an argument. So
38
- * `PdfCanvasSlotSink` reserves a buffer HERE, `PdfCanvasRasterizer` hands that
39
- * pointer to CoreGraphics, and the tile is written once, in place. Then
40
- * `__pdfCanvasTakePixels` hands the buffer to
41
- * `jsi::ArrayBuffer(runtime, shared_ptr<MutableBuffer>)`: Hermes retains the
42
- * `shared_ptr` and points the ArrayBuffer's storage AT it. Still no copy.
43
- *
44
- * ------------------------------------------------------------------------
45
- * RESERVE, FILL, PUBLISH — in that order, and the order is the safety property
46
- * ------------------------------------------------------------------------
47
- *
48
- * A buffer reachable from the process-wide slot map BEFORE it is filled can be
49
- * freed by `PdfCanvasSlotsReleaseAll` — which runs on another thread, on every
50
- * Fast Refresh — while a render worker is still writing into it. The Android JSI
51
- * file describes that outcome exactly: "silent heap corruption, weeks from the
52
- * change that caused it". So a reserved buffer is owned by its sink object alone,
53
- * on the rendering thread's stack, and becomes reachable only at publish.
54
- *
55
- * ------------------------------------------------------------------------
56
- * WHO FREES IT
57
- * ------------------------------------------------------------------------
58
- *
59
- * The slot map drops its reference the instant a take succeeds, so from that
60
- * moment the only owner is the JS `ArrayBuffer` and the buffer dies when Hermes
61
- * collects it. A slot that is stored and never taken — a JS teardown between the
62
- * promise resolving and its continuation running — is freed by
63
- * `PdfCanvasSlotsReleaseAll`, which `PdfCanvasModule.invalidate` calls.
64
- */
65
-
66
- /**
67
- * A `PdfCanvasPixelSink` that reserves C++-owned memory and publishes it under a
68
- * fresh slot id.
69
- *
70
- * The id comes from a PROCESS-WIDE counter, not from the caller, which closes a
71
- * hazard Android has to guard against by hand: its slot ids are allocated by the
72
- * module and the C++ side has to REFUSE a collision, because RN builds a fresh set
73
- * of native modules on every reload while the outgoing one may still have rasters
74
- * parked. Allocating the id at reserve time, next to the map it goes into, makes a
75
- * collision unrepresentable — so `pdfCanvasPublish` cannot fail, and the whole
76
- * "publish refused after the raster was already drawn" path does not exist here.
77
- */
78
- @interface PdfCanvasSlotSink : NSObject <PdfCanvasPixelSink>
79
- @end
80
-
81
- /**
82
- * The self-test payload, mirroring `TRANSPORT_PROBE_BYTES` in
83
- * `src/rasterizer/native-bridge.ts` and `PdfCanvasPixelBridge.PROBE_BYTES` on
84
- * Android.
85
- *
86
- * The three lists are restated rather than derived, which is the point: they are
87
- * the only places the transport's byte fidelity is asserted, and if one is edited
88
- * without the others the probe fails and that platform degrades to base64 rather
89
- * than shipping a shear.
90
- */
91
- FOUNDATION_EXPORT NSData *PdfCanvasSlotsProbeBytes(void);
92
-
93
- /**
94
- * Parks the probe bytes in a fresh slot and returns its id.
95
- *
96
- * What the probe covers: that the runtime accepted the host function, that Hermes
97
- * will build an `ArrayBuffer` over a `MutableBuffer`, and that byte values survive
98
- * the view unmangled. What it does NOT cover is the FILL every subsequent raster
99
- * uses — that is CoreGraphics writing into a reserved buffer, and it is covered by
100
- * `SinkTests.swift` on the host instead. Worth knowing when reading a bug report:
101
- * a passing probe says nothing about the fill.
102
- */
103
- FOUNDATION_EXPORT int64_t PdfCanvasSlotsStoreProbe(void);
104
-
105
- /**
106
- * Installs `globalThis.__pdfCanvasTakePixels` into a JSI runtime.
107
- *
108
- * MUST be called on the JS thread with a live runtime pointer. Returns NO — never
109
- * throws — when the pointer is null or the runtime refuses the host function. The
110
- * JS side then falls back to base64: rendering correctly is worth more than
111
- * rendering fast.
112
- *
113
- * @param runtimePointer a `facebook::jsi::Runtime *`, as an opaque pointer so this
114
- * header stays importable from plain Objective-C. NULLABLE, and checked: the
115
- * bridge genuinely can hand over nothing, and this file is enclosed in
116
- * `NS_ASSUME_NONNULL_BEGIN`, so leaving it implicitly non-null would make Swift
117
- * unable to express the case the implementation handles.
118
- */
119
- FOUNDATION_EXPORT BOOL PdfCanvasSlotsInstall(void *_Nullable runtimePointer);
120
-
121
- /** Drops one slot if it is still there. Idempotent. */
122
- FOUNDATION_EXPORT void PdfCanvasSlotsRelease(int64_t slot);
123
-
124
- /** Drops every slot. Called from `PdfCanvasModule.invalidate`. */
125
- FOUNDATION_EXPORT void PdfCanvasSlotsReleaseAll(void);
126
-
127
- /** Slots currently waiting to be taken. For assertions and diagnostics, not logic. */
128
- FOUNDATION_EXPORT NSInteger PdfCanvasSlotsCount(void);
129
-
130
- /**
131
- * Takes a slot back out as an `NSData`, through the identical storage the
132
- * `ArrayBuffer` would have wrapped.
133
- *
134
- * The only way to check the transport without a JS runtime, which is why it ships
135
- * rather than living in a test target: the storage under test is the production
136
- * one. Nil when the slot is empty.
137
- */
138
- FOUNDATION_EXPORT NSData *_Nullable PdfCanvasSlotsTakeForTest(int64_t slot);
139
-
140
- NS_ASSUME_NONNULL_END
@@ -1,358 +0,0 @@
1
- import Foundation
2
- import XCTest
3
-
4
- @testable import PdfCanvasCore
5
-
6
- /**
7
- Cancellation, the pool, and what happens when a document is closed under a render.
8
-
9
- None of these produce a wrong pixel. They produce a leaked lane that starves every
10
- later render, a freed document a worker is still drawing from, or a superseded
11
- raster that gets ingested into Skia anyway — so each one is worth a test even
12
- though none of them is visible in an image.
13
- */
14
- final class ConcurrencyTests: XCTestCase {
15
-
16
- private func grid(_ name: String, poolSize: Int = 1) throws -> PdfCanvasRasterizer {
17
- try openFixture(
18
- TestPdfs.grid(width: 600, height: 800), name: name, poolSize: poolSize)
19
- }
20
-
21
- // ---------------------------------------------------------------- //
22
- // Cancellation
23
- // ---------------------------------------------------------------- //
24
-
25
- /**
26
- A cancel before the render starts stops it, and says so by stage.
27
-
28
- The stage is in the message on purpose: a cancellation reported "before start"
29
- saved the whole render, and one reported "after render" saved only the ingest.
30
- Those are very different amounts of work and a bug report should not have to
31
- guess which happened.
32
- */
33
- func testACancelBeforeStartStopsTheRender() throws {
34
- let r = try grid("cancel-before.pdf")
35
- let signal = PdfCanvasCancellation()
36
- signal.cancel()
37
- XCTAssertTrue(signal.isCancelled)
38
- XCTAssertThrowsError(
39
- try r.render(
40
- request(width: 600, height: 800, scale: 2), cancellation: signal, sink: nil)
41
- ) { error in
42
- XCTAssertEqual(PdfCanvasErrorCodeOf(error as NSError), .cancelled)
43
- XCTAssertTrue(
44
- (error as NSError).localizedDescription.contains("before start"),
45
- (error as NSError).localizedDescription)
46
- }
47
- r.close()
48
- }
49
-
50
- /// A signal that is never cancelled does not interfere.
51
- func testAnUncancelledSignalIsTransparent() throws {
52
- let r = try grid("cancel-none.pdf")
53
- let signal = PdfCanvasCancellation()
54
- let raster = try r.render(
55
- request(width: 100, height: 100, scale: 1), cancellation: signal, sink: nil)
56
- XCTAssertEqual(raster.width, 100)
57
- XCTAssertFalse(signal.isCancelled)
58
- r.close()
59
- }
60
-
61
- /**
62
- A cancel that lands while a render is IN FLIGHT is observed only after it
63
- finishes.
64
-
65
- `CGContextDrawPDFPage` is synchronous and uninterruptible once entered, and this
66
- test asserts that shape rather than pretending otherwise — it is the same honest
67
- claim `RasterCancellation.java` makes and the reason
68
- `capabilities.interruptibleRender` is false. What the cancel DOES buy is that the
69
- finished raster is not handed back, which saves the Skia ingest on the JS side.
70
-
71
- The render is made large enough to still be running when the cancel fires, and
72
- the assertion tolerates either outcome for exactly one reason: on a fast machine
73
- the render can complete before the 5 ms timer, and a test that demanded the
74
- cancel win would be a flake. What it does NOT tolerate is a raster coming back
75
- from a signal that was already cancelled when the draw ended.
76
- */
77
- func testACancelDuringAnInFlightRenderIsObservedOnlyAfterItFinishes() throws {
78
- let r = try openFixture(
79
- TestPdfs.grid(width: 1728, height: 2592, pitch: 4), name: "cancel-inflight.pdf")
80
- let signal = PdfCanvasCancellation()
81
- let started = Date()
82
- DispatchQueue.global().asyncAfter(deadline: .now() + 0.005) { signal.cancel() }
83
-
84
- do {
85
- let raster = try r.render(
86
- request(width: 1728, height: 2592, scale: 2), cancellation: signal, sink: nil)
87
- // It finished before the timer. That is a legitimate outcome — and the proof
88
- // that the render was not interrupted is that it produced a complete raster.
89
- XCTAssertEqual(raster.width, 3456)
90
- XCTAssertLessThan(
91
- Date().timeIntervalSince(started), 60, "sanity: it did finish")
92
- } catch {
93
- XCTAssertEqual(PdfCanvasErrorCodeOf(error as NSError), .cancelled)
94
- XCTAssertTrue(
95
- (error as NSError).localizedDescription.contains("after render")
96
- || (error as NSError).localizedDescription.contains("before render")
97
- || (error as NSError).localizedDescription.contains("after pool wait"),
98
- "a mid-flight cancel can only be seen at a checkpoint: "
99
- + (error as NSError).localizedDescription)
100
- }
101
- r.close()
102
- }
103
-
104
- /**
105
- A cancel is per-signal: cancelling one render does not affect another.
106
-
107
- Trivial to get wrong if the flag ever moves onto the rasterizer, which is where
108
- it would be tempting to put it.
109
- */
110
- func testCancellationIsPerSignal() throws {
111
- let r = try grid("cancel-per-signal.pdf", poolSize: 2)
112
- let cancelled = PdfCanvasCancellation()
113
- cancelled.cancel()
114
- let live = PdfCanvasCancellation()
115
-
116
- XCTAssertThrowsError(
117
- try r.render(
118
- request(width: 100, height: 100, scale: 1), cancellation: cancelled,
119
- sink: nil))
120
- XCTAssertNoThrow(
121
- try r.render(
122
- request(width: 100, height: 100, scale: 1), cancellation: live, sink: nil))
123
- r.close()
124
- }
125
-
126
- // ---------------------------------------------------------------- //
127
- // The pool
128
- // ---------------------------------------------------------------- //
129
-
130
- /**
131
- N lanes really do render concurrently, and every result is correct.
132
-
133
- The correctness half matters more than the concurrency half: two lanes drawing
134
- from two `CGPDFDocumentRef`s over the same file is the arrangement that would go
135
- wrong if the pool handed the same document to both. Every tile is compared
136
- against the same tile rendered serially, so a lane that produced a torn or
137
- half-drawn raster would fail rather than merely be slow.
138
- */
139
- func testEveryLaneProducesTheSamePixelsAsASerialRender() throws {
140
- let data = TestPdfs.grid(width: 600, height: 800)
141
- let serial = try openFixture(data, name: "lanes-serial.pdf", poolSize: 1)
142
- let tiles: [(Double, Double)] = [
143
- (0, 0), (100, 0), (200, 0), (0, 100), (100, 100), (200, 100),
144
- (300, 200), (400, 300),
145
- ]
146
- var expected: [String: [UInt8]] = [:]
147
- for (x, y) in tiles {
148
- let raster = try serial.render(
149
- request(x: x, y: y, width: 100, height: 100, scale: 2),
150
- cancellation: nil, sink: nil)
151
- expected["\(x),\(y)"] = Array(raster.bytes!)
152
- }
153
- serial.close()
154
-
155
- let parallel = try openFixture(data, name: "lanes-parallel.pdf", poolSize: 4)
156
- XCTAssertEqual(parallel.poolSize, 4)
157
- let lock = NSLock()
158
- var failures: [String] = []
159
- DispatchQueue.concurrentPerform(iterations: tiles.count * 3) { index in
160
- let (x, y) = tiles[index % tiles.count]
161
- do {
162
- let raster = try parallel.render(
163
- request(x: x, y: y, width: 100, height: 100, scale: 2),
164
- cancellation: nil, sink: nil)
165
- let bytes = Array(raster.bytes!)
166
- lock.lock()
167
- if bytes != expected["\(x),\(y)"] {
168
- failures.append("tile \(x),\(y) differs from the serial render")
169
- }
170
- lock.unlock()
171
- } catch {
172
- lock.lock()
173
- failures.append("tile \(x),\(y) failed: \(error)")
174
- lock.unlock()
175
- }
176
- }
177
- XCTAssertEqual(failures, [], "concurrent renders must match the serial ones")
178
- parallel.close()
179
- }
180
-
181
- /**
182
- A one-lane pool SERIALISES rather than failing, and every render still lands.
183
-
184
- The lease blocks; it does not refuse. That is what lets the controller schedule
185
- more items than there are lanes without knowing how many there are.
186
- */
187
- func testAOneLanePoolSerialisesRatherThanFailing() throws {
188
- let r = try grid("one-lane.pdf", poolSize: 1)
189
- XCTAssertEqual(r.poolSize, 1)
190
- let lock = NSLock()
191
- var completed = 0
192
- DispatchQueue.concurrentPerform(iterations: 12) { _ in
193
- if let raster = try? r.render(
194
- request(width: 80, height: 80, scale: 1), cancellation: nil, sink: nil),
195
- raster.width == 80
196
- {
197
- lock.lock()
198
- completed += 1
199
- lock.unlock()
200
- }
201
- }
202
- XCTAssertEqual(completed, 12, "every render must land, one at a time")
203
- r.close()
204
- }
205
-
206
- /**
207
- PDFKit renders concurrently too, and each lane opens its OWN `PDFDocument`.
208
-
209
- The lazy PDFKit document is per-lane, so this is where a shared one would show
210
- up. `PDFDocument` is not documented thread-safe and the failure mode would be
211
- intermittent — which is exactly the kind of thing to pin while it is cheap.
212
- */
213
- func testPdfKitRendersConcurrentlyPerLane() throws {
214
- let r = try openFixture(
215
- TestPdfs.annotatedPage(), name: "pdfkit-lanes.pdf", poolSize: 4)
216
- let lock = NSLock()
217
- var greens = 0
218
- DispatchQueue.concurrentPerform(iterations: 16) { _ in
219
- if let raster = try? r.render(
220
- request(width: 200, height: 200, scale: 1, annotations: true),
221
- cancellation: nil, sink: nil)
222
- {
223
- let p = raster.pixel(100, 100)
224
- lock.lock()
225
- if p.isNear(.green) { greens += 1 }
226
- lock.unlock()
227
- }
228
- }
229
- XCTAssertEqual(greens, 16, "every lane must composite the annotation")
230
- r.close()
231
- }
232
-
233
- // ---------------------------------------------------------------- //
234
- // Close
235
- // ---------------------------------------------------------------- //
236
-
237
- /**
238
- CLOSING WHILE A RENDER IS IN FLIGHT IS SAFE, and this is the test Android cannot
239
- write.
240
-
241
- `PdfRendererPool.close()` closes every renderer including leased ones, and its
242
- own comment calls that "the only honest option" — blocking `close()` until an
243
- in-flight ARCH-D render finishes is worse. It leaves a window.
244
-
245
- There is no window here: a lease OWNS a CoreFoundation reference to its document,
246
- so `close` dropping the pool's references cannot free a document a worker is
247
- still drawing from. The worker's render either completes normally or fails with
248
- `backend-failure`; what it must never do is crash. Run enough times to make a
249
- use-after-free likely if one existed.
250
- */
251
- func testClosingWhileRendersAreInFlightIsSafe() throws {
252
- for attempt in 0..<8 {
253
- let r = try openFixture(
254
- TestPdfs.grid(width: 1728, height: 2592, pitch: 4),
255
- name: "close-race-\(attempt).pdf", poolSize: 4)
256
- let group = DispatchGroup()
257
- for lane in 0..<8 {
258
- DispatchQueue.global().async(group: group) {
259
- // Either outcome is fine. A crash is not.
260
- _ = try? r.render(
261
- request(
262
- x: Double(lane) * 100, y: 0, width: 400, height: 400, scale: 3),
263
- cancellation: nil, sink: nil)
264
- }
265
- }
266
- // Close while they are very likely still drawing.
267
- Thread.sleep(forTimeInterval: 0.002)
268
- r.close()
269
- XCTAssertEqual(
270
- group.wait(timeout: .now() + 30), .success,
271
- "renders should finish or fail, never hang")
272
- XCTAssertTrue(r.isClosed)
273
- }
274
- }
275
-
276
- /**
277
- `close` does not block on an in-flight render.
278
-
279
- The whole reason the leased document is left alive rather than freed: `close`
280
- comes from JS when a document unmounts, and a JS thread waiting on an ARCH-D
281
- raster is a frozen app.
282
- */
283
- func testCloseDoesNotBlockOnAnInFlightRender() throws {
284
- let r = try openFixture(
285
- TestPdfs.grid(width: 1728, height: 2592, pitch: 4), name: "close-nonblock.pdf",
286
- poolSize: 1)
287
- let group = DispatchGroup()
288
- DispatchQueue.global().async(group: group) {
289
- _ = try? r.render(
290
- request(width: 1728, height: 2592, scale: 3), cancellation: nil, sink: nil)
291
- }
292
- Thread.sleep(forTimeInterval: 0.005)
293
- let started = Date()
294
- r.close()
295
- let elapsed = Date().timeIntervalSince(started)
296
- XCTAssertLessThan(
297
- elapsed, 0.5,
298
- "close took \(elapsed * 1000)ms, which means it waited for the render")
299
- _ = group.wait(timeout: .now() + 60)
300
- }
301
-
302
- /**
303
- A render that starts AFTER close fails rather than blocking forever.
304
-
305
- The semaphore accounting is the thing being pinned: `close` empties the free
306
- list without signalling, so a naive implementation leaves a later `lease` waiting
307
- on a count that will never come.
308
- */
309
- func testRendersAfterCloseFailPromptly() throws {
310
- let r = try grid("after-close.pdf", poolSize: 2)
311
- r.close()
312
- let done = expectation(description: "renders return")
313
- done.expectedFulfillmentCount = 4
314
- for _ in 0..<4 {
315
- DispatchQueue.global().async {
316
- // `try?` rather than XCTAssertThrowsError: the assertion helper is not
317
- // usable from a non-throwing block, and what matters here is that the call
318
- // RETURNS at all — a hang is the failure mode being tested for.
319
- let raster = try? r.render(
320
- request(width: 50, height: 50, scale: 1), cancellation: nil, sink: nil)
321
- XCTAssertNil(raster, "a render after close must fail")
322
- done.fulfill()
323
- }
324
- }
325
- wait(for: [done], timeout: 5)
326
- }
327
-
328
- /**
329
- A lane blocked waiting for the pool when `close` lands returns rather than
330
- hanging.
331
-
332
- The one ordering the semaphore has to get right, and the reason `leaseWithError`
333
- signals the count back before failing: with one lane held and one waiter, a
334
- `close` plus the held lane's return has to wake the waiter and let it error out.
335
- */
336
- func testALaneWaitingWhenCloseLandsReturns() throws {
337
- let r = try openFixture(
338
- TestPdfs.grid(width: 1728, height: 2592, pitch: 4), name: "waiter.pdf",
339
- poolSize: 1)
340
- let holding = expectation(description: "the first render finishes")
341
- let waiting = expectation(description: "the queued render returns")
342
- DispatchQueue.global().async {
343
- _ = try? r.render(
344
- request(width: 1728, height: 2592, scale: 3), cancellation: nil, sink: nil)
345
- holding.fulfill()
346
- }
347
- Thread.sleep(forTimeInterval: 0.003)
348
- DispatchQueue.global().async {
349
- // Blocks on the semaphore behind the render above.
350
- _ = try? r.render(
351
- request(width: 100, height: 100, scale: 1), cancellation: nil, sink: nil)
352
- waiting.fulfill()
353
- }
354
- Thread.sleep(forTimeInterval: 0.002)
355
- r.close()
356
- wait(for: [holding, waiting], timeout: 60)
357
- }
358
- }