@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,179 +0,0 @@
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
- }
@@ -1,283 +0,0 @@
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
- }