Skip to content

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

Pre-v1.0 — internal monorepo.