@reekon-tools/react-native-pdf-canvas 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +61 -6
  2. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +64 -10
  3. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +22 -3
  4. package/android/src/main/cpp/pdfcanvas-jni.cpp +47 -3
  5. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +31 -1
  6. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +79 -9
  7. package/android/tools/compile-gate.sh +6 -4
  8. package/dist/controller.d.ts +23 -1
  9. package/dist/controller.js +33 -16
  10. package/dist/rasterizer/fake.d.ts +38 -4
  11. package/dist/rasterizer/fake.js +167 -37
  12. package/dist/rasterizer/native-bridge.d.ts +48 -1
  13. package/dist/rasterizer/native-bridge.js +96 -0
  14. package/dist/rasterizer/native.d.ts +2 -2
  15. package/dist/rasterizer/native.js +9 -7
  16. package/dist/rasterizer/text-search.d.ts +22 -0
  17. package/dist/rasterizer/text-search.js +28 -0
  18. package/dist/rasterizer/web/client.js +69 -49
  19. package/dist/rasterizer/web/engine.d.ts +3 -1
  20. package/dist/rasterizer/web/engine.js +225 -8
  21. package/dist/rasterizer/web/pdfium.d.ts +64 -2
  22. package/dist/rasterizer/web/pdfium.js +21 -0
  23. package/dist/rasterizer/web/protocol.d.ts +23 -6
  24. package/dist/rasterizer/web/protocol.js +4 -1
  25. package/dist/rasterizer/web/session.js +30 -13
  26. package/dist/react/PdfContentView.js +5 -0
  27. package/dist/react/usePdfDocument.d.ts +27 -2
  28. package/dist/react/usePdfDocument.js +65 -12
  29. package/dist/react/usePdfLayer.d.ts +19 -2
  30. package/dist/react/usePdfLayer.js +51 -8
  31. package/dist/rotation.d.ts +59 -0
  32. package/dist/rotation.js +85 -0
  33. package/dist/testing/index.d.ts +1 -1
  34. package/dist/testing/index.js +1 -1
  35. package/dist/types.d.ts +86 -0
  36. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +71 -2
  37. package/native/CMakeLists.txt +7 -0
  38. package/native/core/include/pdfcanvas/document.h +17 -0
  39. package/native/core/include/pdfcanvas/service.h +14 -1
  40. package/native/core/include/pdfcanvas/text_search.h +52 -0
  41. package/native/core/include/pdfcanvas/types.h +49 -0
  42. package/native/core/pdfcanvas-core.cmake +1 -0
  43. package/native/core/src/document.cpp +211 -8
  44. package/native/core/src/service.cpp +43 -28
  45. package/native/core/src/text_search.cpp +115 -0
  46. package/native/tests/fixtures.cpp +27 -0
  47. package/native/tests/fixtures.h +15 -0
  48. package/native/tests/test_document.cpp +486 -0
  49. package/native/tests/test_service.cpp +110 -0
  50. package/package.json +1 -1
@@ -48,6 +48,9 @@ import { PdfError } from '../../types.js';
48
48
  export const FPDF_BITMAP_BGRA = 4;
49
49
  /** Draw PDF-embedded annotations. Answers `RasterRequest.annotations`. */
50
50
  export const FPDF_ANNOT = 0x01;
