@reekon-tools/react-native-pdf-canvas 0.2.1 → 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/README.md CHANGED
@@ -250,6 +250,23 @@ usePdfLayer({document, policy: {sharpnessBand: 1.2, quietZoomMs: 400}});
250
250
  observed p95 raster time. A value set too low permits a user adjusting the zoom to supersede
251
251
  each render before it completes.
252
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
+
253
270
  ## Platform support
254
271
 
255
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) {
@@ -40,6 +40,7 @@
40
40
  * likely to be real.
41
41
  */
42
42
  import { FPDF_ANNOT, FPDF_BITMAP_BGRA, FPDF_REVERSE_BYTE_ORDER, loadError, } from './pdfium.js';
43
+ import { isPageRotation, ROTATIONS, swapsExtents } from '../../rotation.js';
43
44
  import { PdfError } from '../../types.js';
44
45
  /**
45
46
  * MEASURED, not inherited from the other backends. An antialiased opaque-white
@@ -64,7 +65,6 @@ const OPAQUE_WHITE = 0xffffffff;
64
65
  const TRANSPARENT = 0x00000000;
65
66
  /** PDFium's ints are 32-bit; a request past this cannot be expressed at all. */
66
67
  const MAX_INT32 = 0x7fffffff;
67
- const ROTATIONS = [0, 90, 180, 270];
68
68
  /* ------------------------------------------------------------------ *
69
69
  * Open
70
70
  * ------------------------------------------------------------------ */
@@ -215,6 +215,13 @@ function createDocument(api, document, dataPointer, pages) {
215
215
  `${docRect.width}x${docRect.height} at ` +
216
216
  `(${docRect.x}, ${docRect.y}).`);
217
217
  }
218
+ // Read as `?? 0`, so a request built before the field existed renders
219
+ // as it always did; validated, because from here on it is arithmetic
220
+ // and `45 / 90` would silently be "upright".
221
+ const rotation = request.rotation ?? 0;
222
+ if (!isPageRotation(rotation)) {
223
+ throw new PdfError('backend-failure', `Rotation must be 0, 90, 180 or 270, got ${String(rotation)}.`);
224
+ }
218
225
  // At least one pixel: a sub-pixel rect is a legitimate ask at low zoom,
219
226
  // and a zero-sized bitmap is not a thing PDFium (or Skia) accepts.
220
227
  const width = Math.max(1, Math.round(docRect.width * scale));
@@ -223,8 +230,18 @@ function createDocument(api, document, dataPointer, pages) {
223
230
  // the negated one.
224
231
  const startX = -Math.round(docRect.x * scale);
225
232
  const startY = -Math.round(docRect.y * scale);
226
- const pageWidth = Math.round(geometry.width * scale);
227
- const pageHeight = Math.round(geometry.height * scale);
233
+ // THE PAGE BOX PDFIUM IS HANDED IS THE DISPLAYED ONE. `size_x` / `size_y`
234
+ // describe the box the page is drawn INTO after `rotate` is applied —
235
+ // `CPDF_Page::GetDisplayMatrix` maps the page's HEIGHT onto `size_x` for
236
+ // rotate 1 and 3 — so a quarter turn swaps the intrinsic extents here.
237
+ // Without the swap the turned page is squashed into the upright box and
238
+ // every pixel is stretched; with it, `docRect` (already in the turned
239
+ // page's space, see `RasterRequest.rotation`) needs nothing: the start
240
+ // offsets slide the whole turned page exactly as they slide an upright
241
+ // one.
242
+ const turned = swapsExtents(rotation);
243
+ const pageWidth = Math.round((turned ? geometry.height : geometry.width) * scale);
244
+ const pageHeight = Math.round((turned ? geometry.width : geometry.height) * scale);
228
245
  if (width * height > MAX_INT32 / 4 ||
229
246
  pageWidth > MAX_INT32 ||
230
247
  pageHeight > MAX_INT32) {
@@ -255,10 +272,12 @@ function createDocument(api, document, dataPointer, pages) {
255
272
  }
256
273
  const page = loadPage(request.page);
257
274
  api.FPDF_RenderPageBitmap(bitmap, page, startX, startY, pageWidth, pageHeight,
258
- // Device rotation, NOT the page's `/Rotate`. PDFium applies the
259
- // page's own rotation itself, and `geometry` is already post-rotation
260
- // — passing anything but 0 here would rotate a second time.
261
- 0, FPDF_REVERSE_BYTE_ORDER | (request.annotations ? FPDF_ANNOT : 0));
275
+ // The HOST's rotation, in quarter turns clockwise — and never the
276
+ // page's own `/Rotate`. PDFium has already folded that into
277
+ // `geometry` and into the render, so passing it here would turn the
278
+ // page twice; `rotation` is layered on top of it, which is exactly
279
+ // what this argument exists for.
280
+ rotation / 90, FPDF_REVERSE_BYTE_ORDER | (request.annotations ? FPDF_ANNOT : 0));
262
281
  // READ, NEVER ASSUMED. Measured tight (`width * 4`) at 1, 3, 7, 13, 1023
263
282
  // and 1025 px — but "tight today" is not "tight always", and a wrong
264
283
  // stride shears the image instead of failing.