<!-- GENERATED by scripts/build-llms.mjs from llms/viewers.md — do not edit this file. -->

# `lr-pdf-viewer`

- **Import** `import '@aceshooting/lyra-ui/components/lr-pdf-viewer.js';` (stable tag alias; registers the tag)
- **Class** `LyraPdfViewer`, also available unregistered from `@aceshooting/lyra-ui/components/viewers/pdf-viewer/pdf-viewer.class.js`
- **Family** `components/viewers/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md)
- **Deprecations** none
- **Optional peers** `pdfjs-dist` — see `llms/peers.md`
- **Themeable via** 20 parts, 6 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-pdf-viewer`

Fetches a PDF and renders its pages with the optional `pdfjs-dist` peer. Pages are virtualized through
`lr-virtual-list`, and PDF.js's selectable text layer is positioned over each rendered canvas.
The virtual list's raw `lr-visible-range-change` and `lr-virtual-scroll` events are internal and do
not escape the viewer; visible-range changes surface through the documented `lr-page-change` state.

Adopts `DocumentAnchorTarget`: `page`, `text-quote`, and `region` anchors resolve, and `highlights`
paint through one `<lr-highlight-layer>` per page, stacked between the canvas and the text layer
(canvas → highlights → text layer) so starting a text selection over a cited passage keeps working.
Pointer activation is hit-tested at the page-wrapper level (the text layer on top intercepts most
direct pointer events); keyboard activation reaches the highlight layer's own roving-tabindex rects
directly, since z-stacking doesn't affect tab order. Residual: a click that _ends a text-selection
drag_ over a highlighted passage never activates it — the selection-in-progress check exists exactly
to tell that apart from a genuine activation click.

**Properties:** `src: string = ''` and `name: string = ''`. `page: number = 1` is the one-based
current page and
`zoom: number = 1` is clamped to `0.25`–`4`. `maxHeight: string = ''` (attribute `max-height`) is a
CSS length that, once set, overrides `--lr-pdf-viewer-height` — the block size of the virtualized
page list — declaratively, writing it inline on `[part="base"]`; invalid CSS `max-height` values,
declaration breaks, and `url()` are ignored. `workerSrc: string = ''` (attribute `worker-src`) is the
URL of the PDF.js web worker chunk — see **Configuring the PDF.js worker** below.
`anchorKinds: readonly LyraAnchorKind[] = ['page',
'text-quote', 'region']` (this viewer's supported `LyraAnchor.kind` values for the shared
anchor-target contract). The other inherited anchor-target properties are `highlights:
LyraHighlight[] = []` (property only; reassign after mutation), `activeHighlightId: string | null =
null` (attribute `active-highlight-id`), and `anchor: LyraAnchor | string | null = null` (property
only). Page and page-addressed region anchors require an in-range integer page
and are rejected rather than clamped; region rectangles also require finite coordinates and
nonnegative dimensions.

**Events:**

- `lr-render-error` — `detail: { error }` — fetching, parsing, or rendering (page canvas or text
  layer) failed. Both synchronous and rejected text-layer failures are contained and reported
  without an unhandled promise rejection.
- `lr-load` — `detail: { pageCount }` — the document reached `ready`. `page` is reset to `1` first.
- `lr-page-change` — `detail: { page, pageCount }` — fired for scroll-driven page crossings as well
  as `page` assignments and `nextPage()`/`previousPage()`/`goToPage()`.
- `lr-zoom-change` — `detail: { zoom }` — fired for `zoom` assignments as well as
  `zoomIn()`/`zoomOut()`. Never fired for the initial `1`, only for a transition away from it.
- `lr-search-change` — `detail: { query, matchCount, matchCountExact, activeIndex }` — from `search()`/`searchNext()`/
  `searchPrevious()`/`clearSearch()` and effective-locale re-evaluation. A `src` change invalidates document-relative matches and emits
  the canonical reset `{ query: '', matchCount: 0, matchCountExact: true, activeIndex: -1 }`.
- `lr-highlight-activate` — `detail: { highlightId }` — a painted highlight was clicked or activated via
  Enter/Space. On a pointer hit-test, the last entry of `highlights` covering the point wins.
- `lr-text-select` — `detail: { text, anchor, rects }` — a selection ended inside a page's text
  layer. `anchor` is the computed anchor (`null` when none resolves), carrying the resolved `page`
  when it is a `text-quote`.
- `lr-anchor-result` — `detail: { found }` — fired after an `anchor` assignment or a
  `scrollToAnchor()` call.
- `lr-page-viewer-state-change` — `detail.snapshot` is the same immutable atomic state exposed by
  `pageViewerSnapshot` (`identity`, `status`, `page`, `pageCount`). `identity` changes for every load,
  including same-count replacements.

