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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +57 -0
  3. package/PdfCanvas.podspec +68 -39
  4. package/README.md +213 -1316
  5. package/android/build.gradle +67 -54
  6. package/android/consumer-rules.pro +7 -0
  7. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +254 -0
  8. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +60 -468
  9. package/android/src/main/cpp/CMakeLists.txt +54 -0
  10. package/android/src/main/cpp/pdfcanvas-jni.cpp +262 -0
  11. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +167 -0
  12. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +14 -4
  13. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +10 -4
  14. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +160 -376
  15. package/android/tools/compile-gate.sh +81 -101
  16. package/dist/controller.d.ts +14 -0
  17. package/dist/controller.js +4 -3
  18. package/dist/index.native.d.ts +5 -0
  19. package/dist/index.native.js +5 -0
  20. package/dist/index.web.d.ts +10 -0
  21. package/dist/index.web.js +10 -0
  22. package/dist/rasterizer/index.native.d.ts +8 -12
  23. package/dist/rasterizer/index.native.js +15 -21
  24. package/dist/rasterizer/native-bridge.d.ts +51 -75
  25. package/dist/rasterizer/native-bridge.js +49 -33
  26. package/dist/rasterizer/native.d.ts +45 -0
  27. package/dist/rasterizer/native.js +123 -0
  28. package/dist/react/usePdfDocument.d.ts +5 -5
  29. package/dist/react/usePdfLayer.d.ts +10 -1
  30. package/dist/react/usePdfLayer.js +10 -2
  31. package/dist/skia/index.native.d.ts +7 -3
  32. package/dist/skia/index.native.js +7 -3
  33. package/dist/skia/index.web.d.ts +4 -2
  34. package/dist/skia/index.web.js +4 -2
  35. package/dist/testing/scenes.d.ts +3 -3
  36. package/dist/testing/scenes.js +4 -4
  37. package/dist/types.d.ts +36 -42
  38. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +234 -536
  39. package/native/.clangd +5 -0
  40. package/native/CMakeLists.txt +89 -0
  41. package/native/core/include/pdfcanvas/document.h +90 -0
  42. package/native/core/include/pdfcanvas/error.h +47 -0
  43. package/native/core/include/pdfcanvas/jsi.h +18 -0
  44. package/native/core/include/pdfcanvas/jsi_config.h +30 -0
  45. package/native/core/include/pdfcanvas/library.h +28 -0
  46. package/native/core/include/pdfcanvas/pixels.h +92 -0
  47. package/native/core/include/pdfcanvas/service.h +87 -0
  48. package/native/core/include/pdfcanvas/slots.h +113 -0
  49. package/native/core/include/pdfcanvas/types.h +110 -0
  50. package/native/core/pdfcanvas-core.cmake +22 -0
  51. package/native/core/src/document.cpp +516 -0
  52. package/native/core/src/error.cpp +29 -0
  53. package/native/core/src/jsi.cpp +66 -0
  54. package/native/core/src/library.cpp +52 -0
  55. package/native/core/src/pixels.cpp +45 -0
  56. package/native/core/src/service.cpp +156 -0
  57. package/native/core/src/slots.cpp +139 -0
  58. package/native/tests/fixtures.cpp +451 -0
  59. package/native/tests/fixtures.h +96 -0
  60. package/native/tests/harness.h +122 -0
  61. package/native/tests/main.cpp +64 -0
  62. package/native/tests/pixels_util.h +126 -0
  63. package/native/tests/test_document.cpp +485 -0
  64. package/native/tests/test_service.cpp +130 -0
  65. package/native/tests/test_slots.cpp +250 -0
  66. package/package.json +38 -8
  67. package/react-native.config.js +5 -2
  68. package/scripts/fetch-pdfium.mjs +487 -0
  69. package/scripts/pdfium-manifest.json +46 -0
  70. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +0 -226
  71. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +0 -239
  72. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +0 -251
  73. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +0 -221
  74. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +0 -783
  75. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +0 -1388
  76. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +0 -98
  77. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +0 -324
  78. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +0 -775
  79. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +0 -20
  80. package/android/src/jsi/cpp/CMakeLists.txt +0 -33
  81. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +0 -469
  82. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +0 -140
  83. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +0 -45
  84. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +0 -564
  85. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +0 -277
  86. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +0 -214
  87. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +0 -234
  88. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +0 -47
  89. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +0 -143
  90. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +0 -95
  91. package/dist/rasterizer/android.d.ts +0 -121
  92. package/dist/rasterizer/android.js +0 -126
  93. package/dist/rasterizer/ios.d.ts +0 -84
  94. package/dist/rasterizer/ios.js +0 -155
  95. package/ios/Package.swift +0 -58
  96. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +0 -38
  97. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +0 -389
  98. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +0 -57
  99. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +0 -44
  100. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +0 -82
  101. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +0 -37
  102. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +0 -158
  103. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +0 -80
  104. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +0 -65
  105. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +0 -548
  106. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +0 -48
  107. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +0 -26
  108. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +0 -122
  109. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +0 -80
  110. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +0 -79
  111. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +0 -129
  112. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +0 -100
  113. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +0 -65
  114. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +0 -111
  115. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +0 -85
  116. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +0 -163
  117. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +0 -297
  118. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +0 -140
  119. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +0 -358
  120. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +0 -262
  121. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +0 -173
  122. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +0 -114
  123. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +0 -158
  124. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +0 -544
  125. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +0 -287
  126. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +0 -396
  127. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +0 -639
  128. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +0 -179
  129. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +0 -283
@@ -1,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
- }