@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.
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { SkImage } from '@shopify/react-native-skia';
18
18
  import { PdfError } from '../types.js';
19
- import type { DocRect, DocSize, PageGeometry, PageRasterizer, PdfSource, PixelSize, RasterPixels, RasterizerCapabilities, RasterizerHandle } from '../types.js';
19
+ import type { DocRect, DocSize, PageGeometry, PageRasterizer, PageRotation, PdfSource, PixelSize, RasterPixels, RasterizerCapabilities, RasterizerHandle } from '../types.js';
20
20
  /** Install the rasterizer used when `usePdfDocument` / `openPdfDocument` are not
21
21
  * given one explicitly. Returns the previous value so a test can restore it. */
22
22
  export declare function setDefaultRasterizer(rasterizer: PageRasterizer | null): PageRasterizer | null;
@@ -61,6 +61,16 @@ export interface PdfRenderOptions {
61
61
  * compositing onto their own surface.
62
62
  */
63
63
  background?: 'white' | 'transparent';
64
+ /**
65
+ * Turn the page this many degrees clockwise on top of its own `/Rotate`.
66
+ *
67
+ * Defaults to 0. For 90 and 270 the default `docRect` — and therefore the
68
+ * raster — has the page's extents SWAPPED, and a `docRect` you pass yourself
69
+ * is read in that turned space (see `RasterRequest.rotation`). `widthPx` is
70
+ * the width of the turned raster. This is what a thumbnail strip passes so
71
+ * its tiles match a layer the host has rotated.
72
+ */
73
+ rotation?: PageRotation;
64
74
  signal?: AbortSignal;
65
75
  }
66
76
  export interface PdfDocument {
@@ -17,6 +17,7 @@
17
17
  import { useEffect, useState } from 'react';
18
18
  // Through the `./skia` barrel — see the note in `usePdfLayer.ts`.
19
19
  import { imageFromPixels } from '../skia/index.js';
20
+ import { rotatedSize } from '../rotation.js';
20
21
  import { PdfError } from '../types.js';
21
22
  /* ------------------------------------------------------------------ *
22
23
  * Default rasterizer registry
@@ -145,11 +146,15 @@ export async function openPdfDocument(source, rasterizer) {
145
146
  throw new PdfError('cancelled', 'renderPage aborted before it started');
146
147
  }
147
148
  const geometry = handle.pageGeometry(index);
149
+ const rotation = options?.rotation ?? 0;
150
+ // The whole page in the TURNED page's doc space: a 612x792 page at 90 is
151
+ // a 792x612 raster, and a caller's `widthPx` is the width of that.
152
+ const turned = rotatedSize(geometry, rotation);
148
153
  const docRect = options?.docRect ?? {
149
154
  x: 0,
150
155
  y: 0,
151
- width: geometry.width,
152
- height: geometry.height,
156
+ width: turned.width,
157
+ height: turned.height,
153
158
  };
154
159
  if (!(docRect.width > 0) || !(docRect.height > 0)) {
155
160
  throw new PdfError('backend-failure', `renderPage needs a positive docRect, got ${docRect.width}x${docRect.height}`);
@@ -161,18 +166,22 @@ export async function openPdfDocument(source, rasterizer) {
161
166
  if (!(scale > 0) || !Number.isFinite(scale)) {
162
167
  throw new PdfError('backend-failure', `renderPage needs a positive scale, got ${scale}`);
163
168
  }
169
+ const request = {
170
+ page: index,
171
+ docRect,
172
+ scale,
173
+ // A backend that cannot draw annotations would otherwise silently
174
+ // ignore the flag; downgrading here keeps the request honest.
175
+ annotations: (options?.annotations ?? true) && backend.capabilities.annotations,
176
+ background: options?.background ?? 'white',
177
+ };
178
+ // Only when the caller asked for a turn: an unrotated request stays the
179
+ // request it always was, key for key (see the controller's stamp).
180
+ if (rotation !== 0)
181
+ request.rotation = rotation;
164
182
  let pixels;
165
183
  try {
166
- pixels = await handle.render({
167
- page: index,
168
- docRect,
169
- scale,
170
- // A backend that cannot draw annotations would otherwise silently
171
- // ignore the flag; downgrading here keeps the request honest.
172
- annotations: (options?.annotations ?? true) &&
173
- backend.capabilities.annotations,
174
- background: options?.background ?? 'white',
175
- }, options?.signal);
184
+ pixels = await handle.render(request, options?.signal);
176
185
  }
177
186
  catch (cause) {
178
187
  throw toPdfError(cause, `Failed to render page ${index}`);
@@ -20,7 +20,7 @@
20
20
  * host's transform does the panning and zooming; this hook only ever hears about
21
21
  * the viewport through the `controller` methods the host calls.
22
22
  */
23
- import type { PdfContent, PdfController, PdfDiagnostic, PdfPageLayout, RasterPolicy } from '../types.js';
23
+ import type { PageRotation, PdfContent, PdfController, PdfDiagnostic, PdfPageLayout, RasterPolicy } from '../types.js';
24
24
  import type { PdfDocument } from './usePdfDocument.js';
25
25
  /**
26
26
  * Only the pages the viewport actually intersects may hold rasters.
@@ -88,6 +88,23 @@ export interface UsePdfLayerOptions {
88
88
  * and its raster cache: every cached tile was rendered one way or the other.
89
89
  */
90
90
  annotations?: boolean;
91
+ /**
92
+ * A per-page rotation the HOST applies on top of the document's own
93
+ * `/Rotate`, in degrees clockwise, indexed by page. A missing or undefined
94
+ * entry is 0; absent altogether, nothing about the layer changes.
95
+ *
96
+ * The layout is handed the ROTATED page sizes (a 612x792 page turned 90 is
97
+ * laid out as 792x612), the controller is handed geometry in the same
98
+ * orientation, and every tile request carries the rotation so the backend
99
+ * draws the page turned. Doc space is therefore the rotated page's: a host
100
+ * positioning its own layers over a turned page positions them in that
101
+ * space, and `content.pages[i].pageRect` already reports it.
102
+ *
103
+ * Changing an entry REBUILDS the controller and clears its raster cache —
104
+ * every cached tile was drawn one way up — exactly as `annotations` does. An
105
+ * all-zero list is the same controller as no list at all.
106
+ */
107
+ rotations?: readonly PageRotation[];
91
108
  onDiagnostic?: (diagnostic: PdfDiagnostic) => void;
92
109
  }
