@reekon-tools/react-native-pdf-canvas 0.1.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 (161) hide show
  1. package/PdfCanvas.podspec +80 -0
  2. package/README.md +1391 -0
  3. package/android/build.gradle +175 -0
  4. package/android/consumer-rules.pro +3 -0
  5. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  6. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  7. package/android/gradle.properties +2 -0
  8. package/android/gradlew +248 -0
  9. package/android/gradlew.bat +98 -0
  10. package/android/settings.gradle +34 -0
  11. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +226 -0
  12. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +239 -0
  13. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +251 -0
  14. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +221 -0
  15. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +783 -0
  16. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +1388 -0
  17. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +98 -0
  18. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +324 -0
  19. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +775 -0
  20. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +20 -0
  21. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +558 -0
  22. package/android/src/jsi/cpp/CMakeLists.txt +33 -0
  23. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +469 -0
  24. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +140 -0
  25. package/android/src/main/AndroidManifest.xml +7 -0
  26. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +45 -0
  27. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +564 -0
  28. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +33 -0
  29. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +277 -0
  30. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +214 -0
  31. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +30 -0
  32. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +234 -0
  33. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +47 -0
  34. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +143 -0
  35. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +95 -0
  36. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +626 -0
  37. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasPackage.java +33 -0
  38. package/android/tools/compile-gate.sh +289 -0
  39. package/dist/cache.d.ts +161 -0
  40. package/dist/cache.js +389 -0
  41. package/dist/clock.d.ts +21 -0
  42. package/dist/clock.js +31 -0
  43. package/dist/controller.d.ts +131 -0
  44. package/dist/controller.js +1002 -0
  45. package/dist/geometry.d.ts +96 -0
  46. package/dist/geometry.js +248 -0
  47. package/dist/index.d.ts +52 -0
  48. package/dist/index.js +50 -0
  49. package/dist/index.native.d.ts +21 -0
  50. package/dist/index.native.js +23 -0
  51. package/dist/index.web.d.ts +24 -0
  52. package/dist/index.web.js +26 -0
  53. package/dist/layout.d.ts +78 -0
  54. package/dist/layout.js +152 -0
  55. package/dist/peer-check.d.ts +83 -0
  56. package/dist/peer-check.js +183 -0
  57. package/dist/planner.d.ts +35 -0
  58. package/dist/planner.js +530 -0
  59. package/dist/policy.d.ts +14 -0
  60. package/dist/policy.js +168 -0
  61. package/dist/rasterizer/android.d.ts +121 -0
  62. package/dist/rasterizer/android.js +126 -0
  63. package/dist/rasterizer/buffer.d.ts +18 -0
  64. package/dist/rasterizer/buffer.js +18 -0
  65. package/dist/rasterizer/fake.d.ts +92 -0
  66. package/dist/rasterizer/fake.js +433 -0
  67. package/dist/rasterizer/index.d.ts +38 -0
  68. package/dist/rasterizer/index.js +69 -0
  69. package/dist/rasterizer/index.native.d.ts +19 -0
  70. package/dist/rasterizer/index.native.js +80 -0
  71. package/dist/rasterizer/index.web.d.ts +63 -0
  72. package/dist/rasterizer/index.web.js +75 -0
  73. package/dist/rasterizer/ingest.d.ts +180 -0
  74. package/dist/rasterizer/ingest.js +307 -0
  75. package/dist/rasterizer/ios.d.ts +84 -0
  76. package/dist/rasterizer/ios.js +155 -0
  77. package/dist/rasterizer/native-bridge.d.ts +261 -0
  78. package/dist/rasterizer/native-bridge.js +413 -0
  79. package/dist/rasterizer/web/client.d.ts +84 -0
  80. package/dist/rasterizer/web/client.js +359 -0
  81. package/dist/rasterizer/web/engine.d.ts +69 -0
  82. package/dist/rasterizer/web/engine.js +308 -0
  83. package/dist/rasterizer/web/index.d.ts +26 -0
  84. package/dist/rasterizer/web/index.js +21 -0
  85. package/dist/rasterizer/web/pdfium.d.ts +213 -0
  86. package/dist/rasterizer/web/pdfium.js +170 -0
  87. package/dist/rasterizer/web/protocol.d.ts +138 -0
  88. package/dist/rasterizer/web/protocol.js +37 -0
  89. package/dist/rasterizer/web/session.d.ts +106 -0
  90. package/dist/rasterizer/web/session.js +413 -0
  91. package/dist/rasterizer/web/worker.d.ts +44 -0
  92. package/dist/rasterizer/web/worker.js +76 -0
  93. package/dist/react/PdfContentView.d.ts +98 -0
  94. package/dist/react/PdfContentView.js +189 -0
  95. package/dist/react/index.d.ts +6 -0
  96. package/dist/react/index.js +3 -0
  97. package/dist/react/usePdfDocument.d.ts +134 -0
  98. package/dist/react/usePdfDocument.js +275 -0
  99. package/dist/react/usePdfLayer.d.ts +94 -0
  100. package/dist/react/usePdfLayer.js +388 -0
  101. package/dist/skia/index.d.ts +26 -0
  102. package/dist/skia/index.js +25 -0
  103. package/dist/skia/index.native.d.ts +14 -0
  104. package/dist/skia/index.native.js +16 -0
  105. package/dist/skia/index.web.d.ts +12 -0
  106. package/dist/skia/index.web.js +14 -0
  107. package/dist/skia/resolve.native.d.ts +18 -0
  108. package/dist/skia/resolve.native.js +22 -0
  109. package/dist/skia/resolve.web.d.ts +41 -0
  110. package/dist/skia/resolve.web.js +62 -0
  111. package/dist/skia/skia-api.d.ts +94 -0
  112. package/dist/skia/skia-api.js +113 -0
  113. package/dist/testing/index.d.ts +27 -0
  114. package/dist/testing/index.js +28 -0
  115. package/dist/testing/scenes.d.ts +100 -0
  116. package/dist/testing/scenes.js +267 -0
  117. package/dist/types.d.ts +611 -0
  118. package/dist/types.js +21 -0
  119. package/dist/web-init.d.ts +68 -0
  120. package/dist/web-init.js +64 -0
  121. package/ios/Package.swift +58 -0
  122. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +743 -0
  123. package/ios/Sources/PdfCanvasBridge/include/PdfCanvasModule.h +36 -0
  124. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +38 -0
  125. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +389 -0
  126. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +57 -0
  127. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +44 -0
  128. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +82 -0
  129. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +37 -0
  130. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +158 -0
  131. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +80 -0
  132. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +65 -0
  133. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +548 -0
  134. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +48 -0
  135. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +26 -0
  136. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +122 -0
  137. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +80 -0
  138. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +79 -0
  139. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +129 -0
  140. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +100 -0
  141. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +65 -0
  142. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +111 -0
  143. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +85 -0
  144. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +163 -0
  145. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +297 -0
  146. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +140 -0
  147. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +358 -0
  148. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +262 -0
  149. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +173 -0
  150. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +114 -0
  151. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +158 -0
  152. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +544 -0
  153. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +287 -0
  154. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +396 -0
  155. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +639 -0
  156. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +179 -0
  157. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +283 -0
  158. package/package.json +144 -0
  159. package/react-native.config.js +40 -0
  160. package/scripts/check-skia-peer.mjs +86 -0
  161. package/scripts/skia-peer-check.mjs +363 -0