51
+ /** `FPDFText_FindStart` flags. Answer `TextSearchRequest.matchCase` / `.wholeWord`. */
52
+ export const FPDF_MATCHCASE = 0x01;
53
+ export const FPDF_MATCHWHOLEWORD = 0x02;
51
54
  /**
52
55
  * Write RGBA instead of BGRA.
53
56
  *
@@ -96,6 +99,8 @@ export function bindPdfium(module) {
96
99
  malloc: byteLength => pdfium.wasmExports.malloc(byteLength),
97
100
  free: pointer => pdfium.wasmExports.free(pointer),
98
101
  getFloat: pointer => Number(pdfium.getValue(pointer, 'float')),
102
+ getDouble: pointer => Number(pdfium.getValue(pointer, 'double')),
103
+ getInt32: pointer => Number(pdfium.getValue(pointer, 'i32')),
99
104
  FPDF_LoadMemDocument: (pointer, byteLength, password) => module.FPDF_LoadMemDocument(pointer, byteLength, password),
100
105
  FPDF_CloseDocument: document => {
101
106
  module.FPDF_CloseDocument(document);
@@ -118,6 +123,22 @@ export function bindPdfium(module) {
118
123
  FPDF_RenderPageBitmap: (bitmap, page, startX, startY, sizeX, sizeY, rotate, flags) => {
119
124
  module.FPDF_RenderPageBitmap(bitmap, page, startX, startY, sizeX, sizeY, rotate, flags);
120
125
  },
126
+ FPDF_PageToDevice: (page, startX, startY, sizeX, sizeY, rotate, pageX, pageY, deviceXOut, deviceYOut) => module.FPDF_PageToDevice(page, startX, startY, sizeX, sizeY, rotate, pageX, pageY, deviceXOut, deviceYOut),
127
+ FPDFText_LoadPage: page => module.FPDFText_LoadPage(page),
128
+ FPDFText_ClosePage: textPage => {
129
+ module.FPDFText_ClosePage(textPage);
130
+ },
131
+ FPDFText_CountChars: textPage => module.FPDFText_CountChars(textPage),
132
+ FPDFText_GetText: (textPage, startIndex, count, buffer) => module.FPDFText_GetText(textPage, startIndex, count, buffer),
133
+ FPDFText_FindStart: (textPage, findWhat, flags, startIndex) => module.FPDFText_FindStart(textPage, findWhat, flags, startIndex),
134
+ FPDFText_FindNext: handle => module.FPDFText_FindNext(handle),
135
+ FPDFText_FindClose: handle => {
136
+ module.FPDFText_FindClose(handle);
137
+ },
138
+ FPDFText_GetSchResultIndex: handle => module.FPDFText_GetSchResultIndex(handle),
139
+ FPDFText_GetSchCount: handle => module.FPDFText_GetSchCount(handle),
140
+ FPDFText_CountRects: (textPage, startIndex, count) => module.FPDFText_CountRects(textPage, startIndex, count),
141
+ FPDFText_GetRect: (textPage, rectIndex, leftOut, topOut, rightOut, bottomOut) => module.FPDFText_GetRect(textPage, rectIndex, leftOut, topOut, rightOut, bottomOut),
121
142
  };
122
143
  }
123
144
  /* ------------------------------------------------------------------ *
@@ -21,7 +21,7 @@
21
21
  * exists but has not been told where its wasm is" — that could otherwise be
22
22
  * observed by a `render` arriving first.
23
23
  */
24
- import type { AlphaEncoding, PageGeometry, PdfErrorCode, PixelFormat, RasterRequest } from '../../types.js';
24
+ import type { AlphaEncoding, PageGeometry, PdfErrorCode, PixelFormat, RasterRequest, TextMatch, TextSearchRequest } from '../../types.js';
25
25
  /**
26
26
  * Where the worker gets `pdfium.wasm`.
27
27
  *
@@ -65,11 +65,22 @@ export interface RenderRequest {
65
65
  request: RasterRequest;
66
66
  }
67
67
  /**
68
- * Drop a render that has not started yet.
68
+ * Find text on one page. Routed exactly as a render is — the same queue, the
69
+ * same yield, the same `cancel` by id — so a search never overtakes a render
70
+ * that was posted before it and a superseded search can still be dropped
71
+ * before it starts.
72
+ */
73
+ export interface SearchRequest {
74
+ kind: 'search';
75
+ id: number;
76
+ request: TextSearchRequest;
77
+ }
78
+ /**
79
+ * Drop a render or a search that has not started yet.
69
80
  *
70
81
  * BEST EFFORT AND HONEST ABOUT IT. The worker renders synchronously, so a
71
- * `cancel` can only ever be delivered BETWEEN renders — never into one. What it
72
- * genuinely does is stop a QUEUED render from starting, which is why the worker
82
+ * `cancel` can only ever be delivered BETWEEN jobs — never into one. What it
83
+ * genuinely does is stop a QUEUED job from starting, which is why the worker
73
84
  * yields to its event loop between jobs (see `./session.ts`): without that
74
85
  * yield, messages queued behind a burst of renders would not be read until the
75
86
  * burst had finished and this message would be worthless.
@@ -94,7 +105,7 @@ export interface CancelRequest {
94
105
  export interface CloseRequest {
95
106
  kind: 'close';
96
107
  }
97
- export type WorkerRequest = OpenRequest | RenderRequest | CancelRequest | CloseRequest;
108
+ export type WorkerRequest = OpenRequest | RenderRequest | SearchRequest | CancelRequest | CloseRequest;
98
109
  export interface OpenedResponse {
99
110
  kind: 'opened';
100
111
  id: number;
@@ -119,6 +130,12 @@ export interface RenderedResponse {
119
130
  */
