@0k/pdf-viewer 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valentin Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,255 @@
1
- # Temporary Holding Version
1
+ # @0k/pdf-viewer
2
+
3
+ ## Purpose
4
+
5
+ `@0k/pdf-viewer` is a plain-TypeScript in-page PDF viewer. It owns the
6
+ toolbar, the page canvas, paging, zoom, pinch and pan gestures, the fit
7
+ policy, error and retry, accessible controls, scoped styles and
8
+ single-owner teardown. Hosts own the PDF URL and its access policy,
9
+ where the viewer mounts, the affordance around it, and translated
10
+ labels.
11
+
12
+ It renders on `@0k/pdfjs-legacy-runtime`, which owns the PDF.js
13
+ compatibility policy (exact `pdfjs-dist` 3.11.174 pin, legacy build,
14
+ worker wiring, single-owner teardown type). This package never imports
15
+ `pdfjs-dist`.
16
+
17
+ MIT licensed.
18
+
19
+ ## Install
20
+
21
+ ``` bash
22
+ npm install @0k/pdf-viewer
23
+ ```
24
+
25
+ npm ≥7 installs the exact peer `@0k/pdfjs-legacy-runtime` automatically.
26
+ The host's bundler sees one copy of the runtime and emits one PDF.js
27
+ worker asset.
28
+
29
+ ## Usage
30
+
31
+ ``` typescript
32
+ import { mountPdfViewer } from '@0k/pdf-viewer';
33
+ import '@0k/pdf-viewer/style.css';
34
+
35
+ const owner = mountPdfViewer(host, {
36
+ url: 'https://example.com/manual.pdf', // PDF.js range-loads it
37
+ fit: 'width', // or 'page'
38
+ interaction: 'scroll', // in-flow; 'gestures' (default) for full screen
39
+ labels: { previous: 'Page précédente' },
40
+ });
41
+ owner.next();
42
+ await owner.destroy(); // releases the worker; idempotent
43
+ ```
44
+
45
+ The host decides when to mount (lazily, on an "open" click, so a
46
+ collapsed widget costs nothing) and where; the viewer fills its host
47
+ element and is in-flow. A host wanting a full-screen reader wraps it in
48
+ its own overlay.
49
+
50
+ ## Options
51
+
52
+ `mountPdfViewer(host, options)` mounts into an `HTMLElement` and starts
53
+ loading. The required `url` is passed verbatim to PDF.js; hosts own
54
+ access policy, including any cross-origin permissions. Optional fields:
55
+
56
+ - `fit`: `"width"` (default) fills the surface width; `"page"` fits the
57
+ whole page within the surface box using the smaller width/height
58
+ ratio. Both policies follow surface size changes automatically; resize
59
+ bursts are debounced before the page is re-rendered.
60
+ - `zoom`: an object with `min` (0.5), `max` (2), `step` (0.25) and
61
+ `initial` (1). Values are fractions of the fitted size; the initial
62
+ value and subsequent zoom changes are clamped to the bounds.
63
+ - `maxDpr`: backing-canvas device pixel ratio cap, default 2.
64
+ - `interaction`: `"gestures"` (default) owns drag-to-pan, pinch and any
65
+ wheel zoom, and supports double-click reset. `"scroll"` leaves
66
+ one-finger swipes and bare wheel scrolling to the browser while pinch
67
+ and Ctrl/⌘+wheel zoom. `"none"` leaves only the toolbar controls
68
+ active.
69
+ - `labels`: partial overrides of the English defaults below.
70
+ - `onStateChange`: callback receiving a `PdfViewerState` snapshot when
71
+ loading starts, a raster commits, or an error occurs. The snapshot has
72
+ `status` (`"loading"`, `"ready"` or `"error"`), `page`, `pageCount`,
73
+ `zoom` and `error` (a string or `null`). A ready document can still be
74
+ awaiting its first raster; the canvas stays hidden until that commit.
75
+
76
+ The zoom live region changes when a gesture settles (last pointer lifted
77
+ or 250 ms of wheel quiet), not during movement. Toolbar zoom changes the
78
+ readout immediately.
79
+
80
+ ### Labels
81
+
82
+ `labels` accepts any subset of `PdfViewerLabels`:
83
+
84
+ | Key | English default |
85
+ |-------------|-------------------------------|
86
+ | `controls` | PDF controls |
87
+ | `previous` | Previous page |
88
+ | `next` | Next page |
89
+ | `zoomOut` | Zoom out |
90
+ | `zoomIn` | Zoom in |
91
+ | `zoomReset` | Reset zoom |
92
+ | `reset` | Reset zoom and pan |
93
+ | `retry` | Retry |
94
+ | `loading` | Loading… |
95
+ | `failed` | Failed to load PDF: %{reason} |
96
+
97
+ The `%{reason}` placeholder in `failed` is replaced by the literal error
98
+ message. Labels are rendered as text, not HTML; dollar sequences in an
99
+ error reason are preserved.
100
+
101
+ ### Owner handle
102
+
103
+ `mountPdfViewer` returns a `PdfViewerOwner` with a readonly `host` and:
104
+
105
+ - `state()`: return the current state snapshot, including live gesture
106
+ zoom.
107
+ - `goTo(page)`: floor and clamp the page number to the document range.
108
+ - `next()` and `previous()`: move one page within that range.
109
+ - `setZoom(zoom)`: clamp zoom, update the readout and re-render.
110
+ - `resetView()`: restore zoom 1 (clamped to the bounds), not `initial`,
111
+ and clear pan.
112
+ - `reload()`: return a promise for releasing the current document and
113
+ loading the same URL again. Superseded loads cannot overwrite a newer
114
+ load; startup waits for preceding task teardowns to settle.
115
+ - `destroy()`: remove the DOM and return a promise for worker teardown.
116
+ Idempotent, including before loading completes.
117
+
118
+ Paging and zoom methods do nothing unless the document is ready.
119
+ Mounting again on the same host replaces its viewer and waits for the
120
+ previous owner's teardown before creating a new worker. An old owner
121
+ cannot destroy the replacement.
122
+
123
+ ### Exported constants
124
+
125
+ - `PDF_VIEWER_CLASS`: `"pdf-viewer"`, the viewer root class.
126
+ - `PDF_VIEWER_MIN_ZOOM`: 0.5.
127
+ - `PDF_VIEWER_MAX_ZOOM`: 2.
128
+ - `PDF_VIEWER_ZOOM_STEP`: 0.25.
129
+ - `PDF_VIEWER_DEFAULT_ZOOM`: 1.
130
+ - `PDF_VIEWER_MAX_DPR`: 2.
131
+ - `PDF_VIEWER_WHEEL_FACTOR`: 0.9 per 100 CSS pixels of wheel-down
132
+ movement; wheel-up reverses the factor. Line deltas use 16 CSS pixels
133
+ per line, and page deltas use the surface's client height.
134
+ - `PDF_VIEWER_WHEEL_SETTLE_MS`: 250.
135
+ - `DEFAULT_PDF_VIEWER_LABELS`: the English label object above.
136
+
137
+ ## Theming
138
+
139
+ Import `@0k/pdf-viewer/style.css` separately; importing the JavaScript
140
+ does not inject styles. Override these properties on the `.pdf-viewer`
141
+ root with a selector loaded after the package stylesheet:
142
+
143
+ | Property | Default |
144
+ |----------------------------------------|---------------------------|
145
+ | `--pdf-viewer-surface` | transparent |
146
+ | `--pdf-viewer-page-background` | \#fff |
147
+ | `--pdf-viewer-page-border` | rgba(128, 128, 128, 0.2) |
148
+ | `--pdf-viewer-page-radius` | 4px |
149
+ | `--pdf-viewer-surface-max-height` | none |
150
+ | `--pdf-viewer-button-size` | 2.25rem |
151
+ | `--pdf-viewer-button-radius` | 6px |
152
+ | `--pdf-viewer-button-background` | rgba(128, 128, 128, 0.12) |
153
+ | `--pdf-viewer-button-background-hover` | rgba(128, 128, 128, 0.24) |
154
+ | `--pdf-viewer-button-border` | rgba(128, 128, 128, 0.25) |
155
+ | `--pdf-viewer-button-color` | inherit |
156
+ | `--pdf-viewer-focus` | rgba(118, 227, 234, 0.6) |
157
+ | `--pdf-viewer-info` | inherit |
158
+ | `--pdf-viewer-error` | rgb(220, 90, 90) |
159
+ | `--pdf-viewer-error-background` | rgba(239, 68, 68, 0.15) |
160
+ | `--pdf-viewer-error-border` | rgba(239, 68, 68, 0.35) |
161
+ | `--pdf-viewer-reset-background` | rgba(0, 0, 0, 0.55) |
162
+ | `--pdf-viewer-reset-background-hover` | rgba(0, 0, 0, 0.75) |
163
+ | `--pdf-viewer-reset-color` | \#fff |
164
+ | `--pdf-viewer-gap` | 6px |
165
+
166
+ Toolbar button hit areas retain a 44px minimum regardless of button
167
+ size. The viewer fills its host's width and grows with the page unless
168
+ bounded. Set `--pdf-viewer-surface-max-height` (for example `60vh`) to
169
+ cap the surface, or make the root a flex child with a bounded height.
170
+ The surface can shrink (`min-height: 0`); toolbar and status do not
171
+ flex. Scroll and toolbar-only modes use native overflow scrolling;
172
+ gesture mode hides overflow and pans with a transform. Fit-to-page uses
173
+ the resulting surface box, so a full-screen host must provide its own
174
+ height constraint.
175
+
176
+ ## Security
177
+
178
+ The pinned `pdfjs-dist` 3.11.174 is affected by CVE-2024-4367: a crafted
179
+ font can execute JavaScript when evaluation is enabled. The [runtime's
180
+ security note](https://github.com/0k/pdfjs-legacy-runtime#security)
181
+ explains its mitigation: every document is loaded with
182
+ `isEvalSupported: false`, applied after caller options so it cannot be
183
+ re-enabled. A Content-Security-Policy without `'unsafe-eval'` provides a
184
+ second layer. The version remains pinned for Chrome 91 compatibility;
185
+ the mitigation does not remove the version-based `npm audit` advisory.
186
+
187
+ PDF.js also has an optional `canvas` dependency for Node.js rendering.
188
+ Its install tooling pulls in `tar` advisories; it does not enter the
189
+ browser bundle. Browser-only hosts can skip optional dependencies with
190
+ `npm install --omit=optional`, or use an npm `overrides` entry to select
191
+ a patched transitive `tar` version compatible with their install
192
+ tooling. The latter changes installation tooling, not the browser PDF
193
+ runtime.
194
+
195
+ ## Development
196
+
197
+ ``` bash
198
+ npm install
199
+ npm run check # unit tests, strict typecheck, build, Chrome 91 audit
200
+ ```
201
+
202
+ With Python Playwright and system Chrome available,
203
+ `npm run test:browser` bundles the built package in a temporary host and
204
+ verifies real PDF.js worker loading, raster pixels, rapid
205
+ zoom/reload/replacement, CSS, teardown, scroll reachability, fitted
206
+ borders and gesture-settle positions.
207
+
208
+ To try an unreleased change in a host, link the checkout there:
209
+ `npm link <path-to-pdf-viewer>`.
210
+
211
+
212
+ ## Changelog
213
+
214
+
215
+ ### 0.2.0 (2026-10-28)
216
+
217
+ #### New
218
+
219
+ * Add the PDF page viewer with paging, zoom, gestures, fit, retry and teardown. [Valentin Lab]
220
+
221
+ ``mountPdfViewer(host, options)`` mounts a toolbar and a page canvas in the
222
+ host element and returns an owner handle: the ``host`` it mounted in, and
223
+ ``state``, ``goTo``, ``next``, ``previous``, ``setZoom``, ``resetView``,
224
+ ``reload`` and ``destroy``. The host passes a URL; the PDF reader
225
+ range-loads it through ``@0k/pdfjs-legacy-runtime``. An ``interaction``
226
+ option chooses full gestures (pinch, pan, wheel zoom), in-flow scrolling with
227
+ pinch zoom only, or the toolbar only (``none``). Load and render errors show
228
+ a retry control. Labels default to English and are configurable. Styles
229
+ are scoped under the viewer root, themed only through ``--pdf-viewer-*``
230
+ custom properties, and imported by the host from
231
+ ``@0k/pdf-viewer/style.css``.
232
+
233
+ Contract:
234
+ - The loading task is the only owner of worker teardown. A reload, or a
235
+ second mount on the same host, starts its worker only after every
236
+ previous teardown settled; a superseded load never overwrites a newer
237
+ one; destroy releases the canvas storage.
238
+ - The owner handle is a frozen facade, safe to keep in a host's reactive
239
+ state.
240
+ - The page on screen is never cleaned up, and pages render off-screen and
241
+ are committed in one step, so Android WebView 91 never shows a blank
242
+ canvas.
243
+ - Fitting accounts for the surface padding and borders and follows surface
244
+ resizes and the appearance of scrollbars, and settles when a scrollbar
245
+ toggles; a zoomed page stays reachable by scrolling in scroll and
246
+ toolbar-only modes.
247
+ - Wheel zoom scales with the scrolled distance and re-renders once, even
248
+ when a pointer press interrupts it. A settling gesture keeps the page in
249
+ place, and the zoom readout changes once the gesture settles (at once for
250
+ toolbar zoom).
251
+ - Error reasons are shown verbatim, an undefined label keeps its English
252
+ default, and a throwing ``onStateChange`` callback is logged without
253
+ affecting the viewer.
254
+
2
255
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
@@ -0,0 +1,105 @@
1
+ /** Class of the viewer root; every style is scoped under it. */
2
+ export declare const PDF_VIEWER_CLASS = "pdf-viewer";
3
+ /** Default zoom bounds and toolbar step (a fraction of the fitted size). */
4
+ export declare const PDF_VIEWER_MIN_ZOOM = 0.5;
5
+ export declare const PDF_VIEWER_MAX_ZOOM = 2;
6
+ export declare const PDF_VIEWER_ZOOM_STEP = 0.25;
7
+ export declare const PDF_VIEWER_DEFAULT_ZOOM = 1;
8
+ /**
9
+ * Cap on the effective device pixel ratio used to size the backing
10
+ * canvas. 2 is sharp enough while bounding memory on a 3× phone.
11
+ */
12
+ export declare const PDF_VIEWER_MAX_DPR = 2;
13
+ /** Zoom factor for 100 CSS pixels of wheel-down movement. */
14
+ export declare const PDF_VIEWER_WHEEL_FACTOR = 0.9;
15
+ /** Milliseconds of quiet that end a wheel burst — a wheel has no "up". */
16
+ export declare const PDF_VIEWER_WHEEL_SETTLE_MS = 250;
17
+ export interface PdfViewerLabels {
18
+ /** `aria-label` of the toolbar. */
19
+ readonly controls: string;
20
+ readonly previous: string;
21
+ readonly next: string;
22
+ readonly zoomOut: string;
23
+ readonly zoomIn: string;
24
+ /** Back to the fitted size (toolbar). */
25
+ readonly zoomReset: string;
26
+ /** Back to the resting view after a gesture (floating control). */
27
+ readonly reset: string;
28
+ readonly retry: string;
29
+ readonly loading: string;
30
+ /** Failure sentence; `%{reason}` is replaced by the error message. */
31
+ readonly failed: string;
32
+ }
33
+ export declare const DEFAULT_PDF_VIEWER_LABELS: PdfViewerLabels;
34
+ /**
35
+ * How the resting (100 %) zoom relates to the surface:
36
+ * - `width`: the page fills the surface width; taller pages scroll.
37
+ * Right for an in-flow reader in a prose column.
38
+ * - `page`: the whole page fits the surface box (smaller ratio wins).
39
+ * Right for a full-screen reader, where cropping a scanned page would
40
+ * hide the margin a stamp or signature sits in.
41
+ */
42
+ export type PdfViewerFit = "width" | "page";
43
+ export type PdfViewerInteraction = "gestures" | "scroll" | "none";
44
+ export type PdfViewerStatus = "loading" | "ready" | "error";
45
+ export interface PdfViewerState {
46
+ readonly status: PdfViewerStatus;
47
+ readonly page: number;
48
+ readonly pageCount: number;
49
+ readonly zoom: number;
50
+ readonly error: string | null;
51
+ }
52
+ export interface PdfViewerOptions {
53
+ /** The document URL, handed verbatim to PDF.js (range-loaded). */
54
+ readonly url: string;
55
+ readonly fit?: PdfViewerFit;
56
+ readonly labels?: {
57
+ readonly [K in keyof PdfViewerLabels]?: string | undefined;
58
+ };
59
+ readonly zoom?: {
60
+ readonly min?: number;
61
+ readonly max?: number;
62
+ readonly step?: number;
63
+ readonly initial?: number;
64
+ };
65
+ readonly maxDpr?: number;
66
+ /**
67
+ * How the page surface reacts to pointers and the wheel:
68
+ * - `gestures` (default): the surface owns every gesture — drag pans,
69
+ * pinch and any wheel zoom. Right for a full-screen reader, where
70
+ * there is nothing else to scroll.
71
+ * - `scroll`: the BROWSER keeps scrolling (one-finger swipe, bare
72
+ * wheel), so a reader embedded in a scrolling page never traps the
73
+ * reader's finger; pinch and Ctrl/⌘+wheel (what a touchpad pinch
74
+ * emits) still zoom the page.
75
+ * - `none`: toolbar only.
76
+ */
77
+ readonly interaction?: PdfViewerInteraction;
78
+ readonly onStateChange?: (state: PdfViewerState) => void;
79
+ }
80
+ /** The handle a host keeps for the viewer it mounted. */
81
+ export interface PdfViewerOwner {
82
+ readonly host: HTMLElement;
83
+ state(): PdfViewerState;
84
+ goTo(page: number): void;
85
+ next(): void;
86
+ previous(): void;
87
+ setZoom(zoom: number): void;
88
+ /** Restore the resting view: zoom 1, no pan. */
89
+ resetView(): void;
90
+ /** Release the current document (if any) and load the URL again. */
91
+ reload(): Promise<void>;
92
+ /**
93
+ * Release the loading task (the worker), remove the viewer DOM and
94
+ * stop listening. Idempotent; safe on a viewer that never loaded.
95
+ */
96
+ destroy(): Promise<void>;
97
+ }
98
+ /**
99
+ * Mount a viewer into `host` and start loading `options.url`.
100
+ *
101
+ * Mounting twice on one host replaces the previous viewer, releasing its
102
+ * loading task before the replacement starts its worker. The returned
103
+ * owner acts only on the viewer it mounted.
104
+ */
105
+ export declare function mountPdfViewer(host: HTMLElement, options: PdfViewerOptions): PdfViewerOwner;