@reekon-tools/react-native-pdf-canvas 0.2.0 → 0.3.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.
package/PdfCanvas.podspec CHANGED
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
 
3
3
  package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+ pdfium = JSON.parse(File.read(File.join(__dir__, "scripts", "pdfium-manifest.json")))
4
5
 
5
6
  # FETCH PDFIUM WHILE THE PODSPEC IS EVALUATED.
6
7
  #
@@ -50,10 +51,17 @@ Pod::Spec.new do |s|
50
51
  s.authors = { "REEKON Tools" => "hello@reekon.tools" }
51
52
  s.source = { :git => "https://gitlab.com/reekon-tools/software/rock-pro/react-native-pdf-canvas.git", :tag => "#{s.version}" }
52
53
 
53
- # 13.0 is the floor of the PDFium dylib bblanchon ships (MinimumOSVersion in
54
- # the framework's Info.plist is written to match). Nothing in this pod needs
55
- # anything newer.
56
- s.platforms = { :ios => "13.0" }
54
+ # THE FLOOR IS THE ENGINE'S, NOT OURS. Nothing this pod compiles needs anything
55
+ # newer than iOS 13, but the vendored PDFium dylib is built for whatever
56
+ # `ios_deployment_target` bblanchon used (17.0 since 2026-08-14), and that is
57
+ # what dyld enforces on the device and what App Store processing checks
58
+ # against the framework's Info.plist (ITMS-90208). The value is declared once
59
+ # in scripts/pdfium-manifest.json, verified against every slice's Mach-O by
60
+ # fetch-pdfium.mjs above, and written into the framework plist from the same
61
+ # field — so it is read here rather than restated. Declaring it as the pod's
62
+ # platform makes a host with a lower deployment target fail at `pod install`
63
+ # ("PdfCanvas does not support iOS 16.4") instead of at upload.
64
+ s.platforms = { :ios => pdfium["iosMinimumOSVersion"] }
57
65
  s.requires_arc = true
58
66
 
59
67
  # THE CORE IS C++ AND SHARED WITH ANDROID: `native/core` is compiled into this
package/README.md CHANGED
@@ -80,6 +80,11 @@ during CocoaPods podspec evaluation; Android retrieves it from a Gradle task. Ai
80
80
  may direct `PDFCANVAS_PDFIUM_CACHE` or `PDFCANVAS_PDFIUM_MIRROR` at a local copy, which is still
81
81
  verified against the manifest.
82
82
 
83
+ The prebuilt engine sets the native floors: **iOS 17.0** (`iosMinimumOSVersion` in the manifest,
84
+ declared by the pod and checked against the dylib at fetch time) and **Android API 24**. A host app
85
+ with a lower iOS deployment target fails at `pod install`; raise the target rather than the
86
+ framework's plist, since the dylib will not load on older systems either.
87
+
83
88
  ## Installation
84
89
 
