@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.
- package/LICENSE +202 -0
- package/NOTICE +57 -0
- package/PdfCanvas.podspec +68 -39
- package/README.md +213 -1316
- package/android/build.gradle +67 -54
- package/android/consumer-rules.pro +7 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +254 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +60 -468
- package/android/src/main/cpp/CMakeLists.txt +54 -0
- package/android/src/main/cpp/pdfcanvas-jni.cpp +262 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +167 -0
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +14 -4
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +10 -4
- package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +160 -376
- package/android/tools/compile-gate.sh +81 -101
- package/dist/controller.d.ts +14 -0
- package/dist/controller.js +4 -3
- package/dist/index.native.d.ts +5 -0
- package/dist/index.native.js +5 -0
- package/dist/index.web.d.ts +10 -0
- package/dist/index.web.js +10 -0
- package/dist/rasterizer/index.native.d.ts +8 -12
- package/dist/rasterizer/index.native.js +15 -21
- package/dist/rasterizer/native-bridge.d.ts +51 -75
- package/dist/rasterizer/native-bridge.js +49 -33
- package/dist/rasterizer/native.d.ts +45 -0
- package/dist/rasterizer/native.js +123 -0
- package/dist/react/usePdfDocument.d.ts +5 -5
- package/dist/react/usePdfLayer.d.ts +10 -1
- package/dist/react/usePdfLayer.js +10 -2
- package/dist/skia/index.native.d.ts +7 -3
- package/dist/skia/index.native.js +7 -3
- package/dist/skia/index.web.d.ts +4 -2
- package/dist/skia/index.web.js +4 -2
- package/dist/testing/scenes.d.ts +3 -3
- package/dist/testing/scenes.js +4 -4
- package/dist/types.d.ts +36 -42
- package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +234 -536
- package/native/.clangd +5 -0
- package/native/CMakeLists.txt +89 -0
- package/native/core/include/pdfcanvas/document.h +90 -0
- package/native/core/include/pdfcanvas/error.h +47 -0
- package/native/core/include/pdfcanvas/jsi.h +18 -0
- package/native/core/include/pdfcanvas/jsi_config.h +30 -0
- package/native/core/include/pdfcanvas/library.h +28 -0
- package/native/core/include/pdfcanvas/pixels.h +92 -0
- package/native/core/include/pdfcanvas/service.h +87 -0
- package/native/core/include/pdfcanvas/slots.h +113 -0
- package/native/core/include/pdfcanvas/types.h +110 -0
- package/native/core/pdfcanvas-core.cmake +22 -0
- package/native/core/src/document.cpp +516 -0
- package/native/core/src/error.cpp +29 -0
- package/native/core/src/jsi.cpp +66 -0
- package/native/core/src/library.cpp +52 -0
- package/native/core/src/pixels.cpp +45 -0
- package/native/core/src/service.cpp +156 -0
- package/native/core/src/slots.cpp +139 -0
- package/native/tests/fixtures.cpp +451 -0
- package/native/tests/fixtures.h +96 -0
- package/native/tests/harness.h +122 -0
- package/native/tests/main.cpp +64 -0
- package/native/tests/pixels_util.h +126 -0
- package/native/tests/test_document.cpp +485 -0
- package/native/tests/test_service.cpp +130 -0
- package/native/tests/test_slots.cpp +250 -0
- package/package.json +38 -8
- package/react-native.config.js +5 -2
- package/scripts/fetch-pdfium.mjs +487 -0
- package/scripts/pdfium-manifest.json +46 -0
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +0 -226
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +0 -239
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +0 -251
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +0 -221
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +0 -783
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +0 -1388
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +0 -98
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +0 -324
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +0 -775
- package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +0 -20
- package/android/src/jsi/cpp/CMakeLists.txt +0 -33
- package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +0 -469
- package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +0 -140
- package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +0 -45
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +0 -564
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +0 -277
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +0 -214
- package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +0 -234
- package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +0 -47
- package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +0 -143
- package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +0 -95
- package/dist/rasterizer/android.d.ts +0 -121
- package/dist/rasterizer/android.js +0 -126
- package/dist/rasterizer/ios.d.ts +0 -84
- package/dist/rasterizer/ios.js +0 -155
- package/ios/Package.swift +0 -58
- package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +0 -38
- package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +0 -389
- package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +0 -57
- package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +0 -44
- package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +0 -82
- package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +0 -37
- package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +0 -158
- package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +0 -80
- package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +0 -65
- package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +0 -548
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +0 -48
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +0 -26
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +0 -122
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +0 -80
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +0 -79
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +0 -129
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +0 -100
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +0 -65
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +0 -111
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +0 -85
- package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +0 -163
- package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +0 -297
- package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +0 -140
- package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +0 -358
- package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +0 -262
- package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +0 -173
- package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +0 -114
- package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +0 -158
- package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +0 -544
- package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +0 -287
- package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +0 -396
- package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +0 -639
- package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +0 -179
- 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
|
-
}
|