PDF viewer

PDF viewer data CompositeControllerEndpoint

The hx-pdf viewing shell: server-authorized bytes, lazy PDF.js rendering, toolbar slots for paging/zoom, URL deep-links — progressive enhancement over a download link.

Layer: L2 host · Recipe: unset — see docs/agent/pick-a-surface.md. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.

Download

Copy this

html
<section class="pdf" data-pdf data-pdf-src="sample.pdf" data-pdf-lib="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.min.mjs" data-pdf-worker="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.worker.min.mjs">
  <header class="pdf-toolbar" data-pdf-toolbar>
    <button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-prev>Previous</button>
    <label>Page <input class="pdf-page-input" data-pdf-page value="1" inputmode="numeric" aria-label="Page number"></label>
    <span class="pdf-page-count" data-pdf-page-count></span>
    <button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-next>Next</button>
    <span class="pdf-toolbar-spacer"></span>
    <button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-zoom-out aria-label="Zoom out">−</button>
    <button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-zoom-in aria-label="Zoom in">+</button>
    <button type="button" class="button" data-size="sm" data-variant="outline" data-pdf-fit-width>Fit width</button>
    <a href="sample.pdf" class="button" data-size="sm" data-variant="ghost" data-pdf-download-link download>Download</a>
  </header>
  <div class="pdf-status" data-pdf-status aria-live="polite"></div>
  <div class="pdf-stage" data-pdf-viewer tabindex="0">
    <noscript><a href="sample.pdf" download>Download PDF</a></noscript>
  </div>
</section>

Server exchange

When the client affordance finishes (click, confirm, keystroke…), htmx issues this request. Your API must return the response fragment in the table — usually HTML, not JSON (unless the partial says otherwise). Dazzle often renders these routes from the app model; a standalone HTMX4 app implements them explicitly. The Envelope column is the exchange envelope (part of the Swap contract) — what the response may re-emit relative to the persistent slot.

