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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +57 -0
  3. package/PdfCanvas.podspec +77 -40
  4. package/README.md +218 -1316
  5. package/android/build.gradle +67 -54
  6. package/android/consumer-rules.pro +7 -0
  7. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +254 -0
  8. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +60 -468
  9. package/android/src/main/cpp/CMakeLists.txt +54 -0
  10. package/android/src/main/cpp/pdfcanvas-jni.cpp +262 -0
  11. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +167 -0
  12. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +14 -4
  13. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +10 -4
  14. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +160 -376
  15. package/android/tools/compile-gate.sh +81 -101
  16. package/dist/controller.d.ts +14 -0
  17. package/dist/controller.js +4 -3
  18. package/dist/rasterizer/index.native.d.ts +8 -12
  19. package/dist/rasterizer/index.native.js +15 -21
  20. package/dist/rasterizer/native-bridge.d.ts +51 -75
  21. package/dist/rasterizer/native-bridge.js +49 -33
  22. package/dist/rasterizer/native.d.ts +45 -0
  23. package/dist/rasterizer/native.js +123 -0
  24. package/dist/react/usePdfDocument.d.ts +5 -5
  25. package/dist/react/usePdfLayer.d.ts +10 -1
  26. package/dist/react/usePdfLayer.js +10 -2
  27. package/dist/testing/scenes.d.ts +3 -3
  28. package/dist/testing/scenes.js +4 -4
  29. package/dist/types.d.ts +36 -42
  30. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +234 -536
  31. package/native/.clangd +5 -0
  32. package/native/CMakeLists.txt +89 -0
  33. package/native/core/include/pdfcanvas/document.h +90 -0
  34. package/native/core/include/pdfcanvas/error.h +47 -0
  35. package/native/core/include/pdfcanvas/jsi.h +18 -0
  36. package/native/core/include/pdfcanvas/jsi_config.h +30 -0
  37. package/native/core/include/pdfcanvas/library.h +28 -0
  38. package/native/core/include/pdfcanvas/pixels.h +92 -0
  39. package/native/core/include/pdfcanvas/service.h +87 -0
  40. package/native/core/include/pdfcanvas/slots.h +113 -0
  41. package/native/core/include/pdfcanvas/types.h +110 -0
  42. package/native/core/pdfcanvas-core.cmake +22 -0
  43. package/native/core/src/document.cpp +516 -0
  44. package/native/core/src/error.cpp +29 -0
  45. package/native/core/src/jsi.cpp +66 -0
  46. package/native/core/src/library.cpp +52 -0
  47. package/native/core/src/pixels.cpp +45 -0
  48. package/native/core/src/service.cpp +156 -0
  49. package/native/core/src/slots.cpp +139 -0
  50. package/native/tests/fixtures.cpp +451 -0
  51. package/native/tests/fixtures.h +96 -0
  52. package/native/tests/harness.h +122 -0
  53. package/native/tests/main.cpp +64 -0
  54. package/native/tests/pixels_util.h +126 -0
  55. package/native/tests/test_document.cpp +485 -0
  56. package/native/tests/test_service.cpp +130 -0
  57. package/native/tests/test_slots.cpp +250 -0
  58. package/package.json +27 -7
  59. package/react-native.config.js +5 -2
  60. package/scripts/fetch-pdfium.mjs +559 -0
  61. package/scripts/pdfium-manifest.json +48 -0
  62. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +0 -226
  63. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +0 -239
  64. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +0 -251
  65. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +0 -221
  66. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +0 -783
  67. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +0 -1388
  68. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +0 -98
  69. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +0 -324
  70. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +0 -775
  71. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +0 -20
  72. package/android/src/jsi/cpp/CMakeLists.txt +0 -33
  73. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +0 -469
  74. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +0 -140
  75. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +0 -45
  76. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +0 -564
  77. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +0 -277
  78. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +0 -214
  79. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +0 -234
  80. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +0 -47
  81. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +0 -143
  82. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +0 -95
  83. package/dist/rasterizer/android.d.ts +0 -121
  84. package/dist/rasterizer/android.js +0 -126
  85. package/dist/rasterizer/ios.d.ts +0 -84
  86. package/dist/rasterizer/ios.js +0 -155
  87. package/ios/Package.swift +0 -58
  88. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +0 -38
  89. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +0 -389
  90. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +0 -57
  91. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +0 -44
  92. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +0 -82
  93. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +0 -37
  94. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +0 -158
  95. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +0 -80
  96. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +0 -65
  97. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +0 -548
  98. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +0 -48
  99. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +0 -26
  100. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +0 -122
  101. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +0 -80
  102. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +0 -79
  103. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +0 -129
  104. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +0 -100
  105. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +0 -65
  106. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +0 -111
  107. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +0 -85
  108. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +0 -163
  109. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +0 -297
  110. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +0 -140
  111. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +0 -358
  112. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +0 -262
  113. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +0 -173
  114. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +0 -114
  115. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +0 -158
  116. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +0 -544
  117. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +0 -287
  118. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +0 -396
  119. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +0 -639
  120. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +0 -179
  121. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +0 -283