93
110
  export interface PdfLayer {
@@ -100,4 +117,4 @@ export interface PdfLayer {
100
117
  */
101
118
  controller: PdfController;
102
119
  }
103
- export declare function usePdfLayer({ document, pages: pageSelection, layout, policy, annotations, onDiagnostic, }: UsePdfLayerOptions): PdfLayer;
120
+ export declare function usePdfLayer({ document, pages: pageSelection, layout, policy, annotations, rotations, onDiagnostic, }: UsePdfLayerOptions): PdfLayer;
@@ -25,6 +25,7 @@ import { createRasterCache } from '../cache.js';
25
25
  import { createPdfController } from '../controller.js';
26
26
  import { continuousVertical, hasOverlappingPages } from '../layout.js';
27
27
  import { resolvePolicy } from '../policy.js';
28
+ import { resolveRotations, rotatePageGeometry } from '../rotation.js';
28
29
  // Through the `./skia` barrel, not `../rasterizer/ingest.js` directly: that
29
30
  // barrel is the documented home of the ingest seam, and routing every internal
30
31
  // consumer through it is what keeps "which modules can touch Skia" a list of one
@@ -116,11 +117,17 @@ function disposeRaster(raster) {
116
117
  disposedImages.add(raster.image);
117
118
  raster.image.dispose();
118
119
  }
119
- /** Intrinsic geometry for every page, in page order. Cheap: loads no page. */
120
- function allPageGeometry(document) {
120
+ /**
121
+ * Geometry for every page, in page order, as the host wants it displayed: a
122
+ * quarter turn swaps a page's extents (see `../rotation.ts`). Cheap: loads no
123
+ * page. With no rotations it is the intrinsic geometry exactly, object for
124
+ * object — `rotatePageGeometry` returns its input for a rotation that changes
125
+ * nothing.
126
+ */
127
+ function rotatedPageGeometry(document, rotations) {
121
128
  const geometry = [];
122
129
  for (let index = 0; index < document.pageCount; index += 1) {
123
- geometry.push(document.pageGeometry(index));
130
+ geometry.push(rotatePageGeometry(document.pageGeometry(index), rotations?.[index] ?? 0));
124
131
  }
125
132
  return geometry;
126
133
  }
@@ -181,7 +188,7 @@ export function usePdfLayer({ document,
181
188
  // The PUBLIC name stays `pages`; locally it is the SELECTION, and the
182
189
  // controller's own `pages` is the per-page geometry. Two very different things
183
190
  // one rename apart.
184
- pages: pageSelection = DEFAULT_PAGE_SELECTION, layout, policy, annotations = true, onDiagnostic, }) {
191
+ pages: pageSelection = DEFAULT_PAGE_SELECTION, layout, policy, annotations = true, rotations, onDiagnostic, }) {
185
192
  // The diagnostic callback is almost always an inline arrow. Route it through a
186
193
  // ref so a fresh identity on every render never counts as a reason to rebuild
187
194
  // the controller.
@@ -194,14 +201,41 @@ pages: pageSelection = DEFAULT_PAGE_SELECTION, layout, policy, annotations = tru
194
201
  }, []);