Do not reimplement the gallery. Flash toasts (e.g. “Deleted (demo).”), /mock/* paths, and other static-site scaffolding are demo-only (MOCK_HTMX in site/build_site.py). They are not Hyperpart surface and not a product API. If an agent is stuck “making the toast work,” stop — implement the exchange row below instead.

RequestTriggerResponse fragmentSwapEnvelopeStates
GET /_dazzle/documents/{entity}/{id}/{field}/filePDF.js fetching document bytes (initial + Range requests as the user pages)the file field's bytes — 200 whole-body or 206 partial with Content-Range; opaque 404 when the record is out of scope; 416 for unsatisfiable rangesnone (bytes consumed by the rendering engine)none—

Swap contract

Agent-visible HTMX topology (ADR-0054 / decision 0012). exchange envelope = what the response may re-emit relative to the persistent slot (body_only | outer | none | host_owned | document). dual-lock validates part markup only — not this envelope. Stem: stems/morph-safe-hypermedia.md; decision: docs/decisions/0012-swap-identity-contract.md.

Gallery mocks may approximate morph with innerHTML — production follows the Swap + Envelope columns in Server exchange.

Exchanges (swap · envelope)

  • GET /_dazzle/documents/{entity}/{id}/{field}/file → none (bytes consumed by the rendering engine) · envelope=none

Envelope rules

  • body_only — innerHTML / innerMorph into a slot; response is interior only (no re-wrap of slot id / nested data-dz-region).
  • outer — outerHTML / outerMorph; response may carry identity.
  • none — no HTML swap (JSON/204/bytes; client or OOB companion).
  • host_owned — swap target/mode chosen by the host button’s hx-target / hx-swap.
  • document — full navigation / document load (not a fragment).
  • Slot owns stable id / domain keys; state in DOM, not Alpine.

Envelope response examples

What the server returns for each exchange on PDF viewer. Match the exchange envelope; dual-lock still applies to interior markup.

GET /_dazzle/documents/{entity}/{id}/{field}/file · envelope=none

Correct response for none (bytes / no HTML swap).

Do — correct response body

text
# envelope=none — opaque bytes (not an HTML fragment)
# HTTP 200 application/pdf  (or 206 + Content-Range)
# Body: raw file bytes
# No HTML document chrome, no <html> wrapper

Don’t — violates none

text
<!-- WRONG: HTML page wrapping the PDF bytes -->
<!DOCTYPE html><html><body>…embed…</body></html>

How to use it

Seams

  • data-pdf-src points at the document bytes (or range proxy)
  • data-pdf-lib lazy-loads PDF.js as an ES module on first intersect
  • data-pdf-state=url enables ?dzpdf-page / ?dzpdf-zoom deep-links
  • optional [data-pdf-zoom] companion is leftover-honest (parseZoom; rest-state gallery omits it)

Do / Don't

DoDon't
lazy-load the engine from data-pdf-lib when the viewer enters vieweager-import PDF.js on every page that might show a document

Pitfalls

  • application controls ACCESS; PDF.js only renders — do not embed bytes in the bundle
  • without JS the noscript download link IS the experience
  • leftover zoom junk must not invent a scale (do not parseFloat leftover / URL junk)

Keyboard / AT

  • toolbar page/zoom controls remain keyboard-operable
  • noscript download is the progressive-enhancement floor

Related parts

button

DOM contract

What the emitted HTML must satisfy — the table is the required surface; Python under contracts/ is the package-internal dual-lock CI runs (tests/test_contracts.py), not an app route. Standalone HTMX4: implement the API so responses match this markup. Dazzle: the agent emits SSR that already satisfies it. Do not invent attrs outside these tables. For request/response wiring see Server exchange.

contracts/pdf.py

Required in the DOM: root [data-pdf] (part pdf). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).

NodeAttrConstraint
[data-pdf]data-pdf-srcpresent (any value)
[data-pdf]data-pdf-libpresent (any value)
[data-pdf-viewer]——

Module source

Import path is monorepo/package-local (from contracts._kit import …). Source-token form often uses data-*; gallery demos above are unprefixed. Do not copy this into app routes.

python
"""HYPERPART: pdf — progressive PDF shell (access + lazy PDF.js).

Leftover honesty (cycle 2151): leftover page junk (``2abc``, ``zzz``,
out-of-range) must not invent a page jump. ``parseInt("2abc")`` is not
a committed page. Empty input on blur restores from the current page.
Leftover honesty (cycle 2152): leftover zoom junk (``2abc``, ``zzz``,
``1e2``, out-of-[0.25, 8]) must not invent a scale. ``parseFloat("2abc")``
is not a committed zoom; URL ``?dzpdf-zoom`` leftover is the same parse.
Empty zoom companion on blur restores from the current zoom. Optional
``[data-dz-pdf-zoom]`` slot. Rest-state toolbar markup is unchanged.
"""

from contracts._kit import DomContract, Node, Present

DOM_CONTRACT = DomContract(
    part="pdf",
    root="[data-dz-pdf]",
    nodes=(
        Node(
            "[data-dz-pdf]",
            attrs={
                "data-dz-pdf-src": Present(),
                "data-dz-pdf-lib": Present(),
            },
        ),
        Node("[data-dz-pdf-viewer]", attrs={}),
    ),
)

__all__ = ["DOM_CONTRACT"]

Notes

The application renders the shell and CONTROLS ACCESS; PDF.js renders the document. pdf.js lazy-loads the library as an ES module from data-pdf-lib only when the viewer scrolls into view — no PDF bytes or engine in the bundle. In Dazzle, data-pdf-src points at the scope-gated range proxy (/_dazzle/documents/{entity}/{id}/{field}/file — document access IS entity access), and PDF.js range-requests pages on demand. data-pdf-state="url" opts a viewer into ?dzpdf-page/?dzpdf-zoom deep-links (replaceState — Back stays page navigation). Without JS the noscript download link is the whole experience. In production, VENDOR the PDF.js module — dynamic import() cannot carry SRI; the gallery's CDN pin is demo-only.

Source files

One logical Hyperpart, 4 code items (CSS layered, JS bundled). Bound by HYPERPART: pdf — python tools/hyperpart.py pdf lists them.

site/registry.py · contracts/pdf.py · components/pdf.css:1 · controllers/dz-pdf.js

Composed of

Button