From af0d9642aee018cf91647ee147d4efd7ab308989 Mon Sep 17 00:00:00 2001 From: Joackim Pennerup Date: Fri, 12 Jun 2026 14:02:09 +0200 Subject: [PATCH] feat(plugin-zoom): add usePhysicalScaling option When enabled, numeric zoom values are treated as user-space/logical values and multiplied by (96/72) x devicePixelRatio to produce the effective render scale. This makes 100% zoom map to physical inches on-screen (Acrobat-style), where 1 PDF point equals roughly 1.333 CSS pixels on a 96 DPI display. Changes: - ZoomPluginConfig.usePhysicalScaling?: boolean (opt-in, default false) - ZoomDocumentState.currentUserZoomLevel: number - user-space scale exposed for UI display (toolbar, translation strings) - ZoomScope.getDpr() / ZoomCapability.getDpr() - returns the active (96/72) x devicePixelRatio multiplier, or 1 when disabled - handleRequest splits numeric and mode paths: numeric clamps in user-space then scales by DPR; mode path returns effective scale unchanged - zoomIn/zoomOut/requestZoomBy operate in user-space throughout - handleZoomToArea clamps fit-ratio in effective-space to preserve full range - DPR-change listener with 150ms debounce recalculates open documents on display scale change; listener torn down in destroy() - Gesture utilities (pinch, wheel) convert initialZoom to user-space before computing delta, preventing double-application of the DPR factor - Snippet toolbar and translation strings read currentUserZoomLevel so the displayed percentage always reflects the user-space value - pnpm-workspace.yaml: exclude .vercel build artifacts from workspace scan Backwards compatibility: when usePhysicalScaling is unset (default false), getDpr() returns 1 and currentUserZoomLevel === currentZoomLevel at all times, preserving bit-identical behaviour with previous releases. --- .changeset/zoom-physical-scaling.md | 26 ++ packages/plugin-zoom/package.json | 5 +- .../src/lib/__tests__/zoom-plugin.dpr.test.ts | 341 ++++++++++++++++++ packages/plugin-zoom/src/lib/actions.ts | 7 +- packages/plugin-zoom/src/lib/reducer.ts | 4 +- packages/plugin-zoom/src/lib/types.ts | 35 +- packages/plugin-zoom/src/lib/zoom-plugin.ts | 141 ++++++-- .../src/shared/utils/pinch-zoom-logic.ts | 6 +- .../src/shared/utils/zoom-gesture-logic.ts | 6 +- packages/plugin-zoom/tsconfig.json | 1 + pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 4 +- .../src/components/custom-zoom-toolbar.tsx | 2 +- viewers/snippet/src/config/translations.ts | 2 +- .../react/headless/plugins/plugin-zoom.mdx | 7 +- .../docs/react/viewer/plugins/plugin-zoom.mdx | 15 +- .../docs/snippet/plugins/plugin-zoom.mdx | 15 +- .../svelte/headless/plugins/plugin-zoom.mdx | 7 +- .../svelte/viewer/plugins/plugin-zoom.mdx | 15 +- .../docs/vue/headless/plugins/plugin-zoom.mdx | 7 +- .../docs/vue/viewer/plugins/plugin-zoom.mdx | 17 +- 21 files changed, 629 insertions(+), 40 deletions(-) create mode 100644 .changeset/zoom-physical-scaling.md create mode 100644 packages/plugin-zoom/src/lib/__tests__/zoom-plugin.dpr.test.ts diff --git a/.changeset/zoom-physical-scaling.md b/.changeset/zoom-physical-scaling.md new file mode 100644 index 000000000..607049125 --- /dev/null +++ b/.changeset/zoom-physical-scaling.md @@ -0,0 +1,26 @@ +--- +"@embedpdf/plugin-zoom": minor +--- + +Add opt-in `usePhysicalScaling` config option to the zoom plugin. + +When `usePhysicalScaling: true`, every numeric zoom request is treated as a +user-space / logical value and multiplied by `(96 / 72) × devicePixelRatio` +before being applied to the rendering pipeline: + +- `96 / 72` is the fixed CSS-px-per-PDF-pt constant (1 CSS inch = 96 px, + 1 PDF point = 1/72 inch), ensuring "100 %" maps to physical inches on any + screen regardless of pixel density. +- `devicePixelRatio` accounts for OS display scaling so the rendered size + remains correct when the window moves between monitors or the OS zoom changes. + +At 100 % zoom on a 96 DPI, DPR=1 screen an A4 page is ~794 CSS px wide +(its true physical width of 8.27 in × 96 px/in), matching Acrobat's +"Use system setting" behaviour. + +New additions: +- `ZoomPluginConfig.usePhysicalScaling?: boolean` — opt-in flag (default `false`; behaviour is bit-identical to previous releases when unset). +- `ZoomDocumentState.currentUserZoomLevel: number` — user-space scale (= `currentZoomLevel / effectiveMultiplier`). Always equals `currentZoomLevel` when `usePhysicalScaling` is off. +- `ZoomScope.getDpr(): number` / `ZoomCapability.getDpr(): number` — returns the active scale multiplier `(96/72) × devicePixelRatio`, or 1 when disabled. + +Fit modes (`fit-width`, `fit-page`, `automatic`) are unaffected and continue to fit the viewport in CSS-pixel space. diff --git a/packages/plugin-zoom/package.json b/packages/plugin-zoom/package.json index fa5452342..1e2fd323c 100644 --- a/packages/plugin-zoom/package.json +++ b/packages/plugin-zoom/package.json @@ -43,7 +43,8 @@ "build": "pnpm run clean && concurrently -c auto -n base,react,preact,vue,svelte \"vite build --mode base\" \"vite build --mode react\" \"vite build --mode preact\" \"vite build --mode vue\" \"vite build --mode svelte\"", "clean": "rimraf dist", "lint": "eslint src --color", - "lint:fix": "eslint src --color --fix" + "lint:fix": "eslint src --color --fix", + "test": "jest" }, "dependencies": { "@embedpdf/models": "workspace:*" @@ -56,6 +57,8 @@ "@embedpdf/plugin-interaction-manager": "workspace:*", "@embedpdf/plugin-spread": "workspace:*", "@types/react": "^18.2.0", + "@types/jest": "^30.0.0", + "jest": "^30.2.0", "typescript": "^5.0.0" }, "peerDependencies": { diff --git a/packages/plugin-zoom/src/lib/__tests__/zoom-plugin.dpr.test.ts b/packages/plugin-zoom/src/lib/__tests__/zoom-plugin.dpr.test.ts new file mode 100644 index 000000000..a16d1f864 --- /dev/null +++ b/packages/plugin-zoom/src/lib/__tests__/zoom-plugin.dpr.test.ts @@ -0,0 +1,341 @@ +/** + * Unit tests for the DPR-scaling feature of the zoom plugin. + * + * These tests exercise the reducer layer (state transitions) and the + * handleRequest logic indirectly through the action payload — which is the + * part that is fully portable without a DOM or plugin registry. + * + * Test conventions mirror packages/models/src/geometry.test.ts (Jest globals). + */ + +import { zoomReducer, initialDocumentState, initialState } from '../reducer'; +import { setZoomLevel } from '../actions'; +import { ZoomMode, ZoomDocumentState } from '../types'; + +// --------------------------------------------------------------------------- +// Reducer tests — state transitions for SET_ZOOM_LEVEL +// --------------------------------------------------------------------------- + +describe('zoomReducer – SET_ZOOM_LEVEL', () => { + const docId = 'doc-1'; + const stateWithDoc = { + ...initialState, + documents: { [docId]: { ...initialDocumentState } }, + }; + + test('initialDocumentState has currentUserZoomLevel: 1', () => { + expect(initialDocumentState.currentUserZoomLevel).toBe(1); + }); + + test('writes all three fields when usePhysicalScaling is off (user === effective)', () => { + const action = setZoomLevel(docId, 1, 1, 1); + const next = zoomReducer(stateWithDoc, action); + const doc = next.documents[docId] as ZoomDocumentState; + expect(doc.zoomLevel).toBe(1); + expect(doc.currentZoomLevel).toBe(1); + expect(doc.currentUserZoomLevel).toBe(1); + }); + + test('stores user-space in zoomLevel for numeric requests (usePhysicalScaling=true, dpr=2)', () => { + // Simulates: requestZoom(1) on a dpr=2 display. + // newUser = 1 (user-space, clamped) + // newEffective = 2 (1 * dpr) + // stored zoomLevel should be 1 (user-space) — so "100%" preset highlights + const action = setZoomLevel(docId, 1 /* newUser */, 2 /* newEffective */, 1 /* newUser */); + const next = zoomReducer(stateWithDoc, action); + const doc = next.documents[docId] as ZoomDocumentState; + expect(doc.zoomLevel).toBe(1); + expect(doc.currentZoomLevel).toBe(2); + expect(doc.currentUserZoomLevel).toBe(1); + }); + + test('requestZoom(0.5) on dpr=2 → effective 1, user 0.5', () => { + const action = setZoomLevel(docId, 0.5, 1, 0.5); + const next = zoomReducer(stateWithDoc, action); + const doc = next.documents[docId] as ZoomDocumentState; + expect(doc.zoomLevel).toBe(0.5); + expect(doc.currentZoomLevel).toBe(1); + expect(doc.currentUserZoomLevel).toBe(0.5); + }); + + test('fit-width mode stores the mode string in zoomLevel', () => { + // For mode requests, zoomLevel = the mode enum, not a number. + // Simulates: FitWidth resolves to effective=0.7, user=0.35 on dpr=2. + const action = setZoomLevel(docId, ZoomMode.FitWidth, 0.7, 0.35); + const next = zoomReducer(stateWithDoc, action); + const doc = next.documents[docId] as ZoomDocumentState; + expect(doc.zoomLevel).toBe(ZoomMode.FitWidth); + expect(doc.currentZoomLevel).toBe(0.7); + expect(doc.currentUserZoomLevel).toBe(0.35); + }); + + test('usePhysicalScaling=false: user === effective at every zoom level', () => { + // When dpr=1, newUser === newEffective. This exercises the backwards-compat invariant. + const testCases: [number, number][] = [ + [0.25, 0.25], + [1, 1], + [1.5, 1.5], + [2, 2], + [4, 4], + ]; + for (const [zoom, expected] of testCases) { + const action = setZoomLevel(docId, zoom, zoom, zoom); + const next = zoomReducer(stateWithDoc, action); + const doc = next.documents[docId] as ZoomDocumentState; + expect(doc.currentZoomLevel).toBe(expected); + expect(doc.currentUserZoomLevel).toBe(expected); + } + }); + + test('minZoom clamp on numeric path (user-space): requestZoom(0.1) → user=0.25 (dpr=2 → eff=0.5)', () => { + // Simulates handleRequest numeric path with minZoom=0.25: + // level=0.1, clamped user=0.25, effective=0.25*2=0.5 + const action = setZoomLevel(docId, 0.25, 0.5, 0.25); + const next = zoomReducer(stateWithDoc, action); + const doc = next.documents[docId] as ZoomDocumentState; + expect(doc.zoomLevel).toBe(0.25); + expect(doc.currentZoomLevel).toBe(0.5); + expect(doc.currentUserZoomLevel).toBe(0.25); + }); + + test('maxZoom clamp on numeric path (user-space): requestZoom(20) maxZoom=10 → user=10, eff=20', () => { + const action = setZoomLevel(docId, 10, 20, 10); + const next = zoomReducer(stateWithDoc, action); + const doc = next.documents[docId] as ZoomDocumentState; + expect(doc.zoomLevel).toBe(10); + expect(doc.currentZoomLevel).toBe(20); + expect(doc.currentUserZoomLevel).toBe(10); + }); + + test('returns unchanged state for unknown documentId', () => { + const action = setZoomLevel('unknown-doc', 1, 2, 1); + const next = zoomReducer(stateWithDoc, action); + expect(next).toBe(stateWithDoc); // referential equality: no mutation + }); +}); + +// --------------------------------------------------------------------------- +// getDpr() logic (pure function extracted for unit testing) +// --------------------------------------------------------------------------- + +describe('getDpr logic', () => { + // 1 CSS inch = 96 px, 1 PDF pt = 1/72 inch → multiplier = 96/72 + const PT_TO_CSS_PX = 96 / 72; + + /** + * Inline re-implementation of the private getDpr() logic so we can unit-test + * it without instantiating the full plugin. This mirrors zoom-plugin.ts exactly. + */ + function getDpr(usePhysicalScaling: boolean, devicePixelRatio: number | undefined): number { + if (!usePhysicalScaling) return 1; + if (typeof window === 'undefined') return 1; + return PT_TO_CSS_PX * (devicePixelRatio || 1); + } + + test('returns 1 when usePhysicalScaling is false', () => { + expect(getDpr(false, 2)).toBe(1); + expect(getDpr(false, 3)).toBe(1); + expect(getDpr(false, undefined)).toBe(1); + }); + + test('returns PT_TO_CSS_PX × devicePixelRatio when usePhysicalScaling is true', () => { + expect(getDpr(true, 1)).toBeCloseTo(96 / 72); // DPR=1 → ~1.333 + expect(getDpr(true, 2)).toBeCloseTo((96 / 72) * 2); // DPR=2 → ~2.667 + expect(getDpr(true, 1.5)).toBeCloseTo((96 / 72) * 1.5); + }); + + test('falls back to PT_TO_CSS_PX when devicePixelRatio is 0/undefined', () => { + expect(getDpr(true, 0)).toBeCloseTo(96 / 72); + expect(getDpr(true, undefined)).toBeCloseTo(96 / 72); + }); + + test('A4 width at 100% zoom, DPR=1 → ~794 CSS px (physical size)', () => { + // A4 = 210mm = 595.28 PDF points. At physical size: 595.28 × (96/72) ≈ 793.7 CSS px. + const a4Pts = (210 * 72) / 25.4; // ≈ 595.28 + const scale = getDpr(true, 1); + expect(a4Pts * scale).toBeCloseTo(793.7, 0); + }); +}); + +// --------------------------------------------------------------------------- +// Numeric path arithmetic (pure logic, no DOM) +// --------------------------------------------------------------------------- + +describe('handleRequest numeric path arithmetic', () => { + const minZoom = 0.25; + const maxZoom = 10; + const floor3 = (x: number) => Math.floor(x * 1000) / 1000; + + /** + * Inline re-implementation of the numeric path from handleRequest. + */ + function numericPath( + level: number, + delta: number, + dpr: number, + ): { newEffective: number; newUser: number } { + const userBase = Math.min(Math.max(level + delta, minZoom), maxZoom); + const newUser = floor3(userBase); + const newEffective = floor3(userBase * dpr); + return { newEffective, newUser }; + } + + // Full getDpr() value with usePhysicalScaling=true, DPR=1: (96/72) × 1 ≈ 1.333 + const SCALE_DPR1 = 96 / 72; + // Full getDpr() value with usePhysicalScaling=true, DPR=2: (96/72) × 2 ≈ 2.667 + const SCALE_DPR2 = (96 / 72) * 2; + + test('usePhysicalScaling=false (getDpr=1): effective === user (backwards compat)', () => { + expect(numericPath(1, 0, 1)).toEqual({ newEffective: 1, newUser: 1 }); + expect(numericPath(1.5, 0, 1)).toEqual({ newEffective: 1.5, newUser: 1.5 }); + }); + + test('usePhysicalScaling=true, DPR=1: level=1 → user=1, effective≈1.333', () => { + const result = numericPath(1, 0, SCALE_DPR1); + expect(result.newUser).toBe(1); + expect(result.newEffective).toBeCloseTo(96 / 72, 3); + }); + + test('usePhysicalScaling=true, DPR=1: A4 at 100% → ~794 CSS px', () => { + const a4Pts = Math.floor(((210 * 72) / 25.4) * 1000) / 1000; // ≈ 595.275 + const result = numericPath(1, 0, SCALE_DPR1); + expect(a4Pts * result.newEffective).toBeCloseTo(793.7, 0); + }); + + test('usePhysicalScaling=true, DPR=2: level=1 → user=1, effective≈2.667', () => { + const result = numericPath(1, 0, SCALE_DPR2); + expect(result.newUser).toBe(1); + expect(result.newEffective).toBeCloseTo((96 / 72) * 2, 3); + }); + + test('zoomIn: level=1, delta=0.2, DPR=1 → user=1.2, effective≈1.6', () => { + const result = numericPath(1, 0.2, SCALE_DPR1); + expect(result.newUser).toBe(1.2); + expect(result.newEffective).toBeCloseTo(1.2 * (96 / 72), 3); + }); + + test('zoomOut: level=1.2, delta=-0.2, DPR=1 → user=1, effective≈1.333', () => { + const result = numericPath(1.2, -0.2, SCALE_DPR1); + expect(result.newUser).toBe(1); + expect(result.newEffective).toBeCloseTo(96 / 72, 3); + }); + + test('clamps to minZoom (user-space): level=0.1 → user=0.25, DPR=1', () => { + const result = numericPath(0.1, 0, SCALE_DPR1); + expect(result.newUser).toBe(0.25); + expect(result.newEffective).toBeCloseTo(0.25 * (96 / 72), 3); + }); + + test('clamps to maxZoom (user-space): level=20 → user=10, DPR=1', () => { + const result = numericPath(20, 0, SCALE_DPR1); + expect(result.newUser).toBe(10); + expect(result.newEffective).toBeCloseTo(10 * (96 / 72), 3); + }); + + test('quantization consistency: newEffective === floor(newUser * getDpr() * 1000) / 1000', () => { + // Verifies Fix 1: effective is derived from quantized user, not raw userBase. + // With userBase=1.0005, getDpr()=SCALE_DPR1: newUser=1.000, newEffective must be + // floor(1.000 × (96/72) × 1000) / 1000 — not derived from unquantized 1.0005. + const floor3 = (x: number) => Math.floor(x * 1000) / 1000; + const userBase = 1.0005; + const clamped = Math.min(Math.max(userBase, minZoom), maxZoom); + const nUser = floor3(clamped); // 1.000 + const nEffective = floor3(nUser * SCALE_DPR1); + expect(nEffective).toBe(floor3(nUser * SCALE_DPR1)); + // Must NOT equal floor3(userBase * SCALE_DPR1) when they differ + expect(nEffective).toBeCloseTo(96 / 72, 3); + }); + + test('requestZoomBy(+0.2) from user=1 → user=1.2, DPR=1', () => { + // requestZoomBy computes: target = toZoom(curUser + delta), then numeric path. + const curUser = 1; + const delta = 0.2; + const target = parseFloat(Math.min(Math.max(curUser + delta, minZoom), maxZoom).toFixed(2)); + const result = numericPath(target, 0, SCALE_DPR1); + expect(result.newUser).toBe(1.2); + expect(result.newEffective).toBeCloseTo(1.2 * (96 / 72), 3); + }); +}); + +// --------------------------------------------------------------------------- +// zoomToArea conversion: clamp in effective space, then convert to user-space +// --------------------------------------------------------------------------- + +describe('zoomToArea effective-space clamp and user conversion', () => { + const minZoom = 0.25; + const maxZoom = 10; + const PT_TO_CSS_PX = 96 / 72; + + // getDpr() with usePhysicalScaling=true: PT_TO_CSS_PX × devicePixelRatio + const SCALE_DPR1 = PT_TO_CSS_PX * 1; + const SCALE_DPR2 = PT_TO_CSS_PX * 2; + + /** + * Inline re-implementation of the fixed zoomToArea conversion (Fix 2). + * Clamps fitRatio against effective-space bounds so the full user range is reachable. + */ + function zoomToAreaTarget(fitRatio: number, scale: number): number { + const clampEffective = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi); + return Math.floor(clampEffective(fitRatio, minZoom * scale, maxZoom * scale) / scale * 1000) / 1000; + } + + test('usePhysicalScaling=false (scale=1): targetUser === fitRatio (backwards compat)', () => { + expect(zoomToAreaTarget(0.9, 1)).toBe(0.9); + expect(zoomToAreaTarget(1.4, 1)).toBe(1.4); + }); + + test('usePhysicalScaling=true, DPR=1: fitRatio=1.4 → targetUser = 1.4 / (96/72) ≈ 1.05', () => { + expect(zoomToAreaTarget(1.4, SCALE_DPR1)).toBeCloseTo(1.4 / PT_TO_CSS_PX, 2); + }); + + test('usePhysicalScaling=true, DPR=2: fitRatio=1.4 → targetUser = 1.4 / ((96/72)×2)', () => { + expect(zoomToAreaTarget(1.4, SCALE_DPR2)).toBeCloseTo(1.4 / SCALE_DPR2, 2); + }); + + test('usePhysicalScaling=true, DPR=1: tiny fit ratio below minZoom*scale clamps correctly', () => { + // fitRatio=0.1, minZoom*scale = 0.25*(96/72) ≈ 0.333 → clamps to minZoom=0.25 + expect(zoomToAreaTarget(0.1, SCALE_DPR1)).toBe(minZoom); + }); + + test('usePhysicalScaling=true, DPR=1: fitRatio above maxZoom*scale clamps to maxZoom', () => { + // maxZoom*scale = 10*(96/72) ≈ 13.33 + expect(zoomToAreaTarget(20, SCALE_DPR1)).toBe(maxZoom); + }); +}); + +// --------------------------------------------------------------------------- +// Pinch gesture delta conversion +// --------------------------------------------------------------------------- + +describe('pinch gesture user-space delta', () => { + const PT_TO_CSS_PX = 96 / 72; + + test('usePhysicalScaling=false (scale=1): gesture delta is unchanged (backwards compat)', () => { + const currentScale = 1.5; + const initialZoom = 1; // effective scale = 1 when feature is off + const scale = 1; + const initialUser = initialZoom / scale; + const delta = (currentScale - 1) * initialUser; + expect(delta).toBeCloseTo(0.5); + }); + + test('usePhysicalScaling=true, DPR=1: delta is in user-space (divided by PT_TO_CSS_PX)', () => { + // At 100% user zoom, effective = PT_TO_CSS_PX ≈ 1.333 + const currentScale = 1.5; + const initialZoom = PT_TO_CSS_PX; // effective when user is at 100% + const scale = PT_TO_CSS_PX; // getDpr() with DPR=1 + const initialUser = initialZoom / scale; // = 1.0 + const delta = (currentScale - 1) * initialUser; + // user-space delta: (1.5 - 1) × 1 = 0.5 → result: 150% user zoom + expect(delta).toBeCloseTo(0.5); + }); + + test('usePhysicalScaling=true, DPR=2: delta is in user-space (divided by full scale)', () => { + const SCALE_DPR2 = PT_TO_CSS_PX * 2; + const currentScale = 1.5; + const initialZoom = SCALE_DPR2; // effective when user is at 100%, DPR=2 + const initialUser = initialZoom / SCALE_DPR2; // = 1.0 + const delta = (currentScale - 1) * initialUser; + expect(delta).toBeCloseTo(0.5); + }); +}); diff --git a/packages/plugin-zoom/src/lib/actions.ts b/packages/plugin-zoom/src/lib/actions.ts index f636713a1..49a2b1d10 100644 --- a/packages/plugin-zoom/src/lib/actions.ts +++ b/packages/plugin-zoom/src/lib/actions.ts @@ -35,6 +35,7 @@ export interface SetZoomLevelAction extends Action { documentId: string; zoomLevel: ZoomLevel; currentZoomLevel: number; + currentUserZoomLevel: number; }; } @@ -70,8 +71,12 @@ export function setZoomLevel( documentId: string, zoomLevel: ZoomLevel, currentZoomLevel: number, + currentUserZoomLevel: number, ): SetZoomLevelAction { - return { type: SET_ZOOM_LEVEL, payload: { documentId, zoomLevel, currentZoomLevel } }; + return { + type: SET_ZOOM_LEVEL, + payload: { documentId, zoomLevel, currentZoomLevel, currentUserZoomLevel }, + }; } export function setMarqueeZoomActive( diff --git a/packages/plugin-zoom/src/lib/reducer.ts b/packages/plugin-zoom/src/lib/reducer.ts index 5bad66743..0a007a9ba 100644 --- a/packages/plugin-zoom/src/lib/reducer.ts +++ b/packages/plugin-zoom/src/lib/reducer.ts @@ -12,6 +12,7 @@ import { ZoomState, ZoomDocumentState, ZoomMode } from './types'; export const initialDocumentState: ZoomDocumentState = { zoomLevel: ZoomMode.Automatic, currentZoomLevel: 1, + currentUserZoomLevel: 1, isMarqueeZoomActive: false, }; @@ -53,7 +54,7 @@ export const zoomReducer: Reducer = (state = initialState } case SET_ZOOM_LEVEL: { - const { documentId, zoomLevel, currentZoomLevel } = action.payload; + const { documentId, zoomLevel, currentZoomLevel, currentUserZoomLevel } = action.payload; const docState = state.documents[documentId]; if (!docState) return state; @@ -65,6 +66,7 @@ export const zoomReducer: Reducer = (state = initialState ...docState, zoomLevel, currentZoomLevel, + currentUserZoomLevel, }, }, }; diff --git a/packages/plugin-zoom/src/lib/types.ts b/packages/plugin-zoom/src/lib/types.ts index bac74cea3..c3c0d4c5c 100644 --- a/packages/plugin-zoom/src/lib/types.ts +++ b/packages/plugin-zoom/src/lib/types.ts @@ -56,7 +56,10 @@ export interface RegisterMarqueeOnPageOptions { // Per-document zoom state export interface ZoomDocumentState { zoomLevel: ZoomLevel; // last **requested** level - currentZoomLevel: number; // actual numeric factor + currentZoomLevel: number; // actual numeric factor (effective / render scale) + // user-space scale = currentZoomLevel / DPR + // equals currentZoomLevel when usePhysicalScaling is off + currentUserZoomLevel: number; isMarqueeZoomActive: boolean; // whether marquee zoom mode is active } @@ -72,6 +75,12 @@ export interface ZoomScope { toggleMarqueeZoom(): void; isMarqueeZoomActive(): boolean; getState(): ZoomDocumentState; + /** + * The combined physical-scale multiplier currently in effect: + * `(96 / 72) × devicePixelRatio` — the pt-to-CSS-px constant times the + * device pixel ratio. Returns 1 when `usePhysicalScaling` is disabled. + */ + getDpr(): number; onZoomChange: EventHook; onStateChange: EventHook; } @@ -88,6 +97,12 @@ export interface ZoomCapability { toggleMarqueeZoom(): void; isMarqueeZoomActive(): boolean; getState(): ZoomDocumentState; + /** + * The combined physical-scale multiplier currently in effect: + * `(96 / 72) × devicePixelRatio` — the pt-to-CSS-px constant times the + * device pixel ratio. Returns 1 when `usePhysicalScaling` is disabled. + */ + getDpr(): number; // Document-scoped operations forDocument(documentId: string): ZoomScope; @@ -124,6 +139,24 @@ export interface ZoomPluginConfig extends BasePluginConfig { zoomStep?: number; zoomRanges?: ZoomRangeStep[]; presets?: ZoomPreset[]; + /** + * When true, treat all numeric zoom values as logical / user-space values + * and multiply them by `(96 / 72) × devicePixelRatio` to obtain the actual + * render scale. The `96/72` factor converts PDF points to CSS pixels + * (1 CSS inch = 96 px, 1 PDF point = 1/72 inch), ensuring 100 % maps to + * the display's physical DPI. The `devicePixelRatio` factor then keeps the + * rendered size correct as the OS display scale changes. + * + * At 100 % zoom on a standard 96 DPI, DPR=1 screen, an A4 page is ~794 CSS + * pixels wide — its true physical width — matching Acrobat's "Use system + * setting" behaviour. + * + * Fit modes (`fit-width`, `fit-page`, `automatic`) are unaffected — they + * continue to fit the viewport in CSS-pixel space. + * + * Default: `false` (1 PDF point = 1 CSS pixel — CSS-spec behaviour). + */ + usePhysicalScaling?: boolean; } export interface ZoomState { diff --git a/packages/plugin-zoom/src/lib/zoom-plugin.ts b/packages/plugin-zoom/src/lib/zoom-plugin.ts index bd7b94c0f..1595ea987 100644 --- a/packages/plugin-zoom/src/lib/zoom-plugin.ts +++ b/packages/plugin-zoom/src/lib/zoom-plugin.ts @@ -64,6 +64,14 @@ export class ZoomPlugin extends BasePlugin< private readonly minZoom: number; private readonly maxZoom: number; private readonly zoomStep: number; + private readonly usePhysicalScaling: boolean; + // 1 CSS inch = 96 px, 1 PDF point = 1/72 inch → 1 pt = 96/72 CSS px. + private static readonly PT_TO_CSS_PX = 96 / 72; + + // Active matchMedia query + listener — stored so destroy() can tear them down. + private dprMql: MediaQueryList | null = null; + private dprMqlListener: (() => void) | null = null; + private dprDebounceTimer: ReturnType | null = null; constructor(id: string, registry: PluginRegistry, cfg: ZoomPluginConfig) { super(id, registry); @@ -82,6 +90,38 @@ export class ZoomPlugin extends BasePlugin< this.defaultZoomLevel = cfg.defaultZoomLevel; this.presets = cfg.presets ?? []; this.zoomRanges = this.normalizeRanges(cfg.zoomRanges ?? []); + this.usePhysicalScaling = cfg.usePhysicalScaling ?? false; + + // Set up DPR change listener when usePhysicalScaling is enabled. + // matchMedia fires once per threshold, so we re-subscribe after each change. + // The fan-out is debounced (150 ms, matching viewport-resize) to avoid burst + // recalculations during display-scaling animations. + if (this.usePhysicalScaling && typeof window !== 'undefined') { + const onDprChange = () => { + if (this.dprDebounceTimer !== null) clearTimeout(this.dprDebounceTimer); + this.dprDebounceTimer = setTimeout(() => { + this.dprDebounceTimer = null; + for (const id of Object.keys(this.state.documents)) { + this.handleRequest({ level: this.state.documents[id].zoomLevel }, id); + } + }, 150); + }; + const subscribe = () => { + // Explicitly remove the previous listener before overwriting the stored + // references — safe even if it already auto-removed via { once: true }. + const prevMql = this.dprMql; + const prevListener = this.dprMqlListener; + this.dprMql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + const listener = () => { + onDprChange(); + subscribe(); + }; + this.dprMqlListener = listener; + this.dprMql.addEventListener('change', listener, { once: true }); + prevMql?.removeEventListener('change', prevListener!); + }; + subscribe(); + } // Keep automatic modes up to date per document this.viewport.onViewportResize( @@ -182,6 +222,7 @@ export class ZoomPlugin extends BasePlugin< toggleMarqueeZoom: () => this.toggleMarqueeZoom(), isMarqueeZoomActive: () => this.isMarqueeZoomActive(), getState: () => this.getDocumentStateOrThrow(), + getDpr: () => this.getDpr(), // Document-scoped operations forDocument: (documentId: string) => this.createZoomScope(documentId), @@ -212,6 +253,7 @@ export class ZoomPlugin extends BasePlugin< toggleMarqueeZoom: () => this.toggleMarqueeZoom(documentId), isMarqueeZoomActive: () => this.isMarqueeZoomActive(documentId), getState: () => this.getDocumentStateOrThrow(documentId), + getDpr: () => this.getDpr(), onZoomChange: (listener: Listener) => this.zoom$.on((event) => { if (event.documentId === documentId) listener(event); @@ -240,6 +282,16 @@ export class ZoomPlugin extends BasePlugin< return state; } + // ───────────────────────────────────────────────────────── + // DPR Helper + // ───────────────────────────────────────────────────────── + + private getDpr(): number { + if (!this.usePhysicalScaling) return 1; + if (typeof window === 'undefined') return 1; + return ZoomPlugin.PT_TO_CSS_PX * (window.devicePixelRatio || 1); + } + // ───────────────────────────────────────────────────────── // Core Operations // ───────────────────────────────────────────────────────── @@ -251,23 +303,23 @@ export class ZoomPlugin extends BasePlugin< private requestZoomBy(delta: number, center?: Point, documentId?: string): void { const id = documentId ?? this.getActiveDocumentId(); const docState = this.getDocumentStateOrThrow(id); - const cur = docState.currentZoomLevel; - const target = this.toZoom(cur + delta); + const curUser = docState.currentUserZoomLevel; + const target = this.toZoom(curUser + delta); this.handleRequest({ level: target, center }, id); } private zoomIn(documentId?: string): void { const id = documentId ?? this.getActiveDocumentId(); const docState = this.getDocumentStateOrThrow(id); - const cur = docState.currentZoomLevel; - this.handleRequest({ level: cur, delta: this.stepFor(cur) }, id); + const curUser = docState.currentUserZoomLevel; + this.handleRequest({ level: curUser, delta: this.stepFor(curUser) }, id); } private zoomOut(documentId?: string): void { const id = documentId ?? this.getActiveDocumentId(); const docState = this.getDocumentStateOrThrow(id); - const cur = docState.currentZoomLevel; - this.handleRequest({ level: cur, delta: -this.stepFor(cur) }, id); + const curUser = docState.currentUserZoomLevel; + this.handleRequest({ level: curUser, delta: -this.stepFor(curUser) }, id); } private zoomToArea(pageIndex: number, rect: Rect, documentId?: string): void { @@ -321,13 +373,30 @@ export class ZoomPlugin extends BasePlugin< return; } - // Step 1: Resolve target numeric zoom - const base = typeof level === 'number' ? level : this.computeZoomForMode(id, level, metrics); - - if (base === false) return; - - const exactZoom = clamp(base + delta, this.minZoom, this.maxZoom); - const newZoom = Math.floor(exactZoom * 1000) / 1000; + // Step 1: Resolve target numeric zoom, splitting user-space from effective scale. + const dpr = this.getDpr(); + let newEffective: number; + let newUser: number; + + if (typeof level === 'number') { + // Numeric path: input is user-space, clamped in user-space, then scaled by DPR for effective. + // Quantise user first, then derive effective from the already-quantised value so that + // newEffective === newUser * dpr exactly (within the 0.001 precision floor). + const userBase = clamp(level + delta, this.minZoom, this.maxZoom); + newUser = Math.floor(userBase * 1000) / 1000; + newEffective = Math.floor(newUser * dpr * 1000) / 1000; + } else { + // Mode path: computeZoomForMode returns effective scale directly (fits viewport). + // delta on the mode path is unused in current callers (always 0) but we handle + // it symmetrically: treat delta as user-space, scale by dpr. + const modeBase = this.computeZoomForMode(id, level, metrics); + if (modeBase === false) return; + const effectiveBase = modeBase + delta * dpr; + newEffective = Math.floor( + clamp(effectiveBase, this.minZoom * dpr, this.maxZoom * dpr) * 1000, + ) / 1000; + newUser = Math.floor((newEffective / dpr) * 1000) / 1000; + } // Step 2: Figure out viewport point to keep under focus const focusPoint: Point = center ?? { @@ -335,12 +404,12 @@ export class ZoomPlugin extends BasePlugin< vy: focus === VerticalZoomFocus.Top ? 0 : metrics.clientHeight / 2, }; - // Step 3: Compute desired scroll offsets + // Step 3: Compute desired scroll offsets (uses effective scale throughout) const { desiredScrollLeft, desiredScrollTop } = this.computeScrollForZoomChange( id, metrics, oldZoom, - newZoom, + newEffective, focusPoint, align, ); @@ -353,8 +422,18 @@ export class ZoomPlugin extends BasePlugin< }); } - this.dispatch(setZoomLevel(id, typeof level === 'number' ? newZoom : level, newZoom)); - this.dispatchCoreAction(setScale(newZoom, id)); + // zoomLevel stores: + // - numeric requests: user-space value (so preset "100%" comparisons work on Retina) + // - mode requests: the mode string (unchanged) + this.dispatch( + setZoomLevel( + id, + typeof level === 'number' ? newUser : level, + newEffective, + newUser, + ), + ); + this.dispatchCoreAction(setScale(newEffective, id)); if (this.viewport.isGated(id)) { this.viewport.releaseGate('zoom', id); } @@ -368,7 +447,7 @@ export class ZoomPlugin extends BasePlugin< const evt: ZoomChangeEvent = { documentId: id, oldZoom, - newZoom, + newZoom: newEffective, level, center: focusPoint, desiredScrollLeft, @@ -497,9 +576,17 @@ export class ZoomPlugin extends BasePlugin< rotation, ); - const targetZoom = this.toZoom( - Math.min(availableW / rotatedRect.size.width, availableH / rotatedRect.size.height), + // The viewport-fit ratio is an effective scale. Clamp it in effective space + // (against minZoom*dpr / maxZoom*dpr) so that the full user-space range is + // reachable, then convert to user-space for handleRequest. + const dpr = this.getDpr(); + const rawFit = Math.min( + availableW / rotatedRect.size.width, + availableH / rotatedRect.size.height, ); + const targetUser = Math.floor( + clamp(rawFit, this.minZoom * dpr, this.maxZoom * dpr) / dpr * 1000, + ) / 1000; const pageAbsX = vItem.x + pageRel.x; const pageAbsY = vItem.y + pageRel.y; @@ -518,7 +605,7 @@ export class ZoomPlugin extends BasePlugin< this.handleRequest( { - level: targetZoom, + level: targetUser, center: { vx: centerVX, vy: centerVY }, align: 'center', }, @@ -620,6 +707,7 @@ export class ZoomPlugin extends BasePlugin< prevDoc && newDoc && (prevDoc.currentZoomLevel !== newDoc.currentZoomLevel || + prevDoc.currentUserZoomLevel !== newDoc.currentUserZoomLevel || prevDoc.zoomLevel !== newDoc.zoomLevel || prevDoc.isMarqueeZoomActive !== newDoc.isMarqueeZoomActive) ) { @@ -640,6 +728,17 @@ export class ZoomPlugin extends BasePlugin< } async destroy() { + // Remove the DPR change listener to avoid orphaned listeners on hot-reload. + if (this.dprMql && this.dprMqlListener) { + this.dprMql.removeEventListener('change', this.dprMqlListener); + this.dprMql = null; + this.dprMqlListener = null; + } + // Clear the debounce timer so no post-destroy dispatches occur. + if (this.dprDebounceTimer !== null) { + clearTimeout(this.dprDebounceTimer); + this.dprDebounceTimer = null; + } this.zoom$.clear(); this.state$.clear(); super.destroy(); diff --git a/packages/plugin-zoom/src/shared/utils/pinch-zoom-logic.ts b/packages/plugin-zoom/src/shared/utils/pinch-zoom-logic.ts index 3d1873efe..2813a03cb 100644 --- a/packages/plugin-zoom/src/shared/utils/pinch-zoom-logic.ts +++ b/packages/plugin-zoom/src/shared/utils/pinch-zoom-logic.ts @@ -153,7 +153,11 @@ export function setupZoomGestures({ const commitZoom = () => { const { tx, finalWidth } = calculateTransform(currentScale); - const delta = (currentScale - 1) * initialZoom; + // initialZoom is the effective scale; convert to user-space so requestZoomBy + // receives a user-space delta (matches requestZoomBy's contract). + const dpr = zoomScope.getDpr(); + const initialUser = initialZoom / dpr; + const delta = (currentScale - 1) * initialUser; let anchorX: number; let anchorY: number = pointerContainerY; diff --git a/packages/plugin-zoom/src/shared/utils/zoom-gesture-logic.ts b/packages/plugin-zoom/src/shared/utils/zoom-gesture-logic.ts index 49c002485..d08966462 100644 --- a/packages/plugin-zoom/src/shared/utils/zoom-gesture-logic.ts +++ b/packages/plugin-zoom/src/shared/utils/zoom-gesture-logic.ts @@ -143,7 +143,11 @@ export function setupZoomGestures({ const commitZoom = () => { const { tx, finalWidth } = calculateTransform(currentScale); - const delta = (currentScale - 1) * initialZoom; + // initialZoom is the effective scale; convert to user-space so requestZoomBy + // receives a user-space delta (matches requestZoomBy's contract). + const dpr = zoomScope.getDpr(); + const initialUser = initialZoom / dpr; + const delta = (currentScale - 1) * initialUser; let anchorX: number; let anchorY: number = pointerContainerY; diff --git a/packages/plugin-zoom/tsconfig.json b/packages/plugin-zoom/tsconfig.json index aad4da7b5..5cd201f71 100644 --- a/packages/plugin-zoom/tsconfig.json +++ b/packages/plugin-zoom/tsconfig.json @@ -10,6 +10,7 @@ "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", + "types": ["node", "jest"], "jsx": "react-jsx", "jsxImportSource": "react", "rootDir": "src", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da07e65c7..f6c44a839 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2292,9 +2292,15 @@ importers: '@embedpdf/plugin-viewport': specifier: workspace:* version: link:../plugin-viewport + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 '@types/react': specifier: ^18.2.0 version: 18.3.28 + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@22.19.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) typescript: specifier: ^5.0.0 version: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c2b530dee..6a325ee6d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,4 +5,6 @@ packages: - 'viewers/*' - 'website' # exclude the git submodule (and everything inside it) - - '!packages/pdfium/pdfium-src/**' \ No newline at end of file + - '!packages/pdfium/pdfium-src/**' + # exclude Vercel build output artifacts (contain nested package.json files) + - '!examples/**/.vercel/**' \ No newline at end of file diff --git a/viewers/snippet/src/components/custom-zoom-toolbar.tsx b/viewers/snippet/src/components/custom-zoom-toolbar.tsx index f3438c5f1..9329f33fd 100644 --- a/viewers/snippet/src/components/custom-zoom-toolbar.tsx +++ b/viewers/snippet/src/components/custom-zoom-toolbar.tsx @@ -30,7 +30,7 @@ export function CustomZoomToolbar({ documentId }: CustomZoomToolbarProps) { if (!provides) return null; - const zoomPercentage = Math.round(state.currentZoomLevel * 100); + const zoomPercentage = Math.round(state.currentUserZoomLevel * 100); // Sync input value with zoom state when it changes externally useEffect(() => { diff --git a/viewers/snippet/src/config/translations.ts b/viewers/snippet/src/config/translations.ts index b62f5a4b6..f2c513553 100644 --- a/viewers/snippet/src/config/translations.ts +++ b/viewers/snippet/src/config/translations.ts @@ -3938,7 +3938,7 @@ export const brazilianPortugueseTranslations: Locale = { export const paramResolvers: ParamResolvers = { 'zoom.level': ({ state, documentId }) => { const zoomLevel = documentId - ? (state.plugins[ZOOM_PLUGIN_ID]?.documents[documentId]?.currentZoomLevel ?? 1) + ? (state.plugins[ZOOM_PLUGIN_ID]?.documents[documentId]?.currentUserZoomLevel ?? 1) : 1; return { level: Math.round(zoomLevel * 100), diff --git a/website/src/content/docs/react/headless/plugins/plugin-zoom.mdx b/website/src/content/docs/react/headless/plugins/plugin-zoom.mdx index 038e91d30..5662725c7 100644 --- a/website/src/content/docs/react/headless/plugins/plugin-zoom.mdx +++ b/website/src/content/docs/react/headless/plugins/plugin-zoom.mdx @@ -75,7 +75,7 @@ export const ZoomToolbar = ({ documentId }) => { return (
{/* Display current zoom */} - {Math.round(state.currentZoomLevel * 100)}% + {Math.round(state.currentUserZoomLevel * 100)}% {/* Buttons to control zoom */} @@ -184,6 +184,7 @@ You can pass these options when registering the plugin with `createPluginRegistr | **`minZoom`** | `number` | The minimum allowed numeric zoom level.
**Default**: `0.2` | | **`maxZoom`** | `number` | The maximum allowed numeric zoom level.
**Default**: `60` | | **`presets`** | `ZoomPreset[]` | An array of objects `{ name: string, value: ZoomMode \| number }` to define options for a zoom dropdown menu in your UI. Use `provides.getPresets()` to retrieve this list. | +| **`usePhysicalScaling`** | `boolean` | When `true`, numeric zoom values are treated as logical percentages and pages render at their true physical size — 100 % on a standard display makes an A4 page approximately its real-world width. See `currentUserZoomLevel` in the state reference below.
**Default**: `false` | ### Hook: `useZoom(documentId)` @@ -200,7 +201,8 @@ This hook connects your component to the zoom plugin's state and functions for a | Property | Type | Description | | :--- | :--- | :--- | -| **`currentZoomLevel`** | `number` | The actual, calculated zoom factor applied to the document. | +| **`currentZoomLevel`** | `number` | The actual scale factor applied to the document. When `usePhysicalScaling` is enabled this is larger than `currentUserZoomLevel`. | +| **`currentUserZoomLevel`** | `number` | The zoom percentage as shown in the UI. Always matches `currentZoomLevel` unless `usePhysicalScaling` is enabled. Use this value to display the zoom level to users. | | **`zoomLevel`** | `ZoomMode \| number` | The last *requested* zoom level, which might be a mode like `'fit-page'`. | | **`isMarqueeZoomActive`** | `boolean` | `true` if area zoom is currently active. | @@ -213,6 +215,7 @@ This hook connects your component to the zoom plugin's state and functions for a | **`requestZoom(level)`** | Sets the zoom to a specific level (e.g., `1.0` or `ZoomMode.FitWidth`). | | **`toggleMarqueeZoom()`** | Enables or disables the area zoom mode. | | **`getPresets()`** | Returns the array of presets from the configuration. | +| **`getDpr()`** | Returns the internal scale multiplier used to convert user-space zoom values to the effective render scale. Returns `1` when `usePhysicalScaling` is disabled. | ### Component: `` diff --git a/website/src/content/docs/react/viewer/plugins/plugin-zoom.mdx b/website/src/content/docs/react/viewer/plugins/plugin-zoom.mdx index 28ea4138e..168b7b7a8 100644 --- a/website/src/content/docs/react/viewer/plugins/plugin-zoom.mdx +++ b/website/src/content/docs/react/viewer/plugins/plugin-zoom.mdx @@ -28,6 +28,19 @@ import { ZoomMode } from '@embedpdf/react-pdf-viewer'; /> ``` +To render pages at their true physical size — where 100 % zoom makes an A4 page roughly its real-world width on screen — enable `usePhysicalScaling`: + +```tsx + +``` + ### Available Zoom Modes The `ZoomMode` enum provides standard presets: @@ -87,7 +100,7 @@ useEffect(() => { // Subscribe to state changes docZoom?.onStateChange((state) => { - console.log('Current Zoom:', state.currentZoomLevel); + console.log('Current Zoom:', state.currentUserZoomLevel); }); }; diff --git a/website/src/content/docs/snippet/plugins/plugin-zoom.mdx b/website/src/content/docs/snippet/plugins/plugin-zoom.mdx index 06759afab..730e16404 100644 --- a/website/src/content/docs/snippet/plugins/plugin-zoom.mdx +++ b/website/src/content/docs/snippet/plugins/plugin-zoom.mdx @@ -28,6 +28,19 @@ const viewer = EmbedPDF.init({ }); ``` +To render pages at their true physical size — where 100 % zoom makes an A4 page roughly its real-world width on screen — enable `usePhysicalScaling`: + +```javascript +const viewer = EmbedPDF.init({ + type: 'container', + target: document.getElementById('pdf-viewer'), + zoom: { + defaultZoomLevel: ZoomMode.FitPage, + usePhysicalScaling: true + } +}); +``` + ### Available Zoom Modes The `ZoomMode` enum provides standard presets: @@ -94,6 +107,6 @@ const docZoom = zoomPlugin.forDocument('my-document-id'); // Subscribe to state changes docZoom.onStateChange((state) => { - console.log('Current Zoom:', state.currentZoomLevel); + console.log('Current Zoom:', state.currentUserZoomLevel); }); ``` diff --git a/website/src/content/docs/svelte/headless/plugins/plugin-zoom.mdx b/website/src/content/docs/svelte/headless/plugins/plugin-zoom.mdx index e78119439..98ad4972f 100644 --- a/website/src/content/docs/svelte/headless/plugins/plugin-zoom.mdx +++ b/website/src/content/docs/svelte/headless/plugins/plugin-zoom.mdx @@ -69,7 +69,7 @@ const zoom = useZoom(() => documentId); {#if zoom.provides}
- {Math.round(zoom.state.currentZoomLevel * 100)}% + {Math.round(zoom.state.currentUserZoomLevel * 100)}% @@ -177,6 +177,7 @@ You can pass these options when registering the plugin with `createPluginRegistr | **`minZoom`** | `number` | The minimum allowed numeric zoom level.
**Default**: `0.2` | | **`maxZoom`** | `number` | The maximum allowed numeric zoom level.
**Default**: `60` | | **`presets`** | `ZoomPreset[]` | An array of objects `{ name: string, value: ZoomMode \| number }` to define options for a zoom dropdown menu in your UI. Use `provides.getPresets()` to retrieve this list. | +| **`usePhysicalScaling`** | `boolean` | When `true`, numeric zoom values are treated as logical percentages and pages render at their true physical size — 100 % on a standard display makes an A4 page approximately its real-world width. See `currentUserZoomLevel` in the state reference below.
**Default**: `false` | ### Store: `useZoom(documentId)` @@ -199,7 +200,8 @@ This store connects your component to the zoom plugin's state and functions for | Property | Type | Description | | :--- | :--- | :--- | -| **`currentZoomLevel`** | `number` | The actual, calculated zoom factor applied to the document. | +| **`currentZoomLevel`** | `number` | The actual scale factor applied to the document. When `usePhysicalScaling` is enabled this is larger than `currentUserZoomLevel`. | +| **`currentUserZoomLevel`** | `number` | The zoom percentage as shown in the UI. Always matches `currentZoomLevel` unless `usePhysicalScaling` is enabled. Use this value to display the zoom level to users. | | **`zoomLevel`** | `ZoomMode \| number` | The last *requested* zoom level, which might be a mode like `'fit-page'`. | | **`isMarqueeZoomActive`** | `boolean` | `true` if area zoom is currently active. | @@ -212,6 +214,7 @@ This store connects your component to the zoom plugin's state and functions for | **`requestZoom(level)`** | Sets the zoom to a specific level (e.g., `1.0` or `ZoomMode.FitWidth`). | | **`toggleMarqueeZoom()`** | Enables or disables the area zoom mode. | | **`getPresets()`** | Returns the array of presets from the configuration. | +| **`getDpr()`** | Returns the internal scale multiplier used to convert user-space zoom values to the effective render scale. Returns `1` when `usePhysicalScaling` is disabled. | ### Component: `` diff --git a/website/src/content/docs/svelte/viewer/plugins/plugin-zoom.mdx b/website/src/content/docs/svelte/viewer/plugins/plugin-zoom.mdx index 91953376f..5c60dca75 100644 --- a/website/src/content/docs/svelte/viewer/plugins/plugin-zoom.mdx +++ b/website/src/content/docs/svelte/viewer/plugins/plugin-zoom.mdx @@ -29,6 +29,19 @@ You can set the default zoom level and limits in the `config` prop. /> ``` +To render pages at their true physical size — where 100 % zoom makes an A4 page roughly its real-world width on screen — enable `usePhysicalScaling`: + +```svelte + +``` + ### Available Zoom Modes The `ZoomMode` enum provides standard presets: @@ -99,7 +112,7 @@ You can listen for zoom changes using the event system. // Subscribe to state changes docZoom?.onStateChange((state) => { - console.log('Current Zoom:', state.currentZoomLevel); + console.log('Current Zoom:', state.currentUserZoomLevel); }); }; diff --git a/website/src/content/docs/vue/headless/plugins/plugin-zoom.mdx b/website/src/content/docs/vue/headless/plugins/plugin-zoom.mdx index 72cfa679d..7e527992f 100644 --- a/website/src/content/docs/vue/headless/plugins/plugin-zoom.mdx +++ b/website/src/content/docs/vue/headless/plugins/plugin-zoom.mdx @@ -71,7 +71,7 @@ const { provides: zoom, state } = useZoom(() => props.documentId); ``` +To render pages at their true physical size — where 100 % zoom makes an A4 page roughly its real-world width on screen — enable `usePhysicalScaling`: + +```vue + +``` + ### Available Zoom Modes The `ZoomMode` enum provides standard presets: @@ -103,7 +118,7 @@ const handleReady = (registry: PluginRegistry) => { const docZoom = zoomPlugin?.forDocument('my-document-id'); docZoom?.onStateChange((state) => { - console.log('Current Zoom:', state.currentZoomLevel); + console.log('Current Zoom:', state.currentUserZoomLevel); }); };