@@ -0,0 +1,179 @@
1
+ import Foundation
2
+ import XCTest
3
+
4
+ @testable import PdfCanvasCore
5
+
6
+ /**
7
+ Numbers, printed rather than asserted.
8
+
9
+ THESE ARE NOT PASS/FAIL TESTS AND MUST NOT BECOME ONES. Their counterpart is
10
+ `PdfCanvasDeviceTimingTest` on Android, whose own lesson is written into
11
+ `AndroidRasterizerOptions.poolSize`: a synthetic fixture measured two lanes at
12
+ 1.06x-1.25x while a real drawing measured 0.82x, and the synthetic number would
13
+ have shipped the wrong default. So what these produce is a SHAPE, on whatever
14
+ machine ran them, and the numbers that decide anything come from a device with a
15
+ real document on it — `app/dev-pdf-canvas-probe.tsx` in the consuming app.
16
+
17
+ The one thing they do assert is that nothing failed, because a timing loop that
18
+ silently rendered zero tiles would still print a very fast number.
19
+ */
20
+ final class TimingTests: XCTestCase {
21
+
22
+ private func measure(_ label: String, _ body: () -> Void) -> Double {
23
+ var best = Double.infinity
24
+ for _ in 0..<5 {
25
+ let started = Date()
26
+ body()
27
+ best = min(best, Date().timeIntervalSince(started) * 1000)
28
+ }
29
+ print("PDFCANVAS-TIMING \(label): \(String(format: "%.2f", best)) ms")
30
+ return best
31
+ }
32
+
33
+ /**
34
+ Per-tile cost at the sizes the planner actually asks for.
35
+
36
+ `maxPieceDimension` is 1024, so 512² and 1024² are the two that matter, and
37
+ `minEpochIntervalMs` is defined as the measured p95 of one of them.
38
+ */
39
+ func testTileCostBySize() throws {
40
+ let r = try openFixture(
41
+ TestPdfs.grid(width: 1728, height: 2592, pitch: 8), name: "timing-tiles.pdf")
42
+ var rendered = 0
43
+ for side in [256, 512, 1024] {
44
+ let scale = 4.0
45
+ _ = measure("tile \(side)px @4x") {
46
+ if (try? r.render(
47
+ request(
48
+ x: 100, y: 100, width: Double(side) / scale, height: Double(side) / scale,
49
+ scale: scale),
50
+ cancellation: nil, sink: nil)) != nil
51
+ {
52
+ rendered += 1
53
+ }
54
+ }
55
+ }
56
+ XCTAssertEqual(rendered, 15, "every measured render must have succeeded")
57
+ r.close()
58
+ }
59
+
60
+ /**
61
+ A whole ARCH-D base raster at a plausible fit scale.
62
+
63
+ The base layer is `fitZoom * pixelRatio * baseQuality` over the whole sheet, so
64
+ on a DPR-3 phone an ARCH-D page is roughly 1170x1755 — which is what this
65
+ approximates. It is the single largest raster the package ever asks a backend
66
+ for, and therefore the one that sets the worst-case first paint.
67
+ */
68
+ func testWholePageBaseRasterCost() throws {
69
+ let r = try openFixture(
70
+ TestPdfs.grid(width: 1728, height: 2592, pitch: 8), name: "timing-base.pdf")
71
+ var rendered = 0
72
+ _ = measure("ARCH-D base @0.677x (1170x1755)") {
73
+ if (try? r.render(
74
+ request(width: 1728, height: 2592, scale: 0.677), cancellation: nil,
75
+ sink: nil)) != nil
76
+ {
77
+ rendered += 1
78
+ }
79
+ }
80
+ XCTAssertEqual(rendered, 5)
81
+ r.close()
82
+ }
83
+
84
+ /**
85
+ Lane scaling, over the schedule the controller actually runs: N lanes pulling
86
+ from one shared cursor of plan items.
87
+
88
+ ON THIS MACHINE CoreGraphics parallelises — the probe measured 1.53x at two lanes
89
+ and 2.28x at four on a dense ARCH-D page, which is the OPPOSITE of Android, where
90
+ AOSP serialises `PdfRenderer` behind a process-wide PDFium lock that convoys and
91
+ every lane count above one measures worse. That difference is why
92
+ `IosRasterizerOptions.poolSize` does not cite any Android evidence.
93
+ *
94
+ IT IS STILL NOT A REASON TO RAISE THE DEFAULT. This is a Mac with a lot of cores
95
+ and a fixture whose tiles cost about a millisecond; a phone under thermal
96
+ pressure with a real drawing is a different measurement, and it is the one that
97
+ decides. Printed here so the shape is on record and so a device run has something
98
+ to be compared against.
99
+ */
100
+ func testLaneScaling() throws {
101
+ let data = TestPdfs.grid(width: 1728, height: 2592, pitch: 4)
102
+ var items: [(Double, Double)] = []
103
+ for index in 0..<24 {
104
+ let column = Double(index % 6) * 256.0
105
+ let row = Double(index / 6) * 256.0
106
+ items.append((column, row))
107
+ }
108
+ var baseline = 0.0
109
+ for lanes in [1, 2, 4] {
110
+ let r = try openFixture(
111
+ data, name: "timing-lanes-\(lanes).pdf", poolSize: lanes)
112
+ let lock = NSLock()
113
+ var completed = 0
114
+ let ms = measure("\(lanes) lane(s), 24x 1024px tiles @4x") {
115
+ var cursor = 0
116
+ DispatchQueue.concurrentPerform(iterations: lanes) { _ in
117
+ while true {
118
+ lock.lock()
119
+ let index = cursor
120
+ cursor += 1
121
+ lock.unlock()
122
+ if index >= items.count { return }
123
+ let (x, y) = items[index]
124
+ if (try? r.render(
125
+ request(x: x, y: y, width: 256, height: 256, scale: 4),
126
+ cancellation: nil, sink: nil)) != nil
127
+ {
128
+ lock.lock()
129
+ completed += 1
130
+ lock.unlock()
131
+ }
132
+ }
133
+ }
134
+ }
135
+ if lanes == 1 { baseline = ms }
136
+ print(
137
+ "PDFCANVAS-TIMING \(lanes) lane(s) relative: "
138
+ + String(format: "%.2fx", baseline / ms))
139
+ XCTAssertEqual(
140
+ completed, items.count * 5, "every tile in every repetition must land")
141
+ r.close()
142
+ }
143
+ }
144
+
145
+ /**
146
+ The two engines' relative cost, which is what makes `annotations: true` a
147
+ deliberate choice rather than a free one.
148
+
149
+ PDFKit has to build and keep a `PDFDocument` alongside the `CGPDFDocumentRef`, so
150
+ the FIRST annotated render in a lane pays for a second parse. That is why the
151
+ PDFKit document is lazy — every tile the controller asks for passes
152
+ `annotations: false` and never triggers it.
153
+ */
154
+ func testEngineCostComparison() throws {
155
+ let r = try openFixture(
156
+ TestPdfs.grid(width: 1728, height: 2592, pitch: 8), name: "timing-engines.pdf")
157
+ var rendered = 0
158
+ // Warm both, so neither measurement includes its parse.
159
+ _ = try? r.render(
160
+ request(width: 200, height: 200, scale: 1, annotations: false),
161
+ cancellation: nil, sink: nil)
162
+ _ = try? r.render(
163
+ request(width: 200, height: 200, scale: 1, annotations: true),
164
+ cancellation: nil, sink: nil)
165
+
166
+ for annotations in [false, true] {
167
+ _ = measure("512px @4x annotations=\(annotations)") {
168
+ if (try? r.render(
169
+ request(width: 128, height: 128, scale: 4, annotations: annotations),
170
+ cancellation: nil, sink: nil)) != nil
171
+ {
172
+ rendered += 1
173
+ }
174
+ }
175
+ }
176
+ XCTAssertEqual(rendered, 10)
177
+ r.close()
178
+ }
179
+ }
@@ -0,0 +1,283 @@
1
+ import CoreGraphics
2
+ import XCTest
3
+
4
+ @testable import PdfCanvasCore
5
+
6
+ /**
7
+ The three affine transforms, checked by mapping corners — no PDF, no bitmap, no
8
+ CoreGraphics state.
9
+
10
+ THIS FILE IS DELIBERATELY FIRST. Every failure mode of a transform produces a
11
+ plausible-looking image rather than an error: a rotation applied the wrong way
12
+ round, a crop-box origin left in, a y-flip half a pixel off. Checking them here
13
+ means a rendering failure further down is a rendering failure, and not this.
14
+ */
15
+ final class TransformTests: XCTestCase {
16
+
17
+ /// A 200x100 box at the origin, so a swap of width and height is visible.
18
+ private let box = CGRect(x: 0, y: 0, width: 200, height: 100)
19
+
20
+ private func mapped(_ point: CGPoint, _ transform: CGAffineTransform) -> CGPoint {
21
+ point.applying(transform)
22
+ }
23
+
24
+ private func assertPoint(
25
+ _ actual: CGPoint, _ expected: CGPoint, _ what: String,
26
+ file: StaticString = #filePath, line: UInt = #line
27
+ ) {
28
+ XCTAssertEqual(
29
+ Double(actual.x), Double(expected.x), accuracy: 1e-9,
30
+ "\(what): x", file: file, line: line)
31
+ XCTAssertEqual(
32
+ Double(actual.y), Double(expected.y), accuracy: 1e-9,
33
+ "\(what): y", file: file, line: line)
34
+ }
35
+
36
+ // ---------------------------------------------------------------- //
37
+ // PDF user space -> doc space
38
+ // ---------------------------------------------------------------- //
39
+
40
+ /**
41
+ Rotation 0 is a pure y-flip through the box's top edge.
42
+
43
+ The PDF's bottom-left corner is doc space's bottom-LEFT, and the PDF's top-left
44
+ is doc (0, 0). An implementation that forgot the flip entirely would map
45
+ bottom-left to (0, 0), which is the single most likely mistake here.
46
+ */
47
+ func testRotationZeroFlipsYAndNothingElse() {
48
+ let t = PdfCanvasPdfToDocTransform(box, 0)
49
+ assertPoint(mapped(CGPoint(x: 0, y: 100), t), CGPoint(x: 0, y: 0), "PDF top-left")
50
+ assertPoint(
51
+ mapped(CGPoint(x: 0, y: 0), t), CGPoint(x: 0, y: 100), "PDF bottom-left")
52
+ assertPoint(
53
+ mapped(CGPoint(x: 200, y: 100), t), CGPoint(x: 200, y: 0), "PDF top-right")
54
+ assertPoint(
55
+ mapped(CGPoint(x: 200, y: 0), t), CGPoint(x: 200, y: 100), "PDF bottom-right")
56
+ }
57
+
58
+ /**
59
+ `/Rotate 90` is a CLOCKWISE quarter turn of the displayed page.
60
+
61
+ Turning a sheet of paper clockwise sends its bottom-left corner to the top-left
62
+ and its bottom-right to the bottom-left. Those two are what distinguish 90 from
63
+ 270; a test that only checked the page's SIZE would pass for either.
64
+ */
65
+ func testRotation90TurnsClockwise() {
66
+ let t = PdfCanvasPdfToDocTransform(box, 90)
67
+ assertPoint(
68
+ mapped(CGPoint(x: 0, y: 0), t), CGPoint(x: 0, y: 0), "PDF bottom-left")
69
+ assertPoint(
70
+ mapped(CGPoint(x: 200, y: 0), t), CGPoint(x: 0, y: 200), "PDF bottom-right")
71
+ assertPoint(
72
+ mapped(CGPoint(x: 200, y: 100), t), CGPoint(x: 100, y: 200), "PDF top-right")
73
+ assertPoint(
74
+ mapped(CGPoint(x: 0, y: 100), t), CGPoint(x: 100, y: 0), "PDF top-left")
75
+ }
76
+
77
+ func testRotation180TurnsHalfway() {
78
+ let t = PdfCanvasPdfToDocTransform(box, 180)
79
+ assertPoint(
80
+ mapped(CGPoint(x: 0, y: 0), t), CGPoint(x: 200, y: 0), "PDF bottom-left")
81
+ assertPoint(
82
+ mapped(CGPoint(x: 0, y: 100), t), CGPoint(x: 200, y: 100), "PDF top-left")
83
+ assertPoint(
84
+ mapped(CGPoint(x: 200, y: 100), t), CGPoint(x: 0, y: 100), "PDF top-right")
85
+ }
86
+
87
+ func testRotation270TurnsCounterClockwise() {
88
+ let t = PdfCanvasPdfToDocTransform(box, 270)
89
+ assertPoint(
90
+ mapped(CGPoint(x: 0, y: 0), t), CGPoint(x: 100, y: 200), "PDF bottom-left")
91
+ assertPoint(
92
+ mapped(CGPoint(x: 0, y: 100), t), CGPoint(x: 0, y: 200), "PDF top-left")
93
+ assertPoint(
94
+ mapped(CGPoint(x: 200, y: 100), t), CGPoint(x: 0, y: 0), "PDF top-right")
95
+ }
96
+
97
+ /**
98
+ Every rotation is a reflection, not a rotation, in raw coordinates.
99
+
100
+ Doc space is y-down and PDF space is y-up, so a determinant of +1 anywhere here
101
+ would mean the flip had been lost. Cheap, and it catches a whole class of sign
102
+ errors that the corner checks above could miss if two of them were wrong in
103
+ compensating ways.
104
+ */
105
+ func testEveryRotationHasDeterminantMinusOne() {
106
+ for rotation in [0, 90, 180, 270] {
107
+ let t = PdfCanvasPdfToDocTransform(box, rotation)
108
+ XCTAssertEqual(
109
+ Double(t.a * t.d - t.b * t.c), -1, accuracy: 1e-9,
110
+ "rotation \(rotation) should be orientation-reversing")
111
+ }
112
+ }
113
+
114
+ /**
115
+ The crop box's own top-left lands at doc (0, 0), whatever its origin is.
116
+
117
+ The fixture this mirrors has MediaBox `0 0 400 400` and CropBox
118
+ `100 150 300 350`. Without the `-minX` / `minY + height` terms the marker at
119
+ the crop box's top-left would be drawn 100pt right and 50pt down, which on a
120
+ flat page is invisible.
121
+ */
122
+ func testCropBoxOriginIsSubtracted() {
123
+ let crop = CGRect(x: 100, y: 150, width: 200, height: 200)
124
+ let t = PdfCanvasPdfToDocTransform(crop, 0)
125
+ assertPoint(
126
+ mapped(CGPoint(x: 100, y: 350), t), CGPoint(x: 0, y: 0), "crop top-left")
127
+ assertPoint(
128
+ mapped(CGPoint(x: 300, y: 150), t), CGPoint(x: 200, y: 200),
129
+ "crop bottom-right")
130
+ }
131
+
132
+ /// The same, for a rotated page — where the two corrections interact.
133
+ func testCropBoxOriginIsSubtractedUnderRotation() {
134
+ let crop = CGRect(x: 100, y: 150, width: 200, height: 100)
135
+ // Rotated 90, the page is 100 wide and 200 tall.
136
+ let t = PdfCanvasPdfToDocTransform(crop, 90)
137
+ assertPoint(
138
+ mapped(CGPoint(x: 100, y: 150), t), CGPoint(x: 0, y: 0),
139
+ "crop bottom-left -> doc top-left")
140
+ assertPoint(
141
+ mapped(CGPoint(x: 300, y: 150), t), CGPoint(x: 0, y: 200),
142
+ "crop bottom-right -> doc bottom-left")
143
+ assertPoint(
144
+ mapped(CGPoint(x: 100, y: 250), t), CGPoint(x: 100, y: 0),
145
+ "crop top-left -> doc top-right")
146
+ }
147
+
148
+ /// A rotation that is not a multiple of 90 is treated as 0 rather than rounded.
149
+ func testUnusableRotationsFallBackToZero() {
150
+ let zero = PdfCanvasPdfToDocTransform(box, 0)
151
+ for rotation in [45, 1, 359, 137] {
152
+ XCTAssertTrue(
153
+ PdfCanvasPdfToDocTransform(box, rotation) == zero,
154
+ "rotation \(rotation) should behave as 0")
155
+ }
156
+ }
157
+
158
+ /// Negatives and out-of-range multiples normalise rather than falling back.
159
+ func testRotationsNormalise() {
160
+ XCTAssertEqual(PdfCanvasNormalizeRotation(-90), 270)
161
+ XCTAssertEqual(PdfCanvasNormalizeRotation(450), 90)
162
+ XCTAssertEqual(PdfCanvasNormalizeRotation(-450), 270)
163
+ XCTAssertEqual(PdfCanvasNormalizeRotation(720), 0)
164
+ XCTAssertEqual(PdfCanvasNormalizeRotation(45), 0)
165
+ XCTAssertTrue(
166
+ PdfCanvasPdfToDocTransform(box, -90) == PdfCanvasPdfToDocTransform(box, 270),
167
+ "-90 should behave as 270")
168
+ }
169
+
170
+ // ---------------------------------------------------------------- //
171
+ // Doc space -> raster space
172
+ // ---------------------------------------------------------------- //
173
+
174
+ /**
175
+ The doc rect's top-left lands at the raster's top-left, and its bottom-right at
176
+ the bottom-right.
177
+
178
+ Stated in CG's own y-up coordinates, because that is what the transform emits:
179
+ the raster's top-left is (0, H).
180
+ */
181
+ func testDocRectMapsOntoTheWholeRaster() {
182
+ let t = PdfCanvasDocToRasterTransform(37, 23, 4, 164)
183
+ assertPoint(
184
+ mapped(CGPoint(x: 37, y: 23), t), CGPoint(x: 0, y: 164), "doc top-left")
185
+ assertPoint(
186
+ mapped(CGPoint(x: 37 + 64, y: 23 + 41), t), CGPoint(x: 256, y: 0),
187
+ "doc bottom-right")
188
+ }
189
+
190
+ /**
191
+ TWO RASTERS AT THE SAME SCALE AGREE WHERE THEY OVERLAP — the property the cache
192
+ and the overlapping detail pieces depend on, and the one the rounded
193
+ `pixelHeight` in the flip looks like it should break.
194
+ *
195
+ The two rects below have heights that round in OPPOSITE directions at scale 1
196
+ (100.4 -> 100, 100.6 -> 101), so a transform that leaked its rounding into
197
+ content placement would put the same doc point on different rows. The check is
198
+ in ROWS FROM THE TOP, which is how a raster is indexed and where the `H` term
199
+ cancels.
200
+ */
201
+ func testRastersAtTheSameScaleAgreeWhereTheyOverlap() {
202
+ let scale = 1.0
203
+ // Rect A: doc y 0 .. 100.4, 100 rows. Rect B: doc y 50 .. 150.6, 101 rows.
204
+ let a = PdfCanvasDocToRasterTransform(0, 0, scale, 100)
205
+ let b = PdfCanvasDocToRasterTransform(0, 50, scale, 101)
206
+
207
+ for docY in stride(from: 50.0, through: 100.0, by: 5.0) {
208
+ let rowFromTopInA = 100 - Double(mapped(CGPoint(x: 0, y: docY), a).y)
209
+ let rowFromTopInB = 101 - Double(mapped(CGPoint(x: 0, y: docY), b).y)
210
+ XCTAssertEqual(
211
+ rowFromTopInA, docY, accuracy: 1e-9,
212
+ "doc y \(docY) should sit at row \(docY) of A")
213
+ XCTAssertEqual(
214
+ rowFromTopInB, docY - 50, accuracy: 1e-9,
215
+ "doc y \(docY) should sit at row \(docY - 50) of B")
216
+ }
217
+ }
218
+
219
+ /**
220
+ Scale is used exactly as requested, never re-derived from the pixel size.
221
+
222
+ A doc rect of 512.4 points at scale 4 is 2050 pixels wide (2049.6 rounded), and
223
+ a re-derived scale would be 2050/512.4 = 4.00078. Over 2050 pixels that is a
224
+ drift of 1.6 px at the far edge relative to a neighbour drawn at exactly 4 —
225
+ which is precisely the misregistration the overlap rule cannot absorb.
226
+ */
227
+ func testScaleIsNotReDerivedFromThePixelSize() {
228
+ let scale = 4.0
229
+ let docWidth = 512.4
230
+ let r = request(width: docWidth, height: 100, scale: scale)
231
+ XCTAssertEqual(r.pixelWidth, 2050, "512.4 * 4 = 2049.6, rounds to 2050")
232
+
233
+ let t = PdfCanvasDocToRasterTransform(0, 0, scale, r.pixelHeight)
234
+ // The far edge lands 0.4 px short of the raster's right edge — that is the
235
+ // half-pixel the comment on `tileMatrix` describes, left showing the fill.
236
+ XCTAssertEqual(
237
+ Double(mapped(CGPoint(x: docWidth, y: 0), t).x), 2049.6, accuracy: 1e-9)
238
+ // A re-derived scale would have landed it at exactly 2050, agreeing with the
239
+ // raster and disagreeing with every neighbour.
240
+ }
241
+
242
+ // ---------------------------------------------------------------- //
243
+ // Doc space -> PDFKit's space
244
+ // ---------------------------------------------------------------- //
245
+
246
+ /**
247
+ PDFKit's transform is a pure flip through the POST-ROTATION page height.
248
+
249
+ Nothing else, because PDFKit applies `/Rotate` and the crop-box origin itself —
250
+ measured, and the asymmetry with `-[PDFPage boundsForBox:]` is the trap this
251
+ whole function exists to keep out of the rasterizer.
252
+ */
253
+ func testPdfKitTransformIsAPureFlip() {
254
+ let t = PdfCanvasDocToPdfKitTransform(612)
255
+ assertPoint(mapped(CGPoint(x: 0, y: 0), t), CGPoint(x: 0, y: 612), "doc origin")
256
+ assertPoint(
257
+ mapped(CGPoint(x: 792, y: 612), t), CGPoint(x: 792, y: 0),
258
+ "doc bottom-right")
259
+ XCTAssertEqual(Double(t.a * t.d - t.b * t.c), -1, accuracy: 1e-9)
260
+ }
261
+
262
+ // ---------------------------------------------------------------- //
263
+ // Pixel sizing
264
+ // ---------------------------------------------------------------- //
265
+
266
+ /**
267
+ `round`, at least 1, matching `planner.ts` and `RasterRequest.java`.
268
+
269
+ The three have to agree exactly: the planner declares the pixel size in the
270
+ `RasterPlanItem` the cache is keyed on, so a backend that used `ceil` would
271
+ return a raster of a different size than the plan promised.
272
+ */
273
+ func testPixelExtentsRoundAndFloorAtOne() {
274
+ XCTAssertEqual(request(width: 100, height: 100, scale: 1).pixelWidth, 100)
275
+ XCTAssertEqual(request(width: 100.4, height: 1, scale: 1).pixelWidth, 100)
276
+ XCTAssertEqual(request(width: 100.5, height: 1, scale: 1).pixelWidth, 101)
277
+ XCTAssertEqual(request(width: 100.6, height: 1, scale: 1).pixelWidth, 101)
278
+ XCTAssertEqual(request(width: 0.1, height: 0.1, scale: 1).pixelWidth, 1)
279
+ XCTAssertEqual(request(width: 0.1, height: 0.1, scale: 1).pixelHeight, 1)
280
+ XCTAssertEqual(request(width: 512.4, height: 1, scale: 4).pixelWidth, 2050)
281
+ XCTAssertEqual(request(width: 512.5, height: 1, scale: 4).pixelWidth, 2050)
282
+ }
283
+ }
package/package.json ADDED
@@ -0,0 +1,144 @@
1
+ {
2
+ "name": "@reekon-tools/react-native-pdf-canvas",
3
+ "version": "0.1.0",
4
+ "description": "PDF pages as Skia images, drawn at exact doc-space rects inside somebody else's transform. Owns no canvas, no gestures, no viewport state.",
5
+ "author": "REEKON Tools",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://gitlab.com/reekon-tools/software/rock-pro/react-native-pdf-canvas.git"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "//files-scripts": "`scripts` ships because the peer-floor check runs from a CONSUMER'S postinstall, before/without any build of `dist`.",
17
+ "//files": "`*.podspec` SHIPS OR iOS IS SILENTLY ABSENT. Autolinking reads the podspec out of the INSTALLED package, so a podspec that is not in this list means `pod install` finds nothing, `PdfCanvas` never appears in Podfile.lock, and `NativeModules.PdfCanvas` is undefined at runtime with no build error anywhere. `ios/Package.swift` and `ios/Tests` ship as part of `ios` and cost a few KB; they are what lets a consumer run the core's host tests against the exact version they installed.",
18
+ "files": [
19
+ "dist",
20
+ "ios",
21
+ "android",
22
+ "scripts",
23
+ "react-native.config.js",
24
+ "PdfCanvas.podspec",
25
+ "README.md"
26
+ ],
27
+ "bin": {
28
+ "pdf-canvas-check-skia": "./scripts/check-skia-peer.mjs"
29
+ },
30
+ "main": "./dist/index.web.js",
31
+ "types": "./dist/index.d.ts",
32
+ "//exports": "`./skia` IS THE ONLY SUBPATH THAT NEEDS A LIVE SKIA RUNTIME, and the root barrel is deliberately free of module-scope Skia so it can be evaluated in Node during rock-desktop's SSR pass — `Skia.web.js` is `export const Skia = JsiSkApi(global.CanvasKit)`, taken ONCE, so reaching it before LoadSkiaWeb resolves breaks it permanently and unrecoverably in-bundle. `src/__tests__/module-ordering.test.ts` bundles the entries below and greps the output, so this is enforced rather than described. `./skia` is platform-split for the same Metro reason `./rasterizer` is (an `exports` condition is matched LITERALLY, with no platform-extension expansion). BOTH of those subpaths put `types` INSIDE each condition branch and carry NO top-level `types` key, and that placement is load-bearing rather than stylistic: TypeScript walks a conditional-exports object IN KEY ORDER and always matches a bare `types`, so a top-level `types` short-circuits the walk and hands EVERY consumer the same declaration file no matter what conditions are configured — a host's `customConditions` cannot rescue it. That is not cosmetic where the two entries genuinely differ: it made `configureWebPdfium` invisible to a browser consumer (TS2305) and dragged React Native's global declarations into a DOM project behind it. `attw` reports 🟢 either way, because it checks that resolution SUCCEEDS and not that it lands on the right platform. The root `.` keeps a single top-level `types` on purpose — its two entries are pure re-exports of the same barrel, so there is only one declaration to point at. `./web-init` is WEB ONLY and has no `react-native` condition on purpose: there is nothing to initialise on native, and a native import should fail loudly rather than resolve to a no-op. `./worker` is the same, and is a bundler ENTRY POINT rather than a module to import for its values — it binds itself to `self` on evaluation, and a host names it once as a worker (`'…/worker?worker'` under Vite, `new Worker(new URL(…), {type: 'module'})` anywhere else). It is the ONLY file in the package that imports `@embedpdf/pdfium`, which is exactly why that peer can be optional: a native-only consumer never resolves this path. NOTE that a `//comment` key may not go INSIDE the exports object — node rejects an exports map whose keys do not all begin with '.'.",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "react-native": "./dist/index.native.js",
37
+ "browser": "./dist/index.web.js",
38
+ "default": "./dist/index.web.js"
39
+ },
40
+ "./rasterizer": {
41
+ "react-native": {
42
+ "types": "./dist/rasterizer/index.native.d.ts",
43
+ "default": "./dist/rasterizer/index.native.js"
44
+ },
45
+ "browser": {
46
+ "types": "./dist/rasterizer/index.web.d.ts",
47
+ "default": "./dist/rasterizer/index.web.js"
48
+ },
49
+ "default": {
50
+ "types": "./dist/rasterizer/index.web.d.ts",
51
+ "default": "./dist/rasterizer/index.web.js"
52
+ }
53
+ },
54
+ "./skia": {
55
+ "react-native": {
56
+ "types": "./dist/skia/index.native.d.ts",
57
+ "default": "./dist/skia/index.native.js"
58
+ },
59
+ "browser": {
60
+ "types": "./dist/skia/index.web.d.ts",
61
+ "default": "./dist/skia/index.web.js"
62
+ },
63
+ "default": {
64
+ "types": "./dist/skia/index.web.d.ts",
65
+ "default": "./dist/skia/index.web.js"
66
+ }
67
+ },
68
+ "./web-init": {
69
+ "types": "./dist/web-init.d.ts",
70
+ "default": "./dist/web-init.js"
71
+ },
72
+ "./worker": {
73
+ "types": "./dist/rasterizer/web/worker.d.ts",
74
+ "default": "./dist/rasterizer/web/worker.js"
75
+ },
76
+ "./testing": {
77
+ "types": "./dist/testing/index.d.ts",
78
+ "default": "./dist/testing/index.js"
79
+ },
80
+ "./package.json": "./package.json"
81
+ },
82
+ "scripts": {
83
+ "build": "tsc",
84
+ "dev": "tsc --watch --preserveWatchOutput",
85
+ "prepare": "tsc",
86
+ "prepack": "yarn run build",
87
+ "typecheck": "tsc --noEmit",
88
+ "test": "vitest",
89
+ "test:run": "vitest run",
90
+ "format": "prettier --write .",
91
+ "check-format": "prettier --check .",
92
+ "check-exports": "attw --pack . --profile esm-only",
93
+ "check-peers": "node scripts/check-skia-peer.mjs",
94
+ "ci": "yarn run check-format && yarn run typecheck && vitest run && yarn run build"
95
+ },
96
+ "keywords": [
97
+ "react-native",
98
+ "pdf",
99
+ "skia",
100
+ "reanimated"
101
+ ],
102
+ "//peerDependencies": "THE SKIA RANGE IS THE SINGLE SOURCE OF TRUTH FOR THE FLOOR — scripts/skia-peer-check.mjs and src/peer-check.ts both parse it out of here rather than restating 2.8.0, so a bump is this one line. Keep it a bare '>=x.y.z': the checker refuses to guess at a union or a caret and degrades to a warning. 2.8.0 and not 2.11.0 because 2.8.0 is the exact release carrying the RN Web reconciler dispose fix (verified: Container.web.js goes 1 -> 6 dispose calls and Recorder/FrameScope.js first appears AT 2.8.0, not 2.7.0), and the entire web draw path this package touches — Container.web.js, Reconciler.js, HostConfig.js, FrameScope.js, web JsiSkDataFactory, the MakeImage body of web JsiSkImageFactory, and the ColorType/AlphaType enums — is BYTE-IDENTICAL from 2.8.0 through 2.11.0, with canvaskit-wasm pinned 0.41.0 across the whole range. Nothing after 2.8.0 is required. The floor is a memory-correctness claim, not a compile one: the full suite passes against 2.6.2 and 2.8.0 alike. BOLDR now pins 2.8.0 and satisfies it (it pinned 2.6.2 and did not, until the bump) — see 'Why the skia peer floor is >=2.8.0' in README.md for how the floor is enforced given that yarn 1's link: protocol evaluates neither a linked package's peers nor its lifecycle scripts.",
103
+ "//embedpdf-peer": "OPTIONAL, AND WEB ONLY. `@embedpdf/pdfium` is imported by exactly one file — `src/rasterizer/web/worker.ts`, reached through the `./worker` subpath — so a consumer who ships native never resolves it and must not be made to install a 4.6 MB wasm package. It is a PEER rather than a dependency for the reason the note above gives: the package must stay dependency-free, and yarn 1 installs none of a `link:` dependency's deps anyway. The range is `>=2.15.0` because that is the version every measurement in `src/rasterizer/web/` was taken against, and because `EPDF_GetPageRotationByIndex` — how `pageGeometry` reads `/Rotate` without loading a page — is an @embedpdf extension rather than stock PDFium. The wasm and the JS bindings must come from the SAME version, which is why the host supplies the locator (see `PdfiumWasmSource`).",
104
+ "peerDependencies": {
105
+ "@embedpdf/pdfium": ">=2.15.0",
106
+ "@shopify/react-native-skia": ">=2.8.0",
107
+ "react": ">=19.0.0",
108
+ "react-native": ">=0.78.0",
109
+ "react-native-reanimated": ">=3.19.1"
110
+ },
111
+ "peerDependenciesMeta": {
112
+ "@embedpdf/pdfium": {
113
+ "optional": true
114
+ },
115
+ "react-native": {
116
+ "optional": true
117
+ },
118
+ "react-native-reanimated": {
119
+ "optional": true
120
+ }
121
+ },
122
+ "//devDependencies": "`vite` is DECLARED even though vitest already brings it: `src/__tests__/module-ordering.test.ts` imports its `build` API directly to bundle each entry point and inspect the module graph, and a test that depends on a transitive dep is one hoisting change away from breaking. The four versions the app ships are EXACT, not carets. A caret is what caused the skew this alignment fixed: '^2.6.2' had drifted to 2.11.0 while BOLDR ran 2.6.2, so 171 tests — every CanvasKit pixel test among them — had been validating a version the app does not ship. A library validated against a floating version is not validated. `semver` is here and NOT in `dependencies` on purpose: the package must stay dependency-free (yarn 1 installs none of a `link:` dependency's deps anyway, so declaring it would buy nothing for BOLDR), so the peer check carries its own comparator and uses semver only when a tree happens to expose one. semver's real job here is to be the REFEREE in peer-check.test.ts, which pins that comparator and the runtime one to its answers.",
123
+ "devDependencies": {
124
+ "@arethetypeswrong/cli": "^0.18.1",
125
+ "@embedpdf/pdfium": "2.15.0",
126
+ "@shopify/react-native-skia": "2.11.0",
127
+ "@types/emscripten": "^1.41.5",
128
+ "@types/node": "20.19.43",
129
+ "@types/react": "19.2.17",
130
+ "prettier": "^3.5.3",
131
+ "react": "19.2.3",
132
+ "react-native": "0.86.2",
133
+ "react-native-reanimated": "4.5.3",
134
+ "semver": "^7.7.4",
135
+ "typescript": "^5.8.3",
136
+ "vite": "^7.3.6",
137
+ "vitest": "^3.1.3"
138
+ },
139
+ "//engines": "Copied from BOLDR's own engines field rather than invented. @types/node tracks the LOWEST supported major (20.x), not the v24 this machine runs, so tsc rejects a Node 22+/24-only API that the declared floor could not provide.",
140
+ "engines": {
141
+ "node": "^20.19.4 || ^22.13.0 || >=24"
142
+ },
143
+ "packageManager": "yarn@1.22.22"
144
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Autolinking metadata for the React Native CLI.
3
+ *
4
+ * BOTH PLATFORMS NEED AN EXPLICIT ENTRY, for two different reasons.
5
+ *
6
+ * ANDROID: `packageImportPath` / `packageInstance` are not optional here, because
7
+ * of the source-set split. Autolinking discovers a ReactPackage by scanning
8
+ * `android/src/main/java`, and this package's binding deliberately lives in
9
+ * `android/src/reactnative/java` so that `src/main` can be built and tested with no
10
+ * React Native on the classpath at all. Without these two lines the module compiles
11
+ * into the app and is never registered, and every `open()` fails with "the Android
12
+ * PdfCanvas native module was not found".
13
+ *
14
+ * iOS: `podspecPath` is named explicitly because the podspec is `PdfCanvas.podspec`
15
+ * rather than a file named after the npm package. Autolinking's default search
16
+ * takes the first `*.podspec` at the package root, which happens to work today —
17
+ * there is exactly one — but "happens to work because there is only one candidate"
18
+ * is the kind of thing that stops being true the moment somebody adds a second, and
19
+ * the failure is silent: the pod is simply absent from `Podfile.lock` and
20
+ * `NativeModules.PdfCanvas` is undefined at runtime.
21
+ *
22
+ * `ios` was `null` until the CoreGraphics/PDFKit backend landed. If it ever needs
23
+ * to be null again, the message in `src/rasterizer/index.ts` is what a host sees.
24
+ */
25
+ const path = require('path');
26
+
27
+ module.exports = {
28
+ dependency: {
29
+ platforms: {
30
+ android: {
31
+ sourceDir: 'android',
32
+ packageImportPath: 'import tools.reekon.pdfcanvas.rn.PdfCanvasPackage;',
33
+ packageInstance: 'new PdfCanvasPackage()',
34
+ },
35
+ ios: {
36
+ podspecPath: path.join(__dirname, 'PdfCanvas.podspec'),
37
+ },
38
+ },
39
+ },
40
+ };