195
202
  const pagesKey = pageSelectionKey(pageSelection);
196
203
  const policySignature = policyKey(policy);
204
+ /**
205
+ * Normalised every render — the option is an inline literal at every
206
+ * realistic call site, so its identity means nothing — and its joined form
207
+ * is the structural key the memos below depend on instead. `undefined` for
208
+ * an absent AND an all-zero list, so neither spelling of "nothing turned"
209
+ * can tear the controller down (see `resolveRotations`).
210
+ */
211
+ const resolvedRotations = resolveRotations(rotations, document?.pageCount ?? 0);
212
+ const rotationsSignature = resolvedRotations?.join(',') ?? '';
213
+ /**
214
+ * Every page's geometry as the host wants it shown. What the layout sizes
215
+ * pages from and what the controller divides page rects by, so the two are
216
+ * derived from ONE array and cannot disagree about a page's orientation.
217
+ */
218
+ const pageGeometry = useMemo(() => document === null
219
+ ? null
220
+ : rotatedPageGeometry(document, resolvedRotations),
221
+ // `resolvedRotations` is rebuilt every render; its signature is a complete
222
+ // description of it and stands in — the same trade `policy` makes below.
223
+ // eslint-disable-next-line react-hooks/exhaustive-deps
224
+ [document, rotationsSignature]);
197
225
  /**
198
226
  * Split out of the controller memo so the `__DEV__` check below can see the
199
227
  * rects without re-running the layout, and so a warning lives in an EFFECT
200
228
  * rather than inside a memo React is free to invoke twice.
201
229
  */
