@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.
- package/LICENSE +202 -0
- package/NOTICE +57 -0
- package/PdfCanvas.podspec +77 -40
- package/README.md +218 -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/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/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 +27 -7
- package/react-native.config.js +5 -2
- package/scripts/fetch-pdfium.mjs +559 -0
- package/scripts/pdfium-manifest.json +48 -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,544 +0,0 @@
|
|
|
1
|
-
import CoreGraphics
|
|
2
|
-
import XCTest
|
|
3
|
-
|
|
4
|
-
@testable import PdfCanvasCore
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
What the backend actually draws.
|
|
8
|
-
|
|
9
|
-
Every test here names the bug it catches, because every one of these failure modes
|
|
10
|
-
produces a plausible image rather than an error: a channel swap, a stride shear, a
|
|
11
|
-
y-flip, a page ignored, an annotation drawn twice, a transparent page composited
|
|
12
|
-
as black.
|
|
13
|
-
*/
|
|
14
|
-
final class RasterizerTests: XCTestCase {
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------- //
|
|
17
|
-
// The doc-space contract
|
|
18
|
-
// ---------------------------------------------------------------- //
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
The four quadrants land where doc space says, which pins the y-flip, the channel
|
|
22
|
-
order and the stride at once.
|
|
23
|
-
|
|
24
|
-
The fixture is y-UP in PDF space — red bottom-left, green bottom-right, blue
|
|
25
|
-
top-left, black top-right — so in doc space (y-down, origin top-left) they read
|
|
26
|
-
blue, black, red, green clockwise from the top-left. Getting that mapping the
|
|
27
|
-
wrong way round is the single most likely error in the whole backend, and a grey
|
|
28
|
-
or symmetric fixture could not see it.
|
|
29
|
-
*/
|
|
30
|
-
func testQuadrantsLandWhereDocSpaceSaysTheyShould() throws {
|
|
31
|
-
let rasterizer = try openFixture(
|
|
32
|
-
TestPdfs.quadrants(width: 200, height: 100), name: "quadrants.pdf")
|
|
33
|
-
let raster = try renderWholePage(rasterizer, scale: 2)
|
|
34
|
-
XCTAssertEqual(raster.width, 400)
|
|
35
|
-
XCTAssertEqual(raster.height, 200)
|
|
36
|
-
|
|
37
|
-
assertPixel(raster, 20, 20, .blue, "doc top-left is the PDF's top-left")
|
|
38
|
-
assertPixel(raster, 380, 20, .black, "doc top-right is the PDF's top-right")
|
|
39
|
-
assertPixel(raster, 20, 180, .red, "doc bottom-left is the PDF's bottom-left")
|
|
40
|
-
assertPixel(raster, 380, 180, .green, "doc bottom-right")
|
|
41
|
-
rasterizer.close()
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
A tile is pixel-identical to the same region cropped from a whole-page render.
|
|
46
|
-
|
|
47
|
-
THE PROPERTY THE ENTIRE DETAIL LAYER RESTS ON, and the direct counterpart of
|
|
48
|
-
spike S1 on Android (0/262144 mismatching, maxDelta 1) and S3 on PDFium web
|
|
49
|
-
(byte-identical). If an off-page-origin transform were even slightly wrong here,
|
|
50
|
-
every detail piece would be misregistered against the base and against the
|
|
51
|
-
consumer's strokes — with no error anywhere.
|
|
52
|
-
|
|
53
|
-
The tile's origin and size are deliberately not round numbers: (37, 23) 64x41pt
|
|
54
|
-
at scale 4 lands on 148 and 92 device pixels, so a transform that quietly
|
|
55
|
-
rounded the offset to a whole pixel would show up.
|
|
56
|
-
*/
|
|
57
|
-
func testATileIsIdenticalToThatRegionOfAWholePageRender() throws {
|
|
58
|
-
let rasterizer = try openFixture(
|
|
59
|
-
TestPdfs.grid(width: 200, height: 100), name: "grid-tile.pdf")
|
|
60
|
-
let scale = 4.0
|
|
61
|
-
let whole = try renderWholePage(rasterizer, scale: scale)
|
|
62
|
-
|
|
63
|
-
let tile = try rasterizer.render(
|
|
64
|
-
request(x: 37, y: 23, width: 64, height: 41, scale: scale),
|
|
65
|
-
cancellation: nil, sink: nil)
|
|
66
|
-
XCTAssertEqual(tile.width, 256)
|
|
67
|
-
XCTAssertEqual(tile.height, 164)
|
|
68
|
-
|
|
69
|
-
let result = compare(tile, whole, offsetX: Int(37 * scale), offsetY: Int(23 * scale))
|
|
70
|
-
XCTAssertEqual(
|
|
71
|
-
result.differing, 0,
|
|
72
|
-
"\(result.differing)/\(result.total) pixels differ, maxDelta \(result.maxDelta)")
|
|
73
|
-
XCTAssertEqual(result.maxDelta, 0)
|
|
74
|
-
rasterizer.close()
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
Two tiles requested at the same scale agree over their overlap.
|
|
79
|
-
|
|
80
|
-
The property the cache and the overlapping detail pieces depend on, checked
|
|
81
|
-
through a render rather than through the transform alone — and with heights that
|
|
82
|
-
round in OPPOSITE directions, which is what would expose a `pixelHeight` leaking
|
|
83
|
-
into content placement.
|
|
84
|
-
*/
|
|
85
|
-
func testRastersAtTheSameScaleAgreeWhereTheyOverlap() throws {
|
|
86
|
-
let rasterizer = try openFixture(
|
|
87
|
-
TestPdfs.grid(width: 200, height: 200), name: "grid-overlap.pdf")
|
|
88
|
-
let scale = 4.0
|
|
89
|
-
// 25.1pt tall -> 100 rows; 25.4pt tall -> 102 rows. Different roundings.
|
|
90
|
-
let a = try rasterizer.render(
|
|
91
|
-
request(x: 10, y: 10, width: 50.1, height: 25.1, scale: scale),
|
|
92
|
-
cancellation: nil, sink: nil)
|
|
93
|
-
let b = try rasterizer.render(
|
|
94
|
-
request(x: 10, y: 20, width: 50.1, height: 25.4, scale: scale),
|
|
95
|
-
cancellation: nil, sink: nil)
|
|
96
|
-
XCTAssertNotEqual(a.height, b.height, "the fixture must round differently")
|
|
97
|
-
|
|
98
|
-
// The overlap is doc y 20..35.1, i.e. rows 40.. of A and rows 0.. of B.
|
|
99
|
-
let overlapRows = Int((35.1 - 20) * scale) - 1
|
|
100
|
-
var differing = 0
|
|
101
|
-
var maxDelta = 0
|
|
102
|
-
for row in 0..<overlapRows {
|
|
103
|
-
for x in 0..<a.width {
|
|
104
|
-
let pa = a.pixel(x, row + Int(10 * scale))
|
|
105
|
-
let pb = b.pixel(x, row)
|
|
106
|
-
let d = max(
|
|
107
|
-
abs(pa.r - pb.r),
|
|
108
|
-
max(abs(pa.g - pb.g), max(abs(pa.b - pb.b), abs(pa.a - pb.a))))
|
|
109
|
-
if d != 0 { differing += 1 }
|
|
110
|
-
maxDelta = max(maxDelta, d)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
XCTAssertEqual(
|
|
114
|
-
differing, 0,
|
|
115
|
-
"\(differing) pixels differ over the overlap, maxDelta \(maxDelta)")
|
|
116
|
-
rasterizer.close()
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
Cost is FLAT IN SCALE, so a viewport-sized detail raster is constant in zoom.
|
|
121
|
-
|
|
122
|
-
Not a timing assertion with a threshold — those are flaky on shared CI — but a
|
|
123
|
-
shape assertion: rendering the SAME pixel count at 8x costs no more than about
|
|
124
|
-
four times what it costs at 1x. The premise being defended is that CoreGraphics
|
|
125
|
-
clips rather than rasterizing the whole page and discarding most of it; if it did
|
|
126
|
-
the latter, an 8x tile would cost 64x. The measured figures on an Apple-silicon
|
|
127
|
-
Mac are 0.49 / 0.39 / 0.43 / 0.39 ms at 1 / 2 / 4 / 8, i.e. flat.
|
|
128
|
-
*/
|
|
129
|
-
func testCostIsFlatInScale() throws {
|
|
130
|
-
let rasterizer = try openFixture(
|
|
131
|
-
TestPdfs.grid(width: 1728, height: 2592), name: "grid-archd.pdf")
|
|
132
|
-
let side = 256.0
|
|
133
|
-
|
|
134
|
-
func best(scale: Double) -> Double {
|
|
135
|
-
var best = Double.infinity
|
|
136
|
-
for _ in 0..<5 {
|
|
137
|
-
let started = Date()
|
|
138
|
-
_ = try? rasterizer.render(
|
|
139
|
-
request(
|
|
140
|
-
x: 100, y: 100, width: side / scale, height: side / scale, scale: scale),
|
|
141
|
-
cancellation: nil, sink: nil)
|
|
142
|
-
best = min(best, Date().timeIntervalSince(started))
|
|
143
|
-
}
|
|
144
|
-
return best
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
_ = best(scale: 1) // warm
|
|
148
|
-
let one = best(scale: 1)
|
|
149
|
-
let eight = best(scale: 8)
|
|
150
|
-
XCTAssertLessThan(
|
|
151
|
-
eight, one * 8 + 0.010,
|
|
152
|
-
"256px at 8x took \(eight * 1000)ms against \(one * 1000)ms at 1x — if the "
|
|
153
|
-
+ "engine rasterized the whole page and discarded it, this would be ~64x")
|
|
154
|
-
rasterizer.close()
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ---------------------------------------------------------------- //
|
|
158
|
-
// Backgrounds
|
|
159
|
-
// ---------------------------------------------------------------- //
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
The white pre-fill is required, not defensive.
|
|
163
|
-
|
|
164
|
-
A `CGBitmapContext` draws into whatever memory it was handed, and both sinks
|
|
165
|
-
reserve ZEROED memory — which is transparent BLACK. Composited under the app's
|
|
166
|
-
annotation layers that reads as a black page, and `ingest.ts` cannot repair it
|
|
167
|
-
because it cannot tell "the PDF painted black here" from "nothing painted here".
|
|
168
|
-
The fixture's page is 100x100 with a 40x40 marker, so most of it is untouched by
|
|
169
|
-
the content stream.
|
|
170
|
-
*/
|
|
171
|
-
func testWhiteBackgroundFillsEverythingTheContentDoesNotPaint() throws {
|
|
172
|
-
let rasterizer = try openFixture(
|
|
173
|
-
TestPdfs.page(
|
|
174
|
-
mediaBox: (0, 0, 100, 100), content: "1 0 0 rg 0 0 40 40 re f\n"),
|
|
175
|
-
name: "sparse.pdf")
|
|
176
|
-
let raster = try renderWholePage(rasterizer, scale: 1, background: .white)
|
|
177
|
-
assertPixel(raster, 90, 10, .white, "unpainted area must be opaque white")
|
|
178
|
-
assertPixel(raster, 50, 50, .white)
|
|
179
|
-
assertPixel(raster, 10, 90, .red, "the marker is at the PDF's bottom-left")
|
|
180
|
-
rasterizer.close()
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/// And `transparent` leaves the same area with alpha 0 rather than opaque black.
|
|
184
|
-
func testTransparentBackgroundLeavesUnpaintedAreaClear() throws {
|
|
185
|
-
let rasterizer = try openFixture(
|
|
186
|
-
TestPdfs.page(
|
|
187
|
-
mediaBox: (0, 0, 100, 100), content: "1 0 0 rg 0 0 40 40 re f\n"),
|
|
188
|
-
name: "sparse-clear.pdf")
|
|
189
|
-
let raster = try renderWholePage(rasterizer, scale: 1, background: .transparent)
|
|
190
|
-
assertPixel(raster, 90, 10, .clear, "unpainted area must be fully transparent")
|
|
191
|
-
assertPixel(raster, 10, 90, .red, "the marker is still opaque")
|
|
192
|
-
rasterizer.close()
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
ALPHA IS PREMULTIPLIED, measured off the bytes.
|
|
197
|
-
|
|
198
|
-
The one cell the spec's alpha table had marked NOT MEASURED. A 62%-covered
|
|
199
|
-
opaque-white edge reads `[158,158,158,158]` premultiplied and
|
|
200
|
-
`[255,255,255,158]` straight; PDFium and pdf.js both produce the latter, so this
|
|
201
|
-
is the value that makes `RasterPixels.alpha` a field rather than a constant.
|
|
202
|
-
|
|
203
|
-
It is also, on this platform, forced: `CGBitmapContextCreate` refuses
|
|
204
|
-
`kCGImageAlphaLast` and `kCGImageAlphaFirst` outright — asserted below, so the
|
|
205
|
-
claim in `PdfCanvasRasterizer.m` is checked rather than quoted.
|
|
206
|
-
*/
|
|
207
|
-
func testAlphaIsPremultiplied() throws {
|
|
208
|
-
let rasterizer = try openFixture(
|
|
209
|
-
TestPdfs.antialiasedWhiteEdge(), name: "aa-edge.pdf")
|
|
210
|
-
let raster = try renderWholePage(rasterizer, scale: 4, background: .transparent)
|
|
211
|
-
|
|
212
|
-
var partials: [PdfCanvasRasterPixels.Pixel] = []
|
|
213
|
-
for y in 0..<raster.height {
|
|
214
|
-
for x in 0..<raster.width {
|
|
215
|
-
let p = raster.pixel(x, y)
|
|
216
|
-
if p.a > 20 && p.a < 235 { partials.append(p) }
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
XCTAssertGreaterThan(
|
|
220
|
-
partials.count, 50, "the circle's edge should give plenty of partial coverage")
|
|
221
|
-
for p in partials {
|
|
222
|
-
XCTAssertEqual(
|
|
223
|
-
p.r, p.a,
|
|
224
|
-
"premultiplied means channel == alpha for opaque white; got \(p)")
|
|
225
|
-
XCTAssertEqual(p.g, p.a, "got \(p)")
|
|
226
|
-
XCTAssertEqual(p.b, p.a, "got \(p)")
|
|
227
|
-
}
|
|
228
|
-
rasterizer.close()
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/// Straight alpha is not merely unused here — CoreGraphics refuses it.
|
|
232
|
-
func testCoreGraphicsRefusesStraightAlpha() {
|
|
233
|
-
let sRGB = CGColorSpace(name: CGColorSpace.sRGB)!
|
|
234
|
-
var buffer = [UInt8](repeating: 0, count: 16 * 16 * 4)
|
|
235
|
-
func accepts(_ info: UInt32) -> Bool {
|
|
236
|
-
buffer.withUnsafeMutableBytes { raw in
|
|
237
|
-
CGContext(
|
|
238
|
-
data: raw.baseAddress, width: 16, height: 16, bitsPerComponent: 8,
|
|
239
|
-
bytesPerRow: 64, space: sRGB, bitmapInfo: info) != nil
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
XCTAssertTrue(
|
|
243
|
-
accepts(
|
|
244
|
-
CGImageAlphaInfo.premultipliedLast.rawValue
|
|
245
|
-
| CGBitmapInfo.byteOrder32Big.rawValue),
|
|
246
|
-
"the configuration this library uses")
|
|
247
|
-
XCTAssertFalse(
|
|
248
|
-
accepts(CGImageAlphaInfo.last.rawValue | CGBitmapInfo.byteOrder32Big.rawValue),
|
|
249
|
-
"straight RGBA is refused, which is why alpha is premultiplied here")
|
|
250
|
-
XCTAssertFalse(
|
|
251
|
-
accepts(
|
|
252
|
-
CGImageAlphaInfo.first.rawValue | CGBitmapInfo.byteOrder32Little.rawValue),
|
|
253
|
-
"straight BGRA is refused too")
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ---------------------------------------------------------------- //
|
|
257
|
-
// Stride and format
|
|
258
|
-
// ---------------------------------------------------------------- //
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
`rowBytes` is tight, and it is REPORTED rather than left to the reader.
|
|
262
|
-
|
|
263
|
-
Tight is a fact about this backend, not about CoreGraphics: this library chooses
|
|
264
|
-
the stride it hands `CGBitmapContextCreate`. What the reader must not do is
|
|
265
|
-
assume it, because a wrong stride shears rather than fails. Odd and prime widths
|
|
266
|
-
are included because a padding rule would show up there first.
|
|
267
|
-
*/
|
|
268
|
-
func testRowBytesIsTightAndReported() throws {
|
|
269
|
-
let rasterizer = try openFixture(
|
|
270
|
-
TestPdfs.quadrants(width: 200, height: 200), name: "stride.pdf")
|
|
271
|
-
for width in [1, 3, 7, 13, 1023, 1025] {
|
|
272
|
-
let raster = try rasterizer.render(
|
|
273
|
-
request(width: Double(width), height: 4, scale: 1),
|
|
274
|
-
cancellation: nil, sink: nil)
|
|
275
|
-
XCTAssertEqual(raster.width, width)
|
|
276
|
-
XCTAssertEqual(
|
|
277
|
-
raster.rowBytes, width * 4,
|
|
278
|
-
"width \(width) should be tightly packed")
|
|
279
|
-
// And the buffer is exactly as long as the stride claims.
|
|
280
|
-
XCTAssertEqual(raster.bytes?.count, width * 4 * 4)
|
|
281
|
-
}
|
|
282
|
-
rasterizer.close()
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/// The format string is the one `PixelFormat` in `src/types.ts` understands.
|
|
286
|
-
func testFormatIsRgba8888() throws {
|
|
287
|
-
let rasterizer = try openFixture(
|
|
288
|
-
TestPdfs.quadrants(width: 20, height: 20), name: "format.pdf")
|
|
289
|
-
let raster = try renderWholePage(rasterizer)
|
|
290
|
-
XCTAssertEqual(raster.format, "rgba8888")
|
|
291
|
-
XCTAssertEqual(raster.format, PdfCanvasFormatRGBA8888)
|
|
292
|
-
rasterizer.close()
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// ---------------------------------------------------------------- //
|
|
296
|
-
// Pages
|
|
297
|
-
// ---------------------------------------------------------------- //
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
`request.page` is honoured.
|
|
301
|
-
|
|
302
|
-
Catches a backend that renders page 0 for every request — which a single-page
|
|
303
|
-
fixture cannot catch at all, and a multi-page fixture with identical pages
|
|
304
|
-
catches only by luck. One flat colour per page is the cheapest fixture that can.
|
|
305
|
-
*/
|
|
306
|
-
func testEachPageRendersItsOwnContent() throws {
|
|
307
|
-
let rasterizer = try openFixture(
|
|
308
|
-
TestPdfs.coloredPages(
|
|
309
|
-
[(1, 0, 0), (0, 1, 0), (0, 0, 1)], width: 40, height: 40),
|
|
310
|
-
name: "colored.pdf")
|
|
311
|
-
let expected: [PdfCanvasRasterPixels.Pixel] = [.red, .green, .blue]
|
|
312
|
-
for page in 0..<3 {
|
|
313
|
-
let raster = try renderWholePage(rasterizer, page: page)
|
|
314
|
-
assertPixel(raster, 20, 20, expected[page], "page \(page)")
|
|
315
|
-
}
|
|
316
|
-
rasterizer.close()
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
A `/Rotate 90` page draws UPRIGHT, with the marker in the right corner.
|
|
321
|
-
|
|
322
|
-
Dimensions alone are not enough: an implementation that swapped width and height
|
|
323
|
-
and drew the content unrotated would report 792x612 too. The fixture puts red at
|
|
324
|
-
the unrotated top-left, which a clockwise quarter turn sends to the displayed
|
|
325
|
-
TOP-RIGHT, and blue at the unrotated bottom-left, which goes to the displayed
|
|
326
|
-
TOP-LEFT. Getting the direction backwards swaps the two.
|
|
327
|
-
*/
|
|
328
|
-
func testRotatedPagesDrawUpright() throws {
|
|
329
|
-
// /Rotate 90: red -> displayed top-right, blue -> displayed top-left.
|
|
330
|
-
let ninety = try openFixture(TestPdfs.rotated(90), name: "draw-90.pdf")
|
|
331
|
-
let a = try renderWholePage(ninety, scale: 0.25)
|
|
332
|
-
XCTAssertEqual(a.width, 198)
|
|
333
|
-
XCTAssertEqual(a.height, 153)
|
|
334
|
-
assertPixel(a, 6, 6, .blue, "/Rotate 90 puts the unrotated bottom-left top-left")
|
|
335
|
-
assertPixel(a, 190, 6, .red, "/Rotate 90 puts the unrotated top-left top-right")
|
|
336
|
-
ninety.close()
|
|
337
|
-
|
|
338
|
-
// /Rotate 270 is a quarter turn the other way: red -> bottom-left,
|
|
339
|
-
// blue -> bottom-right.
|
|
340
|
-
let twoSeventy = try openFixture(TestPdfs.rotated(270), name: "draw-270.pdf")
|
|
341
|
-
let b = try renderWholePage(twoSeventy, scale: 0.25)
|
|
342
|
-
XCTAssertEqual(b.width, 198)
|
|
343
|
-
XCTAssertEqual(b.height, 153)
|
|
344
|
-
assertPixel(b, 6, 146, .red, "/Rotate 270 puts the unrotated top-left bottom-left")
|
|
345
|
-
assertPixel(
|
|
346
|
-
b, 190, 146, .blue, "/Rotate 270 puts the unrotated bottom-left bottom-right")
|
|
347
|
-
twoSeventy.close()
|
|
348
|
-
|
|
349
|
-
// /Rotate 180 flips both axes: red -> bottom-right, blue -> top-right.
|
|
350
|
-
let oneEighty = try openFixture(TestPdfs.rotated(180), name: "draw-180.pdf")
|
|
351
|
-
let c = try renderWholePage(oneEighty, scale: 0.25)
|
|
352
|
-
XCTAssertEqual(c.width, 153)
|
|
353
|
-
XCTAssertEqual(c.height, 198)
|
|
354
|
-
assertPixel(c, 146, 190, .red, "/Rotate 180 puts the unrotated top-left bottom-right")
|
|
355
|
-
assertPixel(c, 146, 6, .blue)
|
|
356
|
-
oneEighty.close()
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
A crop box offset from the media box puts the crop box's own top-left at doc
|
|
361
|
-
(0, 0).
|
|
362
|
-
|
|
363
|
-
Both engines, because they get there by different routes: the CoreGraphics path
|
|
364
|
-
subtracts the origin in the transform, and PDFKit normalises it itself. A test
|
|
365
|
-
that only covered one would leave the other's behaviour a guess.
|
|
366
|
-
*/
|
|
367
|
-
func testOffsetCropBoxIsHonouredByBothEngines() throws {
|
|
368
|
-
let rasterizer = try openFixture(
|
|
369
|
-
TestPdfs.offsetCropBox(), name: "crop-render.pdf")
|
|
370
|
-
for annotations in [false, true] {
|
|
371
|
-
let raster = try renderWholePage(rasterizer, scale: 1, annotations: annotations)
|
|
372
|
-
XCTAssertEqual(raster.width, 200)
|
|
373
|
-
XCTAssertEqual(raster.height, 200)
|
|
374
|
-
assertPixel(
|
|
375
|
-
raster, 10, 10, .red,
|
|
376
|
-
"crop-local top-left, annotations=\(annotations)")
|
|
377
|
-
assertPixel(
|
|
378
|
-
raster, 190, 190, .blue,
|
|
379
|
-
"crop-local bottom-right, annotations=\(annotations)")
|
|
380
|
-
assertPixel(
|
|
381
|
-
raster, 100, 100, PdfCanvasRasterPixels.Pixel(r: 128, g: 128, b: 128, a: 255),
|
|
382
|
-
tolerance: 2, "the page's own grey, annotations=\(annotations)")
|
|
383
|
-
}
|
|
384
|
-
rasterizer.close()
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// ---------------------------------------------------------------- //
|
|
388
|
-
// The two engines
|
|
389
|
-
// ---------------------------------------------------------------- //
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
ON A PAGE WITH NO ANNOTATIONS THE TWO ENGINES AGREE BYTE-FOR-BYTE.
|
|
393
|
-
|
|
394
|
-
This is what makes selecting an engine per render safe: `annotations` changes
|
|
395
|
-
what is composited and nothing about the geometry. If the two disagreed, a
|
|
396
|
-
thumbnail and a tile of the same region would be misregistered against each
|
|
397
|
-
other, and the app would see a seam that only appears where an annotation-bearing
|
|
398
|
-
raster meets a tile.
|
|
399
|
-
|
|
400
|
-
It is also the test that catches PDFKit's `boundsForBox:` trap: flipping by the
|
|
401
|
-
unrotated height instead of the post-rotation one moves the whole page, so the
|
|
402
|
-
comparison explodes rather than drifting.
|
|
403
|
-
*/
|
|
404
|
-
func testBothEnginesAgreeOnAPageWithNoAnnotations() throws {
|
|
405
|
-
let rasterizer = try openFixture(
|
|
406
|
-
TestPdfs.quadrants(width: 200, height: 100), name: "engines-agree.pdf")
|
|
407
|
-
let viaCG = try renderWholePage(rasterizer, scale: 4, annotations: false)
|
|
408
|
-
let viaKit = try renderWholePage(rasterizer, scale: 4, annotations: true)
|
|
409
|
-
XCTAssertEqual(viaCG.width, viaKit.width)
|
|
410
|
-
XCTAssertEqual(viaCG.height, viaKit.height)
|
|
411
|
-
let result = compare(viaCG, viaKit)
|
|
412
|
-
XCTAssertEqual(
|
|
413
|
-
result.differing, 0,
|
|
414
|
-
"\(result.differing)/\(result.total) differ, maxDelta \(result.maxDelta)"
|
|
415
|
-
+ "\nCG:\n\(viaCG.map())\nPDFKit:\n\(viaKit.map())")
|
|
416
|
-
rasterizer.close()
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/// And they agree on a ROTATED page too, which is where the flip height matters.
|
|
420
|
-
func testBothEnginesAgreeOnARotatedPage() throws {
|
|
421
|
-
let rasterizer = try openFixture(TestPdfs.rotated(90), name: "engines-rot.pdf")
|
|
422
|
-
let viaCG = try renderWholePage(rasterizer, scale: 0.25, annotations: false)
|
|
423
|
-
let viaKit = try renderWholePage(rasterizer, scale: 0.25, annotations: true)
|
|
424
|
-
let result = compare(viaCG, viaKit)
|
|
425
|
-
// Two engines rasterizing the same geometry are allowed to disagree on
|
|
426
|
-
// antialiased edges; what must not happen is a translation or a rotation. A
|
|
427
|
-
// handful of edge pixels is fine, a large fraction is not.
|
|
428
|
-
XCTAssertLessThan(
|
|
429
|
-
Double(result.differing) / Double(result.total), 0.02,
|
|
430
|
-
"\(result.differing)/\(result.total) differ, maxDelta \(result.maxDelta)"
|
|
431
|
-
+ "\nCG:\n\(viaCG.map())\nPDFKit:\n\(viaKit.map())")
|
|
432
|
-
rasterizer.close()
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
`annotations` IS HONOURED IN BOTH DIRECTIONS — the one capability iOS has that
|
|
437
|
-
Android does not.
|
|
438
|
-
|
|
439
|
-
The fixture's content stream paints nothing green; the only green in the file is
|
|
440
|
-
inside an annotation's appearance stream. So green in the raster can only have
|
|
441
|
-
come from compositing the annotation. `annotations: false` must show none of it,
|
|
442
|
-
which is what stops an app that draws its own copy of a PDF-embedded annotation
|
|
443
|
-
from seeing it twice.
|
|
444
|
-
*/
|
|
445
|
-
func testAnnotationsAreDrawnOnlyWhenAsked() throws {
|
|
446
|
-
let rasterizer = try openFixture(
|
|
447
|
-
TestPdfs.annotatedPage(), name: "annotated.pdf")
|
|
448
|
-
|
|
449
|
-
let without = try renderWholePage(rasterizer, scale: 1, annotations: false)
|
|
450
|
-
// The annotation's /Rect is 50 50 150 150 in PDF space, so its centre is doc
|
|
451
|
-
// (100, 100) on a 200x200 page.
|
|
452
|
-
assertPixel(
|
|
453
|
-
without, 100, 100, .white,
|
|
454
|
-
"CGContextDrawPDFPage must not composite an annotation appearance stream")
|
|
455
|
-
assertPixel(without, 20, 180, .red, "the content stream's own marker is drawn")
|
|
456
|
-
|
|
457
|
-
let with = try renderWholePage(rasterizer, scale: 1, annotations: true)
|
|
458
|
-
assertPixel(
|
|
459
|
-
with, 100, 100, .green, "PDFKit must composite the annotation")
|
|
460
|
-
assertPixel(with, 20, 180, .red, "and still draw the content stream")
|
|
461
|
-
rasterizer.close()
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// ---------------------------------------------------------------- //
|
|
465
|
-
// Refusals
|
|
466
|
-
// ---------------------------------------------------------------- //
|
|
467
|
-
|
|
468
|
-
/// An absurd raster is refused by name rather than by a jetsam kill.
|
|
469
|
-
func testAnAbsurdRasterIsRefused() throws {
|
|
470
|
-
let rasterizer = try openFixture(
|
|
471
|
-
TestPdfs.quadrants(width: 100, height: 100), name: "huge.pdf")
|
|
472
|
-
XCTAssertThrowsError(
|
|
473
|
-
try rasterizer.render(
|
|
474
|
-
request(width: 100, height: 100, scale: 900), cancellation: nil, sink: nil)
|
|
475
|
-
) { error in
|
|
476
|
-
XCTAssertEqual(PdfCanvasErrorCodeOf(error as NSError), .outOfMemory)
|
|
477
|
-
XCTAssertTrue(
|
|
478
|
-
(error as NSError).localizedDescription.contains("Refused"),
|
|
479
|
-
(error as NSError).localizedDescription)
|
|
480
|
-
}
|
|
481
|
-
// 90000 x 90000 is 8.1e9 pixels, well past the 64 Mpx backstop.
|
|
482
|
-
XCTAssertGreaterThan(90000 * 90000, PdfCanvasRasterizer.maxRasterPixels)
|
|
483
|
-
rasterizer.close()
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/// A degenerate request is a named failure, not a 1x1 raster of nothing.
|
|
487
|
-
func testDegenerateRequestsAreRefused() throws {
|
|
488
|
-
let rasterizer = try openFixture(
|
|
489
|
-
TestPdfs.quadrants(width: 100, height: 100), name: "degenerate.pdf")
|
|
490
|
-
let bad: [(String, PdfCanvasRasterRequest)] = [
|
|
491
|
-
("zero width", request(width: 0, height: 10, scale: 1)),
|
|
492
|
-
("zero height", request(width: 10, height: 0, scale: 1)),
|
|
493
|
-
("negative width", request(width: -10, height: 10, scale: 1)),
|
|
494
|
-
("zero scale", request(width: 10, height: 10, scale: 0)),
|
|
495
|
-
("negative scale", request(width: 10, height: 10, scale: -2)),
|
|
496
|
-
("infinite scale", request(width: 10, height: 10, scale: .infinity)),
|
|
497
|
-
("nan origin", request(x: .nan, width: 10, height: 10, scale: 1)),
|
|
498
|
-
]
|
|
499
|
-
for (what, req) in bad {
|
|
500
|
-
XCTAssertThrowsError(
|
|
501
|
-
try rasterizer.render(req, cancellation: nil, sink: nil), what
|
|
502
|
-
) { error in
|
|
503
|
-
XCTAssertEqual(
|
|
504
|
-
PdfCanvasErrorCodeOf(error as NSError), .backendFailure, what)
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
rasterizer.close()
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/// A render against a closed handle fails rather than crashing.
|
|
511
|
-
func testRenderingAfterCloseFails() throws {
|
|
512
|
-
let rasterizer = try openFixture(
|
|
513
|
-
TestPdfs.quadrants(width: 100, height: 100), name: "closed.pdf")
|
|
514
|
-
rasterizer.close()
|
|
515
|
-
XCTAssertTrue(rasterizer.isClosed)
|
|
516
|
-
XCTAssertThrowsError(
|
|
517
|
-
try rasterizer.render(
|
|
518
|
-
request(width: 100, height: 100, scale: 1), cancellation: nil, sink: nil)
|
|
519
|
-
) { error in
|
|
520
|
-
XCTAssertEqual(PdfCanvasErrorCodeOf(error as NSError), .backendFailure)
|
|
521
|
-
}
|
|
522
|
-
// Idempotent.
|
|
523
|
-
rasterizer.close()
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/// A page index the document does not have is `not-found`, from `render` too.
|
|
527
|
-
func testRenderingAMissingPageIsNotFound() throws {
|
|
528
|
-
let rasterizer = try openFixture(
|
|
529
|
-
TestPdfs.quadrants(width: 100, height: 100), name: "missing-page.pdf")
|
|
530
|
-
XCTAssertThrowsError(
|
|
531
|
-
try rasterizer.render(
|
|
532
|
-
request(page: 7, width: 100, height: 100, scale: 1),
|
|
533
|
-
cancellation: nil, sink: nil)
|
|
534
|
-
) { error in
|
|
535
|
-
XCTAssertEqual(PdfCanvasErrorCodeOf(error as NSError), .notFound)
|
|
536
|
-
}
|
|
537
|
-
rasterizer.close()
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/// `rotationSupport` is the constant the wire format expects.
|
|
541
|
-
func testRotationSupportIsSupported() {
|
|
542
|
-
XCTAssertEqual(PdfCanvasRasterizer.rotationSupport, "supported")
|
|
543
|
-
}
|
|
544
|
-
}
|