Appearance
PdfViewer — live demo
This is the real @rozie-ui/pdf-vue package running on this page (VitePress is itself a Vue app), rendering a 3-page PDF via PDF.js. Page through it, zoom, rotate, toggle continuous scroll — and select the text (it's a real text layer, not an image). All of it is driven by the one PdfViewer.rozie source that compiles to six frameworks.
The current page is two-way bound with v-model:page (the readout tracks it as you scroll in continuous mode), and the buttons drive the imperative handle (prevPage / nextPage / zoomIn / zoomOut / fitWidth / rotateCW). The worker is bundled locally here via new URL(...); left unset it defaults to a CDN copy. See the full API for every prop, event, and handle verb.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
PdfViewer.rozie — data-bound port of PDF.js (mozilla/pdf.js, `pdfjs-dist` v6).
PDF.js is the de-facto vanilla-JS PDF renderer. Per-framework wrappers are
LOPSIDED: react-pdf (wojtekmaj) is deep and maintained for React; Vue
(vue-pdf-embed), Angular (ng2-pdf-viewer) and Svelte have thinner / older
options, and Solid + Lit have effectively nothing. ONE Rozie source ships six
idiomatic packages — so the five underserved frameworks get a real embeddable
PDF viewer with selectable text, page nav, zoom and rotation for free.
Usage:
<PdfViewer
:src="$data.pdfUrl"
r-model:page="$data.page" (1-based current page, two-way)
:scale="1.2"
render-all-pages
@load="(e) => total = e.numPages"
/>
WORKER — the #1 PDF.js integration friction: PDF.js parses in a Web Worker, so
`GlobalWorkerOptions.workerSrc` MUST be set before getDocument. The `workerSrc`
prop defaults to the version-matched jsDelivr CDN copy, so the component works
with ZERO config. For offline / CSP / bundled-worker setups, override it — e.g.
in Vite: `:worker-src="new URL('pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url).toString()"`.
TEXT LAYER — `textLayer` (default on) renders PDF.js's selectable text spans
over each page canvas so text is copyable/searchable (the differentiator vs a
dumb canvas image). It needs the `.textLayer` CSS + a `--scale-factor` CSS var;
both are shipped by this component (the `:root {}` engine-DOM escape hatch +
a per-page `--scale-factor`), so no extra CSS import is required.
RENDER MODES — default = single page (the two-way `page` drives which page
renders, with prev/next/goToPage). `render-all-pages` = continuous scroll of
every page; the visible page reflects back into the two-way `page` (and the
`pagechange` event) via an IntersectionObserver.
RENDER SOURCE OF TRUTH — the live render is driven by internal `$data.current`
/ `$data.zoom` / `$data.rot` (seeded from the props in $onMount, updated by the
prop $watches AND by the $expose verbs). This decouples the imperative handle
from the consumer's binding: `nextPage()` / `zoomIn()` / `rotateCW()` work
whether or not the consumer two-way-binds `page` (only `page` mirrors back via
$model; `scale`/`rotation` are one-way props the verbs imperatively override).
-->
<rozie name="PdfViewer">
<props>
{
// PDF source — a URL string, a `data:` base64 URL, or binary data
// (ArrayBuffer / Uint8Array). No `type:` → emits `unknown`, so `default:
// undefined` merges cleanly under the strict framework typecheck (the maplibre
// maxBounds idiom).
src: {
default: undefined,
docs: {
description:
'The PDF source — a URL string, a `data:` base64 URL, or binary data (`ArrayBuffer` / `Uint8Array`). Changing it tears down the previous document (via its loading task) and loads the new one; `undefined` renders an empty viewer.',
example: '<PdfViewer :src="pdfUrl" r-model:page="page" />',
},
},
// 1-based current page — two-way. In single mode it drives which page renders;
// in render-all-pages mode it reflects the scrolled-to page (and scrolls when
// the consumer writes it).
page: {
type: Number, default: 1, model: true,
docs: {
description:
'The 1-based current page. The sole `model: true` prop — **two-way** (`r-model:page` / `v-model:page` / `bind:page` / `[(page)]`), so `page` also drives the Angular `ControlValueAccessor`. In single-page mode it drives which page renders; in `render-all-pages` mode it reflects the scrolled-to page (and scrolls the container when the consumer writes it). Clamped to `[1, pageCount]`.',
},
},
// zoom scale (1 = 100%). One-way prop; the zoom verbs override it imperatively.
scale: {
type: Number, default: 1,
docs: {
description:
'The zoom scale (`1` = 100%). One-way: the `setScale` / `zoomIn` / `zoomOut` / `fitWidth` / `fitPage` handle verbs override it imperatively, while a consumer write reconciles the live render.',
},
},
// rotation in degrees (0 / 90 / 180 / 270).
rotation: {
type: Number, default: 0,
docs: {
description:
'Rotation in degrees (`0` / `90` / `180` / `270`). One-way: the `rotateCW` / `rotateCCW` verbs override it. Normalized into `[0, 360)`.',
},
},
// the PDF.js worker URL — defaults to the version-matched CDN so it works with
// zero config; override for offline / CSP / bundled-worker.
workerSrc: {
type: String, default: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/build/pdf.worker.min.mjs',
docs: {
description:
"The PDF.js worker URL, set on `GlobalWorkerOptions.workerSrc` before loading. Defaults to the version-matched jsDelivr CDN copy so the component works with zero config; override for offline / CSP / a bundled worker (e.g. Vite's `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)`).",
},
},
// directory of PDF.js's standard-font data, so the base-14 fonts (Helvetica,
// Times, Courier, …) render with correct glyphs. Version-matched CDN default;
// override (or pass a bundled dir) for offline / CSP.
standardFontDataUrl: {
type: String, default: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/standard_fonts/',
docs: {
description:
"The directory of PDF.js's standard-font data so the base-14 fonts (Helvetica / Times / Courier / …) render with correct glyphs. Version-matched CDN default; override (or pass a bundled dir) for offline / CSP.",
},
},
// continuous-scroll all pages vs single page with nav.
renderAllPages: {
type: Boolean, default: false,
docs: {
description:
'`false` (default) renders a single page with nav (the two-way `page` drives it). `true` renders a continuous scroll of every page; the most-visible page reflects back into `page` and the `pagechange` event via an `IntersectionObserver`.',
},
},
// render PDF.js's selectable text layer over each page canvas.
textLayer: {
type: Boolean, default: true,
docs: {
description:
"Render PDF.js's selectable / copyable text-layer spans over each page canvas (the differentiator vs a dumb canvas image). On by default; the required `.textLayer` CSS + `--scale-factor` var ship with the component, so no extra import is needed.",
},
},
// password for an encrypted PDF.
password: {
default: undefined,
docs: {
description:
'Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.',
},
},
// raw getDocument() DocumentInitParameters passthrough — spread BEFORE the
// curated keys (explicit src/password win). For cMapUrl, httpHeaders,
// withCredentials, etc.
options: {
type: Object, default: () => ({}),
docs: {
description:
"Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.",
},
},
}
</props>
<data>
{
// render source of truth (seeded from props in $onMount).
current: 1,
zoom: 1,
rot: 0,
// bumped once the dynamic import() of pdfjs resolves; a lazy $watch on it
// drives load(). This deliberately does NOT call load() straight from the
// import().then() continuation: on React that continuation is the mount
// effect's render-0 closure, where the just-seeded `$data.zoom = $props.scale`
// is NOT yet visible (React reads $data through a per-render `useState`
// binding, not a live ref), so load()→renderView()→renderPage() would
// rasterize at the stale default zoom (1) instead of the seeded scale. Routing
// load() through a $watch re-runs it in a post-mount, fresh-closure render
// where the seeded zoom/rot/current are committed — the other 5 targets read
// $data live so they were already correct; this aligns React with them.
engineReady: 0,
}
</data>
<script>
// pdfjs is DYNAMICALLY imported in $onMount, NOT a top-level import: pdfjs's main
// build evaluates browser globals (DOMMatrix, …) at module-load time, which
// crashes SSR (Next / Nuxt / SvelteKit / Analog / VitePress). Lazy-importing it on
// mount makes the component SSR-safe for ALL consumers AND code-splits the ~1MB
// engine out of the initial bundle. `pdfjsLib` is a null-let → typeNeutralize
// `any` (so pdfjsLib.getDocument / .TextLayer / .GlobalWorkerOptions are unchecked).
let pdfjsLib = null
// more null-lets (→ `any`): `instance` is the PDFDocumentProxy (whose strict types
// the loosely-typed props don't satisfy — the maplibre mapOptions idiom),
// containerEl is the scroll host, observer is the continuous-mode scroll spy.
let instance = null
let containerEl = null
let observer = null
// the PDFDocumentLoadingTask — it (NOT the PDFDocumentProxy, which has no
// destroy() in pdfjs v6) owns teardown of the worker + document. Held so a
// src/password change or unmount can tear the previous load down.
let loadingTask = null
// monotonic token cancels stale async loads/renders (src can change mid-render,
// pages render async — the SortableList rebuild-cancel discipline).
let renderToken = 0
// guards the scroll-spy → $data.current → scroll-to feedback loop.
let suppressScroll = false
// find/search state. findQuery is the active lowercased query (''=inactive);
// findMatches is a flat per-OCCURRENCE list [{ page }] (drives the count + the
// next/prev cycle); findIndex is the current match (-1=none). TOP-LEVEL lets (not
// $onMount-local) so renderPage's coarse highlight pass + the find verbs can read
// them across renders.
let findQuery = ''
let findMatches = []
let findIndex = -1
// set in the $onMount teardown so a late-resolving dynamic import() bails. A
// TOP-LEVEL let (not $onMount-local): the Solid emitter hoists the $onMount
// teardown into a sibling onCleanup() OUTSIDE the mount closure, so a mount-local
// would be out of scope there.
let cancelled = false
// ─── build the getDocument() source (no sigils beyond $props/$snapshot) ──────
const buildSource = () => {
let cfg = null
cfg = { ...$snapshot($props.options) }
// NOTE: the local must NOT be named `src` — a local `const src = $props.src`
// (same name as the `src` prop) hits a Svelte-emitter scope bug where the
// renamed local's initializer mis-resolves to itself (`const src2 = src2`, a
// TDZ ReferenceError) instead of the prop accessor. Naming it `srcInput`
// sidesteps the shadow on every target.
const srcInput = $props.src
if (typeof srcInput === 'string') {
if (srcInput.startsWith('data:')) {
// decode a `data:` base64 URL to bytes — pdfjs `url` doesn't fetch data URLs.
const base64 = srcInput.substring(srcInput.indexOf(',') + 1)
const bin = atob(base64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
cfg.data = bytes
} else {
cfg.url = srcInput
}
} else if (srcInput) {
cfg.data = srcInput
}
if ($props.password != null) cfg.password = $props.password
if ($props.standardFontDataUrl) cfg.standardFontDataUrl = $props.standardFontDataUrl
return cfg
}
// ─── render one page (canvas + optional text layer) into the container ───────
const renderPage = async (pdf, pageNum, container) => {
const page = await pdf.getPage(pageNum)
const viewport = page.getViewport({ scale: $data.zoom, rotation: $data.rot })
const pageDiv = document.createElement('div')
pageDiv.className = 'rozie-pdf-page'
pageDiv.setAttribute('data-page', String(pageNum))
pageDiv.style.width = Math.floor(viewport.width) + 'px'
pageDiv.style.height = Math.floor(viewport.height) + 'px'
// the text layer positions its spans with calc(var(--scale-factor) * …px).
pageDiv.style.setProperty('--scale-factor', String($data.zoom))
const outputScale = window.devicePixelRatio || 1
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width * outputScale)
canvas.height = Math.floor(viewport.height * outputScale)
canvas.style.width = Math.floor(viewport.width) + 'px'
canvas.style.height = Math.floor(viewport.height) + 'px'
pageDiv.appendChild(canvas)
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined
await page.render({ canvas, viewport, transform }).promise
if ($props.textLayer) {
const tl = document.createElement('div')
tl.className = 'textLayer'
pageDiv.appendChild(tl)
const layer = new pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: tl,
viewport,
})
await layer.render()
// coarse find-highlight: add .rozie-pdf-find to text-layer spans whose text
// CONTAINS the active query. Span-level / COARSE — a query straddling two
// adjacent spans won't highlight (documented). Runs only while a find is active.
if (findQuery) {
const spans = tl.querySelectorAll('span')
for (const sp of spans) {
const t = sp.textContent
if (t && t.toLowerCase().indexOf(findQuery) !== -1) sp.classList.add('rozie-pdf-find')
}
}
}
container.appendChild(pageDiv)
return pageDiv
}
// continuous-mode scroll spy — reflect the most-visible page into $data.current.
const setupScrollSpy = () => {
if (observer) { observer.disconnect(); observer = null }
if (!containerEl) return
observer = new IntersectionObserver(
(entries) => {
let best = null
let bestRatio = 0
for (const e of entries) {
if (e.intersectionRatio > bestRatio) { bestRatio = e.intersectionRatio; best = e.target }
}
if (best) {
const n = Number(best.getAttribute('data-page'))
if (n && n !== $data.current) {
suppressScroll = true
$data.current = n
suppressScroll = false
}
}
},
{ root: containerEl, threshold: [0.25, 0.5, 0.75] },
)
for (const child of containerEl.children) observer.observe(child)
}
const scrollToPage = (n) => {
if (!containerEl) return
const el = containerEl.querySelector('[data-page="' + n + '"]')
if (el) el.scrollIntoView({ block: 'start', behavior: 'auto' })
}
// ─── render the current view (single page, or all pages) ─────────────────────
const renderView = async () => {
if (!instance || !containerEl) return
const token = ++renderToken
if (observer) { observer.disconnect(); observer = null }
containerEl.innerHTML = ''
const total = instance.numPages
const pages = $props.renderAllPages
? Array.from({ length: total }, (_, i) => i + 1)
: [Math.min(Math.max($data.current, 1), total)]
for (const n of pages) {
if (token !== renderToken) return
try {
await renderPage(instance, n, containerEl)
} catch (e) {
if (token === renderToken) $emit('error', e)
}
}
if (token !== renderToken) return
if ($props.renderAllPages) setupScrollSpy()
$emit('pagesrendered')
}
// ─── load the document ───────────────────────────────────────────────────────
const load = async () => {
if (!pdfjsLib) return
const token = ++renderToken
if (observer) { observer.disconnect(); observer = null }
// tear down the previous load via the loading task (PDFDocumentProxy has no
// destroy() in pdfjs v6).
if (loadingTask) { loadingTask.destroy(); loadingTask = null }
instance = null
if (containerEl) containerEl.innerHTML = ''
if (!$props.src) return
try {
loadingTask = pdfjsLib.getDocument(buildSource())
loadingTask.onPassword = (_updatePassword, reason) => { $emit('passwordrequest', { reason }) }
// download progress in bytes; `total` may be 0/undefined when the server sends
// no Content-Length header — pass the raw pdfjs onProgress payload through as-is.
loadingTask.onProgress = (p) => $emit('progress', { loaded: p && p.loaded, total: p && p.total })
const pdf = await loadingTask.promise
// stale (a newer load bumped the token + destroyed this task) — drop it.
if (token !== renderToken) return
instance = pdf
if ($data.current > pdf.numPages) $data.current = pdf.numPages
$emit('load', { numPages: pdf.numPages })
await renderView()
} catch (e) {
// a destroyed task rejects its promise — suppress the abort for stale loads.
if (token === renderToken) $emit('error', e)
}
}
const applyFit = async (mode) => {
if (!instance || !containerEl) return
const n = Math.min(Math.max($data.current, 1), instance.numPages)
const page = await instance.getPage(n)
const vp = page.getViewport({ scale: 1, rotation: $data.rot })
const cw = containerEl.clientWidth - 24
const ch = containerEl.clientHeight - 24
if (mode === 'width') $data.zoom = cw / vp.width
else $data.zoom = Math.min(cw / vp.width, ch / vp.height)
}
$onMount(() => {
cancelled = false
containerEl = $refs.viewerEl
$data.current = Math.max(1, $props.page)
$data.zoom = $props.scale
$data.rot = $props.rotation
// lazy-load the engine (SSR-safe + code-split), then configure the worker and
// load the document.
import('pdfjs-dist').then((mod) => {
if (cancelled) return
pdfjsLib = mod
pdfjsLib.GlobalWorkerOptions.workerSrc = $props.workerSrc
// hand off to the lazy $watch below rather than calling load() from this
// (React: mount-frozen) closure — see the $data.engineReady note above.
$data.engineReady++
})
return () => {
cancelled = true
renderToken++
if (observer) { observer.disconnect(); observer = null }
if (loadingTask) { loadingTask.destroy(); loadingTask = null }
instance = null
}
})
// ─── reconcile props/state into the live view ────────────────────────────────
// engine ready (dynamic import resolved) → first load, from a fresh post-mount
// closure so React sees the seeded $data.zoom/rot/current (see <data> note).
$watch(() => $data.engineReady, () => load())
$watch(() => $props.src, () => load())
$watch(() => $props.password, () => load())
$watch(() => $props.workerSrc, (v) => { if (pdfjsLib && v) pdfjsLib.GlobalWorkerOptions.workerSrc = v })
// prop → internal state (guarded so consumer writes don't fight the verbs).
$watch(() => $props.page, (v) => {
if (typeof v === 'number' && v >= 1 && v !== $data.current) $data.current = v
})
$watch(() => $props.scale, (v) => { if (typeof v === 'number' && v > 0) $data.zoom = v })
$watch(() => $props.rotation, (v) => { if (typeof v === 'number') $data.rot = ((v % 360) + 360) % 360 })
// internal state → render + two-way page echo.
$watch(() => $data.current, (v) => {
$model.page = v
$emit('pagechange', { page: v })
if ($props.renderAllPages) { if (!suppressScroll) scrollToPage(v) }
else renderView()
})
$watch(() => $data.zoom, () => renderView())
$watch(() => $data.rot, () => renderView())
$watch(() => $props.renderAllPages, () => renderView())
$watch(() => $props.textLayer, () => renderView())
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// 19 verbs. Collision-clear: NO `setPage` (React `page`-model auto-setter,
// ROZ524 — use goToPage); none equals an emit name (load/error/pagechange/
// pagesrendered/passwordrequest/progress/findresult); none is a Lit reserved
// lifecycle. The navigation/zoom/rotate verbs drive $data (not the props), so they
// work whether or not the consumer binds `page`. The document-level verbs below
// are cheap passthroughs over the held PDFDocumentProxy (`instance`) that a
// consumer can't reach otherwise without `getDocument()` + pdf.js knowledge:
// - download(filename?): save the original PDF bytes (instance.getData() ->
// Blob -> anchor click) — the single most-expected viewer affordance.
// - getMetadata(): document title/author/page-labels (tab title / info panel).
// - getOutline(): the bookmark/TOC tree (powers a navigation sidebar; outline
// dests map onto goToPage).
// The four find verbs (find/findNext/findPrev/clearFind) drive the coarse
// span-level highlight pass + emit `findresult`. `find/findNext/findPrev/clearFind`
// are collision-vetted (no Lit reserved lifecycle, no `page`-model auto-setter clash).
function getDocument() { return instance }
function getPageCount() { return instance ? instance.numPages : 0 }
function goToPage(n) {
if (!instance) return
$data.current = Math.min(Math.max(n, 1), instance.numPages)
}
function nextPage() { goToPage($data.current + 1) }
function prevPage() { goToPage($data.current - 1) }
function setScale(s) { if (typeof s === 'number' && s > 0) $data.zoom = s }
function zoomIn() { $data.zoom = Math.min($data.zoom * 1.25, 10) }
function zoomOut() { $data.zoom = Math.max($data.zoom / 1.25, 0.1) }
function fitWidth() { applyFit('width') }
function fitPage() { applyFit('page') }
function rotateCW() { $data.rot = ($data.rot + 90) % 360 }
function rotateCCW() { $data.rot = ($data.rot + 270) % 360 }
// Save the original PDF bytes. getData() resolves the raw Uint8Array; wrap in a
// Blob and trigger a download via a transient anchor. Resolves false before mount.
async function download(filename) {
if (!instance) return false
const bytes = await instance.getData()
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = filename || 'document.pdf'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
return true
}
// Document info (title/author/page labels) — resolves null before mount.
function getMetadata() { return instance ? instance.getMetadata() : null }
// Bookmark / table-of-contents tree — resolves null when absent or before mount.
function getOutline() { return instance ? instance.getOutline() : null }
// ─── text find/search (coarse span-level highlight) ──────────────────────────
// find(query) scans EVERY page's extracted text for occurrences, navigates to +
// highlights the first match, returns the match count, and emits `findresult`. The
// highlight is COARSE / span-level: renderPage adds .rozie-pdf-find to whole
// text-layer spans that CONTAIN the query (a query straddling two spans won't
// highlight). findNext/findPrev cycle (wrap) through the per-occurrence match list;
// clearFind resets the query + highlights. All async-safe over the `any`
// PDFDocumentProxy (`instance`); no-op / return 0 before the document loads.
async function find(query) {
const q = (query == null ? '' : String(query)).trim().toLowerCase()
findQuery = q
findMatches = []
findIndex = -1
if (!instance || !q) {
renderView()
$emit('findresult', { query: q, matches: 0, current: 0 })
return 0
}
const total = instance.numPages
for (let p = 1; p <= total; p++) {
const page = await instance.getPage(p)
const tc = await page.getTextContent()
const text = tc.items.map((it) => (it && it.str != null ? it.str : '')).join('').toLowerCase()
let from = 0
while (true) {
const at = text.indexOf(q, from)
if (at === -1) break
findMatches.push({ page: p })
from = at + q.length
}
}
if (findMatches.length) {
findIndex = 0
const target = findMatches[0].page
// navigate if needed; if already on the target page, force a re-render so the
// highlight pass runs (a no-op goToPage wouldn't trip the $data.current $watch).
if (target !== $data.current) goToPage(target)
else renderView()
} else {
renderView()
}
$emit('findresult', { query: q, matches: findMatches.length, current: findMatches.length ? 1 : 0 })
return findMatches.length
}
function findNext() {
if (!findMatches.length) return
findIndex = (findIndex + 1) % findMatches.length
const target = findMatches[findIndex].page
if (target !== $data.current) goToPage(target)
$emit('findresult', { query: findQuery, matches: findMatches.length, current: findIndex + 1 })
}
function findPrev() {
if (!findMatches.length) return
findIndex = (findIndex - 1 + findMatches.length) % findMatches.length
const target = findMatches[findIndex].page
if (target !== $data.current) goToPage(target)
$emit('findresult', { query: findQuery, matches: findMatches.length, current: findIndex + 1 })
}
function clearFind() {
findQuery = ''
findMatches = []
findIndex = -1
renderView()
$emit('findresult', { query: '', matches: 0, current: 0 })
}
$expose({
getDocument, getPageCount, goToPage, nextPage, prevPage,
setScale, zoomIn, zoomOut, fitWidth, fitPage, rotateCW, rotateCCW,
download, getMetadata, getOutline,
find, findNext, findPrev, clearFind,
})
</script>
<template>
<div class="rozie-pdf" ref="viewerEl"></div>
</template>
<style>
.rozie-pdf {
width: 100%;
height: 100%;
min-height: 320px;
overflow: auto;
background: #525659;
padding: 12px 0;
}
:root {
/* engine-created page/canvas/text-layer DOM never carries the
[data-rozie-s-*] scope attribute — reach it via the Phase-34 :root engine-DOM
escape hatch (NOT :global(), a ROZ128 hard error). This also ships the
essential PDF.js text-layer CSS so consumers need no separate import. */
.rozie-pdf .rozie-pdf-page {
position: relative;
margin: 0 auto 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
background: #fff;
}
.rozie-pdf .rozie-pdf-page canvas {
display: block;
}
.rozie-pdf .textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 1;
}
.rozie-pdf .textLayer span,
.rozie-pdf .textLayer br {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.rozie-pdf .textLayer span.markedContent {
top: 0;
height: 0;
}
.rozie-pdf .textLayer ::selection {
background: rgba(0, 100, 255, 0.3);
}
/* coarse find-highlight — the highlight pass adds .rozie-pdf-find to text-layer
spans containing the active query. The spans sit (transparent) over the
canvas, so a visible background makes the match glow through. */
.rozie-pdf .textLayer span.rozie-pdf-find {
background: rgba(255, 196, 0, 0.45);
border-radius: 2px;
}
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/pdf-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { clsx, useControllableState } from '@rozie/runtime-react';
import './PdfViewer.css';
import './PdfViewer.global.css';
interface PdfViewerProps {
/**
* The PDF source — a URL string, a `data:` base64 URL, or binary data (`ArrayBuffer` / `Uint8Array`). Changing it tears down the previous document (via its loading task) and loads the new one; `undefined` renders an empty viewer.
* @example
* <PdfViewer :src="pdfUrl" r-model:page="page" />
*/
src?: unknown;
/**
* The 1-based current page. The sole `model: true` prop — **two-way** (`r-model:page` / `v-model:page` / `bind:page` / `[(page)]`), so `page` also drives the Angular `ControlValueAccessor`. In single-page mode it drives which page renders; in `render-all-pages` mode it reflects the scrolled-to page (and scrolls the container when the consumer writes it). Clamped to `[1, pageCount]`.
*/
page?: number;
defaultPage?: number;
onPageChange?: (page: number) => void;
/**
* The zoom scale (`1` = 100%). One-way: the `setScale` / `zoomIn` / `zoomOut` / `fitWidth` / `fitPage` handle verbs override it imperatively, while a consumer write reconciles the live render.
*/
scale?: number;
/**
* Rotation in degrees (`0` / `90` / `180` / `270`). One-way: the `rotateCW` / `rotateCCW` verbs override it. Normalized into `[0, 360)`.
*/
rotation?: number;
/**
* The PDF.js worker URL, set on `GlobalWorkerOptions.workerSrc` before loading. Defaults to the version-matched jsDelivr CDN copy so the component works with zero config; override for offline / CSP / a bundled worker (e.g. Vite's `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)`).
*/
workerSrc?: string;
/**
* The directory of PDF.js's standard-font data so the base-14 fonts (Helvetica / Times / Courier / …) render with correct glyphs. Version-matched CDN default; override (or pass a bundled dir) for offline / CSP.
*/
standardFontDataUrl?: string;
/**
* `false` (default) renders a single page with nav (the two-way `page` drives it). `true` renders a continuous scroll of every page; the most-visible page reflects back into `page` and the `pagechange` event via an `IntersectionObserver`.
*/
renderAllPages?: boolean;
/**
* Render PDF.js's selectable / copyable text-layer spans over each page canvas (the differentiator vs a dumb canvas image). On by default; the required `.textLayer` CSS + `--scale-factor` var ship with the component, so no extra import is needed.
*/
textLayer?: boolean;
/**
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
*/
password?: unknown;
/**
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
*/
options?: Record<string, any>;
onError?: (...args: any[]) => void;
onPagesrendered?: (...args: any[]) => void;
onPasswordrequest?: (...args: any[]) => void;
onProgress?: (...args: any[]) => void;
onLoad?: (...args: any[]) => void;
onPagechange?: (...args: any[]) => void;
onFindresult?: (...args: any[]) => void;
}
export interface PdfViewerHandle {
getDocument: (...args: any[]) => any;
getPageCount: (...args: any[]) => any;
goToPage: (...args: any[]) => any;
nextPage: (...args: any[]) => any;
prevPage: (...args: any[]) => any;
setScale: (...args: any[]) => any;
zoomIn: (...args: any[]) => any;
zoomOut: (...args: any[]) => any;
fitWidth: (...args: any[]) => any;
fitPage: (...args: any[]) => any;
rotateCW: (...args: any[]) => any;
rotateCCW: (...args: any[]) => any;
download: (...args: any[]) => any;
getMetadata: (...args: any[]) => any;
getOutline: (...args: any[]) => any;
find: (...args: any[]) => any;
findNext: (...args: any[]) => any;
findPrev: (...args: any[]) => any;
clearFind: (...args: any[]) => any;
}
const PdfViewer = forwardRef<PdfViewerHandle, PdfViewerProps>(function PdfViewer(_props: PdfViewerProps, ref): JSX.Element {
const __defaultOptions = useState(() => (() => ({}))())[0];
const props: Omit<PdfViewerProps, 'src' | 'scale' | 'rotation' | 'workerSrc' | 'standardFontDataUrl' | 'renderAllPages' | 'textLayer' | 'password' | 'options'> & { src: unknown; scale: number; rotation: number; workerSrc: string; standardFontDataUrl: string; renderAllPages: boolean; textLayer: boolean; password: unknown; options: Record<string, any> } = {
..._props,
src: _props.src ?? undefined,
scale: _props.scale ?? 1,
rotation: _props.rotation ?? 0,
workerSrc: _props.workerSrc ?? 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/build/pdf.worker.min.mjs',
standardFontDataUrl: _props.standardFontDataUrl ?? 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/standard_fonts/',
renderAllPages: _props.renderAllPages ?? false,
textLayer: _props.textLayer ?? true,
password: _props.password ?? undefined,
options: _props.options ?? __defaultOptions,
};
const attrs: Record<string, unknown> = (() => {
const { src, page, scale, rotation, workerSrc, standardFontDataUrl, renderAllPages, textLayer, password, options, defaultValue, onPageChange, defaultPage, ...rest } = _props as PdfViewerProps & Record<string, unknown>;
void src; void page; void scale; void rotation; void workerSrc; void standardFontDataUrl; void renderAllPages; void textLayer; void password; void options; void defaultValue; void onPageChange; void defaultPage;
return rest;
})();
const cancelled = useRef(false);
const containerEl = useRef<any>(null);
const pdfjsLib = useRef<any>(null);
const renderToken = useRef(0);
const observer = useRef<any>(null);
const loadingTask = useRef<any>(null);
const instance = useRef<any>(null);
const suppressScroll = useRef(false);
const findQuery = useRef('');
const findMatches = useRef([]);
const findIndex = useRef(-1);
const [page, setPage] = useControllableState({
value: props.page,
defaultValue: props.defaultPage ?? 1,
onValueChange: props.onPageChange,
});
const _rotationRef = useRef(props.rotation);
_rotationRef.current = props.rotation;
const _scaleRef = useRef(props.scale);
_scaleRef.current = props.scale;
const _workerSrcRef = useRef(props.workerSrc);
_workerSrcRef.current = props.workerSrc;
const _pageRef = useRef(page);
_pageRef.current = page;
const [current, setCurrent] = useState(1);
const [zoom, setZoom] = useState(1);
const [rot, setRot] = useState(0);
const [engineReady, setEngineReady] = useState(0);
const viewerEl = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const _watch4First = useRef(true);
const _watch5First = useRef(true);
const _watch6First = useRef(true);
const _watch7First = useRef(true);
const _watch8First = useRef(true);
const _watch9First = useRef(true);
const _watch10First = useRef(true);
const _watch11First = useRef(true);
function buildSource() {
let cfg: any = null;
cfg = {
...props.options
};
// NOTE: the local must NOT be named `src` — a local `const src = $props.src`
// (same name as the `src` prop) hits a Svelte-emitter scope bug where the
// renamed local's initializer mis-resolves to itself (`const src2 = src2`, a
// TDZ ReferenceError) instead of the prop accessor. Naming it `srcInput`
// sidesteps the shadow on every target.
const srcInput = props.src;
if (typeof srcInput === 'string') {
if (srcInput.startsWith('data:')) {
// decode a `data:` base64 URL to bytes — pdfjs `url` doesn't fetch data URLs.
const base64 = srcInput.substring(srcInput.indexOf(',') + 1);
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
cfg.data = bytes;
} else {
cfg.url = srcInput;
}
} else if (srcInput) {
cfg.data = srcInput;
}
if (props.password != null) cfg.password = props.password;
if (props.standardFontDataUrl) cfg.standardFontDataUrl = props.standardFontDataUrl;
return cfg;
}
async function renderPage(pdf: any, pageNum: any, container: any) {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({
scale: zoom,
rotation: rot
});
const pageDiv = document.createElement('div');
pageDiv.className = 'rozie-pdf-page';
pageDiv.setAttribute('data-page', String(pageNum));
pageDiv.style.width = Math.floor(viewport.width) + 'px';
pageDiv.style.height = Math.floor(viewport.height) + 'px';
// the text layer positions its spans with calc(var(--scale-factor) * …px).
pageDiv.style.setProperty('--scale-factor', String(zoom));
const outputScale = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
pageDiv.appendChild(canvas);
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({
canvas,
viewport,
transform
}).promise;
if (props.textLayer) {
const tl = document.createElement('div');
tl.className = 'textLayer';
pageDiv.appendChild(tl);
const layer = new pdfjsLib.current.TextLayer({
textContentSource: page.streamTextContent(),
container: tl,
viewport
});
await layer.render();
// coarse find-highlight: add .rozie-pdf-find to text-layer spans whose text
// CONTAINS the active query. Span-level / COARSE — a query straddling two
// adjacent spans won't highlight (documented). Runs only while a find is active.
if (findQuery.current) {
const spans = tl.querySelectorAll('span');
for (const sp of spans as any) {
const t = sp.textContent;
if (t && t.toLowerCase().indexOf(findQuery.current) !== -1) sp.classList.add('rozie-pdf-find');
}
}
}
container.appendChild(pageDiv);
return pageDiv;
}
function setupScrollSpy() {
if (observer.current) {
observer.current.disconnect();
observer.current = null;
}
if (!containerEl.current) return;
observer.current = new IntersectionObserver((entries: any) => {
let best: any = null;
let bestRatio = 0;
for (const e of entries as any) {
if (e.intersectionRatio > bestRatio) {
bestRatio = e.intersectionRatio;
best = e.target;
}
}
if (best) {
const n = Number(best.getAttribute('data-page'));
if (n && n !== current) {
suppressScroll.current = true;
setCurrent(n);
suppressScroll.current = false;
}
}
}, {
root: containerEl.current,
threshold: [0.25, 0.5, 0.75]
});
for (const child of containerEl.current.children as any) observer.current.observe(child);
}
function scrollToPage(n: any) {
if (!containerEl.current) return;
const el = containerEl.current.querySelector('[data-page="' + n + '"]');
if (el) el.scrollIntoView({
block: 'start',
behavior: 'auto'
});
}
async function renderView() {
if (!instance.current || !containerEl.current) return;
const token = ++renderToken.current;
if (observer.current) {
observer.current.disconnect();
observer.current = null;
}
containerEl.current.innerHTML = '';
const total = instance.current.numPages;
const pages = props.renderAllPages ? Array.from({
length: total
}, (_: any, i: any) => i + 1) : [Math.min(Math.max(current, 1), total)];
for (const n of pages as any) {
if (token !== renderToken.current) return;
try {
await renderPage(instance.current, n, containerEl.current);
} catch (e: any) {
if (token === renderToken.current) props.onError && props.onError(e);
}
}
if (token !== renderToken.current) return;
if (props.renderAllPages) setupScrollSpy();
props.onPagesrendered && props.onPagesrendered();
}
async function load() {
if (!pdfjsLib.current) return;
const token = ++renderToken.current;
if (observer.current) {
observer.current.disconnect();
observer.current = null;
}
// tear down the previous load via the loading task (PDFDocumentProxy has no
// destroy() in pdfjs v6).
if (loadingTask.current) {
loadingTask.current.destroy();
loadingTask.current = null;
}
instance.current = null;
if (containerEl.current) containerEl.current.innerHTML = '';
if (!props.src) return;
try {
loadingTask.current = pdfjsLib.current.getDocument(buildSource());
loadingTask.current.onPassword = (_updatePassword: any, reason: any) => {
props.onPasswordrequest && props.onPasswordrequest({
reason
});
};
// download progress in bytes; `total` may be 0/undefined when the server sends
// no Content-Length header — pass the raw pdfjs onProgress payload through as-is.
loadingTask.current.onProgress = (p: any) => props.onProgress && props.onProgress({
loaded: p && p.loaded,
total: p && p.total
});
const pdf = await loadingTask.current.promise;
// stale (a newer load bumped the token + destroyed this task) — drop it.
if (token !== renderToken.current) return;
instance.current = pdf;
if (current > pdf.numPages) setCurrent(pdf.numPages);
props.onLoad && props.onLoad({
numPages: pdf.numPages
});
await renderView();
} catch (e: any) {
// a destroyed task rejects its promise — suppress the abort for stale loads.
if (token === renderToken.current) props.onError && props.onError(e);
}
}
async function applyFit(mode: any) {
if (!instance.current || !containerEl.current) return;
const n = Math.min(Math.max(current, 1), instance.current.numPages);
const page = await instance.current.getPage(n);
const vp = page.getViewport({
scale: 1,
rotation: rot
});
const cw = containerEl.current.clientWidth - 24;
const ch = containerEl.current.clientHeight - 24;
if (mode === 'width') setZoom(cw / vp.width);else setZoom(Math.min(cw / vp.width, ch / vp.height));
}
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// 19 verbs. Collision-clear: NO `setPage` (React `page`-model auto-setter,
// ROZ524 — use goToPage); none equals an emit name (load/error/pagechange/
// pagesrendered/passwordrequest/progress/findresult); none is a Lit reserved
// lifecycle. The navigation/zoom/rotate verbs drive $data (not the props), so they
// work whether or not the consumer binds `page`. The document-level verbs below
// are cheap passthroughs over the held PDFDocumentProxy (`instance`) that a
// consumer can't reach otherwise without `getDocument()` + pdf.js knowledge:
// - download(filename?): save the original PDF bytes (instance.getData() ->
// Blob -> anchor click) — the single most-expected viewer affordance.
// - getMetadata(): document title/author/page-labels (tab title / info panel).
// - getOutline(): the bookmark/TOC tree (powers a navigation sidebar; outline
// dests map onto goToPage).
// The four find verbs (find/findNext/findPrev/clearFind) drive the coarse
// span-level highlight pass + emit `findresult`. `find/findNext/findPrev/clearFind`
// are collision-vetted (no Lit reserved lifecycle, no `page`-model auto-setter clash).
function getDocument() {
return instance.current;
}
function getPageCount() {
return instance.current ? instance.current.numPages : 0;
}
function goToPage(n: any) {
if (!instance.current) return;
setCurrent(Math.min(Math.max(n, 1), instance.current.numPages));
}
function nextPage() {
goToPage(current + 1);
}
function prevPage() {
goToPage(current - 1);
}
function setScale(s: any) {
if (typeof s === 'number' && s > 0) setZoom(s);
}
function zoomIn() {
setZoom(prev => Math.min(prev * 1.25, 10));
}
function zoomOut() {
setZoom(prev => Math.max(prev / 1.25, 0.1));
}
function fitWidth() {
applyFit('width');
}
function fitPage() {
applyFit('page');
}
function rotateCW() {
setRot(prev => (prev + 90) % 360);
}
function rotateCCW() {
setRot(prev => (prev + 270) % 360);
}
// Save the original PDF bytes. getData() resolves the raw Uint8Array; wrap in a
// Blob and trigger a download via a transient anchor. Resolves false before mount.
// Save the original PDF bytes. getData() resolves the raw Uint8Array; wrap in a
// Blob and trigger a download via a transient anchor. Resolves false before mount.
async function download(filename: any) {
if (!instance.current) return false;
const bytes = await instance.current.getData();
const url = URL.createObjectURL(new Blob([bytes], {
type: 'application/pdf'
}));
const a = document.createElement('a');
a.href = url;
a.download = filename || 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return true;
}
// Document info (title/author/page labels) — resolves null before mount.
// Document info (title/author/page labels) — resolves null before mount.
function getMetadata() {
return instance.current ? instance.current.getMetadata() : null;
}
// Bookmark / table-of-contents tree — resolves null when absent or before mount.
// Bookmark / table-of-contents tree — resolves null when absent or before mount.
function getOutline() {
return instance.current ? instance.current.getOutline() : null;
}
// ─── text find/search (coarse span-level highlight) ──────────────────────────
// find(query) scans EVERY page's extracted text for occurrences, navigates to +
// highlights the first match, returns the match count, and emits `findresult`. The
// highlight is COARSE / span-level: renderPage adds .rozie-pdf-find to whole
// text-layer spans that CONTAIN the query (a query straddling two spans won't
// highlight). findNext/findPrev cycle (wrap) through the per-occurrence match list;
// clearFind resets the query + highlights. All async-safe over the `any`
// PDFDocumentProxy (`instance`); no-op / return 0 before the document loads.
// ─── text find/search (coarse span-level highlight) ──────────────────────────
// find(query) scans EVERY page's extracted text for occurrences, navigates to +
// highlights the first match, returns the match count, and emits `findresult`. The
// highlight is COARSE / span-level: renderPage adds .rozie-pdf-find to whole
// text-layer spans that CONTAIN the query (a query straddling two spans won't
// highlight). findNext/findPrev cycle (wrap) through the per-occurrence match list;
// clearFind resets the query + highlights. All async-safe over the `any`
// PDFDocumentProxy (`instance`); no-op / return 0 before the document loads.
async function find(query: any) {
const q = (query == null ? '' : String(query)).trim().toLowerCase();
findQuery.current = q;
findMatches.current = [];
findIndex.current = -1;
if (!instance.current || !q) {
renderView();
props.onFindresult && props.onFindresult({
query: q,
matches: 0,
current: 0
});
return 0;
}
const total = instance.current.numPages;
for (let p = 1; p <= total; p++) {
const page = await instance.current.getPage(p);
const tc = await page.getTextContent();
const text = tc.items.map((it: any) => it && it.str != null ? it.str : '').join('').toLowerCase();
let from = 0;
while (true) {
const at = text.indexOf(q, from);
if (at === -1) break;
findMatches.current.push({
page: p
});
from = at + q.length;
}
}
if (findMatches.current.length) {
findIndex.current = 0;
const target = findMatches.current[0].page;
// navigate if needed; if already on the target page, force a re-render so the
// highlight pass runs (a no-op goToPage wouldn't trip the $data.current $watch).
if (target !== current) goToPage(target);else renderView();
} else {
renderView();
}
props.onFindresult && props.onFindresult({
query: q,
matches: findMatches.current.length,
current: findMatches.current.length ? 1 : 0
});
return findMatches.current.length;
}
function findNext() {
if (!findMatches.current.length) return;
findIndex.current = (findIndex.current + 1) % findMatches.current.length;
const target = findMatches.current[findIndex.current].page;
if (target !== current) goToPage(target);
props.onFindresult && props.onFindresult({
query: findQuery.current,
matches: findMatches.current.length,
current: findIndex.current + 1
});
}
function findPrev() {
if (!findMatches.current.length) return;
findIndex.current = (findIndex.current - 1 + findMatches.current.length) % findMatches.current.length;
const target = findMatches.current[findIndex.current].page;
if (target !== current) goToPage(target);
props.onFindresult && props.onFindresult({
query: findQuery.current,
matches: findMatches.current.length,
current: findIndex.current + 1
});
}
function clearFind() {
findQuery.current = '';
findMatches.current = [];
findIndex.current = -1;
renderView();
props.onFindresult && props.onFindresult({
query: '',
matches: 0,
current: 0
});
}
useEffect(() => {
cancelled.current = false;
containerEl.current = viewerEl.current;
setCurrent(Math.max(1, _pageRef.current));
setZoom(_scaleRef.current);
setRot(_rotationRef.current);
// lazy-load the engine (SSR-safe + code-split), then configure the worker and
// load the document.
import('pdfjs-dist').then((mod: any) => {
if (cancelled.current) return;
pdfjsLib.current = mod;
pdfjsLib.current.GlobalWorkerOptions.workerSrc = _workerSrcRef.current;
// hand off to the lazy $watch below rather than calling load() from this
// (React: mount-frozen) closure — see the $data.engineReady note above.
setEngineReady(prev => prev + 1);
});
return () => {
cancelled.current = true;
renderToken.current++;
if (observer.current) {
observer.current.disconnect();
observer.current = null;
}
if (loadingTask.current) {
loadingTask.current.destroy();
loadingTask.current = null;
}
instance.current = null;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
load();
}, [engineReady]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
load();
}, [props.src]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
load();
}, [props.password]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
const v = props.workerSrc;
if (pdfjsLib.current && v) pdfjsLib.current.GlobalWorkerOptions.workerSrc = v;
}, [props.workerSrc]);
useEffect(() => {
if (_watch4First.current) { _watch4First.current = false; return; }
const v = page;
if (typeof v === 'number' && v >= 1 && v !== current) setCurrent(v);
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch5First.current) { _watch5First.current = false; return; }
const v = props.scale;
if (typeof v === 'number' && v > 0) setZoom(v);
}, [props.scale]);
useEffect(() => {
if (_watch6First.current) { _watch6First.current = false; return; }
const v = props.rotation;
if (typeof v === 'number') setRot((v % 360 + 360) % 360);
}, [props.rotation]);
useEffect(() => {
if (_watch7First.current) { _watch7First.current = false; return; }
const v = current;
setPage(v);
props.onPagechange && props.onPagechange({
page: v
});
if (props.renderAllPages) {
if (!suppressScroll.current) scrollToPage(v);
} else renderView();
}, [current]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch8First.current) { _watch8First.current = false; return; }
renderView();
}, [zoom]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch9First.current) { _watch9First.current = false; return; }
renderView();
}, [rot]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch10First.current) { _watch10First.current = false; return; }
renderView();
}, [props.renderAllPages]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch11First.current) { _watch11First.current = false; return; }
renderView();
}, [props.textLayer]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ getDocument, getPageCount, goToPage, nextPage, prevPage, setScale, zoomIn, zoomOut, fitWidth, fitPage, rotateCW, rotateCCW, download, getMetadata, getOutline, find, findNext, findPrev, clearFind });
_rozieExposeRef.current = { getDocument, getPageCount, goToPage, nextPage, prevPage, setScale, zoomIn, zoomOut, fitWidth, fitPage, rotateCW, rotateCCW, download, getMetadata, getOutline, find, findNext, findPrev, clearFind };
useImperativeHandle(ref, () => ({ getDocument: (...args: Parameters<typeof getDocument>): ReturnType<typeof getDocument> => _rozieExposeRef.current.getDocument(...args), getPageCount: (...args: Parameters<typeof getPageCount>): ReturnType<typeof getPageCount> => _rozieExposeRef.current.getPageCount(...args), goToPage: (...args: Parameters<typeof goToPage>): ReturnType<typeof goToPage> => _rozieExposeRef.current.goToPage(...args), nextPage: (...args: Parameters<typeof nextPage>): ReturnType<typeof nextPage> => _rozieExposeRef.current.nextPage(...args), prevPage: (...args: Parameters<typeof prevPage>): ReturnType<typeof prevPage> => _rozieExposeRef.current.prevPage(...args), setScale: (...args: Parameters<typeof setScale>): ReturnType<typeof setScale> => _rozieExposeRef.current.setScale(...args), zoomIn: (...args: Parameters<typeof zoomIn>): ReturnType<typeof zoomIn> => _rozieExposeRef.current.zoomIn(...args), zoomOut: (...args: Parameters<typeof zoomOut>): ReturnType<typeof zoomOut> => _rozieExposeRef.current.zoomOut(...args), fitWidth: (...args: Parameters<typeof fitWidth>): ReturnType<typeof fitWidth> => _rozieExposeRef.current.fitWidth(...args), fitPage: (...args: Parameters<typeof fitPage>): ReturnType<typeof fitPage> => _rozieExposeRef.current.fitPage(...args), rotateCW: (...args: Parameters<typeof rotateCW>): ReturnType<typeof rotateCW> => _rozieExposeRef.current.rotateCW(...args), rotateCCW: (...args: Parameters<typeof rotateCCW>): ReturnType<typeof rotateCCW> => _rozieExposeRef.current.rotateCCW(...args), download: (...args: Parameters<typeof download>): ReturnType<typeof download> => _rozieExposeRef.current.download(...args), getMetadata: (...args: Parameters<typeof getMetadata>): ReturnType<typeof getMetadata> => _rozieExposeRef.current.getMetadata(...args), getOutline: (...args: Parameters<typeof getOutline>): ReturnType<typeof getOutline> => _rozieExposeRef.current.getOutline(...args), find: (...args: Parameters<typeof find>): ReturnType<typeof find> => _rozieExposeRef.current.find(...args), findNext: (...args: Parameters<typeof findNext>): ReturnType<typeof findNext> => _rozieExposeRef.current.findNext(...args), findPrev: (...args: Parameters<typeof findPrev>): ReturnType<typeof findPrev> => _rozieExposeRef.current.findPrev(...args), clearFind: (...args: Parameters<typeof clearFind>): ReturnType<typeof clearFind> => _rozieExposeRef.current.clearFind(...args) }), []);
return (
<>
<div ref={viewerEl} {...attrs} className={clsx("rozie-pdf", (attrs.className as string | undefined))} data-rozie-s-3c863364="" />
</>
);
});
export default PdfViewer;vue
<template>
<div class="rozie-pdf" ref="viewerElRef" v-bind="$attrs"></div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* The PDF source — a URL string, a `data:` base64 URL, or binary data (`ArrayBuffer` / `Uint8Array`). Changing it tears down the previous document (via its loading task) and loads the new one; `undefined` renders an empty viewer.
* @example
* <PdfViewer :src="pdfUrl" r-model:page="page" />
*/
src?: unknown;
/**
* The zoom scale (`1` = 100%). One-way: the `setScale` / `zoomIn` / `zoomOut` / `fitWidth` / `fitPage` handle verbs override it imperatively, while a consumer write reconciles the live render.
*/
scale?: number;
/**
* Rotation in degrees (`0` / `90` / `180` / `270`). One-way: the `rotateCW` / `rotateCCW` verbs override it. Normalized into `[0, 360)`.
*/
rotation?: number;
/**
* The PDF.js worker URL, set on `GlobalWorkerOptions.workerSrc` before loading. Defaults to the version-matched jsDelivr CDN copy so the component works with zero config; override for offline / CSP / a bundled worker (e.g. Vite's `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)`).
*/
workerSrc?: string;
/**
* The directory of PDF.js's standard-font data so the base-14 fonts (Helvetica / Times / Courier / …) render with correct glyphs. Version-matched CDN default; override (or pass a bundled dir) for offline / CSP.
*/
standardFontDataUrl?: string;
/**
* `false` (default) renders a single page with nav (the two-way `page` drives it). `true` renders a continuous scroll of every page; the most-visible page reflects back into `page` and the `pagechange` event via an `IntersectionObserver`.
*/
renderAllPages?: boolean;
/**
* Render PDF.js's selectable / copyable text-layer spans over each page canvas (the differentiator vs a dumb canvas image). On by default; the required `.textLayer` CSS + `--scale-factor` var ship with the component, so no extra import is needed.
*/
textLayer?: boolean;
/**
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
*/
password?: unknown;
/**
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
*/
options?: Record<string, any>;
}>(),
{ src: undefined, scale: 1, rotation: 0, workerSrc: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/build/pdf.worker.min.mjs', standardFontDataUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/standard_fonts/', renderAllPages: false, textLayer: true, password: undefined, options: () => ({}) }
);
/**
* The 1-based current page. The sole `model: true` prop — **two-way** (`r-model:page` / `v-model:page` / `bind:page` / `[(page)]`), so `page` also drives the Angular `ControlValueAccessor`. In single-page mode it drives which page renders; in `render-all-pages` mode it reflects the scrolled-to page (and scrolls the container when the consumer writes it). Clamped to `[1, pageCount]`.
*/
const page = defineModel<number>('page', { default: 1 });
const emit = defineEmits<{
error: [...args: any[]];
pagesrendered: [...args: any[]];
passwordrequest: [...args: any[]];
progress: [...args: any[]];
load: [...args: any[]];
pagechange: [...args: any[]];
findresult: [...args: any[]];
}>();
const current = ref(1);
const zoom = ref(1);
const rot = ref(0);
const engineReady = ref(0);
const viewerElRef = ref<HTMLElement>();
// pdfjs is DYNAMICALLY imported in $onMount, NOT a top-level import: pdfjs's main
// build evaluates browser globals (DOMMatrix, …) at module-load time, which
// crashes SSR (Next / Nuxt / SvelteKit / Analog / VitePress). Lazy-importing it on
// mount makes the component SSR-safe for ALL consumers AND code-splits the ~1MB
// engine out of the initial bundle. `pdfjsLib` is a null-let → typeNeutralize
// `any` (so pdfjsLib.getDocument / .TextLayer / .GlobalWorkerOptions are unchecked).
let pdfjsLib: any = null;
// more null-lets (→ `any`): `instance` is the PDFDocumentProxy (whose strict types
// the loosely-typed props don't satisfy — the maplibre mapOptions idiom),
// containerEl is the scroll host, observer is the continuous-mode scroll spy.
// more null-lets (→ `any`): `instance` is the PDFDocumentProxy (whose strict types
// the loosely-typed props don't satisfy — the maplibre mapOptions idiom),
// containerEl is the scroll host, observer is the continuous-mode scroll spy.
let instance: any = null;
let containerEl: any = null;
let observer: any = null;
// the PDFDocumentLoadingTask — it (NOT the PDFDocumentProxy, which has no
// destroy() in pdfjs v6) owns teardown of the worker + document. Held so a
// src/password change or unmount can tear the previous load down.
// the PDFDocumentLoadingTask — it (NOT the PDFDocumentProxy, which has no
// destroy() in pdfjs v6) owns teardown of the worker + document. Held so a
// src/password change or unmount can tear the previous load down.
let loadingTask: any = null;
// monotonic token cancels stale async loads/renders (src can change mid-render,
// pages render async — the SortableList rebuild-cancel discipline).
// monotonic token cancels stale async loads/renders (src can change mid-render,
// pages render async — the SortableList rebuild-cancel discipline).
let renderToken = 0;
// guards the scroll-spy → $data.current → scroll-to feedback loop.
// guards the scroll-spy → $data.current → scroll-to feedback loop.
let suppressScroll = false;
// find/search state. findQuery is the active lowercased query (''=inactive);
// findMatches is a flat per-OCCURRENCE list [{ page }] (drives the count + the
// next/prev cycle); findIndex is the current match (-1=none). TOP-LEVEL lets (not
// $onMount-local) so renderPage's coarse highlight pass + the find verbs can read
// them across renders.
// find/search state. findQuery is the active lowercased query (''=inactive);
// findMatches is a flat per-OCCURRENCE list [{ page }] (drives the count + the
// next/prev cycle); findIndex is the current match (-1=none). TOP-LEVEL lets (not
// $onMount-local) so renderPage's coarse highlight pass + the find verbs can read
// them across renders.
let findQuery = '';
let findMatches = [];
let findIndex = -1;
// set in the $onMount teardown so a late-resolving dynamic import() bails. A
// TOP-LEVEL let (not $onMount-local): the Solid emitter hoists the $onMount
// teardown into a sibling onCleanup() OUTSIDE the mount closure, so a mount-local
// would be out of scope there.
// set in the $onMount teardown so a late-resolving dynamic import() bails. A
// TOP-LEVEL let (not $onMount-local): the Solid emitter hoists the $onMount
// teardown into a sibling onCleanup() OUTSIDE the mount closure, so a mount-local
// would be out of scope there.
let cancelled = false;
// ─── build the getDocument() source (no sigils beyond $props/$snapshot) ──────
// ─── build the getDocument() source (no sigils beyond $props/$snapshot) ──────
const buildSource = () => {
let cfg: any = null;
cfg = {
...props.options
};
// NOTE: the local must NOT be named `src` — a local `const src = $props.src`
// (same name as the `src` prop) hits a Svelte-emitter scope bug where the
// renamed local's initializer mis-resolves to itself (`const src2 = src2`, a
// TDZ ReferenceError) instead of the prop accessor. Naming it `srcInput`
// sidesteps the shadow on every target.
const srcInput = props.src;
if (typeof srcInput === 'string') {
if (srcInput.startsWith('data:')) {
// decode a `data:` base64 URL to bytes — pdfjs `url` doesn't fetch data URLs.
const base64 = srcInput.substring(srcInput.indexOf(',') + 1);
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
cfg.data = bytes;
} else {
cfg.url = srcInput;
}
} else if (srcInput) {
cfg.data = srcInput;
}
if (props.password != null) cfg.password = props.password;
if (props.standardFontDataUrl) cfg.standardFontDataUrl = props.standardFontDataUrl;
return cfg;
};
// ─── render one page (canvas + optional text layer) into the container ───────
// ─── render one page (canvas + optional text layer) into the container ───────
const renderPage = async (pdf: any, pageNum: any, container: any) => {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({
scale: zoom.value,
rotation: rot.value
});
const pageDiv = document.createElement('div');
pageDiv.className = 'rozie-pdf-page';
pageDiv.setAttribute('data-page', String(pageNum));
pageDiv.style.width = Math.floor(viewport.width) + 'px';
pageDiv.style.height = Math.floor(viewport.height) + 'px';
// the text layer positions its spans with calc(var(--scale-factor) * …px).
pageDiv.style.setProperty('--scale-factor', String(zoom.value));
const outputScale = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
pageDiv.appendChild(canvas);
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({
canvas,
viewport,
transform
}).promise;
if (props.textLayer) {
const tl = document.createElement('div');
tl.className = 'textLayer';
pageDiv.appendChild(tl);
const layer = new pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: tl,
viewport
});
await layer.render();
// coarse find-highlight: add .rozie-pdf-find to text-layer spans whose text
// CONTAINS the active query. Span-level / COARSE — a query straddling two
// adjacent spans won't highlight (documented). Runs only while a find is active.
if (findQuery) {
const spans = tl.querySelectorAll('span');
for (const sp of spans as any) {
const t = sp.textContent;
if (t && t.toLowerCase().indexOf(findQuery) !== -1) sp.classList.add('rozie-pdf-find');
}
}
}
container.appendChild(pageDiv);
return pageDiv;
};
// continuous-mode scroll spy — reflect the most-visible page into $data.current.
// continuous-mode scroll spy — reflect the most-visible page into $data.current.
const setupScrollSpy = () => {
if (observer) {
observer.disconnect();
observer = null;
}
if (!containerEl) return;
observer = new IntersectionObserver((entries: any) => {
let best: any = null;
let bestRatio = 0;
for (const e of entries as any) {
if (e.intersectionRatio > bestRatio) {
bestRatio = e.intersectionRatio;
best = e.target;
}
}
if (best) {
const n = Number(best.getAttribute('data-page'));
if (n && n !== current.value) {
suppressScroll = true;
current.value = n;
suppressScroll = false;
}
}
}, {
root: containerEl,
threshold: [0.25, 0.5, 0.75]
});
for (const child of containerEl.children as any) observer.observe(child);
};
const scrollToPage = (n: any) => {
if (!containerEl) return;
const el = containerEl.querySelector('[data-page="' + n + '"]');
if (el) el.scrollIntoView({
block: 'start',
behavior: 'auto'
});
};
// ─── render the current view (single page, or all pages) ─────────────────────
// ─── render the current view (single page, or all pages) ─────────────────────
const renderView = async () => {
if (!instance || !containerEl) return;
const token = ++renderToken;
if (observer) {
observer.disconnect();
observer = null;
}
containerEl.innerHTML = '';
const total = instance.numPages;
const pages = props.renderAllPages ? Array.from({
length: total
}, (_: any, i: any) => i + 1) : [Math.min(Math.max(current.value, 1), total)];
for (const n of pages as any) {
if (token !== renderToken) return;
try {
await renderPage(instance, n, containerEl);
} catch (e: any) {
if (token === renderToken) emit('error', e);
}
}
if (token !== renderToken) return;
if (props.renderAllPages) setupScrollSpy();
emit('pagesrendered');
};
// ─── load the document ───────────────────────────────────────────────────────
// ─── load the document ───────────────────────────────────────────────────────
const load = async () => {
if (!pdfjsLib) return;
const token = ++renderToken;
if (observer) {
observer.disconnect();
observer = null;
}
// tear down the previous load via the loading task (PDFDocumentProxy has no
// destroy() in pdfjs v6).
if (loadingTask) {
loadingTask.destroy();
loadingTask = null;
}
instance = null;
if (containerEl) containerEl.innerHTML = '';
if (!props.src) return;
try {
loadingTask = pdfjsLib.getDocument(buildSource());
loadingTask.onPassword = (_updatePassword: any, reason: any) => {
emit('passwordrequest', {
reason
});
};
// download progress in bytes; `total` may be 0/undefined when the server sends
// no Content-Length header — pass the raw pdfjs onProgress payload through as-is.
loadingTask.onProgress = (p: any) => emit('progress', {
loaded: p && p.loaded,
total: p && p.total
});
const pdf = await loadingTask.promise;
// stale (a newer load bumped the token + destroyed this task) — drop it.
if (token !== renderToken) return;
instance = pdf;
if (current.value > pdf.numPages) current.value = pdf.numPages;
emit('load', {
numPages: pdf.numPages
});
await renderView();
} catch (e: any) {
// a destroyed task rejects its promise — suppress the abort for stale loads.
if (token === renderToken) emit('error', e);
}
};
const applyFit = async (mode: any) => {
if (!instance || !containerEl) return;
const n = Math.min(Math.max(current.value, 1), instance.numPages);
const page = await instance.getPage(n);
const vp = page.getViewport({
scale: 1,
rotation: rot.value
});
const cw = containerEl.clientWidth - 24;
const ch = containerEl.clientHeight - 24;
if (mode === 'width') zoom.value = cw / vp.width;else zoom.value = Math.min(cw / vp.width, ch / vp.height);
};
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// 19 verbs. Collision-clear: NO `setPage` (React `page`-model auto-setter,
// ROZ524 — use goToPage); none equals an emit name (load/error/pagechange/
// pagesrendered/passwordrequest/progress/findresult); none is a Lit reserved
// lifecycle. The navigation/zoom/rotate verbs drive $data (not the props), so they
// work whether or not the consumer binds `page`. The document-level verbs below
// are cheap passthroughs over the held PDFDocumentProxy (`instance`) that a
// consumer can't reach otherwise without `getDocument()` + pdf.js knowledge:
// - download(filename?): save the original PDF bytes (instance.getData() ->
// Blob -> anchor click) — the single most-expected viewer affordance.
// - getMetadata(): document title/author/page-labels (tab title / info panel).
// - getOutline(): the bookmark/TOC tree (powers a navigation sidebar; outline
// dests map onto goToPage).
// The four find verbs (find/findNext/findPrev/clearFind) drive the coarse
// span-level highlight pass + emit `findresult`. `find/findNext/findPrev/clearFind`
// are collision-vetted (no Lit reserved lifecycle, no `page`-model auto-setter clash).
function getDocument() {
return instance;
}
function getPageCount() {
return instance ? instance.numPages : 0;
}
function goToPage(n: any) {
if (!instance) return;
current.value = Math.min(Math.max(n, 1), instance.numPages);
}
function nextPage() {
goToPage(current.value + 1);
}
function prevPage() {
goToPage(current.value - 1);
}
function setScale(s: any) {
if (typeof s === 'number' && s > 0) zoom.value = s;
}
function zoomIn() {
zoom.value = Math.min(zoom.value * 1.25, 10);
}
function zoomOut() {
zoom.value = Math.max(zoom.value / 1.25, 0.1);
}
function fitWidth() {
applyFit('width');
}
function fitPage() {
applyFit('page');
}
function rotateCW() {
rot.value = (rot.value + 90) % 360;
}
function rotateCCW() {
rot.value = (rot.value + 270) % 360;
}
// Save the original PDF bytes. getData() resolves the raw Uint8Array; wrap in a
// Blob and trigger a download via a transient anchor. Resolves false before mount.
// Save the original PDF bytes. getData() resolves the raw Uint8Array; wrap in a
// Blob and trigger a download via a transient anchor. Resolves false before mount.
async function download(filename: any) {
if (!instance) return false;
const bytes = await instance.getData();
const url = URL.createObjectURL(new Blob([bytes], {
type: 'application/pdf'
}));
const a = document.createElement('a');
a.href = url;
a.download = filename || 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return true;
}
// Document info (title/author/page labels) — resolves null before mount.
// Document info (title/author/page labels) — resolves null before mount.
function getMetadata() {
return instance ? instance.getMetadata() : null;
}
// Bookmark / table-of-contents tree — resolves null when absent or before mount.
// Bookmark / table-of-contents tree — resolves null when absent or before mount.
function getOutline() {
return instance ? instance.getOutline() : null;
}
// ─── text find/search (coarse span-level highlight) ──────────────────────────
// find(query) scans EVERY page's extracted text for occurrences, navigates to +
// highlights the first match, returns the match count, and emits `findresult`. The
// highlight is COARSE / span-level: renderPage adds .rozie-pdf-find to whole
// text-layer spans that CONTAIN the query (a query straddling two spans won't
// highlight). findNext/findPrev cycle (wrap) through the per-occurrence match list;
// clearFind resets the query + highlights. All async-safe over the `any`
// PDFDocumentProxy (`instance`); no-op / return 0 before the document loads.
// ─── text find/search (coarse span-level highlight) ──────────────────────────
// find(query) scans EVERY page's extracted text for occurrences, navigates to +
// highlights the first match, returns the match count, and emits `findresult`. The
// highlight is COARSE / span-level: renderPage adds .rozie-pdf-find to whole
// text-layer spans that CONTAIN the query (a query straddling two spans won't
// highlight). findNext/findPrev cycle (wrap) through the per-occurrence match list;
// clearFind resets the query + highlights. All async-safe over the `any`
// PDFDocumentProxy (`instance`); no-op / return 0 before the document loads.
async function find(query: any) {
const q = (query == null ? '' : String(query)).trim().toLowerCase();
findQuery = q;
findMatches = [];
findIndex = -1;
if (!instance || !q) {
renderView();
emit('findresult', {
query: q,
matches: 0,
current: 0
});
return 0;
}
const total = instance.numPages;
for (let p = 1; p <= total; p++) {
const page = await instance.getPage(p);
const tc = await page.getTextContent();
const text = tc.items.map((it: any) => it && it.str != null ? it.str : '').join('').toLowerCase();
let from = 0;
while (true) {
const at = text.indexOf(q, from);
if (at === -1) break;
findMatches.push({
page: p
});
from = at + q.length;
}
}
if (findMatches.length) {
findIndex = 0;
const target = findMatches[0].page;
// navigate if needed; if already on the target page, force a re-render so the
// highlight pass runs (a no-op goToPage wouldn't trip the $data.current $watch).
if (target !== current.value) goToPage(target);else renderView();
} else {
renderView();
}
emit('findresult', {
query: q,
matches: findMatches.length,
current: findMatches.length ? 1 : 0
});
return findMatches.length;
}
function findNext() {
if (!findMatches.length) return;
findIndex = (findIndex + 1) % findMatches.length;
const target = findMatches[findIndex].page;
if (target !== current.value) goToPage(target);
emit('findresult', {
query: findQuery,
matches: findMatches.length,
current: findIndex + 1
});
}
function findPrev() {
if (!findMatches.length) return;
findIndex = (findIndex - 1 + findMatches.length) % findMatches.length;
const target = findMatches[findIndex].page;
if (target !== current.value) goToPage(target);
emit('findresult', {
query: findQuery,
matches: findMatches.length,
current: findIndex + 1
});
}
function clearFind() {
findQuery = '';
findMatches = [];
findIndex = -1;
renderView();
emit('findresult', {
query: '',
matches: 0,
current: 0
});
}
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
cancelled = false;
containerEl = viewerElRef.value;
current.value = Math.max(1, page.value);
zoom.value = props.scale;
rot.value = props.rotation;
// lazy-load the engine (SSR-safe + code-split), then configure the worker and
// load the document.
import('pdfjs-dist').then((mod: any) => {
if (cancelled) return;
pdfjsLib = mod;
pdfjsLib.GlobalWorkerOptions.workerSrc = props.workerSrc;
// hand off to the lazy $watch below rather than calling load() from this
// (React: mount-frozen) closure — see the $data.engineReady note above.
engineReady.value++;
});
_cleanup_0 = () => {
cancelled = true;
renderToken++;
if (observer) {
observer.disconnect();
observer = null;
}
if (loadingTask) {
loadingTask.destroy();
loadingTask = null;
}
instance = null;
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => engineReady.value, () => load());
watch(() => props.src, () => load());
watch(() => props.password, () => load());
watch(() => props.workerSrc, (v: any) => {
if (pdfjsLib && v) pdfjsLib.GlobalWorkerOptions.workerSrc = v;
});
watch(() => page.value, (v: any) => {
if (typeof v === 'number' && v >= 1 && v !== current.value) current.value = v;
});
watch(() => props.scale, (v: any) => {
if (typeof v === 'number' && v > 0) zoom.value = v;
});
watch(() => props.rotation, (v: any) => {
if (typeof v === 'number') rot.value = (v % 360 + 360) % 360;
});
watch(() => current.value, (v: any) => {
page.value = v;
emit('pagechange', {
page: v
});
if (props.renderAllPages) {
if (!suppressScroll) scrollToPage(v);
} else renderView();
});
watch(() => zoom.value, () => renderView());
watch(() => rot.value, () => renderView());
watch(() => props.renderAllPages, () => renderView());
watch(() => props.textLayer, () => renderView());
defineExpose({ getDocument, getPageCount, goToPage, nextPage, prevPage, setScale, zoomIn, zoomOut, fitWidth, fitPage, rotateCW, rotateCCW, download, getMetadata, getOutline, find, findNext, findPrev, clearFind });
</script>
<style scoped>
.rozie-pdf {
width: 100%;
height: 100%;
min-height: 320px;
overflow: auto;
background: #525659;
padding: 12px 0;
}
</style>
<style>
.rozie-pdf .rozie-pdf-page {
position: relative;
margin: 0 auto 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
background: #fff;
}
.rozie-pdf .rozie-pdf-page canvas {
display: block;
}
.rozie-pdf .textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 1;
}
.rozie-pdf .textLayer span,
.rozie-pdf .textLayer br {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.rozie-pdf .textLayer span.markedContent {
top: 0;
height: 0;
}
.rozie-pdf .textLayer ::selection {
background: rgba(0, 100, 255, 0.3);
}
.rozie-pdf .textLayer span.rozie-pdf-find {
background: rgba(255, 196, 0, 0.45);
border-radius: 2px;
}
</style>svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
import { onMount, untrack } from 'svelte';
interface Props {
/**
* The PDF source — a URL string, a `data:` base64 URL, or binary data (`ArrayBuffer` / `Uint8Array`). Changing it tears down the previous document (via its loading task) and loads the new one; `undefined` renders an empty viewer.
* @example
* <PdfViewer :src="pdfUrl" r-model:page="page" />
*/
src?: unknown;
/**
* The 1-based current page. The sole `model: true` prop — **two-way** (`r-model:page` / `v-model:page` / `bind:page` / `[(page)]`), so `page` also drives the Angular `ControlValueAccessor`. In single-page mode it drives which page renders; in `render-all-pages` mode it reflects the scrolled-to page (and scrolls the container when the consumer writes it). Clamped to `[1, pageCount]`.
*/
page?: number;
/**
* The zoom scale (`1` = 100%). One-way: the `setScale` / `zoomIn` / `zoomOut` / `fitWidth` / `fitPage` handle verbs override it imperatively, while a consumer write reconciles the live render.
*/
scale?: number;
/**
* Rotation in degrees (`0` / `90` / `180` / `270`). One-way: the `rotateCW` / `rotateCCW` verbs override it. Normalized into `[0, 360)`.
*/
rotation?: number;
/**
* The PDF.js worker URL, set on `GlobalWorkerOptions.workerSrc` before loading. Defaults to the version-matched jsDelivr CDN copy so the component works with zero config; override for offline / CSP / a bundled worker (e.g. Vite's `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)`).
*/
workerSrc?: string;
/**
* The directory of PDF.js's standard-font data so the base-14 fonts (Helvetica / Times / Courier / …) render with correct glyphs. Version-matched CDN default; override (or pass a bundled dir) for offline / CSP.
*/
standardFontDataUrl?: string;
/**
* `false` (default) renders a single page with nav (the two-way `page` drives it). `true` renders a continuous scroll of every page; the most-visible page reflects back into `page` and the `pagechange` event via an `IntersectionObserver`.
*/
renderAllPages?: boolean;
/**
* Render PDF.js's selectable / copyable text-layer spans over each page canvas (the differentiator vs a dumb canvas image). On by default; the required `.textLayer` CSS + `--scale-factor` var ship with the component, so no extra import is needed.
*/
textLayer?: boolean;
/**
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
*/
password?: unknown;
/**
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
*/
options?: any;
onerror?: (...args: unknown[]) => void;
onpagesrendered?: (...args: unknown[]) => void;
onpasswordrequest?: (...args: unknown[]) => void;
onprogress?: (...args: unknown[]) => void;
onload?: (...args: unknown[]) => void;
onpagechange?: (...args: unknown[]) => void;
onfindresult?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let __defaultOptions = (() => ({}))();
let {
src = undefined,
page = $bindable(1),
scale = 1,
rotation = 0,
workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/build/pdf.worker.min.mjs',
standardFontDataUrl = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/standard_fonts/',
renderAllPages = false,
textLayer = true,
password = undefined,
options = __defaultOptions,
onerror,
onpagesrendered,
onpasswordrequest,
onprogress,
onload,
onpagechange,
onfindresult,
...__rozieAttrs
}: Props = $props();
let current = $state(1);
let zoom = $state(1);
let rot = $state(0);
let engineReady = $state(0);
let viewerEl = $state<HTMLElement | undefined>(undefined);
// pdfjs is DYNAMICALLY imported in $onMount, NOT a top-level import: pdfjs's main
// build evaluates browser globals (DOMMatrix, …) at module-load time, which
// crashes SSR (Next / Nuxt / SvelteKit / Analog / VitePress). Lazy-importing it on
// mount makes the component SSR-safe for ALL consumers AND code-splits the ~1MB
// engine out of the initial bundle. `pdfjsLib` is a null-let → typeNeutralize
// `any` (so pdfjsLib.getDocument / .TextLayer / .GlobalWorkerOptions are unchecked).
let pdfjsLib: any = null;
// more null-lets (→ `any`): `instance` is the PDFDocumentProxy (whose strict types
// the loosely-typed props don't satisfy — the maplibre mapOptions idiom),
// containerEl is the scroll host, observer is the continuous-mode scroll spy.
// more null-lets (→ `any`): `instance` is the PDFDocumentProxy (whose strict types
// the loosely-typed props don't satisfy — the maplibre mapOptions idiom),
// containerEl is the scroll host, observer is the continuous-mode scroll spy.
let instance: any = null;
let containerEl: any = null;
let observer: any = null;
// the PDFDocumentLoadingTask — it (NOT the PDFDocumentProxy, which has no
// destroy() in pdfjs v6) owns teardown of the worker + document. Held so a
// src/password change or unmount can tear the previous load down.
// the PDFDocumentLoadingTask — it (NOT the PDFDocumentProxy, which has no
// destroy() in pdfjs v6) owns teardown of the worker + document. Held so a
// src/password change or unmount can tear the previous load down.
let loadingTask: any = null;
// monotonic token cancels stale async loads/renders (src can change mid-render,
// pages render async — the SortableList rebuild-cancel discipline).
// monotonic token cancels stale async loads/renders (src can change mid-render,
// pages render async — the SortableList rebuild-cancel discipline).
let renderToken = 0;
// guards the scroll-spy → $data.current → scroll-to feedback loop.
// guards the scroll-spy → $data.current → scroll-to feedback loop.
let suppressScroll = false;
// find/search state. findQuery is the active lowercased query (''=inactive);
// findMatches is a flat per-OCCURRENCE list [{ page }] (drives the count + the
// next/prev cycle); findIndex is the current match (-1=none). TOP-LEVEL lets (not
// $onMount-local) so renderPage's coarse highlight pass + the find verbs can read
// them across renders.
// find/search state. findQuery is the active lowercased query (''=inactive);
// findMatches is a flat per-OCCURRENCE list [{ page }] (drives the count + the
// next/prev cycle); findIndex is the current match (-1=none). TOP-LEVEL lets (not
// $onMount-local) so renderPage's coarse highlight pass + the find verbs can read
// them across renders.
let findQuery = '';
let findMatches = [];
let findIndex = -1;
// set in the $onMount teardown so a late-resolving dynamic import() bails. A
// TOP-LEVEL let (not $onMount-local): the Solid emitter hoists the $onMount
// teardown into a sibling onCleanup() OUTSIDE the mount closure, so a mount-local
// would be out of scope there.
// set in the $onMount teardown so a late-resolving dynamic import() bails. A
// TOP-LEVEL let (not $onMount-local): the Solid emitter hoists the $onMount
// teardown into a sibling onCleanup() OUTSIDE the mount closure, so a mount-local
// would be out of scope there.
let cancelled = false;
// ─── build the getDocument() source (no sigils beyond $props/$snapshot) ──────
// ─── build the getDocument() source (no sigils beyond $props/$snapshot) ──────
const buildSource = () => {
let cfg: any = null;
cfg = {
...$state.snapshot(options)
};
// NOTE: the local must NOT be named `src` — a local `const src = $props.src`
// (same name as the `src` prop) hits a Svelte-emitter scope bug where the
// renamed local's initializer mis-resolves to itself (`const src2 = src2`, a
// TDZ ReferenceError) instead of the prop accessor. Naming it `srcInput`
// sidesteps the shadow on every target.
const srcInput = src;
if (typeof srcInput === 'string') {
if (srcInput.startsWith('data:')) {
// decode a `data:` base64 URL to bytes — pdfjs `url` doesn't fetch data URLs.
const base64 = srcInput.substring(srcInput.indexOf(',') + 1);
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
cfg.data = bytes;
} else {
cfg.url = srcInput;
}
} else if (srcInput) {
cfg.data = srcInput;
}
if (password != null) cfg.password = password;
if (standardFontDataUrl) cfg.standardFontDataUrl = standardFontDataUrl;
return cfg;
};
// ─── render one page (canvas + optional text layer) into the container ───────
// ─── render one page (canvas + optional text layer) into the container ───────
const renderPage = async (pdf: any, pageNum: any, container: any) => {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({
scale: zoom,
rotation: rot
});
const pageDiv = document.createElement('div');
pageDiv.className = 'rozie-pdf-page';
pageDiv.setAttribute('data-page', String(pageNum));
pageDiv.style.width = Math.floor(viewport.width) + 'px';
pageDiv.style.height = Math.floor(viewport.height) + 'px';
// the text layer positions its spans with calc(var(--scale-factor) * …px).
pageDiv.style.setProperty('--scale-factor', String(zoom));
const outputScale = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
pageDiv.appendChild(canvas);
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({
canvas,
viewport,
transform
}).promise;
if (textLayer) {
const tl = document.createElement('div');
tl.className = 'textLayer';
pageDiv.appendChild(tl);
const layer = new pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: tl,
viewport
});
await layer.render();
// coarse find-highlight: add .rozie-pdf-find to text-layer spans whose text
// CONTAINS the active query. Span-level / COARSE — a query straddling two
// adjacent spans won't highlight (documented). Runs only while a find is active.
if (findQuery) {
const spans = tl.querySelectorAll('span');
for (const sp of spans as any) {
const t = sp.textContent;
if (t && t.toLowerCase().indexOf(findQuery) !== -1) sp.classList.add('rozie-pdf-find');
}
}
}
container.appendChild(pageDiv);
return pageDiv;
};
// continuous-mode scroll spy — reflect the most-visible page into $data.current.
// continuous-mode scroll spy — reflect the most-visible page into $data.current.
const setupScrollSpy = () => {
if (observer) {
observer.disconnect();
observer = null;
}
if (!containerEl) return;
observer = new IntersectionObserver((entries: any) => {
let best: any = null;
let bestRatio = 0;
for (const e of entries as any) {
if (e.intersectionRatio > bestRatio) {
bestRatio = e.intersectionRatio;
best = e.target;
}
}
if (best) {
const n = Number(best.getAttribute('data-page'));
if (n && n !== current) {
suppressScroll = true;
current = n;
suppressScroll = false;
}
}
}, {
root: containerEl,
threshold: [0.25, 0.5, 0.75]
});
for (const child of containerEl.children as any) observer.observe(child);
};
const scrollToPage = (n: any) => {
if (!containerEl) return;
const el = containerEl.querySelector('[data-page="' + n + '"]');
if (el) el.scrollIntoView({
block: 'start',
behavior: 'auto'
});
};
// ─── render the current view (single page, or all pages) ─────────────────────
// ─── render the current view (single page, or all pages) ─────────────────────
const renderView = async () => {
if (!instance || !containerEl) return;
const token = ++renderToken;
if (observer) {
observer.disconnect();
observer = null;
}
containerEl.innerHTML = '';
const total = instance.numPages;
const pages = renderAllPages ? Array.from({
length: total
}, (_: any, i: any) => i + 1) : [Math.min(Math.max(current, 1), total)];
for (const n of pages as any) {
if (token !== renderToken) return;
try {
await renderPage(instance, n, containerEl);
} catch (e: any) {
if (token === renderToken) onerror?.(e);
}
}
if (token !== renderToken) return;
if (renderAllPages) setupScrollSpy();
onpagesrendered?.();
};
// ─── load the document ───────────────────────────────────────────────────────
// ─── load the document ───────────────────────────────────────────────────────
const load = async () => {
if (!pdfjsLib) return;
const token = ++renderToken;
if (observer) {
observer.disconnect();
observer = null;
}
// tear down the previous load via the loading task (PDFDocumentProxy has no
// destroy() in pdfjs v6).
if (loadingTask) {
loadingTask.destroy();
loadingTask = null;
}
instance = null;
if (containerEl) containerEl.innerHTML = '';
if (!src) return;
try {
loadingTask = pdfjsLib.getDocument(buildSource());
loadingTask.onPassword = (_updatePassword: any, reason: any) => {
onpasswordrequest?.({
reason
});
};
// download progress in bytes; `total` may be 0/undefined when the server sends
// no Content-Length header — pass the raw pdfjs onProgress payload through as-is.
loadingTask.onProgress = (p: any) => onprogress?.({
loaded: p && p.loaded,
total: p && p.total
});
const pdf = await loadingTask.promise;
// stale (a newer load bumped the token + destroyed this task) — drop it.
if (token !== renderToken) return;
instance = pdf;
if (current > pdf.numPages) current = pdf.numPages;
onload?.({
numPages: pdf.numPages
});
await renderView();
} catch (e: any) {
// a destroyed task rejects its promise — suppress the abort for stale loads.
if (token === renderToken) onerror?.(e);
}
};
const applyFit = async (mode: any) => {
if (!instance || !containerEl) return;
const n = Math.min(Math.max(current, 1), instance.numPages);
const page = await instance.getPage(n);
const vp = page.getViewport({
scale: 1,
rotation: rot
});
const cw = containerEl.clientWidth - 24;
const ch = containerEl.clientHeight - 24;
if (mode === 'width') zoom = cw / vp.width;else zoom = Math.min(cw / vp.width, ch / vp.height);
};
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// 19 verbs. Collision-clear: NO `setPage` (React `page`-model auto-setter,
// ROZ524 — use goToPage); none equals an emit name (load/error/pagechange/
// pagesrendered/passwordrequest/progress/findresult); none is a Lit reserved
// lifecycle. The navigation/zoom/rotate verbs drive $data (not the props), so they
// work whether or not the consumer binds `page`. The document-level verbs below
// are cheap passthroughs over the held PDFDocumentProxy (`instance`) that a
// consumer can't reach otherwise without `getDocument()` + pdf.js knowledge:
// - download(filename?): save the original PDF bytes (instance.getData() ->
// Blob -> anchor click) — the single most-expected viewer affordance.
// - getMetadata(): document title/author/page-labels (tab title / info panel).
// - getOutline(): the bookmark/TOC tree (powers a navigation sidebar; outline
// dests map onto goToPage).
// The four find verbs (find/findNext/findPrev/clearFind) drive the coarse
// span-level highlight pass + emit `findresult`. `find/findNext/findPrev/clearFind`
// are collision-vetted (no Lit reserved lifecycle, no `page`-model auto-setter clash).
export function getDocument() {
return instance;
}
export function getPageCount() {
return instance ? instance.numPages : 0;
}
export function goToPage(n: any) {
if (!instance) return;
current = Math.min(Math.max(n, 1), instance.numPages);
}
export function nextPage() {
goToPage(current + 1);
}
export function prevPage() {
goToPage(current - 1);
}
export function setScale(s: any) {
if (typeof s === 'number' && s > 0) zoom = s;
}
export function zoomIn() {
zoom = Math.min(zoom * 1.25, 10);
}
export function zoomOut() {
zoom = Math.max(zoom / 1.25, 0.1);
}
export function fitWidth() {
applyFit('width');
}
export function fitPage() {
applyFit('page');
}
export function rotateCW() {
rot = (rot + 90) % 360;
}
export function rotateCCW() {
rot = (rot + 270) % 360;
}
// Save the original PDF bytes. getData() resolves the raw Uint8Array; wrap in a
// Blob and trigger a download via a transient anchor. Resolves false before mount.
// Save the original PDF bytes. getData() resolves the raw Uint8Array; wrap in a
// Blob and trigger a download via a transient anchor. Resolves false before mount.
export async function download(filename: any) {
if (!instance) return false;
const bytes = await instance.getData();
const url = URL.createObjectURL(new Blob([bytes], {
type: 'application/pdf'
}));
const a = document.createElement('a');
a.href = url;
a.download = filename || 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return true;
}
// Document info (title/author/page labels) — resolves null before mount.
// Document info (title/author/page labels) — resolves null before mount.
export function getMetadata() {
return instance ? instance.getMetadata() : null;
}
// Bookmark / table-of-contents tree — resolves null when absent or before mount.
// Bookmark / table-of-contents tree — resolves null when absent or before mount.
export function getOutline() {
return instance ? instance.getOutline() : null;
}
// ─── text find/search (coarse span-level highlight) ──────────────────────────
// find(query) scans EVERY page's extracted text for occurrences, navigates to +
// highlights the first match, returns the match count, and emits `findresult`. The
// highlight is COARSE / span-level: renderPage adds .rozie-pdf-find to whole
// text-layer spans that CONTAIN the query (a query straddling two spans won't
// highlight). findNext/findPrev cycle (wrap) through the per-occurrence match list;
// clearFind resets the query + highlights. All async-safe over the `any`
// PDFDocumentProxy (`instance`); no-op / return 0 before the document loads.
// ─── text find/search (coarse span-level highlight) ──────────────────────────
// find(query) scans EVERY page's extracted text for occurrences, navigates to +
// highlights the first match, returns the match count, and emits `findresult`. The
// highlight is COARSE / span-level: renderPage adds .rozie-pdf-find to whole
// text-layer spans that CONTAIN the query (a query straddling two spans won't
// highlight). findNext/findPrev cycle (wrap) through the per-occurrence match list;
// clearFind resets the query + highlights. All async-safe over the `any`
// PDFDocumentProxy (`instance`); no-op / return 0 before the document loads.
export async function find(query: any) {
const q = (query == null ? '' : String(query)).trim().toLowerCase();
findQuery = q;
findMatches = [];
findIndex = -1;
if (!instance || !q) {
renderView();
onfindresult?.({
query: q,
matches: 0,
current: 0
});
return 0;
}
const total = instance.numPages;
for (let p = 1; p <= total; p++) {
const page = await instance.getPage(p);
const tc = await page.getTextContent();
const text = tc.items.map((it: any) => it && it.str != null ? it.str : '').join('').toLowerCase();
let from = 0;
while (true) {
const at = text.indexOf(q, from);
if (at === -1) break;
findMatches.push({
page: p
});
from = at + q.length;
}
}
if (findMatches.length) {
findIndex = 0;
const target = findMatches[0].page;
// navigate if needed; if already on the target page, force a re-render so the
// highlight pass runs (a no-op goToPage wouldn't trip the $data.current $watch).
if (target !== current) goToPage(target);else renderView();
} else {
renderView();
}
onfindresult?.({
query: q,
matches: findMatches.length,
current: findMatches.length ? 1 : 0
});
return findMatches.length;
}
export function findNext() {
if (!findMatches.length) return;
findIndex = (findIndex + 1) % findMatches.length;
const target = findMatches[findIndex].page;
if (target !== current) goToPage(target);
onfindresult?.({
query: findQuery,
matches: findMatches.length,
current: findIndex + 1
});
}
export function findPrev() {
if (!findMatches.length) return;
findIndex = (findIndex - 1 + findMatches.length) % findMatches.length;
const target = findMatches[findIndex].page;
if (target !== current) goToPage(target);
onfindresult?.({
query: findQuery,
matches: findMatches.length,
current: findIndex + 1
});
}
export function clearFind() {
findQuery = '';
findMatches = [];
findIndex = -1;
renderView();
onfindresult?.({
query: '',
matches: 0,
current: 0
});
}
onMount(() => {
cancelled = false;
containerEl = viewerEl;
current = Math.max(1, page);
zoom = scale;
rot = rotation;
// lazy-load the engine (SSR-safe + code-split), then configure the worker and
// load the document.
import('pdfjs-dist').then((mod: any) => {
if (cancelled) return;
pdfjsLib = mod;
pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;
// hand off to the lazy $watch below rather than calling load() from this
// (React: mount-frozen) closure — see the $data.engineReady note above.
engineReady++;
});
return () => {
cancelled = true;
renderToken++;
if (observer) {
observer.disconnect();
observer = null;
}
if (loadingTask) {
loadingTask.destroy();
loadingTask = null;
}
instance = null;
};
});
let __rozieWatchInitial_0 = true;
$effect(() => { (() => engineReady)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } (() => load())(); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { (() => src)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => load())(); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { (() => password)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } (() => load())(); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => workerSrc)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => {
if (pdfjsLib && v) pdfjsLib.GlobalWorkerOptions.workerSrc = v;
})(__watchVal); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { const __watchVal = (() => page)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } ((v: any) => {
if (typeof v === 'number' && v >= 1 && v !== current) current = v;
})(__watchVal); }); });
let __rozieWatchInitial_5 = true;
$effect(() => { const __watchVal = (() => scale)(); untrack(() => { if (__rozieWatchInitial_5) { __rozieWatchInitial_5 = false; return; } ((v: any) => {
if (typeof v === 'number' && v > 0) zoom = v;
})(__watchVal); }); });
let __rozieWatchInitial_6 = true;
$effect(() => { const __watchVal = (() => rotation)(); untrack(() => { if (__rozieWatchInitial_6) { __rozieWatchInitial_6 = false; return; } ((v: any) => {
if (typeof v === 'number') rot = (v % 360 + 360) % 360;
})(__watchVal); }); });
let __rozieWatchInitial_7 = true;
$effect(() => { const __watchVal = (() => current)(); untrack(() => { if (__rozieWatchInitial_7) { __rozieWatchInitial_7 = false; return; } ((v: any) => {
page = v;
onpagechange?.({
page: v
});
if (renderAllPages) {
if (!suppressScroll) scrollToPage(v);
} else renderView();
})(__watchVal); }); });
let __rozieWatchInitial_8 = true;
$effect(() => { (() => zoom)(); untrack(() => { if (__rozieWatchInitial_8) { __rozieWatchInitial_8 = false; return; } (() => renderView())(); }); });
let __rozieWatchInitial_9 = true;
$effect(() => { (() => rot)(); untrack(() => { if (__rozieWatchInitial_9) { __rozieWatchInitial_9 = false; return; } (() => renderView())(); }); });
let __rozieWatchInitial_10 = true;
$effect(() => { (() => renderAllPages)(); untrack(() => { if (__rozieWatchInitial_10) { __rozieWatchInitial_10 = false; return; } (() => renderView())(); }); });
let __rozieWatchInitial_11 = true;
$effect(() => { (() => textLayer)(); untrack(() => { if (__rozieWatchInitial_11) { __rozieWatchInitial_11 = false; return; } (() => renderView())(); }); });
</script>
<div bind:this={viewerEl} {...__rozieAttrs} class={["rozie-pdf", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-3c863364></div>
<style>
:global {
.rozie-pdf[data-rozie-s-3c863364] {
width: 100%;
height: 100%;
min-height: 320px;
overflow: auto;
background: #525659;
padding: 12px 0;
}
}
:global {
.rozie-pdf .rozie-pdf-page {
position: relative;
margin: 0 auto 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
background: #fff;
}
.rozie-pdf .rozie-pdf-page canvas {
display: block;
}
.rozie-pdf .textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 1;
}
.rozie-pdf .textLayer span,
.rozie-pdf .textLayer br {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.rozie-pdf .textLayer span.markedContent {
top: 0;
height: 0;
}
.rozie-pdf .textLayer ::selection {
background: rgba(0, 100, 255, 0.3);
}
.rozie-pdf .textLayer span.rozie-pdf-find {
background: rgba(255, 196, 0, 0.45);
border-radius: 2px;
}
}
</style>ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'rozie-pdf-viewer',
standalone: true,
template: `
<div class="rozie-pdf" #viewerEl #rozieSpread_0 #rozieListenersTarget_1></div>
`,
styles: [`
.rozie-pdf {
width: 100%;
height: 100%;
min-height: 320px;
overflow: auto;
background: #525659;
padding: 12px 0;
}
::ng-deep .rozie-pdf .rozie-pdf-page {
position: relative;
margin: 0 auto 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
background: #fff;
}
::ng-deep .rozie-pdf .rozie-pdf-page canvas {
display: block;
}
::ng-deep .rozie-pdf .textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 1;
}
::ng-deep .rozie-pdf .textLayer span,
::ng-deep .rozie-pdf .textLayer br {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
::ng-deep .rozie-pdf .textLayer span.markedContent {
top: 0;
height: 0;
}
::ng-deep .rozie-pdf .textLayer ::selection {
background: rgba(0, 100, 255, 0.3);
}
::ng-deep .rozie-pdf .textLayer span.rozie-pdf-find {
background: rgba(255, 196, 0, 0.45);
border-radius: 2px;
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PdfViewer),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class PdfViewer {
/**
* The PDF source — a URL string, a `data:` base64 URL, or binary data (`ArrayBuffer` / `Uint8Array`). Changing it tears down the previous document (via its loading task) and loads the new one; `undefined` renders an empty viewer.
* @example
* <PdfViewer :src="pdfUrl" r-model:page="page" />
*/
src = input<unknown>(undefined);
/**
* The 1-based current page. The sole `model: true` prop — **two-way** (`r-model:page` / `v-model:page` / `bind:page` / `[(page)]`), so `page` also drives the Angular `ControlValueAccessor`. In single-page mode it drives which page renders; in `render-all-pages` mode it reflects the scrolled-to page (and scrolls the container when the consumer writes it). Clamped to `[1, pageCount]`.
*/
page = model<number>(1);
/**
* The zoom scale (`1` = 100%). One-way: the `setScale` / `zoomIn` / `zoomOut` / `fitWidth` / `fitPage` handle verbs override it imperatively, while a consumer write reconciles the live render.
*/
scale = input<number>(1);
/**
* Rotation in degrees (`0` / `90` / `180` / `270`). One-way: the `rotateCW` / `rotateCCW` verbs override it. Normalized into `[0, 360)`.
*/
rotation = input<number>(0);
/**
* The PDF.js worker URL, set on `GlobalWorkerOptions.workerSrc` before loading. Defaults to the version-matched jsDelivr CDN copy so the component works with zero config; override for offline / CSP / a bundled worker (e.g. Vite's `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)`).
*/
workerSrc = input<string>('https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/build/pdf.worker.min.mjs');
/**
* The directory of PDF.js's standard-font data so the base-14 fonts (Helvetica / Times / Courier / …) render with correct glyphs. Version-matched CDN default; override (or pass a bundled dir) for offline / CSP.
*/
standardFontDataUrl = input<string>('https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/standard_fonts/');
/**
* `false` (default) renders a single page with nav (the two-way `page` drives it). `true` renders a continuous scroll of every page; the most-visible page reflects back into `page` and the `pagechange` event via an `IntersectionObserver`.
*/
renderAllPages = input<boolean>(false);
/**
* Render PDF.js's selectable / copyable text-layer spans over each page canvas (the differentiator vs a dumb canvas image). On by default; the required `.textLayer` CSS + `--scale-factor` var ship with the component, so no extra import is needed.
*/
textLayer = input<boolean>(true);
/**
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
*/
password = input<unknown>(undefined);
/**
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
*/
options = input<Record<string, any>>((() => ({}))());
current = signal(1);
zoom = signal(1);
rot = signal(0);
engineReady = signal(0);
viewerEl = viewChild<ElementRef<HTMLDivElement>>('viewerEl');
error = output<unknown>();
pagesrendered = output<void>();
passwordrequest = output<unknown>();
progress = output<unknown>();
load = output<unknown>();
pagechange = output<unknown>();
findresult = output<unknown>();
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_4 = true;
private __rozieWatchInitial_5 = true;
private __rozieWatchInitial_6 = true;
private __rozieWatchInitial_7 = true;
private __rozieWatchInitial_8 = true;
private __rozieWatchInitial_9 = true;
private __rozieWatchInitial_10 = true;
private __rozieWatchInitial_11 = true;
constructor() {
effect(() => { const __watchVal = (() => this.engineReady())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => this._load())(); }); });
effect(() => { const __watchVal = (() => this.src())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => this._load())(); }); });
effect(() => { const __watchVal = (() => this.password())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } (() => this._load())(); }); });
effect(() => { const __watchVal = (() => this.workerSrc())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => {
if (this.pdfjsLib && v) this.pdfjsLib.GlobalWorkerOptions.workerSrc = v;
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.page())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => {
if (typeof v === 'number' && v >= 1 && v !== this.current()) this.current.set(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.scale())(); untracked(() => { if (this.__rozieWatchInitial_5) { this.__rozieWatchInitial_5 = false; return; } ((v: any) => {
if (typeof v === 'number' && v > 0) this.zoom.set(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.rotation())(); untracked(() => { if (this.__rozieWatchInitial_6) { this.__rozieWatchInitial_6 = false; return; } ((v: any) => {
if (typeof v === 'number') this.rot.set((v % 360 + 360) % 360);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.current())(); untracked(() => { if (this.__rozieWatchInitial_7) { this.__rozieWatchInitial_7 = false; return; } ((v: any) => {
this.page.set(v), this.__rozieCvaOnChange(v);
this.pagechange.emit({
page: v
});
if (this.renderAllPages()) {
if (!this.suppressScroll) this.scrollToPage(v);
} else this.renderView();
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.zoom())(); untracked(() => { if (this.__rozieWatchInitial_8) { this.__rozieWatchInitial_8 = false; return; } (() => this.renderView())(); }); });
effect(() => { const __watchVal = (() => this.rot())(); untracked(() => { if (this.__rozieWatchInitial_9) { this.__rozieWatchInitial_9 = false; return; } (() => this.renderView())(); }); });
effect(() => { const __watchVal = (() => this.renderAllPages())(); untracked(() => { if (this.__rozieWatchInitial_10) { this.__rozieWatchInitial_10 = false; return; } (() => this.renderView())(); }); });
effect(() => { const __watchVal = (() => this.textLayer())(); untracked(() => { if (this.__rozieWatchInitial_11) { this.__rozieWatchInitial_11 = false; return; } (() => this.renderView())(); }); });
}
ngAfterViewInit() {
this.cancelled = false;
this.containerEl = this.viewerEl()?.nativeElement;
this.current.set(Math.max(1, this.page()));
this.zoom.set(this.scale());
this.rot.set(this.rotation());
// lazy-load the engine (SSR-safe + code-split), then configure the worker and
// load the document.
// lazy-load the engine (SSR-safe + code-split), then configure the worker and
// load the document.
import('pdfjs-dist').then((mod: any) => {
if (this.cancelled) return;
this.pdfjsLib = mod;
this.pdfjsLib.GlobalWorkerOptions.workerSrc = this.workerSrc();
// hand off to the lazy $watch below rather than calling load() from this
// (React: mount-frozen) closure — see the $data.engineReady note above.
this.engineReady.set(this.engineReady() + 1);
});
this.__rozieDestroyRef.onDestroy(() => {
this.cancelled = true;
this.renderToken++;
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.loadingTask) {
this.loadingTask.destroy();
this.loadingTask = null;
}
this.instance = null;
});
}
pdfjsLib: any = null;
instance: any = null;
containerEl: any = null;
observer: any = null;
loadingTask: any = null;
renderToken = 0;
suppressScroll = false;
findQuery = '';
findMatches = [];
findIndex = -1;
cancelled = false;
buildSource = () => {
const __password = this.password();
const __standardFontDataUrl = this.standardFontDataUrl();
let cfg: any = null;
cfg = {
...this.options()
};
// NOTE: the local must NOT be named `src` — a local `const src = $props.src`
// (same name as the `src` prop) hits a Svelte-emitter scope bug where the
// renamed local's initializer mis-resolves to itself (`const src2 = src2`, a
// TDZ ReferenceError) instead of the prop accessor. Naming it `srcInput`
// sidesteps the shadow on every target.
const srcInput = this.src();
if (typeof srcInput === 'string') {
if (srcInput.startsWith('data:')) {
// decode a `data:` base64 URL to bytes — pdfjs `url` doesn't fetch data URLs.
const base64 = srcInput.substring(srcInput.indexOf(',') + 1);
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
cfg.data = bytes;
} else {
cfg.url = srcInput;
}
} else if (srcInput) {
cfg.data = srcInput;
}
if (__password != null) cfg.password = __password;
if (__standardFontDataUrl) cfg.standardFontDataUrl = __standardFontDataUrl;
return cfg;
};
renderPage = async (pdf: any, pageNum: any, container: any) => {
const __zoom = this.zoom();
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({
scale: __zoom,
rotation: this.rot()
});
const pageDiv = document.createElement('div');
pageDiv.className = 'rozie-pdf-page';
pageDiv.setAttribute('data-page', String(pageNum));
pageDiv.style.width = Math.floor(viewport.width) + 'px';
pageDiv.style.height = Math.floor(viewport.height) + 'px';
// the text layer positions its spans with calc(var(--scale-factor) * …px).
pageDiv.style.setProperty('--scale-factor', String(__zoom));
const outputScale = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
pageDiv.appendChild(canvas);
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({
canvas,
viewport,
transform
}).promise;
if (this.textLayer()) {
const tl = document.createElement('div');
tl.className = 'textLayer';
pageDiv.appendChild(tl);
const layer = new this.pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: tl,
viewport
});
await layer.render();
// coarse find-highlight: add .rozie-pdf-find to text-layer spans whose text
// CONTAINS the active query. Span-level / COARSE — a query straddling two
// adjacent spans won't highlight (documented). Runs only while a find is active.
if (this.findQuery) {
const spans = tl.querySelectorAll('span');
for (const sp of spans as any) {
const t = sp.textContent;
if (t && t.toLowerCase().indexOf(this.findQuery) !== -1) sp.classList.add('rozie-pdf-find');
}
}
}
container.appendChild(pageDiv);
return pageDiv;
};
setupScrollSpy = () => {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (!this.containerEl) return;
this.observer = new IntersectionObserver((entries: any) => {
let best: any = null;
let bestRatio = 0;
for (const e of entries as any) {
if (e.intersectionRatio > bestRatio) {
bestRatio = e.intersectionRatio;
best = e.target;
}
}
if (best) {
const n = Number(best.getAttribute('data-page'));
if (n && n !== this.current()) {
this.suppressScroll = true;
this.current.set(n);
this.suppressScroll = false;
}
}
}, {
root: this.containerEl,
threshold: [0.25, 0.5, 0.75]
});
for (const child of this.containerEl.children as any) this.observer.observe(child);
};
scrollToPage = (n: any) => {
if (!this.containerEl) return;
const el = this.containerEl.querySelector('[data-page="' + n + '"]');
if (el) el.scrollIntoView({
block: 'start',
behavior: 'auto'
});
};
renderView = async () => {
const __renderAllPages = this.renderAllPages();
if (!this.instance || !this.containerEl) return;
const token = ++this.renderToken;
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
this.containerEl.innerHTML = '';
const total = this.instance.numPages;
const pages = __renderAllPages ? Array.from({
length: total
}, (_: any, i: any) => i + 1) : [Math.min(Math.max(this.current(), 1), total)];
for (const n of pages as any) {
if (token !== this.renderToken) return;
try {
await this.renderPage(this.instance, n, this.containerEl);
} catch (e: any) {
if (token === this.renderToken) this.error.emit(e);
}
}
if (token !== this.renderToken) return;
if (__renderAllPages) this.setupScrollSpy();
this.pagesrendered.emit();
};
_load = async () => {
if (!this.pdfjsLib) return;
const token = ++this.renderToken;
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// tear down the previous load via the loading task (PDFDocumentProxy has no
// destroy() in pdfjs v6).
if (this.loadingTask) {
this.loadingTask.destroy();
this.loadingTask = null;
}
this.instance = null;
if (this.containerEl) this.containerEl.innerHTML = '';
if (!this.src()) return;
try {
this.loadingTask = this.pdfjsLib.getDocument(this.buildSource());
this.loadingTask.onPassword = (_updatePassword: any, reason: any) => {
this.passwordrequest.emit({
reason
});
};
// download progress in bytes; `total` may be 0/undefined when the server sends
// no Content-Length header — pass the raw pdfjs onProgress payload through as-is.
this.loadingTask.onProgress = (p: any) => this.progress.emit({
loaded: p && p.loaded,
total: p && p.total
});
const pdf = await this.loadingTask.promise;
// stale (a newer load bumped the token + destroyed this task) — drop it.
if (token !== this.renderToken) return;
this.instance = pdf;
if (this.current() > pdf.numPages) this.current.set(pdf.numPages);
this.load.emit({
numPages: pdf.numPages
});
await this.renderView();
} catch (e: any) {
// a destroyed task rejects its promise — suppress the abort for stale loads.
if (token === this.renderToken) this.error.emit(e);
}
};
applyFit = async (mode: any) => {
if (!this.instance || !this.containerEl) return;
const n = Math.min(Math.max(this.current(), 1), this.instance.numPages);
const page = await this.instance.getPage(n);
const vp = page.getViewport({
scale: 1,
rotation: this.rot()
});
const cw = this.containerEl.clientWidth - 24;
const ch = this.containerEl.clientHeight - 24;
if (mode === 'width') this.zoom.set(cw / vp.width);else this.zoom.set(Math.min(cw / vp.width, ch / vp.height));
};
getDocument = () => {
return this.instance;
};
getPageCount = () => {
return this.instance ? this.instance.numPages : 0;
};
goToPage = (n: any) => {
if (!this.instance) return;
this.current.set(Math.min(Math.max(n, 1), this.instance.numPages));
};
nextPage = () => {
this.goToPage(this.current() + 1);
};
prevPage = () => {
this.goToPage(this.current() - 1);
};
setScale = (s: any) => {
if (typeof s === 'number' && s > 0) this.zoom.set(s);
};
zoomIn = () => {
this.zoom.set(Math.min(this.zoom() * 1.25, 10));
};
zoomOut = () => {
this.zoom.set(Math.max(this.zoom() / 1.25, 0.1));
};
fitWidth = () => {
this.applyFit('width');
};
fitPage = () => {
this.applyFit('page');
};
rotateCW = () => {
this.rot.set((this.rot() + 90) % 360);
};
rotateCCW = () => {
this.rot.set((this.rot() + 270) % 360);
};
download = async (filename: any) => {
if (!this.instance) return false;
const bytes = await this.instance.getData();
const url = URL.createObjectURL(new Blob([bytes], {
type: 'application/pdf'
}));
const a = document.createElement('a');
a.href = url;
a.download = filename || 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return true;
};
getMetadata = () => {
return this.instance ? this.instance.getMetadata() : null;
};
getOutline = () => {
return this.instance ? this.instance.getOutline() : null;
};
find = async (query: any) => {
const q = (query == null ? '' : String(query)).trim().toLowerCase();
this.findQuery = q;
this.findMatches = [];
this.findIndex = -1;
if (!this.instance || !q) {
this.renderView();
this.findresult.emit({
query: q,
matches: 0,
current: 0
});
return 0;
}
const total = this.instance.numPages;
for (let p = 1; p <= total; p++) {
const page = await this.instance.getPage(p);
const tc = await page.getTextContent();
const text = tc.items.map((it: any) => it && it.str != null ? it.str : '').join('').toLowerCase();
let from = 0;
while (true) {
const at = text.indexOf(q, from);
if (at === -1) break;
this.findMatches.push({
page: p
});
from = at + q.length;
}
}
if (this.findMatches.length) {
this.findIndex = 0;
const target = this.findMatches[0].page;
// navigate if needed; if already on the target page, force a re-render so the
// highlight pass runs (a no-op goToPage wouldn't trip the $data.current $watch).
if (target !== this.current()) this.goToPage(target);else this.renderView();
} else {
this.renderView();
}
this.findresult.emit({
query: q,
matches: this.findMatches.length,
current: this.findMatches.length ? 1 : 0
});
return this.findMatches.length;
};
findNext = () => {
if (!this.findMatches.length) return;
this.findIndex = (this.findIndex + 1) % this.findMatches.length;
const target = this.findMatches[this.findIndex].page;
if (target !== this.current()) this.goToPage(target);
this.findresult.emit({
query: this.findQuery,
matches: this.findMatches.length,
current: this.findIndex + 1
});
};
findPrev = () => {
if (!this.findMatches.length) return;
this.findIndex = (this.findIndex - 1 + this.findMatches.length) % this.findMatches.length;
const target = this.findMatches[this.findIndex].page;
if (target !== this.current()) this.goToPage(target);
this.findresult.emit({
query: this.findQuery,
matches: this.findMatches.length,
current: this.findIndex + 1
});
};
clearFind = () => {
this.findQuery = '';
this.findMatches = [];
this.findIndex = -1;
this.renderView();
this.findresult.emit({
query: '',
matches: 0,
current: 0
});
};
private __rozieCvaOnChange: (v: number) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: number | null): void {
this.page.set(v ?? 1);
}
registerOnChange(fn: (v: number) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
}
export default PdfViewer;tsx
import type { JSX } from 'solid-js';
import { createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal } from '@rozie/runtime-solid';
__rozieInjectStyle('PdfViewer-3c863364', `.rozie-pdf[data-rozie-s-3c863364] {
width: 100%;
height: 100%;
min-height: 320px;
overflow: auto;
background: #525659;
padding: 12px 0;
}
.rozie-pdf .rozie-pdf-page {
position: relative;
margin: 0 auto 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
background: #fff;
}
.rozie-pdf .rozie-pdf-page canvas {
display: block;
}
.rozie-pdf .textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 1;
}
.rozie-pdf .textLayer span,
.rozie-pdf .textLayer br {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.rozie-pdf .textLayer span.markedContent {
top: 0;
height: 0;
}
.rozie-pdf .textLayer ::selection {
background: rgba(0, 100, 255, 0.3);
}
.rozie-pdf .textLayer span.rozie-pdf-find {
background: rgba(255, 196, 0, 0.45);
border-radius: 2px;
}`);
interface PdfViewerProps {
/**
* The PDF source — a URL string, a `data:` base64 URL, or binary data (`ArrayBuffer` / `Uint8Array`). Changing it tears down the previous document (via its loading task) and loads the new one; `undefined` renders an empty viewer.
* @example
* <PdfViewer :src="pdfUrl" r-model:page="page" />
*/
src?: unknown;
/**
* The 1-based current page. The sole `model: true` prop — **two-way** (`r-model:page` / `v-model:page` / `bind:page` / `[(page)]`), so `page` also drives the Angular `ControlValueAccessor`. In single-page mode it drives which page renders; in `render-all-pages` mode it reflects the scrolled-to page (and scrolls the container when the consumer writes it). Clamped to `[1, pageCount]`.
*/
page?: number;
defaultPage?: number;
onPageChange?: (page: number) => void;
/**
* The zoom scale (`1` = 100%). One-way: the `setScale` / `zoomIn` / `zoomOut` / `fitWidth` / `fitPage` handle verbs override it imperatively, while a consumer write reconciles the live render.
*/
scale?: number;
/**
* Rotation in degrees (`0` / `90` / `180` / `270`). One-way: the `rotateCW` / `rotateCCW` verbs override it. Normalized into `[0, 360)`.
*/
rotation?: number;
/**
* The PDF.js worker URL, set on `GlobalWorkerOptions.workerSrc` before loading. Defaults to the version-matched jsDelivr CDN copy so the component works with zero config; override for offline / CSP / a bundled worker (e.g. Vite's `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)`).
*/
workerSrc?: string;
/**
* The directory of PDF.js's standard-font data so the base-14 fonts (Helvetica / Times / Courier / …) render with correct glyphs. Version-matched CDN default; override (or pass a bundled dir) for offline / CSP.
*/
standardFontDataUrl?: string;
/**
* `false` (default) renders a single page with nav (the two-way `page` drives it). `true` renders a continuous scroll of every page; the most-visible page reflects back into `page` and the `pagechange` event via an `IntersectionObserver`.
*/
renderAllPages?: boolean;
/**
* Render PDF.js's selectable / copyable text-layer spans over each page canvas (the differentiator vs a dumb canvas image). On by default; the required `.textLayer` CSS + `--scale-factor` var ship with the component, so no extra import is needed.
*/
textLayer?: boolean;
/**
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
*/
password?: unknown;
/**
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
*/
options?: Record<string, any>;
onError?: (...args: unknown[]) => void;
onPagesrendered?: (...args: unknown[]) => void;
onPasswordrequest?: (...args: unknown[]) => void;
onProgress?: (...args: unknown[]) => void;
onLoad?: (...args: unknown[]) => void;
onPagechange?: (...args: unknown[]) => void;
onFindresult?: (...args: unknown[]) => void;
ref?: (h: PdfViewerHandle) => void;
}
export interface PdfViewerHandle {
getDocument: (...args: any[]) => any;
getPageCount: (...args: any[]) => any;
goToPage: (...args: any[]) => any;
nextPage: (...args: any[]) => any;
prevPage: (...args: any[]) => any;
setScale: (...args: any[]) => any;
zoomIn: (...args: any[]) => any;
zoomOut: (...args: any[]) => any;
fitWidth: (...args: any[]) => any;
fitPage: (...args: any[]) => any;
rotateCW: (...args: any[]) => any;
rotateCCW: (...args: any[]) => any;
download: (...args: any[]) => any;
getMetadata: (...args: any[]) => any;
getOutline: (...args: any[]) => any;
find: (...args: any[]) => any;
findNext: (...args: any[]) => any;
findPrev: (...args: any[]) => any;
clearFind: (...args: any[]) => any;
}
export default function PdfViewer(_props: PdfViewerProps): JSX.Element {
const _merged = mergeProps({ src: undefined, scale: 1, rotation: 0, workerSrc: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/build/pdf.worker.min.mjs', standardFontDataUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/standard_fonts/', renderAllPages: false, textLayer: true, password: undefined, options: (() => ({}))() }, _props);
const [local, attrs] = splitProps(_merged, ['src', 'page', 'scale', 'rotation', 'workerSrc', 'standardFontDataUrl', 'renderAllPages', 'textLayer', 'password', 'options', 'ref']);
onMount(() => { local.ref?.({ getDocument, getPageCount, goToPage, nextPage, prevPage, setScale, zoomIn, zoomOut, fitWidth, fitPage, rotateCW, rotateCCW, download, getMetadata, getOutline, find, findNext, findPrev, clearFind }); });
const [page, setPage] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'page', 1);
const [current, setCurrent] = createSignal(1);
const [zoom, setZoom] = createSignal(1);
const [rot, setRot] = createSignal(0);
const [engineReady, setEngineReady] = createSignal(0);
onMount(() => {
const _cleanup = (() => {
cancelled = false;
containerEl = viewerElRef;
setCurrent(Math.max(1, page()));
setZoom(local.scale);
setRot(local.rotation);
// lazy-load the engine (SSR-safe + code-split), then configure the worker and
// load the document.
import('pdfjs-dist').then((mod: any) => {
if (cancelled) return;
pdfjsLib = mod;
pdfjsLib.GlobalWorkerOptions.workerSrc = local.workerSrc;
// hand off to the lazy $watch below rather than calling load() from this
// (React: mount-frozen) closure — see the $data.engineReady note above.
setEngineReady(engineReady() + 1);
});
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => {
cancelled = true;
renderToken++;
if (observer) {
observer.disconnect();
observer = null;
}
if (loadingTask) {
loadingTask.destroy();
loadingTask = null;
}
instance = null;
});
});
createEffect(on(() => (() => engineReady())(), (v) => untrack(() => (() => load())()), { defer: true }));
createEffect(on(() => (() => local.src)(), (v) => untrack(() => (() => load())()), { defer: true }));
createEffect(on(() => (() => local.password)(), (v) => untrack(() => (() => load())()), { defer: true }));
createEffect(on(() => (() => local.workerSrc)(), (v) => untrack(() => ((v: any) => {
if (pdfjsLib && v) pdfjsLib.GlobalWorkerOptions.workerSrc = v;
})(v)), { defer: true }));
createEffect(on(() => (() => page())(), (v) => untrack(() => ((v: any) => {
if (typeof v === 'number' && v >= 1 && v !== current()) setCurrent(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.scale)(), (v) => untrack(() => ((v: any) => {
if (typeof v === 'number' && v > 0) setZoom(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.rotation)(), (v) => untrack(() => ((v: any) => {
if (typeof v === 'number') setRot((v % 360 + 360) % 360);
})(v)), { defer: true }));
createEffect(on(() => (() => current())(), (v) => untrack(() => ((v: any) => {
setPage(v);
_props.onPagechange?.({
page: v
});
if (local.renderAllPages) {
if (!suppressScroll) scrollToPage(v);
} else renderView();
})(v)), { defer: true }));
createEffect(on(() => (() => zoom())(), (v) => untrack(() => (() => renderView())()), { defer: true }));
createEffect(on(() => (() => rot())(), (v) => untrack(() => (() => renderView())()), { defer: true }));
createEffect(on(() => (() => local.renderAllPages)(), (v) => untrack(() => (() => renderView())()), { defer: true }));
createEffect(on(() => (() => local.textLayer)(), (v) => untrack(() => (() => renderView())()), { defer: true }));
let viewerElRef: HTMLElement | null = null;
// pdfjs is DYNAMICALLY imported in $onMount, NOT a top-level import: pdfjs's main
// build evaluates browser globals (DOMMatrix, …) at module-load time, which
// crashes SSR (Next / Nuxt / SvelteKit / Analog / VitePress). Lazy-importing it on
// mount makes the component SSR-safe for ALL consumers AND code-splits the ~1MB
// engine out of the initial bundle. `pdfjsLib` is a null-let → typeNeutralize
// `any` (so pdfjsLib.getDocument / .TextLayer / .GlobalWorkerOptions are unchecked).
let pdfjsLib: any = null;
// more null-lets (→ `any`): `instance` is the PDFDocumentProxy (whose strict types
// the loosely-typed props don't satisfy — the maplibre mapOptions idiom),
// containerEl is the scroll host, observer is the continuous-mode scroll spy.
let instance: any = null;
let containerEl: any = null;
let observer: any = null;
// the PDFDocumentLoadingTask — it (NOT the PDFDocumentProxy, which has no
// destroy() in pdfjs v6) owns teardown of the worker + document. Held so a
// src/password change or unmount can tear the previous load down.
let loadingTask: any = null;
// monotonic token cancels stale async loads/renders (src can change mid-render,
// pages render async — the SortableList rebuild-cancel discipline).
let renderToken = 0;
// guards the scroll-spy → $data.current → scroll-to feedback loop.
let suppressScroll = false;
// find/search state. findQuery is the active lowercased query (''=inactive);
// findMatches is a flat per-OCCURRENCE list [{ page }] (drives the count + the
// next/prev cycle); findIndex is the current match (-1=none). TOP-LEVEL lets (not
// $onMount-local) so renderPage's coarse highlight pass + the find verbs can read
// them across renders.
let findQuery = '';
let findMatches = [];
let findIndex = -1;
// set in the $onMount teardown so a late-resolving dynamic import() bails. A
// TOP-LEVEL let (not $onMount-local): the Solid emitter hoists the $onMount
// teardown into a sibling onCleanup() OUTSIDE the mount closure, so a mount-local
// would be out of scope there.
let cancelled = false;
// ─── build the getDocument() source (no sigils beyond $props/$snapshot) ──────
function buildSource() {
let cfg: any = null;
cfg = {
...local.options
};
// NOTE: the local must NOT be named `src` — a local `const src = $props.src`
// (same name as the `src` prop) hits a Svelte-emitter scope bug where the
// renamed local's initializer mis-resolves to itself (`const src2 = src2`, a
// TDZ ReferenceError) instead of the prop accessor. Naming it `srcInput`
// sidesteps the shadow on every target.
const srcInput = local.src;
if (typeof srcInput === 'string') {
if (srcInput.startsWith('data:')) {
// decode a `data:` base64 URL to bytes — pdfjs `url` doesn't fetch data URLs.
const base64 = srcInput.substring(srcInput.indexOf(',') + 1);
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
cfg.data = bytes;
} else {
cfg.url = srcInput;
}
} else if (srcInput) {
cfg.data = srcInput;
}
if (local.password != null) cfg.password = local.password;
if (local.standardFontDataUrl) cfg.standardFontDataUrl = local.standardFontDataUrl;
return cfg;
}
// ─── render one page (canvas + optional text layer) into the container ───────
async function renderPage(pdf: any, pageNum: any, container: any) {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({
scale: zoom(),
rotation: rot()
});
const pageDiv = document.createElement('div');
pageDiv.className = 'rozie-pdf-page';
pageDiv.setAttribute('data-page', String(pageNum));
pageDiv.style.width = Math.floor(viewport.width) + 'px';
pageDiv.style.height = Math.floor(viewport.height) + 'px';
// the text layer positions its spans with calc(var(--scale-factor) * …px).
pageDiv.style.setProperty('--scale-factor', String(zoom()));
const outputScale = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
pageDiv.appendChild(canvas);
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({
canvas,
viewport,
transform
}).promise;
if (local.textLayer) {
const tl = document.createElement('div');
tl.className = 'textLayer';
pageDiv.appendChild(tl);
const layer = new pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: tl,
viewport
});
await layer.render();
// coarse find-highlight: add .rozie-pdf-find to text-layer spans whose text
// CONTAINS the active query. Span-level / COARSE — a query straddling two
// adjacent spans won't highlight (documented). Runs only while a find is active.
if (findQuery) {
const spans = tl.querySelectorAll('span');
for (const sp of spans as any) {
const t = sp.textContent;
if (t && t.toLowerCase().indexOf(findQuery) !== -1) sp.classList.add('rozie-pdf-find');
}
}
}
container.appendChild(pageDiv);
return pageDiv;
}
// continuous-mode scroll spy — reflect the most-visible page into $data.current.
function setupScrollSpy() {
if (observer) {
observer.disconnect();
observer = null;
}
if (!containerEl) return;
observer = new IntersectionObserver((entries: any) => {
let best: any = null;
let bestRatio = 0;
for (const e of entries as any) {
if (e.intersectionRatio > bestRatio) {
bestRatio = e.intersectionRatio;
best = e.target;
}
}
if (best) {
const n = Number(best.getAttribute('data-page'));
if (n && n !== current()) {
suppressScroll = true;
setCurrent(n);
suppressScroll = false;
}
}
}, {
root: containerEl,
threshold: [0.25, 0.5, 0.75]
});
for (const child of containerEl.children as any) observer.observe(child);
}
function scrollToPage(n: any) {
if (!containerEl) return;
const el = containerEl.querySelector('[data-page="' + n + '"]');
if (el) el.scrollIntoView({
block: 'start',
behavior: 'auto'
});
}
// ─── render the current view (single page, or all pages) ─────────────────────
async function renderView() {
if (!instance || !containerEl) return;
const token = ++renderToken;
if (observer) {
observer.disconnect();
observer = null;
}
containerEl.innerHTML = '';
const total = instance.numPages;
const pages = local.renderAllPages ? Array.from({
length: total
}, (_: any, i: any) => i + 1) : [Math.min(Math.max(current(), 1), total)];
for (const n of pages as any) {
if (token !== renderToken) return;
try {
await renderPage(instance, n, containerEl);
} catch (e: any) {
if (token === renderToken) _props.onError?.(e);
}
}
if (token !== renderToken) return;
if (local.renderAllPages) setupScrollSpy();
_props.onPagesrendered?.();
}
// ─── load the document ───────────────────────────────────────────────────────
async function load() {
if (!pdfjsLib) return;
const token = ++renderToken;
if (observer) {
observer.disconnect();
observer = null;
}
// tear down the previous load via the loading task (PDFDocumentProxy has no
// destroy() in pdfjs v6).
if (loadingTask) {
loadingTask.destroy();
loadingTask = null;
}
instance = null;
if (containerEl) containerEl.innerHTML = '';
if (!local.src) return;
try {
loadingTask = pdfjsLib.getDocument(buildSource());
loadingTask.onPassword = (_updatePassword: any, reason: any) => {
_props.onPasswordrequest?.({
reason
});
};
// download progress in bytes; `total` may be 0/undefined when the server sends
// no Content-Length header — pass the raw pdfjs onProgress payload through as-is.
loadingTask.onProgress = (p: any) => _props.onProgress?.({
loaded: p && p.loaded,
total: p && p.total
});
const pdf = await loadingTask.promise;
// stale (a newer load bumped the token + destroyed this task) — drop it.
if (token !== renderToken) return;
instance = pdf;
if (current() > pdf.numPages) setCurrent(pdf.numPages);
_props.onLoad?.({
numPages: pdf.numPages
});
await renderView();
} catch (e: any) {
// a destroyed task rejects its promise — suppress the abort for stale loads.
if (token === renderToken) _props.onError?.(e);
}
}
async function applyFit(mode: any) {
if (!instance || !containerEl) return;
const n = Math.min(Math.max(current(), 1), instance.numPages);
const page = await instance.getPage(n);
const vp = page.getViewport({
scale: 1,
rotation: rot()
});
const cw = containerEl.clientWidth - 24;
const ch = containerEl.clientHeight - 24;
if (mode === 'width') setZoom(cw / vp.width);else setZoom(Math.min(cw / vp.width, ch / vp.height));
}
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// 19 verbs. Collision-clear: NO `setPage` (React `page`-model auto-setter,
// ROZ524 — use goToPage); none equals an emit name (load/error/pagechange/
// pagesrendered/passwordrequest/progress/findresult); none is a Lit reserved
// lifecycle. The navigation/zoom/rotate verbs drive $data (not the props), so they
// work whether or not the consumer binds `page`. The document-level verbs below
// are cheap passthroughs over the held PDFDocumentProxy (`instance`) that a
// consumer can't reach otherwise without `getDocument()` + pdf.js knowledge:
// - download(filename?): save the original PDF bytes (instance.getData() ->
// Blob -> anchor click) — the single most-expected viewer affordance.
// - getMetadata(): document title/author/page-labels (tab title / info panel).
// - getOutline(): the bookmark/TOC tree (powers a navigation sidebar; outline
// dests map onto goToPage).
// The four find verbs (find/findNext/findPrev/clearFind) drive the coarse
// span-level highlight pass + emit `findresult`. `find/findNext/findPrev/clearFind`
// are collision-vetted (no Lit reserved lifecycle, no `page`-model auto-setter clash).
function getDocument() {
return instance;
}
function getPageCount() {
return instance ? instance.numPages : 0;
}
function goToPage(n: any) {
if (!instance) return;
setCurrent(Math.min(Math.max(n, 1), instance.numPages));
}
function nextPage() {
goToPage(current() + 1);
}
function prevPage() {
goToPage(current() - 1);
}
function setScale(s: any) {
if (typeof s === 'number' && s > 0) setZoom(s);
}
function zoomIn() {
setZoom(Math.min(zoom() * 1.25, 10));
}
function zoomOut() {
setZoom(Math.max(zoom() / 1.25, 0.1));
}
function fitWidth() {
applyFit('width');
}
function fitPage() {
applyFit('page');
}
function rotateCW() {
setRot((rot() + 90) % 360);
}
function rotateCCW() {
setRot((rot() + 270) % 360);
}
// Save the original PDF bytes. getData() resolves the raw Uint8Array; wrap in a
// Blob and trigger a download via a transient anchor. Resolves false before mount.
async function download(filename: any) {
if (!instance) return false;
const bytes = await instance.getData();
const url = URL.createObjectURL(new Blob([bytes], {
type: 'application/pdf'
}));
const a = document.createElement('a');
a.href = url;
a.download = filename || 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return true;
}
// Document info (title/author/page labels) — resolves null before mount.
function getMetadata() {
return instance ? instance.getMetadata() : null;
}
// Bookmark / table-of-contents tree — resolves null when absent or before mount.
function getOutline() {
return instance ? instance.getOutline() : null;
}
// ─── text find/search (coarse span-level highlight) ──────────────────────────
// find(query) scans EVERY page's extracted text for occurrences, navigates to +
// highlights the first match, returns the match count, and emits `findresult`. The
// highlight is COARSE / span-level: renderPage adds .rozie-pdf-find to whole
// text-layer spans that CONTAIN the query (a query straddling two spans won't
// highlight). findNext/findPrev cycle (wrap) through the per-occurrence match list;
// clearFind resets the query + highlights. All async-safe over the `any`
// PDFDocumentProxy (`instance`); no-op / return 0 before the document loads.
async function find(query: any) {
const q = (query == null ? '' : String(query)).trim().toLowerCase();
findQuery = q;
findMatches = [];
findIndex = -1;
if (!instance || !q) {
renderView();
_props.onFindresult?.({
query: q,
matches: 0,
current: 0
});
return 0;
}
const total = instance.numPages;
for (let p = 1; p <= total; p++) {
const page = await instance.getPage(p);
const tc = await page.getTextContent();
const text = tc.items.map((it: any) => it && it.str != null ? it.str : '').join('').toLowerCase();
let from = 0;
while (true) {
const at = text.indexOf(q, from);
if (at === -1) break;
findMatches.push({
page: p
});
from = at + q.length;
}
}
if (findMatches.length) {
findIndex = 0;
const target = findMatches[0].page;
// navigate if needed; if already on the target page, force a re-render so the
// highlight pass runs (a no-op goToPage wouldn't trip the $data.current $watch).
if (target !== current()) goToPage(target);else renderView();
} else {
renderView();
}
_props.onFindresult?.({
query: q,
matches: findMatches.length,
current: findMatches.length ? 1 : 0
});
return findMatches.length;
}
function findNext() {
if (!findMatches.length) return;
findIndex = (findIndex + 1) % findMatches.length;
const target = findMatches[findIndex].page;
if (target !== current()) goToPage(target);
_props.onFindresult?.({
query: findQuery,
matches: findMatches.length,
current: findIndex + 1
});
}
function findPrev() {
if (!findMatches.length) return;
findIndex = (findIndex - 1 + findMatches.length) % findMatches.length;
const target = findMatches[findIndex].page;
if (target !== current()) goToPage(target);
_props.onFindresult?.({
query: findQuery,
matches: findMatches.length,
current: findIndex + 1
});
}
function clearFind() {
findQuery = '';
findMatches = [];
findIndex = -1;
renderView();
_props.onFindresult?.({
query: '',
matches: 0,
current: 0
});
}
return (
<>
<div ref={(el) => { viewerElRef = el as HTMLElement; }} {...attrs} class={"rozie-pdf" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-3c863364="" />
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { createLitControllableProperty, injectGlobalStyles, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
@customElement('rozie-pdf-viewer')
export default class PdfViewer extends SignalWatcher(LitElement) {
static styles = css`
.rozie-pdf[data-rozie-s-3c863364] {
width: 100%;
height: 100%;
min-height: 320px;
overflow: auto;
background: #525659;
padding: 12px 0;
}
.rozie-pdf .rozie-pdf-page {
position: relative;
margin: 0 auto 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
background: #fff;
}
.rozie-pdf .rozie-pdf-page canvas {
display: block;
}
.rozie-pdf .textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 1;
}
.rozie-pdf .textLayer span,
.rozie-pdf .textLayer br {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.rozie-pdf .textLayer span.markedContent {
top: 0;
height: 0;
}
.rozie-pdf .textLayer ::selection {
background: rgba(0, 100, 255, 0.3);
}
.rozie-pdf .textLayer span.rozie-pdf-find {
background: rgba(255, 196, 0, 0.45);
border-radius: 2px;
}
`;
/**
* The PDF source — a URL string, a `data:` base64 URL, or binary data (`ArrayBuffer` / `Uint8Array`). Changing it tears down the previous document (via its loading task) and loads the new one; `undefined` renders an empty viewer.
* @example
* <PdfViewer :src="pdfUrl" r-model:page="page" />
*/
@property({ type: Object }) src: unknown = undefined;
/**
* The 1-based current page. The sole `model: true` prop — **two-way** (`r-model:page` / `v-model:page` / `bind:page` / `[(page)]`), so `page` also drives the Angular `ControlValueAccessor`. In single-page mode it drives which page renders; in `render-all-pages` mode it reflects the scrolled-to page (and scrolls the container when the consumer writes it). Clamped to `[1, pageCount]`.
*/
@property({ type: Number, attribute: 'page' }) _page_attr: number = 1;
private _pageControllable = createLitControllableProperty<number>({ host: this, eventName: 'page-change', defaultValue: 1, initialControlledValue: undefined });
/**
* The zoom scale (`1` = 100%). One-way: the `setScale` / `zoomIn` / `zoomOut` / `fitWidth` / `fitPage` handle verbs override it imperatively, while a consumer write reconciles the live render.
*/
@property({ type: Number, reflect: true }) scale: number = 1;
/**
* Rotation in degrees (`0` / `90` / `180` / `270`). One-way: the `rotateCW` / `rotateCCW` verbs override it. Normalized into `[0, 360)`.
*/
@property({ type: Number, reflect: true }) rotation: number = 0;
/**
* The PDF.js worker URL, set on `GlobalWorkerOptions.workerSrc` before loading. Defaults to the version-matched jsDelivr CDN copy so the component works with zero config; override for offline / CSP / a bundled worker (e.g. Vite's `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)`).
*/
@property({ type: String, reflect: true }) workerSrc: string = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/build/pdf.worker.min.mjs';
/**
* The directory of PDF.js's standard-font data so the base-14 fonts (Helvetica / Times / Courier / …) render with correct glyphs. Version-matched CDN default; override (or pass a bundled dir) for offline / CSP.
*/
@property({ type: String, reflect: true }) standardFontDataUrl: string = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/standard_fonts/';
/**
* `false` (default) renders a single page with nav (the two-way `page` drives it). `true` renders a continuous scroll of every page; the most-visible page reflects back into `page` and the `pagechange` event via an `IntersectionObserver`.
*/
@property({ type: Boolean, reflect: true }) renderAllPages: boolean = false;
/**
* Render PDF.js's selectable / copyable text-layer spans over each page canvas (the differentiator vs a dumb canvas image). On by default; the required `.textLayer` CSS + `--scale-factor` var ship with the component, so no extra import is needed.
*/
@property({ type: Boolean, reflect: true }) textLayer: boolean = true;
/**
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
*/
@property({ type: Object }) password: unknown = undefined;
/**
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
*/
@property({ type: Object }) options: any = {};
private _current = signal(1);
private _zoom = signal(1);
private _rot = signal(0);
private _engineReady = signal(0);
@query('[data-rozie-ref="viewerEl"]') private _refViewerEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_4 = true;
private __rozieWatchInitial_7 = true;
private __rozieWatchInitial_8 = true;
private __rozieWatchInitial_9 = true;
private __rozieFirstUpdateDone = false;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
firstUpdated(): void {
this._disconnectCleanups.push((() => {
this.cancelled = true;
this.renderToken++;
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.loadingTask) {
this.loadingTask.destroy();
this.loadingTask = null;
}
this.instance = null;
}));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this._engineReady.value)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => this.load())(); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.page)(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => {
if (typeof v === 'number' && v >= 1 && v !== this._current.value) this._current.value = v;
})(__watchVal); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this._current.value)(); untracked(() => { if (this.__rozieWatchInitial_7) { this.__rozieWatchInitial_7 = false; return; } ((v: any) => {
this._pageControllable.write(v);
this.dispatchEvent(new CustomEvent("pagechange", {
detail: {
page: v
},
bubbles: true,
composed: true
}));
if (this.renderAllPages) {
if (!this.suppressScroll) this.scrollToPage(v);
} else this.renderView();
})(__watchVal); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this._zoom.value)(); untracked(() => { if (this.__rozieWatchInitial_8) { this.__rozieWatchInitial_8 = false; return; } (() => this.renderView())(); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this._rot.value)(); untracked(() => { if (this.__rozieWatchInitial_9) { this.__rozieWatchInitial_9 = false; return; } (() => this.renderView())(); }); }));
this.cancelled = false;
this.containerEl = this._refViewerEl;
this._current.value = Math.max(1, this.page);
this._zoom.value = this.scale;
this._rot.value = this.rotation;
// lazy-load the engine (SSR-safe + code-split), then configure the worker and
// load the document.
// lazy-load the engine (SSR-safe + code-split), then configure the worker and
// load the document.
import('pdfjs-dist').then((mod: any) => {
if (this.cancelled) return;
this.pdfjsLib = mod;
this.pdfjsLib.GlobalWorkerOptions.workerSrc = this.workerSrc;
// hand off to the lazy $watch below rather than calling load() from this
// (React: mount-frozen) closure — see the $data.engineReady note above.
this._engineReady.value++;
});
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('src'))) { const __watchVal = (() => this.src)(); (() => this.load())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('password'))) { const __watchVal = (() => this.password)(); (() => this.load())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('workerSrc'))) { const __watchVal = (() => this.workerSrc)(); ((v: any) => {
if (this.pdfjsLib && v) this.pdfjsLib.GlobalWorkerOptions.workerSrc = v;
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('scale'))) { const __watchVal = (() => this.scale)(); ((v: any) => {
if (typeof v === 'number' && v > 0) this._zoom.value = v;
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('rotation'))) { const __watchVal = (() => this.rotation)(); ((v: any) => {
if (typeof v === 'number') this._rot.value = (v % 360 + 360) % 360;
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('renderAllPages'))) { const __watchVal = (() => this.renderAllPages)(); (() => this.renderView())(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('textLayer'))) { const __watchVal = (() => this.textLayer)(); (() => this.renderView())(); }
this.__rozieFirstUpdateDone = true;
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'page') this._pageControllable.notifyAttributeChange(value === null ? 1 : Number(value));
}
render() {
return html`
<div class="rozie-pdf" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="viewerEl" data-rozie-s-3c863364></div>
`;
}
pdfjsLib: any = null;
instance: any = null;
containerEl: any = null;
observer: any = null;
loadingTask: any = null;
renderToken = 0;
suppressScroll = false;
findQuery = '';
findMatches = [];
findIndex = -1;
cancelled = false;
buildSource = () => {
let cfg: any = null;
cfg = {
...this.options
};
// NOTE: the local must NOT be named `src` — a local `const src = $props.src`
// (same name as the `src` prop) hits a Svelte-emitter scope bug where the
// renamed local's initializer mis-resolves to itself (`const src2 = src2`, a
// TDZ ReferenceError) instead of the prop accessor. Naming it `srcInput`
// sidesteps the shadow on every target.
const srcInput = this.src;
if (typeof srcInput === 'string') {
if (srcInput.startsWith('data:')) {
// decode a `data:` base64 URL to bytes — pdfjs `url` doesn't fetch data URLs.
const base64 = srcInput.substring(srcInput.indexOf(',') + 1);
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
cfg.data = bytes;
} else {
cfg.url = srcInput;
}
} else if (srcInput) {
cfg.data = srcInput;
}
if (this.password != null) cfg.password = this.password;
if (this.standardFontDataUrl) cfg.standardFontDataUrl = this.standardFontDataUrl;
return cfg;
};
renderPage = async (pdf: any, pageNum: any, container: any) => {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({
scale: this._zoom.value,
rotation: this._rot.value
});
const pageDiv = document.createElement('div');
pageDiv.className = 'rozie-pdf-page';
pageDiv.setAttribute('data-page', String(pageNum));
pageDiv.style.width = Math.floor(viewport.width) + 'px';
pageDiv.style.height = Math.floor(viewport.height) + 'px';
// the text layer positions its spans with calc(var(--scale-factor) * …px).
pageDiv.style.setProperty('--scale-factor', String(this._zoom.value));
const outputScale = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
pageDiv.appendChild(canvas);
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({
canvas,
viewport,
transform
}).promise;
if (this.textLayer) {
const tl = document.createElement('div');
tl.className = 'textLayer';
pageDiv.appendChild(tl);
const layer = new this.pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: tl,
viewport
});
await layer.render();
// coarse find-highlight: add .rozie-pdf-find to text-layer spans whose text
// CONTAINS the active query. Span-level / COARSE — a query straddling two
// adjacent spans won't highlight (documented). Runs only while a find is active.
if (this.findQuery) {
const spans = tl.querySelectorAll('span');
for (const sp of spans as any) {
const t = sp.textContent;
if (t && t.toLowerCase().indexOf(this.findQuery) !== -1) sp.classList.add('rozie-pdf-find');
}
}
}
container.appendChild(pageDiv);
return pageDiv;
};
setupScrollSpy = () => {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (!this.containerEl) return;
this.observer = new IntersectionObserver((entries: any) => {
let best: any = null;
let bestRatio = 0;
for (const e of entries as any) {
if (e.intersectionRatio > bestRatio) {
bestRatio = e.intersectionRatio;
best = e.target;
}
}
if (best) {
const n = Number(best.getAttribute('data-page'));
if (n && n !== this._current.value) {
this.suppressScroll = true;
this._current.value = n;
this.suppressScroll = false;
}
}
}, {
root: this.containerEl,
threshold: [0.25, 0.5, 0.75]
});
for (const child of this.containerEl.children as any) this.observer.observe(child);
};
scrollToPage = (n: any) => {
if (!this.containerEl) return;
const el = this.containerEl.querySelector('[data-page="' + n + '"]');
if (el) el.scrollIntoView({
block: 'start',
behavior: 'auto'
});
};
renderView = async () => {
if (!this.instance || !this.containerEl) return;
const token = ++this.renderToken;
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
this.containerEl.innerHTML = '';
const total = this.instance.numPages;
const pages = this.renderAllPages ? Array.from({
length: total
}, (_: any, i: any) => i + 1) : [Math.min(Math.max(this._current.value, 1), total)];
for (const n of pages as any) {
if (token !== this.renderToken) return;
try {
await this.renderPage(this.instance, n, this.containerEl);
} catch (e: any) {
if (token === this.renderToken) this.dispatchEvent(new CustomEvent("error", {
detail: e,
bubbles: true,
composed: true
}));
}
}
if (token !== this.renderToken) return;
if (this.renderAllPages) this.setupScrollSpy();
this.dispatchEvent(new CustomEvent("pagesrendered", {
detail: undefined,
bubbles: true,
composed: true
}));
};
load = async () => {
if (!this.pdfjsLib) return;
const token = ++this.renderToken;
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// tear down the previous load via the loading task (PDFDocumentProxy has no
// destroy() in pdfjs v6).
if (this.loadingTask) {
this.loadingTask.destroy();
this.loadingTask = null;
}
this.instance = null;
if (this.containerEl) this.containerEl.innerHTML = '';
if (!this.src) return;
try {
this.loadingTask = this.pdfjsLib.getDocument(this.buildSource());
this.loadingTask.onPassword = (_updatePassword: any, reason: any) => {
this.dispatchEvent(new CustomEvent("passwordrequest", {
detail: {
reason
},
bubbles: true,
composed: true
}));
};
// download progress in bytes; `total` may be 0/undefined when the server sends
// no Content-Length header — pass the raw pdfjs onProgress payload through as-is.
this.loadingTask.onProgress = (p: any) => this.dispatchEvent(new CustomEvent("progress", {
detail: {
loaded: p && p.loaded,
total: p && p.total
},
bubbles: true,
composed: true
}));
const pdf = await this.loadingTask.promise;
// stale (a newer load bumped the token + destroyed this task) — drop it.
if (token !== this.renderToken) return;
this.instance = pdf;
if (this._current.value > pdf.numPages) this._current.value = pdf.numPages;
this.dispatchEvent(new CustomEvent("load", {
detail: {
numPages: pdf.numPages
},
bubbles: true,
composed: true
}));
await this.renderView();
} catch (e: any) {
// a destroyed task rejects its promise — suppress the abort for stale loads.
if (token === this.renderToken) this.dispatchEvent(new CustomEvent("error", {
detail: e,
bubbles: true,
composed: true
}));
}
};
applyFit = async (mode: any) => {
if (!this.instance || !this.containerEl) return;
const n = Math.min(Math.max(this._current.value, 1), this.instance.numPages);
const page = await this.instance.getPage(n);
const vp = page.getViewport({
scale: 1,
rotation: this._rot.value
});
const cw = this.containerEl.clientWidth - 24;
const ch = this.containerEl.clientHeight - 24;
if (mode === 'width') this._zoom.value = cw / vp.width;else this._zoom.value = Math.min(cw / vp.width, ch / vp.height);
};
getDocument() {
return this.instance;
}
getPageCount() {
return this.instance ? this.instance.numPages : 0;
}
goToPage(n: any) {
if (!this.instance) return;
this._current.value = Math.min(Math.max(n, 1), this.instance.numPages);
}
nextPage() {
this.goToPage(this._current.value + 1);
}
prevPage() {
this.goToPage(this._current.value - 1);
}
setScale(s: any) {
if (typeof s === 'number' && s > 0) this._zoom.value = s;
}
zoomIn() {
this._zoom.value = Math.min(this._zoom.value * 1.25, 10);
}
zoomOut() {
this._zoom.value = Math.max(this._zoom.value / 1.25, 0.1);
}
fitWidth() {
this.applyFit('width');
}
fitPage() {
this.applyFit('page');
}
rotateCW() {
this._rot.value = (this._rot.value + 90) % 360;
}
rotateCCW() {
this._rot.value = (this._rot.value + 270) % 360;
}
async download(filename: any) {
if (!this.instance) return false;
const bytes = await this.instance.getData();
const url = URL.createObjectURL(new Blob([bytes], {
type: 'application/pdf'
}));
const a = document.createElement('a');
a.href = url;
a.download = filename || 'document.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return true;
}
getMetadata() {
return this.instance ? this.instance.getMetadata() : null;
}
getOutline() {
return this.instance ? this.instance.getOutline() : null;
}
async find(query: any) {
const q = (query == null ? '' : String(query)).trim().toLowerCase();
this.findQuery = q;
this.findMatches = [];
this.findIndex = -1;
if (!this.instance || !q) {
this.renderView();
this.dispatchEvent(new CustomEvent("findresult", {
detail: {
query: q,
matches: 0,
current: 0
},
bubbles: true,
composed: true
}));
return 0;
}
const total = this.instance.numPages;
for (let p = 1; p <= total; p++) {
const page = await this.instance.getPage(p);
const tc = await page.getTextContent();
const text = tc.items.map((it: any) => it && it.str != null ? it.str : '').join('').toLowerCase();
let from = 0;
while (true) {
const at = text.indexOf(q, from);
if (at === -1) break;
this.findMatches.push({
page: p
});
from = at + q.length;
}
}
if (this.findMatches.length) {
this.findIndex = 0;
const target = this.findMatches[0].page;
// navigate if needed; if already on the target page, force a re-render so the
// highlight pass runs (a no-op goToPage wouldn't trip the $data.current $watch).
if (target !== this._current.value) this.goToPage(target);else this.renderView();
} else {
this.renderView();
}
this.dispatchEvent(new CustomEvent("findresult", {
detail: {
query: q,
matches: this.findMatches.length,
current: this.findMatches.length ? 1 : 0
},
bubbles: true,
composed: true
}));
return this.findMatches.length;
}
findNext() {
if (!this.findMatches.length) return;
this.findIndex = (this.findIndex + 1) % this.findMatches.length;
const target = this.findMatches[this.findIndex].page;
if (target !== this._current.value) this.goToPage(target);
this.dispatchEvent(new CustomEvent("findresult", {
detail: {
query: this.findQuery,
matches: this.findMatches.length,
current: this.findIndex + 1
},
bubbles: true,
composed: true
}));
}
findPrev() {
if (!this.findMatches.length) return;
this.findIndex = (this.findIndex - 1 + this.findMatches.length) % this.findMatches.length;
const target = this.findMatches[this.findIndex].page;
if (target !== this._current.value) this.goToPage(target);
this.dispatchEvent(new CustomEvent("findresult", {
detail: {
query: this.findQuery,
matches: this.findMatches.length,
current: this.findIndex + 1
},
bubbles: true,
composed: true
}));
}
clearFind() {
this.findQuery = '';
this.findMatches = [];
this.findIndex = -1;
this.renderView();
this.dispatchEvent(new CustomEvent("findresult", {
detail: {
query: '',
matches: 0,
current: 0
},
bubbles: true,
composed: true
}));
}
get page(): number { return this._pageControllable.read(); }
set page(v: number) { this._pageControllable.notifyPropertyWrite(v); }
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['src', 'page', 'scale', 'rotation', 'worker-src', 'workersrc', 'standard-font-data-url', 'standardfontdataurl', 'render-all-pages', 'renderallpages', 'text-layer', 'textlayer', 'password', 'options']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}
injectGlobalStyles('rozie-pdf-viewer-global', `
.rozie-pdf .rozie-pdf-page {
position: relative;
margin: 0 auto 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
background: #fff;
}
.rozie-pdf .rozie-pdf-page canvas {
display: block;
}
.rozie-pdf .textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 1;
}
.rozie-pdf .textLayer span,
.rozie-pdf .textLayer br {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.rozie-pdf .textLayer span.markedContent {
top: 0;
height: 0;
}
.rozie-pdf .textLayer ::selection {
background: rgba(0, 100, 255, 0.3);
}
.rozie-pdf .textLayer span.rozie-pdf-find {
background: rgba(255, 196, 0, 0.45);
border-radius: 2px;
}
`);Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element — with the same props, events, and 12-verb imperative handle, all from the one source above.
See also
- PdfViewer — showcase & API — install, quick starts for all six frameworks, the worker setup, and the full reference.
- PDF libraries comparison — how
@rozie-ui/pdfstacks up against react-pdf, vue-pdf-embed, ng2-pdf-viewer, and the underserved frameworks.