**`lr-page-change`/`lr-zoom-change` are state broadcasts, not user-intent signals.** This differs
deliberately from the same-named `<lr-pagination>` event and from `<lr-pan-zoom>`'s `lr-zoom-change`,
and the distinction is worth internalizing before wiring a handler. Pagination and pan-zoom are
*controlled*: pagination never mutates its own `page`, so its event is a request the host applies,
and pan-zoom's `zoom` is bound by a parent, so echoing a programmatic write back would feed a loop.
This viewer instead *owns* `page` and `zoom` — scrolling the page list changes `page` with no
consumer action at all — so there is no useful "the user did this" subset to isolate, and every
accepted transition is announced identically: scrolling, `nextPage()`/`previousPage()`/`goToPage()`,
`zoomIn()`/`zoomOut()`, anchor resolution, the reset to page 1 when a new `src` finishes loading,
and a plain `viewer.page = 3` / `viewer.zoom = 2` assignment. A handler that mirrors the event back
into the property it came from is therefore idempotent rather than looping, but it is also
redundant — read the property, or `pageViewerSnapshot`, instead.

A write that clamps or rounds back onto the value already in effect is not a transition and stays
silent: on a 3-page document already showing page 1, `viewer.page = -7` and `viewer.page = 1.4` both
settle on page 1 and emit nothing, and `viewer.zoom = 999` at 4× emits nothing.

For page-addressed integrations prefer the atomic `pageViewerSnapshot` /
`lr-page-viewer-state-change` channel over this pair: it carries page, count, load status and
document `identity` together, and a late subscriber can read the current value synchronously instead
of having missed the announcement. `<lr-page-rail>` binds that channel for any viewer exposing
`pageViewerSnapshot` — including this one — and falls back to `lr-load`/`lr-page-change` only for
older structural sources that do not.

**Methods:** `nextPage()`, `previousPage()`, `zoomIn()`, and `zoomOut()` update the corresponding
controlled state within its supported range. `getPageText(page)` resolves the raw reading-order text
of one page (per-page LRU-cached, 64 pages), rejecting on no loaded document or an out-of-range page.
`pageViewerSnapshot` is a synchronously readable readonly lifecycle/page/count snapshot for page
rails and other late subscribers. `renderPageThumbnail(page, canvas, options?)` renders `page` into a caller-owned `canvas` at
`options.width` CSS px (default 96), devicePixelRatio-aware, resolving `false` when not ready or out
of range. `goToPage(page)` scrolls the virtualized list to `page`, resolving `true` once mounted (or
`false` for an out-of-range value, without changing `page`). `getOutline()` resolves the document's
table of contents as `PdfOutlineItem[]` (`{ title, page?, children? }`), `[]` when there is none;
peer output is capped at 10,000 unique items and 100 levels, with cycles ignored.
`search(query)` resolves the match count across all pages (empty/whitespace query behaves like
`clearSearch()`), accepting at most 4,096 query code units and scanning at most 1,000 pages,
1,000,000 corpus code units, and 4,000,000 search code units while retaining 10,000 matches;
`searchNext()` and `searchPrevious()` advance/step back through matches (wrapping,
resolving `false` when there are none); `clearSearch()` clears the query, matches, and painted marks.

**CSS parts:** `base` (the named region with explicit `aria-busy="true"|"false"`), `toolbar`,
`previous-button`, `next-button`, `zoom-out-button`,
`zoom-in-button` (the four toolbar controls — previously reachable only through `::part(toolbar)
button`, which is invalid: a descendant combinator after `::part()` never matches, so each button now
carries its own part name), `page-indicator`, `zoom-indicator`, `pages`, `page`, `page-canvas`
(the canvas one page's content is painted onto), `text-layer`, `text-span` (one generated text run
inside a page's text layer — PDF.js creates these imperatively, and they carry the part so a rule can
reach them without a descendant combinator), `search-match` (a `<mark>` painted into a mounted page's
text layer around one search match), `search-match-active` (the currently active match, also carries
`search-match`), `page-error`, `page-error-visible`, `spinner`, `error`, and `anchor-live-region` (an
aria-hidden, non-live shadow mirror of the latest anchor-jump message; the spoken copy is appended
to the shared document-level polite sink only while the viewer and its composed ancestors are
exposed to the accessibility tree). Search painting is best-effort: a page outside the virtualized
render window is skipped and repainted once its text layer mounts, and a match spanning a text-layer
span boundary that `Range.surroundContents()` can't wrap stays unpainted (still reachable via
`searchNext()`). The loading skeleton is decorative and paired with an ordinary visually-hidden
localized label; later loading and error transitions use the shared document-level polite and
assertive sinks, respectively, without adding live semantics inside the viewer shadow.

**Known capability boundaries** — deliberate, not defects, and stated here so they need not be
rediscovered: text search and `LyraAnchor` text-quote resolution match **exact** text only, after
whitespace and soft-hyphen normalization, with no fuzzy or approximate mode. Non-Latin
cMap-encoded fonts get no special handling beyond whatever the `pdfjs-dist` peer resolves on its
own — no `cMapUrl`/`cMapPacked` is configured. A scanned or image-only PDF has no text layer at
all, so it has nothing to select, search or anchor into; it still renders and paginates normally.