85
90
  ```sh
@@ -245,6 +250,23 @@ usePdfLayer({document, policy: {sharpnessBand: 1.2, quietZoomMs: 400}});
245
250
  observed p95 raster time. A value set too low permits a user adjusting the zoom to supersede
246
251
  each render before it completes.
247
252
 
253
+ ### Page rotation
254
+
255
+ A host may show a page turned a quarter, half or three-quarter turn clockwise on top of the
256
+ document's own `/Rotate`, per page:
257
+
258
+ ```ts
259
+ // Page 0 shown turned 90° clockwise; pages without an entry stay upright.
260
+ usePdfLayer({document, rotations: [90]});
261
+ // A matching thumbnail. `widthPx` is the width of the TURNED raster.
262
+ document.renderPage(0, {widthPx: 160, rotation: 90});
263
+ ```
264
+
265
+ Doc space for a turned page is the turned page's: a 612x792 page at 90 is laid out as 792x612,
266
+ `content.pages[i].pageRect` reports that rect, and anything the host draws over the page is
267
+ positioned in the same space. Changing a page's rotation rebuilds the layer's raster cache,
268
+ exactly as changing `annotations` does.
269
+
248
270
  ## Platform support
249
271
 
250
272
  | Platform | Engine | Backend identifier |
@@ -102,7 +102,7 @@ public class PdfCanvasNativeTest {
102
102
 
103
103
  long[] shape = new long[4];
104
104
  byte[] bytes = PdfCanvasNative.render(
105
- service, handle, 0, 0, 0, 200, 100, 2, false, false, 1, false, shape);
105
+ service, handle, 0, 0, 0, 200, 100, 2, 0, false, false, 1, false, shape);
106
106
  assertNotNull("a heap render returns bytes", bytes);
107
107
  assertEquals(0L, shape[0]);
108
108
  assertEquals(400L, shape[1]);
@@ -122,14 +122,38 @@ public class PdfCanvasNativeTest {
122
122
  public void annotationsFlagReachesTheCore() throws Exception {
123
123
  int handle = open(TestPdfs.annotated(ctx(), "jni-annotated.pdf"));
124
124
  long[] shape = new long[4];
125
- byte[] with = PdfCanvasNative.render(service, handle, 0, 0, 0, 200, 200, 1, true, false, 1, false, shape);
126
- byte[] without = PdfCanvasNative.render(service, handle, 0, 0, 0, 200, 200, 1, false, false, 2, false, shape);
125
+ byte[] with = PdfCanvasNative.render(service, handle, 0, 0, 0, 200, 200, 1, 0, true, false, 1, false, shape);
126
+ byte[] without = PdfCanvasNative.render(service, handle, 0, 0, 0, 200, 200, 1, 0, false, false, 2, false, shape);
127
127
  int rowBytes = (int) shape[3];
128
128
  assertPixel(with, rowBytes, 100, 100, 0, 255, 0);
129
129
  assertPixel(without, rowBytes, 100, 100, 255, 255, 255);
130
130
  PdfCanvasNative.close(service, handle);
131
131
  }
132
132
 
133
+ /**
134
+ * CATCHES: the rotation not surviving the JNI int hop, or landing in the
135
+ * wrong argument slot (a swapped {@code annotations} would still compile).
136
+ * Doc top-left is blue and bottom-left red; a quarter turn clockwise puts
137
+ * the bottom-left at the top-left and the top-left at the top-right. The
138
+ * rect is the TURNED page's: 100x200 for a 200x100 page.
139
+ */
140
+ @Test
141
+ public void rotationReachesTheCore() throws Exception {
142
+ int handle = open(TestPdfs.quadrants(ctx(), "jni-rotated.pdf", 200, 100));
143
+ long[] shape = new long[4];
144
+ byte[] bytes =
145
+ PdfCanvasNative.render(service, handle, 0, 0, 0, 100, 200, 2, 90, false, false, 1, false, shape);
146
+ assertNotNull(bytes);
147
+ assertEquals(200L, shape[1]);
148
+ assertEquals(400L, shape[2]);
149
+ int rowBytes = (int) shape[3];
150
+ assertPixel(bytes, rowBytes, 20, 20, 255, 0, 0); // red: doc bottom-left, now top-left
151
+ assertPixel(bytes, rowBytes, 180, 20, 0, 0, 255); // blue: doc top-left, now top-right
152
+ assertPixel(bytes, rowBytes, 180, 380, 0, 0, 0); // black: doc top-right, now bottom-right
153
+ assertPixel(bytes, rowBytes, 20, 380, 0, 255, 0); // green: doc bottom-right, now bottom-left
154
+ PdfCanvasNative.close(service, handle);
155
+ }
156
+
133
157
  /* ================================================================ *
134
158
  * 2. The transport
135
159
  * ================================================================ */
@@ -144,11 +168,11 @@ public class PdfCanvasNativeTest {
144
168
  public void slotBytesEqualHeapBytesForARealRaster() throws Exception {
145
169
  int handle = open(TestPdfs.quadrants(ctx(), "jni-slot.pdf", 300, 200));
146
170
  long[] heapShape = new long[4];
147
- byte[] heap = PdfCanvasNative.render(service, handle, 0, 37, 23, 64, 41, 4, false, false, 1, false, heapShape);
171
+ byte[] heap = PdfCanvasNative.render(service, handle, 0, 37, 23, 64, 41, 4, 0, false, false, 1, false, heapShape);
148
172
  assertNotNull(heap);
149
173
 
150
174
  long[] slotShape = new long[4];
151
- byte[] none = PdfCanvasNative.render(service, handle, 0, 37, 23, 64, 41, 4, false, false, 2, true, slotShape);
175
+ byte[] none = PdfCanvasNative.render(service, handle, 0, 37, 23, 64, 41, 4, 0, false, false, 2, true, slotShape);
152
176
  assertNull("a slot render returns no bytes", none);
153
177
  assertTrue("a slot render names a slot", slotShape[0] > 0);
154
178
  assertEquals(heapShape[1], slotShape[1]);
@@ -215,7 +239,7 @@ public class PdfCanvasNativeTest {
215
239
  }
216
240
  int handle = open(TestPdfs.quadrants(ctx(), "jni-range.pdf", 40, 40));
217
241
  try {
218
- PdfCanvasNative.render(service, handle, 7, 0, 0, 10, 10, 1, false, false, 1, false, new long[4]);
242
+ PdfCanvasNative.render(service, handle, 7, 0, 0, 10, 10, 1, 0, false, false, 1, false, new long[4]);
219
243
  fail("an out-of-range page must throw");
220
244
  } catch (PdfRasterException e) {
221
245
  assertEquals(PdfErrorCode.NOT_FOUND, e.code());
@@ -228,24 +252,24 @@ public class PdfCanvasNativeTest {
228
252
  PdfCanvasNative.registerCancellation(service, handle, 5);
229
253
  PdfCanvasNative.cancel(service, handle, 5);
230
254
  try {
231
- PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, false, false, 5, false, new long[4]);
255
+ PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, 0, false, false, 5, false, new long[4]);
232
256
  fail("a cancelled render must throw");
233
257
  } catch (PdfRasterException e) {
234
258
  assertEquals(PdfErrorCode.CANCELLED, e.code());
235
259
  }
236
260
  // The signal was consumed: the same token renders fine afterwards.
237
- assertNotNull(PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, false, false, 5, false, new long[4]));
261
+ assertNotNull(PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, 0, false, false, 5, false, new long[4]));
238
262
  }
239
263
 
240
264
  @Test
241
265
  public void invalidateClosesEverythingAndRefusesAfterwards() throws Exception {
242
266
  int handle = open(TestPdfs.quadrants(ctx(), "jni-invalidate.pdf", 40, 40));
243
- PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, false, false, 1, true, new long[4]);
267
+ PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, 0, false, false, 1, true, new long[4]);
244
268
  assertEquals(1, PdfCanvasNative.slotCount());
245
269
  PdfCanvasNative.invalidateService(service);
246
270
  assertEquals("invalidate drains parked slots", 0, PdfCanvasNative.slotCount());
247
271
  try {
248
- PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, false, false, 2, false, new long[4]);
272
+ PdfCanvasNative.render(service, handle, 0, 0, 0, 40, 40, 1, 0, false, false, 2, false, new long[4]);
249
273
  fail("a render after invalidate must throw");
250
274
  } catch (PdfRasterException e) {
251
275
  assertEquals(PdfErrorCode.BACKEND_FAILURE, e.code());
@@ -172,8 +172,8 @@ JNIEXPORT void JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_registerCance
172
172
 
173
173
  JNIEXPORT jbyteArray JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_render(
174
174
  JNIEnv* env, jclass, jlong service, jint handle, jint page, jdouble x, jdouble y, jdouble width,
175
- jdouble height, jdouble scale, jboolean annotations, jboolean transparent, jlong token,
176
- jboolean useSlot, jlongArray out) {
175
+ jdouble height, jdouble scale, jint rotation, jboolean annotations, jboolean transparent,
176
+ jlong token, jboolean useSlot, jlongArray out) {
177
177
  return guarded<jbyteArray>(env, nullptr, [&]() -> jbyteArray {
178
178
  Service* s = serviceOf(service);
179
179
  if (s == nullptr) throw PdfError(ErrorCode::BackendFailure, "The native service is gone.");
@@ -188,6 +188,8 @@ JNIEXPORT jbyteArray JNICALL Java_tools_reekon_pdfcanvas_PdfCanvasNative_render(
188
188
  request.width = width;
189
189
  request.height = height;
190
190
  request.scale = scale;
191
+ // Degrees clockwise on top of the page's /Rotate; the core validates it.
192
+ request.rotation = rotation;
191
193
  request.annotations = annotations == JNI_TRUE;
192
194
  request.background = transparent == JNI_TRUE ? pdfcanvas::Background::Transparent
193
195
  : pdfcanvas::Background::White;
@@ -107,6 +107,10 @@ public final class PdfCanvasNative {
107
107
  /**
108
108
  * Renders one raster on the calling thread.
109
109
  *
110
+ * @param rotation the HOST's rotation on top of the page's {@code /Rotate},
111
+ * in degrees clockwise: 0, 90, 180 or 270. The rect is in the TURNED
112
+ * page's doc space — extents swapped for a quarter turn. The core refuses
113
+ * anything else.
110
114
  * @param useSlot ask for the zero-copy transport. The store may still refuse,
111
115
  * so the result says where the pixels ARE: a non-null return is heap
112
116
  * bytes, a null return means {@code out[0]} names a slot.
@@ -122,6 +126,7 @@ public final class PdfCanvasNative {
122
126
  double width,
123
127
  double height,
124
128
  double scale,
129
+ int rotation,
125
130
  boolean annotations,
126
131
  boolean transparent,
127
132
  long token,
@@ -294,7 +294,7 @@ public class PdfCanvasModule extends ReactContextBaseJavaModule {
294
294
  return;
295
295
  }
296
296
  // The whole request is turned into plain values HERE, on the bridge thread.
297
- final int page;
297
+ final int page, rotation;
298
298
  final double x, y, width, height, scale;
299
299
  final boolean annotations, transparent;
300
300
  try {
@@ -304,6 +304,13 @@ public class PdfCanvasModule extends ReactContextBaseJavaModule {
304
304
  width = request.getDouble("width");
305
305
  height = request.getDouble("height");
306
306
  scale = request.getDouble("scale");
307
+ // OPTIONAL, DEFAULTING TO 0: a JS bundle older than this binary never
308
+ // sends it, and a page must not turn because a key is missing. The core
309
+ // validates the value; this only decides what "absent" means.
310
+ rotation =
311
+ request.hasKey("rotation") && !request.isNull("rotation")
312
+ ? request.getInt("rotation")
313
+ : 0;
307
314
  annotations = !request.hasKey("annotations") || request.getBoolean("annotations");
308
315
  transparent = "transparent".equals(request.getString("background"));
309
316
  } catch (RuntimeException e) {
@@ -329,8 +336,8 @@ public class PdfCanvasModule extends ReactContextBaseJavaModule {
329
336
  final long[] shape = new long[4];
330
337
  final byte[] bytes =
331
338
  PdfCanvasNative.render(
332
- service, handle, page, x, y, width, height, scale, annotations, transparent,
333
- token, fast, shape);
339
+ service, handle, page, x, y, width, height, scale, rotation, annotations,
340
+ transparent, token, fast, shape);
334
341
  slot = shape[0];
335
342
 
336
343
  WritableMap result = Arguments.createMap();
@@ -37,7 +37,7 @@
37
37
  * which key comes from `./planner.js`. A private copy of either is how the two
38
38
  * silently drift until the tested one is testing nothing that runs.
39
39
  */
40
- import type { DocRect, PageGeometry, PdfContent, PdfController, PdfDiagnostic, PdfRaster, RasterizerHandle, RasterPixels, RasterPolicy, RasterRole } from './types.js';
40
+ import type { DocRect, PageGeometry, PageRotation, PdfContent, PdfController, PdfDiagnostic, PdfRaster, RasterizerHandle, RasterPixels, RasterPolicy, RasterRole } from './types.js';
41
41
  import type { RasterCache } from './cache.js';
42
42
  export interface ScheduledHandle {
43
43
  cancel(): void;
@@ -62,6 +62,28 @@ export interface PdfControllerOptions {
62
62
  pageRects: DocRect[];
63
63
  /** Parallel to `pageRects`. Every page, in page order. */
64
64
  pages: PageGeometry[];
65
+ /**
66
+ * The HOST's per-page rotation on top of the document's `/Rotate`, indexed
67
+ * by page like `pageRects`; a missing or undefined entry is 0. Stamped onto
68
+ * every `RasterRequest` for that page as `rotation` — and only that. The
69
+ * controller does no rotation arithmetic of its own.
70
+ *
71
+ * WHY A PARALLEL ARRAY AND NOT A FIELD ON `PageGeometry`. `PageGeometry` is
72
+ * the backend's statement of a page's intrinsic size, and its `rotation` is
73
+ * already taken: it is the document's own `/Rotate`. A host preference on
74
+ * the same object would give the type two rotations with different meanings,
75
+ * and would ride into every backend that constructs one (the fake, the web
76
+ * engine, the native bridge) for a value none of them produce. This
77
+ * controller already takes its per-page inputs as parallel arrays, so a
78
+ * third costs nothing new and touches nothing outside this file.
79
+ *
80
+ * THE GEOMETRY MUST ALREADY BE ROTATED. For a page turned 90 or 270, `pages`
81
+ * and `pageRects` must both carry the SWAPPED extents — see `../rotation.ts`
82
+ * — because `layoutScale` is `pageRect.width / geometry.width` and is only
83
+ * right when both sides describe the same orientation. `usePdfLayer` does
84
+ * the swap; a host driving this directly must too.
85
+ */
86
+ rotations?: readonly PageRotation[];
65
87
  policy: RasterPolicy;
66
88
  ingest: (pixels: RasterPixels, meta: RasterIngestMeta) => PdfRaster;
67
89
  cache: RasterCache;
@@ -137,6 +137,7 @@ export function createPdfController(options) {
137
137
  page,
138
138
  pageRect,
139
139
  layoutScale: Number.isFinite(raw) && raw > 0 ? raw : 1,
140
+ rotation: options.rotations?.[page] ?? 0,
140
141
  };
141
142
  });
142
143
  const listeners = new Set();
@@ -405,24 +406,35 @@ export function createPdfController(options) {
405
406
  };
406
407
  const renderOne = async (item, slot, info) => {
407
408
  const startedAt = now();
409
+ const request = {
410
+ page: item.page,
411
+ // Page-LOCAL rect: the planner works in LAYOUT space, but a backend
412
+ // renders a page and knows nothing about where the layout put it.
413
+ // For a page the host has turned this is the ROTATED page's space:
414
+ // `pageRects` and `pages` both carry the swapped extents, which is
415
+ // what keeps `layoutScale` at 1 and lets the backend slide the whole
416
+ // turned page exactly as it slides an upright one.
417
+ docRect: {
418
+ x: (item.docRect.x - info.pageRect.x) / info.layoutScale,
419
+ y: (item.docRect.y - info.pageRect.y) / info.layoutScale,
420
+ width: item.docRect.width / info.layoutScale,
421
+ height: item.docRect.height / info.layoutScale,
422
+ },
423
+ scale: item.scale * info.layoutScale,
424
+ // See `PdfControllerOptions.annotations`: true unless the host
425
+ // draws the PDF's annotations itself.
426
+ annotations: drawAnnotations,
427
+ background: 'white',
428
+ };
429
+ // STAMPED ONLY WHEN IT IS NOT THE DEFAULT. A host that never rotates
430
+ // hands its backend the exact request it always did, key for key — a
431
+ // host-injected backend that compares requests structurally sees no
432
+ // change at all — and every shipped backend reads `rotation ?? 0`.
433
+ if (info.rotation !== 0)
434
+ request.rotation = info.rotation;
408
435
  let pixels;
409
436
  try {
410
- pixels = await handle.render({
411
- page: item.page,
412
- // Page-LOCAL rect: the planner works in LAYOUT space, but a backend
413
- // renders a page and knows nothing about where the layout put it.
414
- docRect: {
415
- x: (item.docRect.x - info.pageRect.x) / info.layoutScale,
416
- y: (item.docRect.y - info.pageRect.y) / info.layoutScale,
417
- width: item.docRect.width / info.layoutScale,
418
- height: item.docRect.height / info.layoutScale,
419
- },
420
- scale: item.scale * info.layoutScale,
421
- // See `PdfControllerOptions.annotations`: true unless the host
422
- // draws the PDF's annotations itself.
423
- annotations: drawAnnotations,
424
- background: 'white',
425
- }, state.abort.signal);
437
+ pixels = await handle.render(request, state.abort.signal);
426
438
  }
427
439
  catch (error) {
428
440
  missing.push(item.key);
@@ -14,8 +14,14 @@
14
14
  * checks and an accidental clip is visible as a blank margin;
15
15
  * - each piece carries a 1px border, so a piece that has been inset or
16
16
  * stretched shows its border in the wrong place;
17
- * - each piece encodes its own doc rect into a corner swatch, so a test can
18
- * read back what a raster BELIEVES it is without any Skia in the loop.
17
+ * - each piece encodes its own doc rect AND its rotation into a corner
18
+ * swatch, so a test can read back what a raster BELIEVES it is without any
19
+ * Skia in the loop;
20
+ * - the checker is drawn in the PAGE's own space and turned by the request's
21
+ * `rotation`, the way a real engine turns its page — so a raster at 90 is
22
+ * the raster at 0 turned a quarter clockwise, pixel for pixel outside the
23
+ * border and swatch, and a pipeline that rotated its geometry but forgot
24
+ * to stamp the request shows an unturned board in a turned frame.
19
25
  *
20
26
  * Everything here is a pure function of the request, so the same request always
21
27
  * produces byte-identical output.
@@ -89,4 +95,10 @@ export declare function fakePageColor(page: number): [number, number, number];
89
95
  * was too small to carry a swatch.
90
96
  */
91
97
  export declare function decodeFakeDocRect(pixels: RasterPixels): DocRect | null;
98
+ /**
99
+ * Read the rotation a fake raster was rendered at, or null when the raster was
100
+ * too small to carry a swatch. What a pipeline test reads to prove the
101
+ * controller's stamp reached the backend.
102
+ */
103
+ export declare function decodeFakeRotation(pixels: RasterPixels): PageRotation | null;
92
104
  export declare function createFakeRasterizer(options?: FakeRasterizerOptions): PageRasterizer;
@@ -14,12 +14,19 @@
14
14
  * checks and an accidental clip is visible as a blank margin;
15
15
  * - each piece carries a 1px border, so a piece that has been inset or
16
16
  * stretched shows its border in the wrong place;
17
- * - each piece encodes its own doc rect into a corner swatch, so a test can
18
- * read back what a raster BELIEVES it is without any Skia in the loop.
17
+ * - each piece encodes its own doc rect AND its rotation into a corner
18
+ * swatch, so a test can read back what a raster BELIEVES it is without any
19
+ * Skia in the loop;
20
+ * - the checker is drawn in the PAGE's own space and turned by the request's
21
+ * `rotation`, the way a real engine turns its page — so a raster at 90 is
22
+ * the raster at 0 turned a quarter clockwise, pixel for pixel outside the
23
+ * border and swatch, and a pipeline that rotated its geometry but forgot
24
+ * to stamp the request shows an unturned board in a turned frame.
19
25
  *
20
26
  * Everything here is a pure function of the request, so the same request always
21
27
  * produces byte-identical output.
22
28
  */
29
+ import { isPageRotation } from '../rotation.js';
23
30
  import { PdfError } from '../types.js';
24
31
  /* ------------------------------------------------------------------ *
25
32
  * Page sizes, in PDF points (1pt = 1/72in)
@@ -171,9 +178,10 @@ function writePixel(out, offset, r, g, b, a, format) {
171
178
  * The corner swatch
172
179
  * ------------------------------------------------------------------ */
173
180
  /**
174
- * The swatch is RAW BYTES, not a colour: four pixels, each carrying one doc
175
- * rect component as a 24-bit fixed-point value in channels 0..2 with channel 3
176
- * held at 255. Because it is written without the format swizzle, the decoder is
181
+ * The swatch is RAW BYTES, not a colour: five pixels, each carrying one value
182
+ * as a 24-bit fixed-point number in channels 0..2 with channel 3 held at 255 —
183
+ * the four doc rect components, then the request's rotation in degrees.
184
+ * Because it is written without the format swizzle, the decoder is
177
185
  * format-independent — it reads back the same numbers from an RGBA and a BGRA
178
186
  * raster.
179
187
  */
@@ -182,7 +190,9 @@ const SWATCH_FIXED_POINT = 16;
182
190
  const SWATCH_BIAS = 0x800000;
183
191
  const SWATCH_ORIGIN_X = 1;
184
192
  const SWATCH_ROW = 1;
185
- const SWATCH_PIXELS = 4;
193
+ const SWATCH_PIXELS = 5;
194
+ /** Index of the rotation value within the swatch, after x, y, width, height. */
195
+ const SWATCH_ROTATION = 4;
186
196
  /** Smallest raster that has room for the swatch inside its border. */
187
197
  const SWATCH_MIN_WIDTH = SWATCH_ORIGIN_X + SWATCH_PIXELS + 1;
188
198
  const SWATCH_MIN_HEIGHT = SWATCH_ROW + 2;
@@ -194,7 +204,7 @@ function encodeSwatchValue(out, offset, value) {
194
204
  out[offset + 2] = clamped & 0xff;
195
205
  out[offset + 3] = 255;
196
206
  }
197
- function encodeSwatch(out, rowBytes, width, height, docRect) {
207
+ function encodeSwatch(out, rowBytes, width, height, docRect, rotation) {
198
208
  if (width < SWATCH_MIN_WIDTH || height < SWATCH_MIN_HEIGHT) {
199
209
  return;
200
210
  }
@@ -203,39 +213,81 @@ function encodeSwatch(out, rowBytes, width, height, docRect) {
203
213
  encodeSwatchValue(out, base + BYTES_PER_PIXEL, docRect.y);
204
214
  encodeSwatchValue(out, base + BYTES_PER_PIXEL * 2, docRect.width);
205
215
  encodeSwatchValue(out, base + BYTES_PER_PIXEL * 3, docRect.height);
216
+ encodeSwatchValue(out, base + BYTES_PER_PIXEL * SWATCH_ROTATION, rotation);
217
+ }
218
+ /** One swatch value back out, or null when the raster is too small to carry a
219
+ * swatch at all. `component` is the pixel index within the swatch. */
220
+ function decodeSwatchValue(pixels, component) {
221
+ const { bytes, width, height, rowBytes } = pixels;
222
+ if (width < SWATCH_MIN_WIDTH || height < SWATCH_MIN_HEIGHT) {
223
+ return null;
224
+ }
225
+ const o = SWATCH_ROW * rowBytes + (SWATCH_ORIGIN_X + component) * BYTES_PER_PIXEL;
226
+ // In range by the size guard above; `?? 0` satisfies
227
+ // noUncheckedIndexedAccess on TypedArray indexing.
228
+ const raw = ((bytes[o] ?? 0) << 16) | ((bytes[o + 1] ?? 0) << 8) | (bytes[o + 2] ?? 0);
229
+ return (raw - SWATCH_BIAS) / SWATCH_FIXED_POINT;
206
230
  }
207
231
  /**
208
232
  * Read the doc rect a fake raster believes it covers, or null when the raster
209
233
  * was too small to carry a swatch.
210
234
  */
211
235
  export function decodeFakeDocRect(pixels) {
212
- const { bytes, width, height, rowBytes } = pixels;
213
- if (width < SWATCH_MIN_WIDTH || height < SWATCH_MIN_HEIGHT) {
236
+ const x = decodeSwatchValue(pixels, 0);
237
+ const y = decodeSwatchValue(pixels, 1);
238
+ const width = decodeSwatchValue(pixels, 2);
239
+ const height = decodeSwatchValue(pixels, 3);
240
+ if (x === null || y === null || width === null || height === null) {
214
241
  return null;
215
242
  }
216
- const base = SWATCH_ROW * rowBytes + SWATCH_ORIGIN_X * BYTES_PER_PIXEL;
217
- const read = (component) => {
218
- const o = base + component * BYTES_PER_PIXEL;
219
- // In range by the size guard above; `?? 0` satisfies
220
- // noUncheckedIndexedAccess on TypedArray indexing.
221
- const raw = ((bytes[o] ?? 0) << 16) |
222
- ((bytes[o + 1] ?? 0) << 8) |
223
- (bytes[o + 2] ?? 0);
224
- return (raw - SWATCH_BIAS) / SWATCH_FIXED_POINT;
225
- };
226
- return {
227
- x: read(0),
228
- y: read(1),
229
- width: read(2),
230
- height: read(3),
231
- };
243
+ return { x, y, width, height };
244
+ }
245
+ /**
246
+ * Read the rotation a fake raster was rendered at, or null when the raster was
247
+ * too small to carry a swatch. What a pipeline test reads to prove the
248
+ * controller's stamp reached the backend.
249
+ */
250
+ export function decodeFakeRotation(pixels) {
251
+ const rotation = decodeSwatchValue(pixels, SWATCH_ROTATION);
252
+ if (rotation === null)
253
+ return null;
254
+ return isPageRotation(rotation) ? rotation : null;
232
255
  }
233
256
  /* ------------------------------------------------------------------ *
234
257
  * Rendering
235
258
  * ------------------------------------------------------------------ */
236
- function renderPixels(request, cfg) {
259
+ /**
260
+ * The inverse of the request's rotation: a point in the TURNED page's doc space
261
+ * back to the page's own. `pageWidth` / `pageHeight` are the page's intrinsic
262
+ * (unturned) extents.
263
+ *
264
+ * These are the inverses of PDFium's `CPDF_Page::GetDisplayMatrix` for rotate
265
+ * 1..3, restated in y-down doc units — the same mapping the real backends'
266
+ * tests hold a turned render to, so the fake and the engine agree on what "90
267
+ * clockwise" means.
268
+ */
269
+ function unrotate(rotation, pageWidth, pageHeight) {
270
+ switch (rotation) {
271
+ case 90:
272
+ return (x, y) => [y, pageHeight - x];
273
+ case 180:
274
+ return (x, y) => [pageWidth - x, pageHeight - y];
275
+ case 270:
276
+ return (x, y) => [pageWidth - y, x];
277
+ default:
278
+ return (x, y) => [x, y];
279
+ }
280
+ }
281
+ function renderPixels(request, cfg, geometry) {
237
282
  const { docRect, scale, page, background } = request;
238
283
  const { width, height } = fakePixelSize(request);
284
+ // Read as `?? 0` and then VALIDATED, as every real backend does: the fake
285
+ // stands in for them in the controller suite, so it must refuse what they
286
+ // refuse rather than quietly draw an upright page.
287
+ const rotation = request.rotation ?? 0;
288
+ if (!isPageRotation(rotation)) {
289
+ throw new PdfError('backend-failure', `Fake rasterizer: rotation must be 0, 90, 180 or 270, got ${String(rotation)}.`);
290
+ }
239
291
  if (width * height > cfg.maxPixels) {
240
292
  throw new PdfError('out-of-memory', `Fake rasterizer refused a ${width}x${height} raster ` +
241
293
  `(${width * height} pixels > maxPixels ${cfg.maxPixels}).`);
@@ -256,18 +308,23 @@ function renderPixels(request, cfg) {
256
308
  writePixel(bytes, o, tintR, tintG, tintB, 255, cfg.format);
257
309
  }
258
310
  }
259
- // Dark checker cells, in DOC space. Pixel CENTRES are converted to doc
260
- // coordinates — a corner-based conversion puts the sample exactly on a cell
261
- // boundary whenever the piece origin happens to align with one, and the
262
- // parity then flips on a floating-point tie.
311
+ // Dark checker cells, in the PAGE's DOC space. Pixel CENTRES are converted
312
+ // to doc coordinates — a corner-based conversion puts the sample exactly on
313
+ // a cell boundary whenever the piece origin happens to align with one, and
314
+ // the parity then flips on a floating-point tie. The request's rect is in
315
+ // the TURNED page's space, so each centre is mapped back through the inverse
316
+ // rotation before its cell is looked up: that is what makes the board turn
317
+ // with the request, the way a real engine's page does.
263
318
  const cell = cfg.checkerDocSize;
319
+ const toPage = unrotate(rotation, geometry.width, geometry.height);
264
320
  for (let py = 0; py < height; py++) {
265
- const docY = docRect.y + (py + 0.5) / scale;
266
- const cellY = Math.floor(docY / cell);
321
+ const turnedY = docRect.y + (py + 0.5) / scale;
267
322
  let offset = py * rowBytes;
268
323
  for (let px = 0; px < width; px++, offset += BYTES_PER_PIXEL) {
269
- const docX = docRect.x + (px + 0.5) / scale;
324
+ const turnedX = docRect.x + (px + 0.5) / scale;
325
+ const [docX, docY] = toPage(turnedX, turnedY);
270
326
  const cellX = Math.floor(docX / cell);
327
+ const cellY = Math.floor(docY / cell);
271
328
  if (((cellX + cellY) & 1) === 1) {
272
329
  writePixel(bytes, offset, r, g, b, 255, cfg.format);
273
330
  }
@@ -288,7 +345,7 @@ function renderPixels(request, cfg) {
288
345
  writePixel(bytes, row + lastCol, 0, 0, 0, 255, cfg.format);
289
346
  }
290
347
  }
291
- encodeSwatch(bytes, rowBytes, width, height, docRect);
348
+ encodeSwatch(bytes, rowBytes, width, height, docRect, rotation);
292
349
  return {
293
350
  bytes,
294
351
  width,
@@ -384,7 +441,7 @@ function createHandle(cfg) {
384
441
  async render(request, signal) {
385
442
  throwIfAborted(signal);
386
443
  requireOpen();
387
- requirePage(request.page);
444
+ const geometry = requirePage(request.page);
388
445
  if (cfg.latencyMs > 0) {
389
446
  await delay(cfg.latencyMs, signal);
390
447
  }
@@ -396,7 +453,7 @@ function createHandle(cfg) {
396
453
  if (failure) {
397
454
  throw new PdfError(failure, `Fake rasterizer failed page ${request.page} by configuration.`);
398
455
  }
399
- return renderPixels(request, cfg);
456
+ return renderPixels(request, cfg, geometry);
400
457
  },
401
458
  close() {
402
459
  closed = true;
@@ -81,6 +81,17 @@ export interface NativeRenderRequest {
81
81
  scale: number;
82
82
  annotations: boolean;
83
83
  background: 'white' | 'transparent';
84
+ /**
85
+ * Degrees clockwise, 0 / 90 / 180 / 270, on top of the page's `/Rotate`.
86
+ *
87
+ * ALWAYS PRESENT on the wire — 0 when the `RasterRequest` carried none —
88
+ * while both native modules read it as optional-defaulting-to-0. That pair
89
+ * is what makes a version skew safe in both directions: a JS bundle newer
90
+ * than its binary sends a key the binary never looks for, and a binary newer
91
+ * than its bundle reads 0 for a key that never arrives. Neither can turn a
92
+ * page by accident.
93
+ */
94
+ rotation: number;
84
95
  }
85
96
  export interface NativeSource {
86
97
  uri?: string;
@@ -343,6 +343,7 @@ export function createNativeHandle(native, opened, transport, alpha, label) {
343
343
  scale: request.scale,
344
344
  annotations: request.annotations,
345
345
  background: request.background,
346
+ rotation: request.rotation ?? 0,
346
347
  }, token);
347
348
  }
348
349
  catch (error) {