120
131
  alpha: AlphaEncoding;
121
132
  }
133
+ /** Plain data, structured-cloned whole: `DocRect`s and strings. */
134
+ export interface SearchedResponse {
135
+ kind: 'searched';
136
+ id: number;
137
+ matches: TextMatch[];
138
+ }
122
139
  export interface ErrorResponse {
123
140
  kind: 'error';
124
141
  /** Null for a failure that belongs to no single request. */
@@ -126,7 +143,7 @@ export interface ErrorResponse {
126
143
  code: PdfErrorCode;
127
144
  message: string;
128
145
  }
129
- export type WorkerResponse = OpenedResponse | RenderedResponse | ErrorResponse;
146
+ export type WorkerResponse = OpenedResponse | RenderedResponse | SearchedResponse | ErrorResponse;
130
147
  /**
131
148
  * Is this a message from our worker?
132
149
  *
@@ -33,5 +33,8 @@ export function isWorkerResponse(value) {
33
33
  if (typeof value !== 'object' || value === null)
34
34
  return false;
35
35
  const kind = value.kind;
36
- return kind === 'opened' || kind === 'rendered' || kind === 'error';
36
+ return (kind === 'opened' ||
37
+ kind === 'rendered' ||
38
+ kind === 'searched' ||
39
+ kind === 'error');
37
40
  }
@@ -180,6 +180,13 @@ export function createWorkerSession(deps) {
180
180
  let opening = null;
181
181
  let document = null;
182
182
  let disposed = false;
183
+ /**
184
+ * Renders AND searches, one queue. A search is a synchronous PDFium call
185
+ * like a render, wants the same between-jobs cancel window, and must not
186
+ * overtake a render posted before it — a page's text is parsed from the
187
+ * same loaded page the render uses, so keeping them in order keeps the
188
+ * engine's one-page cache hot for both.
189
+ */
183
190
  const queue = [];
184
191
  /**
185
192
  * Ids the main thread has given up on.
@@ -286,18 +293,27 @@ export function createWorkerSession(deps) {
286
293
  if (cancelled.delete(job.id))
287
294
  continue;
288
295
  try {
289
- const raster = target.render(job.request);
290
- const buffer = raster.bytes.buffer;
291
- deps.post({
292
- kind: 'rendered',
293
- id: job.id,
294
- bytes: buffer,
295
- width: raster.width,
296
- height: raster.height,
297
- rowBytes: raster.rowBytes,
298
- format: raster.format,
299
- alpha: raster.alpha,
300
- }, [buffer]);
296
+ if (job.kind === 'search') {
297
+ deps.post({
298
+ kind: 'searched',
299
+ id: job.id,
300
+ matches: target.search(job.request),
301
+ });
302
+ }
303
+ else {
304
+ const raster = target.render(job.request);
305
+ const buffer = raster.bytes.buffer;
306
+ deps.post({
307
+ kind: 'rendered',
308
+ id: job.id,
309
+ bytes: buffer,
310
+ width: raster.width,
311
+ height: raster.height,
312
+ rowBytes: raster.rowBytes,
313
+ format: raster.format,
314
+ alpha: raster.alpha,
315
+ }, [buffer]);
316
+ }
301
317
  }
302
318
  catch (error) {
303
319
  postError(job.id, error);
@@ -387,7 +403,8 @@ export function createWorkerSession(deps) {
387
403
  });
388
404
  return;
389
405
  }
390
- case 'render': {
406
+ case 'render':
407
+ case 'search': {
391
408
  queue.push(request);
392
409
  void drain();
393
410
  return;
@@ -158,6 +158,11 @@ export function PdfContentView({ content, zIndex, sampling = 'linear', paperColo
158
158
  const out = [];
159
159
  if (paperColor != null) {
160
160
  for (const page of content.pages) {
161
+ // A page outside the allow-list holds nothing and gets no paper: in a
162
+ // stacked layout its sheet would sit at the origin beside the one page
163
+ // that is meant to show (see PdfPageContent.drawable).
164
+ if (!page.drawable)
165
+ continue;
161
166
  const { pageRect } = page;
162
167
  out.push(_jsx(Rect, { x: pageRect.x, y: pageRect.y, width: pageRect.width, height: pageRect.height, color: paperColor }, `paper:${page.page}`));
163
168
  }
@@ -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, TextMatch, TextSearchOptions } 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 {
@@ -73,7 +83,22 @@ export interface PdfDocument {
73
83
  /** Intrinsic sizes of every page, in the order a `PdfPageLayout` expects. */
74
84
  pageSizes(): DocSize[];
75
85
  renderPage(index: number, options?: PdfRenderOptions): Promise<PdfPageRender>;
76
- /** True once `close()` has run. Every further call rejects with `cancelled`. */
86
+ /**
87
+ * Every occurrence of `query` on one page, in reading order.
88
+ *
89
+ * `TextMatch.rects` are in the page's doc space — PDF points, origin at the
90
+ * DISPLAYED page's top-left, y down — with `options.rotation` applied, so
91
+ * pass the same rotation the page's layer or `renderPage` uses and a hit
92
+ * drawn at a rect lands on the glyphs. An empty or whitespace-only query
93
+ * resolves `[]` without asking the backend. Rejects `backend-failure` for a
94
+ * page index out of range or a closed document, `cancelled` when
95
+ * `options.signal` aborts.
96
+ */
97
+ searchPage(page: number, query: string, options?: TextSearchOptions): Promise<TextMatch[]>;
98
+ /**
99
+ * True once `close()` has run. Every further `renderPage` rejects with
100
+ * `cancelled`, every further `searchPage` with `backend-failure`.
101
+ */
77
102
  readonly closed: boolean;
78
103
  /**
79
104
  * Release the underlying file handle. `usePdfDocument` owns this for documents
@@ -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}`);
@@ -201,6 +210,50 @@ export async function openPdfDocument(source, rasterizer) {
201
210
  },
202
211
  };
203
212
  },
213
+ async searchPage(page, query, options) {
214
+ // `backend-failure`, not `renderPage`'s `cancelled`: nothing was
215
+ // superseded — the caller asked a closed document a question, which is
216
+ // a bug in the caller, and "cancelled" is the one code a host is
217
+ // expected to swallow silently.
218
+ if (closed) {
219
+ throw new PdfError('backend-failure', 'searchPage called on a closed PdfDocument');
220
+ }
221
+ if (!Number.isInteger(page) || page < 0 || page >= handle.pageCount) {
222
+ throw new PdfError('backend-failure', `page index ${page} is out of range (pageCount ${handle.pageCount})`);
223
+ }
224
+ // Nothing to find, and every engine would either refuse the query or
225
+ // answer it with nothing — so neither is asked. This is also what makes
226
+ // a find box that clears itself free.
227
+ if (query.trim().length === 0) {
228
+ return [];
229
+ }
230
+ if (isAborted(options?.signal)) {
231
+ throw new PdfError('cancelled', 'searchPage aborted before it started');
232
+ }
233
+ // RESOLVED HERE, once, for every backend: a backend is handed the three
234
+ // options with no defaults of its own left to drift.
235
+ const request = {
236
+ page,
237
+ query,
238
+ matchCase: options?.matchCase ?? false,
239
+ wholeWord: options?.wholeWord ?? false,
240
+ rotation: options?.rotation ?? 0,
241
+ };
242
+ let matches;
243
+ try {
244
+ matches = await handle.searchText(request, options?.signal);
245
+ }
246
+ catch (cause) {
247
+ throw toPdfError(cause, `Failed to search page ${page}`);
248
+ }
249
+ // A backend that answered anyway — the web worker cannot interrupt a
250
+ // search it has started — must not hand hits to a caller that already
251
+ // moved on to the next keystroke.
252
+ if (isAborted(options?.signal)) {
253
+ throw new PdfError('cancelled', 'searchPage aborted');
254
+ }
255
+ return matches;
256
+ },
204
257
  close() {
205
258
  if (closed) {
206
259
  return;
@@ -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
+ }