`page`, `page-canvas`, `text-layer`, `text-span`, `search-match` and `search-match-active` are
rendered inside the virtualizing `lr-virtual-list`'s own shadow root and forwarded out through
`exportparts`, so `lr-pdf-viewer::part(page)` (and each of the others) works from a consumer
stylesheet exactly like the parts in this viewer's own shadow root. The selection tint over a page's
text is styled on `text-span` rather than on `text-layer`, because a highlight pseudo-element is
matched against the element the selected text originates in:
`lr-pdf-viewer::part(text-span)::selection { background: … }`.

**Themeable custom properties:** `--lr-pdf-viewer-height` (default `var(--lr-size-24rem)`) — block
size of the virtualized page list (`[part="pages"]`); also settable via the `maxHeight` property,
which writes this token inline on `[part="base"]`. `--lr-pdf-viewer-toolbar-bg` (default
`var(--lr-color-brand-quiet)`) — background of the `toolbar` part, independent of the shared
`--lr-color-brand-quiet` token. `--lr-pdf-viewer-toolbar-button-hover-bg`
(default `var(--lr-color-surface)`) — hover fill of the toolbar buttons; it defaults to the surface
fill rather than the toolbar's own `--lr-color-brand-quiet` tint precisely so the hover state is
visible against the toolbar behind it. `--lr-pdf-viewer-text-selection-bg` (default
`var(--lr-color-brand-quiet)`) — background of a native text selection over a `text-span`,
independent of the shared `--lr-color-brand-quiet` token. `--lr-pdf-viewer-search-match-bg` (default
`var(--lr-color-warning-quiet)`) and `--lr-pdf-viewer-search-match-active-bg` (default
`var(--lr-color-warning)`) retint the painted `search-match`/`search-match-active` marks without
overriding the shared warning tokens directly. Everything else below the page list is retuned
through the exported parts above rather than through dedicated custom properties.

**Optional peer dependency:** install `pdfjs-dist` with `pnpm add pdfjs-dist`. The component registers
a lazy `application/pdf` renderer with `<lr-document-viewer>` so the PDF library is loaded only when
a PDF is opened. An absent `pdfjs-dist` fails closed and renders
`[part="error"]` with the localized `pdfViewerMissingLibrary` message — there is no partial PDF
rendering without it.

Importing `pdf-viewer.js` (this component's own registration entry) loads `<lr-pdf-viewer>`'s class
module immediately, before any PDF is ever opened — `pdfjs-dist` itself is the only thing that
entry actually defers. A granular consumer (not importing the `all.js` compatibility bundle) who
wants the class module deferred too can instead import
`@aceshooting/lyra-ui/components/viewers/pdf-viewer/pdf-viewer-register.js`, which installs the same
`application/pdf` registration without pulling in `<lr-pdf-viewer>`'s class module until a matching
file is actually opened, and exports `PDF_VIEWER_TAG` (`'lr-pdf-viewer'`) as a stable reference to
the tag it eventually registers.

**Configuring the PDF.js worker.** PDF.js renders in a web worker and rejects every document with
`No "GlobalWorkerOptions.workerSrc" specified.` until it has been told where that worker lives. The
worker is a separate file inside the peer (`pdfjs-dist/build/pdf.worker.min.mjs`) that only the
application's own bundler can turn into a real URL — a bare package specifier does not resolve in a
browser without an import map, and resolving it against this library's own module URL would point
into `@aceshooting/lyra-ui`'s files, where no worker exists. So a bundled application supplies it,
either way round:

```ts
// 1. Per element, through the public property/attribute.
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?worker&url'; // Vite; see your bundler
const viewer = document.querySelector('lr-pdf-viewer');
viewer.workerSrc = workerUrl;
// or, declaratively: <lr-pdf-viewer worker-src="/assets/pdf.worker.min.mjs"></lr-pdf-viewer>

// 2. Once for the whole application, on PDF.js's own singleton, before any viewer loads a document.
import { GlobalWorkerOptions } from 'pdfjs-dist';
GlobalWorkerOptions.workerSrc = workerUrl;
```

`workerSrc` accepts a document-relative URL (resolved against the document base) as well as an
absolute one; only `http:`, `https:`, `blob:` and `file:` URLs are accepted, and anything else — a
`javascript:` or `data:` URL in particular — is ignored rather than handed to `new Worker()`.

`GlobalWorkerOptions` is PDF.js's process-wide singleton and the PDF.js module itself is loaded once
per page, so `workerSrc` is applied only while that singleton is still unset. Two consequences worth
knowing before reaching for the per-element form: a worker the application configured itself is never
overwritten, and when several viewers carry different `workerSrc` values only the first one to load
PDF.js takes effect for the whole page. Assigning `workerSrc` after PDF.js has already loaded is not
silently dropped — it is re-applied on the next load — but it still cannot displace an
already-configured worker. Option 2 above is the right choice for an application that wants one
explicit worker everywhere.

Remote resources are capped at 25 MB; exceeding it surfaces the localized
`documentPreviewResourceTooLarge` message instead of the PDF.

Anchor navigation is generation-guarded: a newer anchor, document replacement, or disconnect
prevents stale page/text/region work from scrolling or reporting success.