@@ -1,639 +0,0 @@
1
- import Foundation
2
-
3
- /**
4
- PDFs built in memory, so a test has no asset to ship and the content is known
5
- exactly rather than eyeballed.
6
-
7
- A PORT OF `android/src/androidTest/.../TestPdfs.java`, and the port is the point:
8
- the two backends are checked against the same geometry, the same markers and the
9
- same encryption scheme, so a disagreement between platforms is a disagreement
10
- about rendering rather than about fixtures.
11
-
12
- One thing is written by hand here that Android gets from the platform:
13
- `android.graphics.pdf.PdfDocument` can author a page but has no way to set
14
- `/Rotate`, so the Java version hand-writes the rotated fixture and uses the
15
- platform for the rest. There is no PDF *writer* in CoreGraphics that lets a test
16
- set `/Rotate` or `/CropBox` either, and `CGPDFContext` would author through the
17
- same drawing model being tested — so every fixture here is hand-written. That is
18
- strictly better for a rendering test: the bytes are the input, and nothing about
19
- what they mean depends on the code under test.
20
- */
21
- enum TestPdfs {
22
- static let letterW = 612
23
- static let letterH = 792
24
- /// ARCH-D, the sheet size this package exists for.
25
- static let archDW = 1728
26
- static let archDH = 2592
27
-
28
- // ---------------------------------------------------------------- //
29
- // The writer
30
- // ---------------------------------------------------------------- //
31
-
32
- /// Assembles objects and writes a correct xref table, which every reader prefers.
33
- final class Writer {
34
- private var out = Data()
35
- private var offsets: [Int] = []
36
-
37
- init() {
38
- write("%PDF-1.4\n")
39
- // A binary comment line marks the file as binary, per the spec's advice.
40
- out.append(contentsOf: [0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A])
41
- }
42
-
43
- func write(_ s: String) {
44
- // Latin-1, not UTF-8: a PDF's syntax layer is bytes, and every string this
45
- // writer emits is ASCII. Using UTF-8 would silently change the byte length
46
- // of any non-ASCII content and invalidate a /Length.
47
- out.append(s.data(using: .isoLatin1)!)
48
- }
49
-
50
- func write(_ bytes: Data) {
51
- out.append(bytes)
52
- }
53
-
54
- /// How many objects have been written. The next `add` returns this plus one.
55
- var count: Int { offsets.count }
56
-
57
- /// - Returns: the object number, 1-based.
58
- @discardableResult
59
- func add(_ body: String) -> Int {
60
- add(body.data(using: .isoLatin1)!)
61
- }
62
-
63
- @discardableResult
64
- func add(_ body: Data) -> Int {
65
- let number = offsets.count + 1
66
- offsets.append(out.count)
67
- write("\(number) 0 obj\n")
68
- write(body)
69
- write("\nendobj\n")
70
- return number
71
- }
72
-
73
- /**
74
- Writes at least `atLeast` bytes of PDF COMMENT lines.
75
-
76
- A `%` line is legal anywhere between objects and is never drawn, so this
77
- changes the file's LENGTH and nothing else — which is the only lever a
78
- header-window test needs. Call it after the last `add()` and before
79
- `finish()`, so the trailer moves while every recorded xref offset stays
80
- correct.
81
- */
82
- func pad(atLeast: Int) {
83
- var s = ""
84
- var line = 0
85
- while s.utf8.count < atLeast {
86
- s += "% padding line \(line) - legal PDF comment, never drawn\n"
87
- line += 1
88
- }
89
- write(s)
90
- }
91
-
92
- func finish(rootObject: Int, extraTrailer: String = "") -> Data {
93
- let xref = out.count
94
- let size = offsets.count + 1
95
- write("xref\n0 \(size)\n")
96
- write("0000000000 65535 f \n")
97
- for offset in offsets {
98
- write(String(format: "%010d 00000 n \n", offset))
99
- }
100
- write(
101
- "trailer\n<< /Size \(size) /Root \(rootObject) 0 R \(extraTrailer) >>\n"
102
- + "startxref\n\(xref)\n%%EOF\n")
103
- return out
104
- }
105
- }
106
-
107
- // ---------------------------------------------------------------- //
108
- // Single-page fixtures
109
- // ---------------------------------------------------------------- //
110
-
111
- /// A single page with an explicit MediaBox, optional CropBox, `/Rotate` and content.
112
- static func page(
113
- mediaBox: (Int, Int, Int, Int),
114
- cropBox: (Int, Int, Int, Int)? = nil,
115
- rotate: Int = 0,
116
- annots: String? = nil,
117
- extraObjects: ((Writer) -> String)? = nil,
118
- content: String
119
- ) -> Data {
120
- let w = Writer()
121
- let stream = content.data(using: .isoLatin1)!
122
- let contents = w.add("<< /Length \(stream.count) >>\nstream\n\(content)endstream")
123
- // Whatever the caller needs to exist BEFORE the page dictionary that names it
124
- // — an annotation and its appearance stream, say. It is handed the writer, so
125
- // it learns its own object numbers rather than being told them.
126
- let extra = extraObjects?(w) ?? ""
127
- // COMPUTED, not hardcoded. `/Parent` has to name the page tree, which is
128
- // written two objects from now; an earlier version of this writer wrote
129
- // "3 0 R" and broke the moment `extraObjects` added anything, because a page
130
- // whose /Parent points at its own annotation makes readers rebuild the tree
131
- // and a test would still pass for the wrong reason.
132
- let pagesNumber = w.count + 2
133
- var dict = "<< /Type /Page /Parent \(pagesNumber) 0 R /MediaBox "
134
- + "[\(mediaBox.0) \(mediaBox.1) \(mediaBox.2) \(mediaBox.3)]"
135
- if let crop = cropBox {
136
- dict += " /CropBox [\(crop.0) \(crop.1) \(crop.2) \(crop.3)]"
137
- }
138
- if rotate != 0 { dict += " /Rotate \(rotate)" }
139
- if let annots { dict += " /Annots \(annots)" }
140
- dict += extra
141
- dict += " /Contents \(contents) 0 R /Resources << >> >>"
142
- let pageObj = w.add(dict)
143
- let pages = w.add("<< /Type /Pages /Kids [\(pageObj) 0 R] /Count 1 >>")
144
- let catalog = w.add("<< /Type /Catalog /Pages \(pages) 0 R >>")
145
- precondition(pages == pagesNumber, "Pages object is \(pages), not \(pagesNumber)")
146
- return w.finish(rootObject: catalog)
147
- }
148
-
149
- /**
150
- Four saturated quadrants in PDF user space (y-up): red bottom-left, green
151
- bottom-right, blue top-left, black top-right.
152
-
153
- Saturated is what makes a channel-order test possible at all — a grey or white
154
- page reads identically in RGBA and BGRA, so the two orderings are
155
- indistinguishable on it. Quadrants also make a STRIDE error visible: a raster
156
- read back with the wrong row stride walks diagonally across the boundaries, so
157
- the sampled colours come out wrong rather than merely shifted. And because the
158
- four differ in both axes, a y-flip and an x/y transposition each move half the
159
- pixels.
160
- */
161
- static func quadrants(width: Int, height: Int, rotate: Int = 0) -> Data {
162
- let hw = width / 2
163
- let hh = height / 2
164
- let content = """
165
- 1 0 0 rg 0 0 \(hw) \(hh) re f
166
- 0 1 0 rg \(hw) 0 \(hw) \(hh) re f
167
- 0 0 1 rg 0 \(hh) \(hw) \(hh) re f
168
- 0 0 0 rg \(hw) \(hh) \(hw) \(hh) re f
169
-
170
- """
171
- return page(mediaBox: (0, 0, width, height), rotate: rotate, content: content)
172
- }
173
-
174
- /**
175
- Detail at every spatial frequency: a `pitch`-point grid catches a scale error,
176
- and the two diagonals catch an x/y transposition.
177
-
178
- Also the fixture the timing tests use, because a page of 500+ stroked lines is
179
- the shape a real drawing has — a flat colour fill measures the memory system
180
- rather than the rasterizer.
181
- */
182
- static func grid(width: Int, height: Int, pitch: Int = 8) -> Data {
183
- var content = "1 1 1 rg 0 0 \(width) \(height) re f\n0 0 0 RG 0.5 w\n"
184
- for x in stride(from: 0, through: width, by: pitch) {
185
- content += "\(x) 0 m \(x) \(height) l S\n"
186
- }
187
- for y in stride(from: 0, through: height, by: pitch) {
188
- content += "0 \(y) m \(width) \(y) l S\n"
189
- }
190
- content += "1 0 0 RG 2 w 0 0 m \(width) \(height) l S "
191
- + "\(width) 0 m 0 \(height) l S\n"
192
- return page(mediaBox: (0, 0, width, height), content: content)
193
- }
194
-
195
- /**
196
- A 612x792 page carrying `/Rotate 90`, red filling the UNROTATED top-left
197
- 112x100 points and blue the unrotated bottom-left.
198
-
199
- `/Rotate` is "degrees the page shall be rotated CLOCKWISE when displayed", so a
200
- correct renderer reports 792x612 and puts red at the displayed TOP-RIGHT and
201
- blue at the displayed TOP-LEFT. Both markers matter: swapped dimensions alone
202
- would also be produced by an implementation that swapped width and height and
203
- drew the content unrotated, and a single marker cannot tell a 90 from a 270.
204
-
205
- PDF user space is y-UP from the bottom-left, so the top-left 112x100 of the
206
- page is the rect (0, 692) to (112, 792).
207
- */
208
- static func rotated(_ degrees: Int) -> Data {
209
- let content = """
210
- 1 1 1 rg 0 0 \(letterW) \(letterH) re f
211
- 1 0 0 rg 0 692 112 100 re f
212
- 0 0 1 rg 0 0 112 100 re f
213
-
214
- """
215
- return page(mediaBox: (0, 0, letterW, letterH), rotate: degrees, content: content)
216
- }
217
-
218
- /**
219
- A page whose CropBox is offset from its MediaBox, with a red marker at the crop
220
- box's own top-left and a blue one at its bottom-right.
221
-
222
- The only fixture that can tell "the box origin was subtracted" from "the box
223
- origin was ignored": with MediaBox `0 0 400 400` and CropBox `100 150 300 350`,
224
- a renderer that ignores the offset draws the markers 100pt too far right and
225
- 50pt too far down, which on a grey page is invisible unless something samples
226
- the corners.
227
- */
228
- static func offsetCropBox() -> Data {
229
- // CropBox (100, 150) to (300, 350): 200 x 200 points, top-left at (100, 350).
230
- let content = """
231
- 0.5 0.5 0.5 rg 0 0 400 400 re f
232
- 1 0 0 rg 100 310 40 40 re f
233
- 0 0 1 rg 260 150 40 40 re f
234
-
235
- """
236
- return page(
237
- mediaBox: (0, 0, 400, 400), cropBox: (100, 150, 300, 350), content: content)
238
- }
239
-
240
- /// A CropBox that overhangs its MediaBox, so the spec-mandated intersection is testable.
241
- static func cropBoxOverhangingMediaBox() -> Data {
242
- let content = "0.5 0.5 0.5 rg 0 0 200 200 re f\n"
243
- return page(
244
- mediaBox: (0, 0, 200, 200), cropBox: (50, 50, 400, 400), content: content)
245
- }
246
-
247
- /**
248
- An antialiased opaque-white circle on an otherwise empty page.
249
-
250
- Rendered with `background: transparent` this is the fixture that decides the
251
- alpha encoding: a 62%-covered white edge reads `[255,255,255,158]` straight and
252
- `[158,158,158,158]` premultiplied. Every pixel on the circle's boundary is
253
- partially covered, so there is no need to hunt for one.
254
- */
255
- static func antialiasedWhiteEdge() -> Data {
256
- let content = """
257
- 1 1 1 rg
258
- 50 10 m
259
- 72.09 10 90 27.91 90 50 c
260
- 90 72.09 72.09 90 50 90 c
261
- 27.91 90 10 72.09 10 50 c
262
- 10 27.91 27.91 10 50 10 c
263
- f
264
-
265
- """
266
- return page(mediaBox: (0, 0, 100, 100), content: content)
267
- }
268
-
269
- /**
270
- A page whose only green content is inside an ANNOTATION's appearance stream.
271
-
272
- The fixture the `annotations` flag is decided by, and it has to be built this
273
- way round: the content stream paints nothing green, so green in the raster can
274
- only have come from compositing the annotation. `annotations: true` (PDFKit)
275
- must show it and `annotations: false` (`CGContextDrawPDFPage`) must not — which
276
- is the one capability difference between this backend and Android's, where the
277
- flag cannot be honoured at all.
278
-
279
- A `/Square` annotation with an explicit `/AP << /N ... >>` normal appearance
280
- stream, because an annotation with no appearance stream is up to the reader to
281
- synthesise and two readers will not agree on it.
282
- */
283
- static func annotatedPage() -> Data {
284
- let content = "1 1 1 rg 0 0 200 200 re f\n1 0 0 rg 10 10 30 30 re f\n"
285
- // The annotation and its appearance stream have to be written BEFORE the page
286
- // dictionary that names them, so the `/Annots` array cannot be a literal — it
287
- // is built from the object number `extraObjects` actually got. An earlier
288
- // version hardcoded `[5 0 R]` and pointed at the page tree instead, which made
289
- // the annotation unreachable and would have let the test pass by drawing
290
- // nothing.
291
- var annotsArray = ""
292
- let data = page(
293
- mediaBox: (0, 0, 200, 200),
294
- annots: nil,
295
- extraObjects: { w in
296
- let appearance = "0 1 0 rg 0 0 100 100 re f\n"
297
- let stream = appearance.data(using: .isoLatin1)!
298
- let ap = w.add(
299
- "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] "
300
- + "/Length \(stream.count) >>\nstream\n\(appearance)endstream")
301
- let annot = w.add(
302
- "<< /Type /Annot /Subtype /Square /Rect [50 50 150 150] /F 4 "
303
- + "/AP << /N \(ap) 0 R >> >>")
304
- annotsArray = "[\(annot) 0 R]"
305
- return " /Annots [\(annot) 0 R]"
306
- },
307
- content: content)
308
- precondition(!annotsArray.isEmpty, "the annotation was never written")
309
- return data
310
- }
311
-
312
- // ---------------------------------------------------------------- //
313
- // Multi-page fixtures
314
- // ---------------------------------------------------------------- //
315
-
316
- /**
317
- Pages of DIFFERENT sizes in one document, so a geometry test can catch an
318
- implementation that probes page 0 and reports its size for every page.
319
- */
320
- static let mixedSizes: [(Int, Int)] = [
321
- (letterW, letterH), (archDW, archDH), (200, 900),
322
- ]
323
-
324
- static func mixedSizeDocument() -> Data {
325
- multiPage(mixedSizes.map { size in
326
- (size, 0, "0.5 0.5 0.5 rg 0 0 \(size.0) \(size.1) re f\n")
327
- })
328
- }
329
-
330
- /**
331
- One flat colour per page.
332
-
333
- Catches a backend that ignores `RasterRequest.page` and renders page 0 for every
334
- request — which a single-page fixture cannot catch at all, and a multi-page
335
- fixture with identical pages catches only by luck.
336
- */
337
- static func coloredPages(_ colors: [(Double, Double, Double)], width: Int, height: Int)
338
- -> Data
339
- {
340
- multiPage(
341
- colors.map { color in
342
- (
343
- (width, height), 0,
344
- "\(color.0) \(color.1) \(color.2) rg 0 0 \(width) \(height) re f\n"
345
- )
346
- })
347
- }
348
-
349
- /// Pages with different `/Rotate` values, so post-rotation geometry is testable per page.
350
- static func rotatedPages(_ rotations: [Int]) -> Data {
351
- multiPage(
352
- rotations.map { rotation in
353
- ((200, 100), rotation, "1 0 0 rg 0 0 40 40 re f\n0 0 1 rg 0 60 40 40 re f\n")
354
- })
355
- }
356
-
357
- /// - Parameter pages: `(size, rotate, content)` per page.
358
- static func multiPage(_ pages: [((Int, Int), Int, String)]) -> Data {
359
- let w = Writer()
360
- // The page tree is object 1 and every page's /Parent points at it, so the
361
- // pages have to be numbered afterwards. Object 1 is therefore RESERVED with a
362
- // placeholder and rewritten at the end — which a linear writer cannot do, so
363
- // instead the content streams and pages come first and the tree last, with
364
- // /Parent naming a number computed up front.
365
- let contentObjects = pages.map { page -> Int in
366
- let stream = page.2.data(using: .isoLatin1)!
367
- return w.add("<< /Length \(stream.count) >>\nstream\n\(page.2)endstream")
368
- }
369
- // After the content streams, the next `pages.count` objects are the pages, and
370
- // the one after those is the tree.
371
- let treeNumber = contentObjects.count + pages.count + 1
372
- var pageObjects: [Int] = []
373
- for (index, page) in pages.enumerated() {
374
- var dict = "<< /Type /Page /Parent \(treeNumber) 0 R /MediaBox "
375
- + "[0 0 \(page.0.0) \(page.0.1)]"
376
- if page.1 != 0 { dict += " /Rotate \(page.1)" }
377
- dict += " /Contents \(contentObjects[index]) 0 R /Resources << >> >>"
378
- pageObjects.append(w.add(dict))
379
- }
380
- let kids = pageObjects.map { "\($0) 0 R" }.joined(separator: " ")
381
- let tree = w.add("<< /Type /Pages /Kids [\(kids)] /Count \(pages.count) >>")
382
- precondition(tree == treeNumber, "tree is \(tree), predicted \(treeNumber)")
383
- let catalog = w.add("<< /Type /Catalog /Pages \(tree) 0 R >>")
384
- return w.finish(rootObject: catalog)
385
- }
386
-
387
- // ---------------------------------------------------------------- //
388
- // Not-PDFs
389
- // ---------------------------------------------------------------- //
390
-
391
- /// A valid `%PDF-` header and nothing else that parses.
392
- static func headerOnlyGarbage() -> Data {
393
- var s = "%PDF-1.7\n"
394
- for i in 0..<200 { s += "garbage line \(i)\n" }
395
- return s.data(using: .isoLatin1)!
396
- }
397
-
398
- /// No `%PDF-` header anywhere in the first 1024 bytes.
399
- static func notAPdfAtAll() -> Data {
400
- var s = ""
401
- for i in 0..<200 { s += "this is a text file, line \(i)\n" }
402
- s += "%PDF-1.7 too late to count\n"
403
- return s.data(using: .isoLatin1)!
404
- }
405
-
406
- // ---------------------------------------------------------------- //
407
- // Encryption (standard security handler, R=2, V=1, RC4-40)
408
- // ---------------------------------------------------------------- //
409
-
410
- static let userPassword = "secret"
411
- /// Non-empty, and that is load-bearing — see the note in `encrypted()`.
412
- static let ownerPassword = "owner"
413
-
414
- private static let pad: [UInt8] = [
415
- 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56,
416
- 0xFF, 0xFA, 0x01, 0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80,
417
- 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A,
418
- ]
419
-
420
- private static func padded(_ password: String) -> [UInt8] {
421
- let pw = Array(password.data(using: .isoLatin1)!)
422
- var out = [UInt8](repeating: 0, count: 32)
423
- let n = min(pw.count, 32)
424
- for i in 0..<n { out[i] = pw[i] }
425
- for i in n..<32 { out[i] = pad[i - n] }
426
- return out
427
- }
428
-
429
- /// RC4, by hand, because CommonCrypto's RC4 is deprecated and gated.
430
- private static func rc4(key: [UInt8], data: [UInt8]) -> [UInt8] {
431
- var s = Array(0..<256)
432
- var j = 0
433
- for i in 0..<256 {
434
- j = (j + s[i] + Int(key[i % key.count])) & 0xFF
435
- s.swapAt(i, j)
436
- }
437
- var out = [UInt8](repeating: 0, count: data.count)
438
- var i = 0
439
- j = 0
440
- for k in 0..<data.count {
441
- i = (i + 1) & 0xFF
442
- j = (j + s[i]) & 0xFF
443
- s.swapAt(i, j)
444
- out[k] = data[k] ^ UInt8(s[(s[i] + s[j]) & 0xFF])
445
- }
446
- return out
447
- }
448
-
449
- private static func md5(_ parts: [[UInt8]]) -> [UInt8] {
450
- // Insertion-sort-simple MD5, written out rather than taken from CommonCrypto:
451
- // `CC_MD5` is deprecated with an availability warning that fails a
452
- // warnings-as-errors build, and CryptoKit's `Insecure.MD5` is available but
453
- // pulls a framework into a test target that otherwise needs none.
454
- var message = [UInt8]()
455
- for part in parts { message += part }
456
- return MD5.digest(message)
457
- }
458
-
459
- /**
460
- A 200x200 red page encrypted with the standard security handler, R=2, V=1,
461
- RC4-40, user password `userPassword` and owner password `ownerPassword`.
462
-
463
- Hand-written because nothing in CoreGraphics or PDFKit writes an encrypted PDF.
464
- Without it, "password-protected produces a distinguishable error" is an
465
- assertion about code that has never met an encrypted file — and on iOS it is
466
- also the only way to test the `password-incorrect` branch, which Android cannot
467
- reach at all (`PdfRendererPool` refuses any password with `unsupported`,
468
- because `LoadParams` landed at API 35).
469
- */
470
- static func encrypted() -> Data {
471
- let contentString = "1 0 0 rg 0 0 200 200 re f\n"
472
- let content = Array(contentString.data(using: .isoLatin1)!)
473
-
474
- // A fixed file ID, so the fixture is byte-stable across runs.
475
- let fileId: [UInt8] = [
476
- 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
477
- 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
478
- ]
479
- // -1 as a 32-bit little-endian signed int: every permission granted.
480
- let permissions: Int32 = -1
481
- let pBytes: [UInt8] = [
482
- UInt8(truncatingIfNeeded: permissions),
483
- UInt8(truncatingIfNeeded: permissions >> 8),
484
- UInt8(truncatingIfNeeded: permissions >> 16),
485
- UInt8(truncatingIfNeeded: permissions >> 24),
486
- ]
487
-
488
- // Algorithm 3: /O is the padded USER password, RC4'd with the MD5 of the
489
- // padded OWNER password.
490
- //
491
- // THE OWNER PASSWORD MUST NOT BE EMPTY, and getting that wrong is how this
492
- // fixture first shipped as a document that opens with no password at all.
493
- // MEASURED: with `ownerPassword = ""`, CoreGraphics reported
494
- // `isEncrypted = true` and `isUnlocked = true` before any password was
495
- // supplied, and PDFKit reported `isLocked = false`. The reason is Algorithm 7,
496
- // the OWNER authentication path every reader tries after the user one: it
497
- // decrypts /O with MD5(padded(supplied)) and runs Algorithm 6 on the result.
498
- // An empty owner password makes the empty string decrypt /O correctly, which
499
- // recovers the real user password, which authenticates. The spec's own
500
- // instruction — "if there is no owner password, use the user password
501
- // instead" — exists precisely to close that hole. `"owner"` here matches the
502
- // Android fixture exactly, so the two platforms are tested against the same
503
- // bytes.
504
- let ownerKey = Array(md5([padded(ownerPassword)])[0..<5])
505
- let o = rc4(key: ownerKey, data: padded(userPassword))
506
-
507
- // Algorithm 2: the file encryption key.
508
- let userPad = padded(userPassword)
509
- let fileKey = Array(md5([userPad, o, pBytes, fileId])[0..<5])
510
-
511
- // Algorithm 4 (R=2): /U is the pad string RC4'd with the file key.
512
- let u = rc4(key: fileKey, data: pad)
513
-
514
- // Each string and stream is encrypted with a key derived from the file key
515
- // plus its own object and generation number (Algorithm 1).
516
- func objectKey(_ number: Int) -> [UInt8] {
517
- let extra: [UInt8] = [
518
- UInt8(number & 0xFF), UInt8((number >> 8) & 0xFF),
519
- UInt8((number >> 16) & 0xFF), 0, 0,
520
- ]
521
- let digest = md5([fileKey, extra])
522
- return Array(digest[0..<min(fileKey.count + 5, 16)])
523
- }
524
-
525
- let w = Writer()
526
- // Object 1 is the content stream, so its key uses 1.
527
- let encryptedContent = rc4(key: objectKey(1), data: content)
528
- var streamObject = Data("<< /Length \(encryptedContent.count) >>\nstream\n"
529
- .data(using: .isoLatin1)!)
530
- streamObject.append(contentsOf: encryptedContent)
531
- streamObject.append("\nendstream".data(using: .isoLatin1)!)
532
- let contents = w.add(streamObject)
533
- let pageObj = w.add(
534
- "<< /Type /Page /Parent 3 0 R /MediaBox [0 0 200 200] /Contents "
535
- + "\(contents) 0 R /Resources << >> >>")
536
- let pages = w.add("<< /Type /Pages /Kids [\(pageObj) 0 R] /Count 1 >>")
537
- let catalog = w.add("<< /Type /Catalog /Pages \(pages) 0 R >>")
538
- let encrypt = w.add(
539
- "<< /Filter /Standard /V 1 /R 2 /O <\(hex(o))> /U <\(hex(u))> /P "
540
- + "\(permissions) >>")
541
- precondition(pages == 3, "Pages object is \(pages), not 3")
542
- return w.finish(
543
- rootObject: catalog,
544
- extraTrailer: "/Encrypt \(encrypt) 0 R /ID [<\(hex(fileId))> <\(hex(fileId))>]")
545
- }
546
-
547
- private static func hex(_ bytes: [UInt8]) -> String {
548
- bytes.map { String(format: "%02X", $0) }.joined()
549
- }
550
-
551
- // ---------------------------------------------------------------- //
552
- // Files
553
- // ---------------------------------------------------------------- //
554
-
555
- /// Writes a fixture to a temporary file and returns its URL.
556
- static func write(_ data: Data, name: String) throws -> URL {
557
- let dir = URL(fileURLWithPath: NSTemporaryDirectory())
558
- .appendingPathComponent("pdfcanvas-tests", isDirectory: true)
559
- try FileManager.default.createDirectory(
560
- at: dir, withIntermediateDirectories: true)
561
- let url = dir.appendingPathComponent(name)
562
- try data.write(to: url)
563
- return url
564
- }
565
- }
566
-
567
- /// MD5, 60 lines, because CommonCrypto's is deprecated and CryptoKit is a framework.
568
- enum MD5 {
569
- private static let s: [UInt32] = [
570
- 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
571
- 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
572
- 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
573
- 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
574
- ]
575
-
576
- private static let k: [UInt32] = (0..<64).map {
577
- UInt32(truncatingIfNeeded: Int64(abs(sin(Double($0 + 1))) * 4294967296.0))
578
- }
579
-
580
- static func digest(_ message: [UInt8]) -> [UInt8] {
581
- var msg = message
582
- let bitLength = UInt64(message.count) * 8
583
- msg.append(0x80)
584
- while msg.count % 64 != 56 { msg.append(0) }
585
- for i in 0..<8 { msg.append(UInt8(truncatingIfNeeded: bitLength >> (8 * UInt64(i)))) }
586
-
587
- var a0: UInt32 = 0x6745_2301
588
- var b0: UInt32 = 0xefcd_ab89
589
- var c0: UInt32 = 0x98ba_dcfe
590
- var d0: UInt32 = 0x1032_5476
591
-
592
- for chunk in stride(from: 0, to: msg.count, by: 64) {
593
- var m = [UInt32](repeating: 0, count: 16)
594
- for i in 0..<16 {
595
- let o = chunk + i * 4
596
- m[i] =
597
- UInt32(msg[o]) | (UInt32(msg[o + 1]) << 8) | (UInt32(msg[o + 2]) << 16)
598
- | (UInt32(msg[o + 3]) << 24)
599
- }
600
- var a = a0
601
- var b = b0
602
- var c = c0
603
- var d = d0
604
- for i in 0..<64 {
605
- var f: UInt32
606
- var g: Int
607
- switch i / 16 {
608
- case 0:
609
- f = (b & c) | (~b & d)
610
- g = i
611
- case 1:
612
- f = (d & b) | (~d & c)
613
- g = (5 * i + 1) % 16
614
- case 2:
615
- f = b ^ c ^ d
616
- g = (3 * i + 5) % 16
617
- default:
618
- f = c ^ (b | ~d)
619
- g = (7 * i) % 16
620
- }
621
- f = f &+ a &+ k[i] &+ m[g]
622
- a = d
623
- d = c
624
- c = b
625
- b = b &+ ((f << s[i]) | (f >> (32 - s[i])))
626
- }
627
- a0 = a0 &+ a
628
- b0 = b0 &+ b
629
- c0 = c0 &+ c
630
- d0 = d0 &+ d
631
- }
632
-
633
- var out = [UInt8]()
634
- for word in [a0, b0, c0, d0] {
635
- for i in 0..<4 { out.append(UInt8(truncatingIfNeeded: word >> (8 * UInt32(i)))) }
636
- }
637
- return out
638
- }
639
- }