202
- const pageRects = useMemo(() => document === null
230
+ const pageRects = useMemo(() => pageGeometry === null
203
231
  ? null
204
- : (layout ?? DEFAULT_LAYOUT)(document.pageSizes()), [document, layout]);
232
+ : (layout ?? DEFAULT_LAYOUT)(
233
+ // The ROTATED sizes: a page the host turned a quarter is laid out
234
+ // at its displayed extents, and doc space is that page's.
235
+ pageGeometry.map(page => ({
236
+ width: page.width,
237
+ height: page.height,
238
+ }))), [pageGeometry, layout]);
205
239
  /**
206
240
  * A stacked layout (`singlePage()`) is correct ONLY with a one-element `pages`
207
241
  * list. Any other selection lets every page hold a base at the same doc rect,
@@ -234,7 +268,7 @@ pages: pageSelection = DEFAULT_PAGE_SELECTION, layout, policy, annotations = tru
234
268
  'continuousVertical() / spread().');
235
269
  }, [document, pageRects, pageSelection]);
236
270
  const layer = useMemo(() => {
237
- if (document === null || pageRects === null) {
271
+ if (document === null || pageGeometry === null || pageRects === null) {
238
272
  return null;
239
273
  }
240
274
  const resolvedPolicy = resolvePolicy(policy);
@@ -245,7 +279,11 @@ pages: pageSelection = DEFAULT_PAGE_SELECTION, layout, policy, annotations = tru
245
279
  const controller = createPdfController({
246
280
  handle: document.handle,
247
281
  pageRects,
248
- pages: allPageGeometry(document),
282
+ pages: pageGeometry,
283
+ // Undefined when nothing is turned, so the controller's default path
284
+ // — and the requests it builds — are exactly what they were before
285
+ // rotation existed.
286
+ rotations: resolvedRotations,
249
287
  policy: resolvedPolicy,
250
288
  ingest: ingestRaster,
251
289
  cache,
@@ -277,12 +315,17 @@ pages: pageSelection = DEFAULT_PAGE_SELECTION, layout, policy, annotations = tru
277
315
  // layout the resident cost goes from one page to as many as five. The measured
278
316
  // cost of the teardown is one page's rasters, which is the page you are turning
279
317
  // to and would have paid for anyway. See `__tests__/single-page.test.ts`.
318
+ //
319
+ // `rotations` follows the same rule as the two above: `rotationsSignature`
320
+ // stands in for it, and `resolvedRotations` is read inside on its strength.
280
321
  // eslint-disable-next-line react-hooks/exhaustive-deps
281
322
  [
282
323
  document,
324
+ pageGeometry,
283
325
  pageRects,
284
326
  pagesKey,
285
327
  policySignature,
328
+ rotationsSignature,
286
329
  annotations,
287
330
  emitDiagnostic,
288
331
  ]);
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The HOST's page rotation, and the one place its arithmetic lives.
3
+ *
4
+ * Two rotations exist in this package and they must not be confused:
5
+ *
6
+ * - `PageGeometry.rotation` is the document's own `/Rotate`. PDFium folds it
7
+ * into every size it reports and every render it draws, so from this
8
+ * package's vantage point it is already applied and purely informational.
9
+ * - `RasterRequest.rotation` — what this module is about — is a rotation the
10
+ * host layers ON TOP of that: "show me this page turned a quarter
11
+ * clockwise". It is PDFium's `rotate` argument, which every engine here
12
+ * hard-coded to 0 until this existed.
13
+ *
14
+ * THE CONTRACT, stated once and honoured by every backend: doc space for a
15
+ * rotated page is the ROTATED page's. A 612x792 page turned 90 is a 792x612
16
+ * page whose origin is its displayed top-left, and a request's `docRect` and
17
+ * `scale` are in that space. So the layout lays out swapped sizes, the planner
18
+ * plans in them, the controller divides swapped page rects by swapped geometry
19
+ * (and `layoutScale` stays 1), and a backend keeps its negative-offset rect
20
+ * mechanism unchanged — the only thing it swaps is the page box it hands the
21
+ * engine. This module does the swapping; nothing else in `src/` repeats it.
22
+ *
23
+ * Pure, and deliberately without React: `usePdfLayer` calls it, and so does the
24
+ * test that pins what the layout is handed.
25
+ */
26
+ import type { DocSize, PageGeometry, PageRotation } from './types.js';
27
+ export declare const ROTATIONS: readonly PageRotation[];
28
+ /**
29
+ * True for exactly the four values the type admits. For a value that arrived
30
+ * from JavaScript rather than through the type — a backend validating a request
31
+ * it did not build.
32
+ */
33
+ export declare function isPageRotation(value: unknown): value is PageRotation;
34
+ /** A quarter turn either way swaps a page's extents; a half turn does not. */
35
+ export declare function swapsExtents(rotation: PageRotation): boolean;
36
+ /** A page's size as displayed under `rotation`. */
37
+ export declare function rotatedSize(size: DocSize, rotation: PageRotation): DocSize;
38
+ /**
39
+ * The geometry the controller and the layout see for a page a host has turned.
40
+ *
41
+ * Returns the SAME object for a rotation that changes nothing, so a document
42
+ * with no rotations hands downstream exactly the geometry it did before this
43
+ * existed. `rotation` — the document's `/Rotate` — is carried through
44
+ * untouched: it describes the file, not the host's view of it.
45
+ */
46
+ export declare function rotatePageGeometry(geometry: PageGeometry, rotation: PageRotation): PageGeometry;
47
+ /**
48
+ * A host's `rotations` option, normalised to one entry per page — or
49
+ * `undefined` when it would change nothing.
50
+ *
51
+ * `undefined` for BOTH an absent option and an all-zero list, on purpose: the
52
+ * two mean the same controller, and `usePdfLayer` keys its controller memo on
53
+ * the result, so a host toggling between "no rotations" and `[0, 0, 0]` must
54
+ * not tear its raster cache down over a distinction without a difference. A
55
+ * value the type does not admit (from JavaScript) reads as 0 rather than
56
+ * throwing: a malformed entry should leave that page upright, not blank the
57
+ * document.
58
+ */
59
+ export declare function resolveRotations(rotations: readonly PageRotation[] | undefined, pageCount: number): PageRotation[] | undefined;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The HOST's page rotation, and the one place its arithmetic lives.
3
+ *
4
+ * Two rotations exist in this package and they must not be confused:
5
+ *
6
+ * - `PageGeometry.rotation` is the document's own `/Rotate`. PDFium folds it
7
+ * into every size it reports and every render it draws, so from this
8
+ * package's vantage point it is already applied and purely informational.
9
+ * - `RasterRequest.rotation` — what this module is about — is a rotation the
10
+ * host layers ON TOP of that: "show me this page turned a quarter
11
+ * clockwise". It is PDFium's `rotate` argument, which every engine here
12
+ * hard-coded to 0 until this existed.
13
+ *
14
+ * THE CONTRACT, stated once and honoured by every backend: doc space for a
15
+ * rotated page is the ROTATED page's. A 612x792 page turned 90 is a 792x612
16
+ * page whose origin is its displayed top-left, and a request's `docRect` and
17
+ * `scale` are in that space. So the layout lays out swapped sizes, the planner
18
+ * plans in them, the controller divides swapped page rects by swapped geometry
19
+ * (and `layoutScale` stays 1), and a backend keeps its negative-offset rect
20
+ * mechanism unchanged — the only thing it swaps is the page box it hands the
21
+ * engine. This module does the swapping; nothing else in `src/` repeats it.
22
+ *
23
+ * Pure, and deliberately without React: `usePdfLayer` calls it, and so does the
24
+ * test that pins what the layout is handed.
25
+ */
26
+ export const ROTATIONS = Object.freeze([
27
+ 0, 90, 180, 270,
28
+ ]);
29
+ /**
30
+ * True for exactly the four values the type admits. For a value that arrived
31
+ * from JavaScript rather than through the type — a backend validating a request
32
+ * it did not build.
33
+ */
34
+ export function isPageRotation(value) {
35
+ return ROTATIONS.includes(value);
36
+ }
37
+ /** A quarter turn either way swaps a page's extents; a half turn does not. */
38
+ export function swapsExtents(rotation) {
39
+ return rotation === 90 || rotation === 270;
40
+ }
41
+ /** A page's size as displayed under `rotation`. */
42
+ export function rotatedSize(size, rotation) {
43
+ return swapsExtents(rotation)
44
+ ? { width: size.height, height: size.width }
45
+ : { width: size.width, height: size.height };
46
+ }
47
+ /**
48
+ * The geometry the controller and the layout see for a page a host has turned.
49
+ *
50
+ * Returns the SAME object for a rotation that changes nothing, so a document
51
+ * with no rotations hands downstream exactly the geometry it did before this
52
+ * existed. `rotation` — the document's `/Rotate` — is carried through
53
+ * untouched: it describes the file, not the host's view of it.
54
+ */
55
+ export function rotatePageGeometry(geometry, rotation) {
56
+ if (!swapsExtents(rotation))
57
+ return geometry;
58
+ return { ...geometry, width: geometry.height, height: geometry.width };
59
+ }
60
+ /**
61
+ * A host's `rotations` option, normalised to one entry per page — or
62
+ * `undefined` when it would change nothing.
63
+ *
64
+ * `undefined` for BOTH an absent option and an all-zero list, on purpose: the
65
+ * two mean the same controller, and `usePdfLayer` keys its controller memo on
66
+ * the result, so a host toggling between "no rotations" and `[0, 0, 0]` must
67
+ * not tear its raster cache down over a distinction without a difference. A
68
+ * value the type does not admit (from JavaScript) reads as 0 rather than
69
+ * throwing: a malformed entry should leave that page upright, not blank the
70
+ * document.
71
+ */
72
+ export function resolveRotations(rotations, pageCount) {
73
+ if (rotations === undefined)
74
+ return undefined;
75
+ const resolved = [];
76
+ let any = false;
77
+ for (let page = 0; page < pageCount; page += 1) {
78
+ const rotation = rotations[page];
79
+ const clean = isPageRotation(rotation) ? rotation : 0;
80
+ if (clean !== 0)
81
+ any = true;
82
+ resolved.push(clean);
83
+ }
84
+ return any ? resolved : undefined;
85
+ }
package/dist/types.d.ts CHANGED
@@ -220,6 +220,26 @@ export interface RasterRequest {
220
220
  */
221
221
  annotations: boolean;
222
222
  background: 'white' | 'transparent';
223
+ /**
224
+ * DEVICE rotation applied on top of the page's own `/Rotate`, in degrees
225
+ * clockwise. Optional: absent means 0, and every backend reads it as
226
+ * `rotation ?? 0`, so a request built before the field existed renders
227
+ * exactly as it did.
228
+ *
229
+ * THE COORDINATE CONTRACT: `docRect` and `scale` are expressed in the
230
+ * ROTATED page's doc space. For 90 and 270 the page's extents are swapped —
231
+ * a 612x792 page rotated 90 is a 792x612 page whose origin is its displayed
232
+ * top-left — so a whole-page request for it is `{0, 0, 792, 612}`. That is
233
+ * what lets a backend keep the negative-offset rect mechanism unchanged: it
234
+ * describes the whole TURNED page at `scale` and slides it by
235
+ * `(-round(x·s), -round(y·s))`, exactly as it does an upright one. The only
236
+ * thing a backend swaps is the page box it hands the engine.
237
+ *
238
+ * This is NOT `PageGeometry.rotation`. That one is the document's `/Rotate`,
239
+ * already folded into every size and render by PDFium and informational
240
+ * here; this one is a host's choice, layered on top. See `./rotation.ts`.
241
+ */
242
+ rotation?: PageRotation;
223
243
  }
