# @wrnexus/viewer
Complete implementation of the
[file viewer plan](../../docs/plans/2026-09-08-file-viewer-design.md).
Add `@wrnexus/viewer` to an app's dependencies. Its package plugin discovers the component,
stylesheet and browser runtime automatically. Explicit plugin configuration can import
`viewerPlugin` from `@wrnexus/viewer/plugin`; do not register it twice.
```wrn
import Viewer from "@wrnexus/viewer/components/Viewer.wrn"
` remains usable if the editor cannot load.
**Tier 2 — in-package parsers, still no runtime dependencies.**
| Files | Preview |
| -------------- | ------------------------------------------------------------------ |
| `.csv`, `.tsv` | Windowed table (RFC 4180 quoting, delimiter detection), raw toggle |
| `.json` | Lazy collapsible tree, raw toggle |
| `.xml` | Lazy element tree with attributes, raw toggle |
| `.svg` | Sanitised markup inside a sandboxed frame, source toggle |
| `.tar`, `.gz` | Entry listing with sizes; gzip is expanded in the browser |
Each Tier 2 renderer is registered per extension and sits _in front of_ the source view for
its kind, so a parse failure — malformed JSON, a `.csv` that is really prose, an unreadable
archive — falls through to the highlighted source rather than erroring.
**Tier 2 with an optional dependency.** Declared as optional peer dependencies and never
installed for you. Each is served as its own runtime asset, contributed only when the package
resolves on the server, and fetched by the browser the first time such a file is opened — an
app that never views a spreadsheet neither builds nor ships `xlsx`.
| Files | Package | Preview |
| --------------- | ------------ | ------------------------------------------------------- |
| `.xls`, `.xlsx` | `xlsx` | Sheet tabs over the same windowed table as CSV |
| `.zip` | `fflate` | Entry listing; the archive is never inflated |
| `.docx` | `mammoth` | Semantic HTML in a sandboxed frame — layout is not kept |
| `.pdf` | `pdfjs-dist` | Opt-in page rasterisation with page count (see below) |
`.docx` sits _behind_ the Tier 3 converter: LibreOffice's PDF keeps the page layout, which
mammoth cannot, so mammoth is the fallback when no converter is reachable. Rich PDF is opt-in
because the native frame costs nothing:
```ts
import { registerRenderer } from "@wrnexus/viewer";
import { richPdfRenderer } from "@wrnexus/viewer/renderers/pdf-rich";
registerRenderer(richPdfRenderer());
```
pdf.js is served from its own published build rather than bundled — putting its ESM entry
through a bundler breaks the worker handshake and `getDocument()` never settles. Page
rendering is driven by `requestAnimationFrame`, which does not run while the document is
hidden, so each page render is bounded by a timeout (20s by default) and a page that will not
draw degrades to the native frame rather than hanging.
**Office documents.** Everything below reads in the browser with no binary on the host. All of
it recovers content and structure, not a faithful page layout, so wherever a converter is
reachable its PDF is preferred and these are the rung beneath it — the exception is
spreadsheets, where the data matters more than the layout and the table view comes first.
| Files | Read by | Preview |
| ------------------------- | ------------------- | ------------------------------------------ |
| `.pptx`, `.ppsx`, `.potx` | in-package + fflate | Slide-by-slide reader with presenter notes |
| `.odt` | in-package + fflate | Headings, paragraphs and lists |
| `.odp` | in-package + fflate | Slide-by-slide reader |
| `.ods` | SheetJS, then ODF | Sheet tabs over the windowed table |
| `.rtf` | in-package | Text, with a Source toggle — no dependency |
| `.docx` | mammoth | Semantic HTML in a sandboxed frame |
| `.xls`, `.xlsx` | SheetJS | Sheet tabs over the windowed table |
`.doc` and `.ppt` are the legacy OLE compound formats and stay converter-only: there is no
honest way to read them in a browser. With LibreOffice installed every format in this table
converts to PDF instead.
**Tier 3 — server-side conversion, opt-in.** `.doc`, `.docx`, `.odt`, `.rtf`, `.ppt`,
`.pptx`, `.xls` and `.xlsx` convert to PDF; `.psd`, `.ai`, `.tif` and `.tiff` to PNG; `.avi`
and `.mkv` to a poster frame; `.zip`, `.rar` and `.7z` to a listing. Disabled by default —
see Conversion below.
HTML opens in a sanitized, fully sandboxed preview with a Source toggle. Markup masquerading as another format stays inert source. Anything with no renderer and no converter displays
a metadata/download fallback, and native playback errors do the same.
## Sources and streaming
Image, media and PDF renderers are `streaming`: for an `http(s)` source the element is
pointed at the original URL, so the browser fetches and decodes it, playback starts without
buffering the whole file here, and the request is governed by the app's own CSP rather than
by a `blob:` URL (which `default-src 'self'` blocks). A local `File`/`Blob` still renders
from an object URL served under its _sniffed_ type.
The PDF renderer uses an iframe. The framework now defaults to `frame-ancestors 'self'`
and `X-Frame-Options: SAMEORIGIN`, so an app can embed its own files. Apps can retain full
framing denial with `security.frameOptions: "DENY"`; explicit CSP directives still win.
## Conversion
```ts
import { viewerPlugin } from "@wrnexus/viewer/plugin";
import { localBinaryConverter } from "@wrnexus/viewer/convert";
plugins: [viewerPlugin({ convert: { converters: [localBinaryConverter()] } })];
```
That mounts `GET /api/viewer/capabilities`, `/preview`, `/thumb` and `/text`. Without the
`convert` option no routes exist and no external process is ever run.
The client asks `capabilities` once per page and only requests a conversion for an extension
the server actually advertises, so a host without the binaries costs one small request and
every affected file keeps its download card. `localBinaryConverter` probes for `soffice`,
`magick`, `ffmpeg` and `7z` on first use; **a missing binary is not an error**.
Converted output is cached in-process, keyed by stable source identity, extension, operation, converter name
and version, and returned with `X-Viewer-Cache: hit|miss`. The source resolver runs before every cache lookup,
so an expired or withdrawn signed link cannot read cached output. Unused source streams are
cancelled on cache hits. Responses are private and no-store.
Apps that store files privately implement the contract themselves:
```ts
viewerPlugin({
convert: {
converters: [myGotenbergConverter],
// Apply your own access checks here; the default accepts same-origin URLs only.
resolveSource: async (request, { src, name }) => ({
body: await openStream(src, name),
identity: objectRevision(src),
}),
maxInputBytes: 32 * 1024 * 1024,
},
});
```
### Converter safety
`soffice`, `magick` and `ffmpeg` are large C/C++ parsers with a long CVE history, pointed at
untrusted files. Every run gets a hard wall-clock timeout, a private scratch directory as its
only writable path, a stripped environment (no proxy or credential variables), no stdin, and
a cap on the bytes read back. Input is size-capped before it reaches a binary. Converter
error messages stay server-side — they can carry file paths — and the client sees only a 422.
The default source policy accepts same-origin URLs and fetches them without credentials, so
the endpoint cannot be used to reach a private network or to read anything the caller could
not already read anonymously.
## Security
Uploaded files are hostile input, so nothing here trusts a filename.
- Classification uses the extension, declared/server MIME, and then up to 4 KiB of signature
bytes. Extension overrides cannot bypass conflicting signatures, and executable names or
signatures always fall back.
- Nothing from a file ever reaches `innerHTML`. Text, tables and trees are built as DOM nodes
with `textContent`.
- The SVG frame is its own document, so theme tokens and `currentColor` cannot reach it and
its base background is opaque. The host's resolved text and surface colours are copied into
the frame by value, which keeps themed artwork legible in light and dark.
- SVG is sanitised to an element/attribute allowlist — no `script`, no `foreignObject`, no
event handlers, and only same-document `#fragment` references — _and_ rendered inside an
`iframe` with `sandbox=""` under `default-src 'none'`. Neither layer is trusted alone.
- A file whose bytes look like active markup is shown as inert source regardless of its name,
including a `.json` or `.png` that carries a `