224
244
  export interface RasterizerCapabilities {
225
245
  /** Can render PDF-embedded annotations into the raster. */
@@ -324,6 +324,11 @@ RCT_EXPORT_METHOD(render
324
324
  req.width = [request[@"width"] doubleValue];
325
325
  req.height = [request[@"height"] doubleValue];
326
326
  req.scale = [request[@"scale"] doubleValue];
327
+ // OPTIONAL, DEFAULTING TO 0: a JS bundle older than this binary never sends it,
328
+ // and a page must not turn because a key is missing. The class check also covers
329
+ // a JS `null`, which the bridge delivers as `NSNull` rather than `nil`. The core
330
+ // validates the value; this only decides what "absent" means.
331
+ req.rotation = [request[@"rotation"] isKindOfClass:NSNumber.class] ? [request[@"rotation"] intValue] : 0;
327
332
  req.annotations = request[@"annotations"] == nil ? true : [request[@"annotations"] boolValue];
328
333
  req.background = [@"transparent" isEqual:request[@"background"]] ? pdfcanvas::Background::Transparent
329
334
  : pdfcanvas::Background::White;
@@ -80,6 +80,13 @@ if(WIN32)
80
80
  elseif(APPLE)
81
81
  set_target_properties(pdfcanvas_tests PROPERTIES
82
82
  BUILD_RPATH "${PDFCANVAS_PDFIUM_DIR}/lib")
83
+ # The rpath above cannot help on its own: the release dylib's install name is
84
+ # the CWD-relative `./libpdfium.dylib` (`otool -D`), not `@rpath/…`, so dyld
85
+ # looks beside the working directory and nowhere else. ctest runs the binary
86
+ # from the build directory, so — as on Windows — the library is copied there.
87
+ add_custom_command(TARGET pdfcanvas_tests POST_BUILD
88
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
89
+ "${PDFCANVAS_PDFIUM_DIR}/lib/libpdfium.dylib" "$<TARGET_FILE_DIR:pdfcanvas_tests>")
83
90
  else()
84
91
  set_target_properties(pdfcanvas_tests PROPERTIES
85
92
  BUILD_RPATH "${PDFCANVAS_PDFIUM_DIR}/lib")
@@ -48,6 +48,14 @@ struct RasterRequest {
48
48
  double scale = 1;
49
49
  /// Draw PDF-embedded annotations (and form fields) into the raster.
50
50
  bool annotations = true;
51
+ /// The HOST's rotation on top of the page's `/Rotate`, in degrees clockwise:
52
+ /// 0, 90, 180 or 270 — anything else is refused. `x`/`y`/`width`/`height`
53
+ /// are in the TURNED page's doc space (extents swapped for a quarter turn),
54
+ /// which is what keeps the negative-offset rect mechanism unchanged: the
55
+ /// only thing the render swaps is the page box it hands PDFium. Absent on
56
+ /// the wire means 0 on both bindings, so a JS/binary version skew cannot
57
+ /// turn a page by accident.
58
+ int rotation = 0;
51
59
  Background background = Background::White;
52
60
 
53
61
  /// Destination width in device pixels: ROUND, not ceil, and at least 1 —
@@ -365,6 +365,14 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
365
365
  " at (" + std::to_string(request.x) + ", " + std::to_string(request.y) + ").");
366
366
  }
367
367
 
368
+ if (request.rotation != 0 && request.rotation != 90 && request.rotation != 180 &&
369
+ request.rotation != 270) {
370
+ // Refused rather than folded: `45 / 90` is 0 in integer arithmetic, and a
371
+ // request that asked for a turn must not quietly come back upright.
372
+ throw PdfError(ErrorCode::BackendFailure,
373
+ "Rotation must be 0, 90, 180 or 270, got " + std::to_string(request.rotation) + ".");
374
+ }
375
+
368
376
  const int pxW = request.pixelWidth();
369
377
  const int pxH = request.pixelHeight();
370
378
  const long long pixels = static_cast<long long>(pxW) * pxH;
@@ -377,8 +385,16 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
377
385
  // byte-identical to the matrix form on every rect it tried.
378
386
  const long long startX = -std::llround(request.x * request.scale);
379
387
  const long long startY = -std::llround(request.y * request.scale);
380
- const long long pageW = std::llround(geometry.width * request.scale);
381
- const long long pageH = std::llround(geometry.height * request.scale);
388
+ // THE PAGE BOX HANDED TO PDFIUM IS THE DISPLAYED ONE. `size_x` / `size_y`
389
+ // describe the box the page is drawn INTO after `rotate` is applied —
390
+ // `CPDF_Page::GetDisplayMatrix` maps the page's HEIGHT onto `size_x` for
391
+ // rotate 1 and 3 — so a quarter turn swaps the intrinsic extents here. The
392
+ // request's rect is already in that space (see `RasterRequest::rotation`), so
393
+ // the start offsets above need nothing: they slide the whole turned page
394
+ // exactly as they slide an upright one.
395
+ const bool turned = request.rotation == 90 || request.rotation == 270;
396
+ const long long pageW = std::llround((turned ? geometry.height : geometry.width) * request.scale);
397
+ const long long pageH = std::llround((turned ? geometry.width : geometry.height) * request.scale);
382
398
  if (pixels > kMaxRasterPixels) {
383
399
  throw PdfError(ErrorCode::OutOfMemory,
384
400
  "Refused a " + std::to_string(pxW) + "x" + std::to_string(pxH) + " raster (" +
@@ -451,6 +467,11 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
451
467
  const int sy = static_cast<int>(startY);
452
468
  const int pw = static_cast<int>(pageW);
453
469
  const int ph = static_cast<int>(pageH);
470
+ // The HOST's rotation in quarter turns clockwise — never the page's own
471
+ // `/Rotate`, which PDFium has already folded into `geometry` and into the
472
+ // render. Passed to BOTH passes below, so a form field lands where the turned
473
+ // content says and not where the upright page had it.
474
+ const int rotate = request.rotation / 90;
454
475
 
455
476
  auto abandonWith = [&](PdfError error) -> PdfError {
456
477
  FPDFBitmap_Destroy(bitmap);
@@ -466,8 +487,7 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
466
487
  // TOBECONTINUED when the pause adapter asked to stop, which it does only
467
488
  // once the signal is set; a live render runs to DONE in one call.
468
489
  PauseAdapter pause(signal);
469
- int status = FPDF_RenderPageBitmap_Start(bitmap, page, sx, sy, pw, ph, /*rotate=*/0, flags,
470
- &pause.pause);
490
+ int status = FPDF_RenderPageBitmap_Start(bitmap, page, sx, sy, pw, ph, rotate, flags, &pause.pause);
471
491
  while (status == FPDF_RENDER_TOBECONTINUED) {
472
492
  if (signal != nullptr && signal->isCancelled()) {
473
493
  FPDF_RenderPage_Close(page);
@@ -487,7 +507,7 @@ RasterPixels Document::render(const RasterRequest& request, Cancellation* signal
487
507
  // state — with the same offsets, so they land exactly where the page put
488
508
  // them.
489
509
  if (request.annotations && impl_->form != nullptr) {
490
- FPDF_FFLDraw(impl_->form, bitmap, page, sx, sy, pw, ph, /*rotate=*/0, flags);
510
+ FPDF_FFLDraw(impl_->form, bitmap, page, sx, sy, pw, ph, rotate, flags);
491
511
  }
492
512
 
493
513
  FPDFBitmap_Destroy(bitmap);