Ship file viewer, editor improvements, and private package releases

This commit is contained in:
Clintchiz committed 2026-09-09 11:20:11 +05:30
1 parent 645914955c
commit a4a31032b9
158 files changed
+10523 -137

No files matched your search

+437
View File
@@ -0,0 +1,437 @@
# @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"
<Viewer src={file.url} name={file.name} />
```
Optional props: `mime`, `size` (byte count as a string), `class`, and `theme` (`auto`, `light`,
`dark`), `files` (an array of source descriptors), and `richPdf` (opt-in canvas rendering). Pinned themes use the host's generated theme tokens. The `/file-viewer` page in
`examples/basic-app` provides local samples for every supported group.
For an upload preview before any network request, mount from the browser:
```ts
import { mountViewer } from "@wrnexus/viewer/runtime";
const dispose = mountViewer(container, { blob: file, name: file.name });
// Before replacing the preview or removing your component:
dispose();
```
## What renders
**Tier 1 — no dependencies.** Native image formats with zoom, rotation, reset and
scroll/drag panning; PDF and audio/video through native controls; UTF-8 text and code
through the block editor's lazy read-only CodeMirror mount, with theme tokens, line numbers
and a wrap toggle. HTML/XML use XML highlighting; other languages use plain mode. A text-only
`<pre>` 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 `<script>` tag.
## Resolution and limits
`resolve(source)` returns a renderer and ordered fallback list. Signature checks select a
renderer; the browser decoder still validates the complete file.
For remote sources the resolver requests HEAD before a bounded Range read. A server without
HEAD/Range support can still work: the response stream is cancelled at the sniff limit.
Complete preview reads enforce a byte cap even if Content-Length is absent or inaccurate,
and the downloaded body is reclassified before rendering. A single network chunk can be
larger than the cap; excess bytes are never retained as a preview. HTTP(S), blob and data
URLs are supported; remote servers must allow browser CORS access. A kind with no renderer
at all reports that no preview exists rather than a size limit.
Defaults: text/code 2 MiB, SVG 2 MiB, CSV/JSON/XML 8 MiB, images/PDF 32 MiB, audio/video and
archives 64 MiB. Delimited files are additionally capped at 200,000 parsed rows, and only the
visible window of rows is kept in the DOM. Remote native media streams from its original URL; local blobs remain bounded. Disposal aborts pending requests, destroys
editors and revokes object URLs. Automatic mounts observe source changes and framework
navigation.
## Uploader-backed sources
Core is source-agnostic — `src` or `blob` is all it needs, and `@wrnexus/uploader` is an
optional peer dependency. Enabling `uploads` lets a component name a storage key instead:
```wrn
<Viewer key={file.key} store="reports" />
```
```ts
import { viewerPlugin } from "@wrnexus/viewer/plugin";
plugins: [
viewerPlugin({
uploads: {
stores: ["media", "reports"],
secret: process.env.VIEWER_FILE_SECRET!,
ttlMs: 5 * 60 * 1000,
authorize: async (request, { store, key }) => canRead(await currentUser(request), store, key),
},
}),
];
```
That mounts `GET /api/viewer/source` and `GET /api/viewer/file`. The browser exchanges the key
for a descriptor, and only then loads anything:
- A **public** object answers with its own URL — no token, nothing signed, nothing to leak.
- A **private** object answers with a short-lived signed link to the file route, and only
after `authorize` agrees. Signing uses the uploader's own `createSignedFileToken`.
The file route serves `Content-Disposition: inline`, `X-Content-Type-Options: nosniff` and
`Cache-Control: private, no-store` — a per-viewer link must never enter a shared cache — and
answers HEAD and byte ranges so audio and video can seek. An expired, forged, or
withdrawn-store link is answered exactly like a missing one.
There is deliberately **no default access check**: a private store without both a `secret` and
an `authorize` hook is refused rather than served. `stores` is an allow-list; anything outside
it is refused before a key is even looked at, and keys that could escape their store
(`..`, absolute paths, control characters) are rejected outright.
The MIME in the descriptor comes from the uploader's own extension table, not a second copy of
it here — and the viewer still sniffs the bytes before choosing a renderer.
For conversion, `uploadedConversionSource()` makes converted output inherit exactly the access
check of its input: a converter can only open what a valid signed link already grants.
```ts
import { uploadedConversionSource } from "@wrnexus/viewer/uploader";
viewerPlugin({
convert: { converters: [localBinaryConverter()], resolveSource: uploadedConversionSource() },
uploads: {/* … */},
});
```
## Where the preview appears
Inline in a card is the default. Three other placements are built in.
**Over the page.** `openViewer` puts a file in a native `<dialog>`, which takes the top layer,
traps focus, closes on Escape and restores focus to whatever opened it. Closing disposes the
viewer, so a video stops and object URLs are revoked rather than leaking behind the overlay.
```ts
import { openViewer } from "@wrnexus/viewer";
const preview = openViewer({ src: file.url, name: file.name });
// preview.close() when you need to close it yourself.
```
Nothing needs wiring for the common case — any element carrying `data-viewer-open` opens the
file it names:
```wrn
<button type="button" data-viewer-open data-src={file.url} data-name={file.name}>Preview</button>
```
It reads the same attributes the component takes (`data-src`, `data-key`, `data-store`,
`data-name`, `data-mime`, `data-size`, `data-files`), so a storage key or a whole filmstrip
opens the same way.
**Covering the page.** `fullScreen` mounts the viewer over the page with a Close control that
returns it inline. It is a layered overlay rather than the Fullscreen API, so it needs no user
gesture and can be the state a viewer starts in. Toggling the attribute later re-mounts it.
```wrn
<Viewer src={file.url} name={file.name} fullScreen={true} />
```
**A whole page.** `layout="page"` drops the card chrome and fills the container, for a route
given over to one file.
```wrn
<Viewer src={file.url} name={file.name} layout="page" />
```
## Toolbar actions
Every control is an icon button carrying its name in `aria-label`, in a visually
hidden span for screen readers, and in a CSS tooltip on hover and focus. The icons are drawn
as SVG nodes from a small built-in set rather than markup strings, because the framework
enables Trusted Types in production where `innerHTML` is refused.
**Print** is offered on any preview that actually rendered — slides, tables, documents,
images and PDFs — and withheld on the download card, where there is nothing to print. It
prints the preview rather than the page: the viewer is marked and `viewer.css` hides
everything else, while a same-origin PDF frame is asked to print itself. Opt out with
`<Viewer print={false} />` or `mountViewer(host, source, { print: false })`.
Apps add their own actions, either globally or per viewer:
```ts
import { registerViewerAction } from "@wrnexus/viewer";
const unregister = registerViewerAction({
id: "open-in-tab",
label: "Open in new tab",
icon: "preview", // a name from ICONS; falls back to one from the label
placement: "actions", // or "controls", beside the renderer's own buttons
when: ({ source, kind }) => kind === "pdf" && Boolean(source.src),
run: ({ source, content }) => window.open(source.src, "_blank", "noopener"),
});
```
```ts
mountViewer(host, source, {
actions: [{ id: "send", label: "Send for review", run: ({ source }) => share(source) }],
});
```
Actions are offered after every render, so `when` sees the renderer and kind that actually
drew, and a re-render (a Raw/Table toggle, say) never leaves duplicates behind. `run` receives
the `content` element the renderer drew into, so an action can read what is on screen instead
of fetching the file again. An action is offered on the download card too — only its `when`
decides — so "report this file" still works for something nothing can preview.
## Custom renderers
```ts
import { registerRenderer } from "@wrnexus/viewer";
import { mountViewer } from "@wrnexus/viewer/runtime";
const unregister = registerRenderer({
kind: "text",
exts: [".log"],
tier: 2,
component: "LogViewer",
maxInlineBytes: 1024 * 1024,
load: () => import("./log-parser"),
async mount({ parent, file, button }) {
parent.textContent = await file.text();
button("Reload", () => location.reload());
return () => parent.replaceChildren();
},
});
```
Every renderer — built-in and app-defined — receives the same `ViewerMountContext`: the
`parent` container, the `file` blob, the `source` descriptor, the resolved `kind`, a `button`
factory for toolbar controls, an `objectUrl` helper whose URLs are revoked on disposal, a
`degrade` callback that abandons the renderer for the download card, and an abort `signal`.
`component` selects a presentation template when one exists; the mount adapter determines rendering. A renderer without a
`mount` can never be displayed, and the ladder moves to the next candidate.
Bundle registration and manual mounting in the same browser entry. New registrations take
precedence and return an unregister function; isolated `RendererRegistry` instances can be
passed to `resolve`. Failed optional loads move to the next renderer. Custom adapters are
trusted application code and must handle file contents safely.
The parsers are exported for reuse: `parseDelimited`, `detectDelimiter`, `sanitizeSvg`,
`parseTar`, `isTar`, `gzipName`, `gunzip`.
## Source composition
The example accepts both signed uploader objects and anonymous static files:
```ts
import { firstOf, sameOriginSource } from "@wrnexus/viewer/convert";
import { uploadedConversionSource } from "@wrnexus/viewer/uploader";
resolveSource: firstOf(uploadedConversionSource(), sameOriginSource());
```
A resolver returns undefined when it cannot handle a source. Errors stop composition. The
same-origin resolver refuses viewer API links, cross-origin URLs, embedded credentials and
redirects. Uploader identities use the store and object key rather than the rotating token.
For mutable custom sources include a revision in `identity`; otherwise replacement bytes
may remain cached until TTL expiry. The URL remains the fallback identity.
## Presentation components
`ViewerShell` composes `ViewerToolbar`, `DownloadCard`, and renderer templates:
`ImageViewer`, `VectorViewer`, `PdfViewer`, `CodeViewer`, `SheetViewer`, `DocViewer`,
`MediaViewer`, and `ArchiveViewer`. Copy a component into `app/components` with the same
name to override its presentation. Keep its `data-viewer-*` hooks. The browser mounts the
existing safe adapters into `data-viewer-mount`, preserves custom classes, and restores the
shell on disposal. Components are presentation shells; source handling stays with Viewer.
## Filmstrip, waveform and extracted text
```wrn
<Viewer files={[{src: '/photo.png', name: 'photo.png'}, {src: '/report.pdf', name: 'report.pdf'}]} />
<Viewer src="/report.pdf" name="report.pdf" richPdf={true} />
```
Manual mounting accepts the same source array, including local blobs. Up to 500 files are
shown; one preview is active. Arrow keys, Home and End move selection. Switching disposes
media, editors, worker work, object URLs and pending fetches. Image thumbnails use the source;
convertible files request `/api/viewer/thumb` sequentially with a 2 MiB limit. Missing or
non-image thumbnails keep the filename button. No converter is required to use a filmstrip.
Audio has a **Show waveform** action. It decodes on demand, caps the encoded read at 8 MiB,
and accepts known durations up to ten minutes; unsupported or larger files retain native
playback. The waveform combines all channels and includes an accessible seek slider.
Convertible sources have an **Extract text** action. The exported `extractText(source,
{baseURI, signal})` returns at most 2 MiB of plain text for search/index consumers. The action
shows the same text inline and reports unavailable extraction without breaking the preview.
Client conversion and optional-module helpers accept the host base URI for embedded documents
and tests, instead of requiring the global document.
Rich PDF success was verified in ordinary Chrome on 2026-09-09: the canvas visibly painted
text and the renderer reported one completed page. Timeout fallback remains for hidden tabs.
## Accepted limits
The conversion cache is per-process rather than shared, and byte
ranges on a signed link are served only for buffered objects — a streaming driver answers the
whole body, so seeking a large private video re-reads it.
Run `bun test packages/viewer`, `bun run --cwd packages/viewer typecheck`.
@@ -0,0 +1,10 @@
component ArchiveViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-archiveviewer {class}" data-viewer-renderer="ArchiveViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
+10
View File
@@ -0,0 +1,10 @@
component CodeViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-codeviewer {class}" data-viewer-renderer="CodeViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
+10
View File
@@ -0,0 +1,10 @@
component DocViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-docviewer {class}" data-viewer-renderer="DocViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
@@ -0,0 +1,20 @@
component DownloadCard {
props {
reason: string = "Preview unavailable for this file"
class: string = ""
}
view {
<div class="wrn-viewer-download {class}">
<div class="wrn-viewer-download-art" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" />
<path d="M14 3v5h5" />
<path d="M9.5 13.5l5 5" />
<path d="M14.5 13.5l-5 5" />
</svg>
</div>
<p class="wrn-viewer-download-reason" data-viewer-reason="" role="status">{reason}</p>
<p class="wrn-viewer-download-meta" data-viewer-size=""></p>
</div>
}
}
@@ -0,0 +1,10 @@
component ImageViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-imageviewer {class}" data-viewer-renderer="ImageViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
@@ -0,0 +1,10 @@
component MediaViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-mediaviewer {class}" data-viewer-renderer="MediaViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
+10
View File
@@ -0,0 +1,10 @@
component PdfViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-pdfviewer {class}" data-viewer-renderer="PdfViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
@@ -0,0 +1,10 @@
component SheetViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-sheetviewer {class}" data-viewer-renderer="SheetViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
@@ -0,0 +1,10 @@
component VectorViewer {
props {
class: string = ""
}
view {
<div class="wrn-viewer-renderer wrn-vectorviewer {class}" data-viewer-renderer="VectorViewer">
<div data-viewer-mount=""><slot></slot></div>
</div>
}
}
+24
View File
@@ -0,0 +1,24 @@
import ViewerShell from "@wrnexus/viewer/components/ViewerShell.wrn"
component Viewer {
props {
files: unknown[] = []
richPdf: boolean = false
fullScreen: boolean = false
layout: string = "inline"
src: string = ""
key: string = ""
store: string = ""
name: string = ""
mime: string = ""
size: string = ""
theme: string = "auto"
class: string = ""
}
view {
<section class="wrn-viewer {class}" data-theme="{theme === 'auto' ? '' : theme}" data-wrnexus-runtime="viewer" data-files="{JSON.stringify(files)}" data-rich-pdf="{richPdf}" data-full-screen="{fullScreen}" data-layout="{layout}" data-src="{src}" data-key="{key}" data-store="{store}" data-name="{name}" data-mime="{mime}" data-size="{size}" aria-label="{name === '' ? 'File preview' : name}">
<ViewerShell name={name} />
<noscript>Enable JavaScript to preview this file.</noscript>
</section>
}
}
@@ -0,0 +1,31 @@
import ViewerToolbar from "@wrnexus/viewer/components/ViewerToolbar.wrn"
import DownloadCard from "@wrnexus/viewer/components/DownloadCard.wrn"
import ImageViewer from "@wrnexus/viewer/components/ImageViewer.wrn"
import VectorViewer from "@wrnexus/viewer/components/VectorViewer.wrn"
import PdfViewer from "@wrnexus/viewer/components/PdfViewer.wrn"
import CodeViewer from "@wrnexus/viewer/components/CodeViewer.wrn"
import SheetViewer from "@wrnexus/viewer/components/SheetViewer.wrn"
import DocViewer from "@wrnexus/viewer/components/DocViewer.wrn"
import MediaViewer from "@wrnexus/viewer/components/MediaViewer.wrn"
import ArchiveViewer from "@wrnexus/viewer/components/ArchiveViewer.wrn"
component ViewerShell {
props {
name: string = "File"
class: string = ""
}
view {
<div class="wrn-viewer-shell {class}">
<ViewerToolbar name={name} />
<div class="wrn-viewer-content" data-viewer-content=""><p role="status">Loading preview…</p></div>
<template data-viewer-template="DownloadCard"><DownloadCard /></template>
<template data-viewer-template="ImageViewer"><ImageViewer /></template>
<template data-viewer-template="VectorViewer"><VectorViewer /></template>
<template data-viewer-template="PdfViewer"><PdfViewer /></template>
<template data-viewer-template="CodeViewer"><CodeViewer /></template>
<template data-viewer-template="SheetViewer"><SheetViewer /></template>
<template data-viewer-template="DocViewer"><DocViewer /></template>
<template data-viewer-template="MediaViewer"><MediaViewer /></template>
<template data-viewer-template="ArchiveViewer"><ArchiveViewer /></template>
</div>
}
}
@@ -0,0 +1,13 @@
component ViewerToolbar {
props {
name: string = "File"
class: string = ""
}
view {
<div class="wrn-viewer-toolbar {class}" data-viewer-toolbar="" role="toolbar" aria-label="File preview controls">
<span data-viewer-title="">{name}</span>
<span data-viewer-controls=""></span>
<span data-viewer-actions=""></span>
</div>
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"name": "@wrnexus/viewer",
"version": "0.8.9",
"type": "module",
"description": "Read-only file previews with bounded loading and graceful fallback.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./plugin": "./src/plugin.ts",
"./runtime": "./src/runtime.ts",
"./code-editor": "./src/code-editor.ts",
"./optional/sheet": "./src/optional/sheet.ts",
"./optional/docx": "./src/optional/docx.ts",
"./optional/zip": "./src/optional/zip.ts",
"./optional/office": "./src/optional/office.ts",
"./optional/pdf": "./src/optional/pdf.ts",
"./renderers/pdf-rich": "./src/renderers/pdf-rich.ts",
"./viewer.css": "./viewer.css",
"./components/*": "./components/*",
"./convert": "./src/convert/index.ts",
"./uploader": "./src/uploads/index.ts"
},
"files": [
"src",
"components",
"viewer.css",
"README.md"
],
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@wrnexus/block-editor": "workspace:*",
"@wrnexus/plugin": "workspace:*",
"@wrnexus/ui": "workspace:*"
},
"wrnexus": {
"plugin": {
"plugin": "./src/plugin.ts",
"export": "default",
"factory": true
}
},
"peerDependencies": {
"fflate": "*",
"mammoth": "*",
"pdfjs-dist": "*",
"xlsx": "*",
"@wrnexus/uploader": "workspace:*"
},
"peerDependenciesMeta": {
"fflate": {
"optional": true
},
"mammoth": {
"optional": true
},
"pdfjs-dist": {
"optional": true
},
"xlsx": {
"optional": true
},
"@wrnexus/uploader": {
"optional": true
}
},
"devDependencies": {
"fflate": "^0.8.2",
"mammoth": "^1.8.0",
"pdfjs-dist": "^4.10.38",
"xlsx": "^0.18.5",
"@wrnexus/uploader": "workspace:*"
}
}
+60
View File
@@ -0,0 +1,60 @@
import type { Renderer, ViewerSource } from "./types.ts";
/**
* What an action can see when it is offered and when it runs. `content` is the
* element the renderer drew into, so an action can read or print what is on
* screen rather than re-fetching the file.
*/
export interface ViewerActionContext {
host: HTMLElement;
content: HTMLElement;
source: ViewerSource;
/** Absent while the preview is still resolving, or when nothing could render. */
renderer?: Renderer;
kind?: string;
}
export interface ViewerAction {
/** Stable identifier; registering the same id again replaces the earlier one. */
id: string;
label: string;
/** Name from the built-in icon set. Falls back to one derived from the label. */
icon?: string;
/** Toolbar side: alongside the renderer's own controls, or with download. */
placement?: "controls" | "actions";
/** Offered only when this returns true. Defaults to always. */
when?: (context: ViewerActionContext) => boolean;
run: (context: ViewerActionContext) => void | Promise<void>;
}
const registered = new Map<string, ViewerAction>();
/**
* Adds a toolbar action to every viewer in the page. Returns an unregister
* function, so a component can add one for as long as it is mounted.
*
* ```ts
* registerViewerAction({
* id: "open-in-tab",
* label: "Open in new tab",
* when: ({ source }) => Boolean(source.src),
* run: ({ source }) => window.open(source.src, "_blank", "noopener"),
* });
* ```
*/
export function registerViewerAction(action: ViewerAction): () => void {
registered.set(action.id, action);
return () => {
if (registered.get(action.id) === action) registered.delete(action.id);
};
}
/** Globally registered actions, in registration order. */
export function viewerActions(): ViewerAction[] {
return [...registered.values()];
}
/** Testing helper: drops every globally registered action. */
export function clearViewerActions(): void {
registered.clear();
}
+2
View File
@@ -0,0 +1,2 @@
/** Separately served by viewerPlugin; downloaded only when a text preview opens. */
export { mountBuilderSourceEditor } from "@wrnexus/block-editor/source-editor";
+52
View File
@@ -0,0 +1,52 @@
import type { ViewerSource } from "../types.ts";
export const CONVERT_BASE = "/api/viewer";
export interface ConverterClientOptions {
baseURI?: string;
fetch?: typeof fetch;
}
const probes = new Map<string, Promise<Record<string, string>>>();
const base = (value?: string) =>
value ?? (typeof document === "undefined" ? undefined : document.baseURI);
export async function converterFormats(
options: ConverterClientOptions = {},
): Promise<Record<string, string>> {
const baseURI = base(options.baseURI);
if (!baseURI) return Promise.resolve({});
let url: string;
try {
url = new URL(`${CONVERT_BASE}/capabilities`, baseURI).href;
} catch {
return {};
}
const request = () =>
(options.fetch ?? fetch)(url)
.then((response) => (response.ok ? response.json() : { formats: {} }))
.then((body: { formats?: Record<string, string> }) => body.formats ?? {})
.catch(() => ({}));
if (options.fetch) return request();
if (!probes.has(url)) probes.set(url, request());
return probes.get(url)!;
}
export async function requireConverter(
exts: string[],
options: ConverterClientOptions = {},
): Promise<string> {
const formats = await converterFormats(options);
for (const ext of exts) if (formats[ext]) return formats[ext]!;
throw new Error(`No converter for ${exts.join(", ")}`);
}
export function convertUrl(
source: ViewerSource,
operation: "preview" | "thumb" | "text" = "preview",
baseURI?: string,
): string | undefined {
const origin = base(baseURI);
if (!source.src || !origin) return undefined;
const url = new URL(`${CONVERT_BASE}/${operation}`, origin);
url.searchParams.set("src", new URL(source.src, origin).href);
url.searchParams.set("name", source.name);
return url.href;
}
export function resetConverterProbe(): void {
probes.clear();
}
+16
View File
@@ -0,0 +1,16 @@
import type { ConversionOptions } from "./types.ts";
const KEY = "__wrnexusViewerConversion";
/**
* Route modules are loaded by the router, not by the plugin factory, so the
* configuration is handed over through a global — the same arrangement
* `@wrnexus/graphql` uses for its handler.
*/
export function configureConversion(options: ConversionOptions | undefined): void {
(globalThis as Record<string, unknown>)[KEY] = options;
}
export function conversionConfig(): ConversionOptions | undefined {
return (globalThis as Record<string, unknown>)[KEY] as ConversionOptions | undefined;
}
+14
View File
@@ -0,0 +1,14 @@
export { configureConversion, conversionConfig } from "./config.ts";
export { handleCapabilities, handleConvert } from "./service.ts";
export { localBinaryConverter } from "./local.ts";
export { firstOf, sameOriginSource } from "./sources.ts";
export type { LocalConverterOptions } from "./local.ts";
export type {
ConversionOptions,
ConvertMeta,
ConvertOperation,
ConvertOutput,
ResolvedSource,
SourceResolver,
ViewerConverter,
} from "./types.ts";
+254
View File
@@ -0,0 +1,254 @@
import { spawn, spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConvertMeta, ConvertOutput, ViewerConverter } from "./types.ts";
import type { ViewerKind } from "../types.ts";
type Binary = "soffice" | "magick" | "ffmpeg" | "7z";
interface Recipe {
binary: Binary;
mime: string;
/** Output is read from this file, or from stdout when omitted. */
output?: string;
args: (input: string, dir: string) => string[];
}
const DOCUMENT: Recipe = {
binary: "soffice",
mime: "application/pdf",
output: "input.pdf",
args: (input, dir) => [
"--headless",
"--norestore",
"--nolockcheck",
`-env:UserInstallation=file://${dir.replace(/\\/g, "/")}/profile`,
"--convert-to",
"pdf",
"--outdir",
dir,
input,
],
};
const RASTER: Recipe = {
binary: "magick",
mime: "image/png",
output: "output.png",
// [0] takes the first layer/page only, which bounds the work for huge files.
args: (input, dir) => [`${input}[0]`, "-flatten", join(dir, "output.png")],
};
const POSTER: Recipe = {
binary: "ffmpeg",
mime: "image/png",
output: "output.png",
args: (input, dir) => [
"-nostdin",
"-y",
"-i",
input,
"-frames:v",
"1",
"-vf",
"scale='min(1280,iw)':-2",
join(dir, "output.png"),
],
};
const LISTING: Recipe = {
binary: "7z",
mime: "text/plain; charset=utf-8",
args: (input) => ["l", "-ba", "-p", input],
};
const RECIPES: Record<string, Recipe> = {
".doc": DOCUMENT,
".docx": DOCUMENT,
".odt": DOCUMENT,
".rtf": DOCUMENT,
".ppt": DOCUMENT,
".pptx": DOCUMENT,
".xls": DOCUMENT,
".xlsx": DOCUMENT,
".dotx": DOCUMENT,
".potx": DOCUMENT,
".ppsx": DOCUMENT,
".odp": DOCUMENT,
".ods": DOCUMENT,
".psd": RASTER,
".ai": RASTER,
".tif": RASTER,
".tiff": RASTER,
".avi": POSTER,
".mkv": POSTER,
".zip": LISTING,
".rar": LISTING,
".7z": LISTING,
};
export interface LocalConverterOptions {
/** Wall-clock limit for one conversion. Default 30s. */
timeoutMs?: number;
/** Refuse output larger than this. Default 64 MiB. */
maxOutputBytes?: number;
/** Override or disable a binary, e.g. `{ ffmpeg: false }`. */
binaries?: Partial<Record<Binary, string | false>>;
/** Parent directory for per-job scratch space. Defaults to the system temp. */
tempDir?: string;
}
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const parts: Uint8Array[] = [];
let length = 0;
while (true) {
const next = await reader.read();
if (next.done) break;
parts.push(next.value);
length += next.value.length;
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
}
function probe(binary: string): boolean {
try {
const finder = process.platform === "win32" ? "where" : "which";
return spawnSync(finder, [binary], { stdio: "ignore" }).status === 0;
} catch {
return false;
}
}
/**
* Runs LibreOffice, ImageMagick, ffmpeg and 7-Zip on the host.
*
* These are large C/C++ parsers with a long history of memory-safety bugs, and
* they are being pointed at untrusted files, so every run is given a hard
* timeout, a private scratch directory as its only writable path, a stripped
* environment, no stdin, and a cap on how much output is read back. A missing
* binary is not an error: the format simply reports as unsupported and the
* viewer falls back to its download card.
*/
export function localBinaryConverter(options: LocalConverterOptions = {}): ViewerConverter {
const timeoutMs = options.timeoutMs ?? 30_000;
const maxOutputBytes = options.maxOutputBytes ?? 64 * 1024 * 1024;
const available = new Map<Binary, string | false>();
const resolveBinary = (binary: Binary): string | false => {
const cached = available.get(binary);
if (cached !== undefined) return cached;
const override = options.binaries?.[binary];
const command = override === false ? false : (override ?? binary);
const found = command === false ? false : probe(command) ? command : false;
available.set(binary, found);
return found;
};
const recipeFor = (ext: string): Recipe | undefined => {
const recipe = RECIPES[ext];
return recipe && resolveBinary(recipe.binary) ? recipe : undefined;
};
async function run(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<ConvertOutput> {
const recipe = recipeFor(meta.ext);
const command = recipe && resolveBinary(recipe.binary);
if (!recipe || !command) throw new Error(`No local converter for ${meta.ext}`);
const dir = await mkdtemp(join(options.tempDir ?? tmpdir(), "wrn-viewer-"));
try {
const inputPath = join(dir, `input${meta.ext}`);
await writeFile(inputPath, await collect(input));
const bytes = await execute(command, recipe.args(inputPath, dir), dir, meta.signal);
if (recipe.output) {
const produced = await readFile(join(dir, recipe.output));
if (produced.byteLength > maxOutputBytes) throw new Error("Converted output too large");
return { mime: recipe.mime, bytes: new Uint8Array(produced) };
}
return { mime: recipe.mime, bytes };
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
async function execute(
command: string,
args: string[],
dir: string,
signal: AbortSignal,
): Promise<Uint8Array> {
return new Promise<Uint8Array>((resolve, reject) => {
const child = spawn(command, args, {
cwd: dir,
stdio: ["ignore", "pipe", "ignore"],
// A stripped environment: no proxies, credentials or user profile leak
// into a process parsing an untrusted file.
env: {
PATH: process.env.PATH ?? "",
HOME: dir,
TMPDIR: dir,
TEMP: dir,
TMP: dir,
SystemRoot: process.env.SystemRoot ?? "",
},
});
const parts: Buffer[] = [];
let length = 0;
let settled = false;
const finish = (error?: Error, value?: Uint8Array) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
if (error) {
child.kill("SIGKILL");
reject(error);
} else resolve(value!);
};
const timer = setTimeout(
() => finish(new Error(`Conversion timed out after ${timeoutMs}ms`)),
timeoutMs,
);
const onAbort = () => finish(new Error("Conversion aborted"));
signal.addEventListener("abort", onAbort, { once: true });
child.stdout.on("data", (chunk: Buffer) => {
length += chunk.byteLength;
if (length > maxOutputBytes) {
finish(new Error("Converted output too large"));
return;
}
parts.push(chunk);
});
child.on("error", (error) => finish(error));
child.on("close", (code) =>
code === 0
? finish(undefined, new Uint8Array(Buffer.concat(parts)))
: finish(new Error(`${command} exited with ${code}`)),
);
});
}
return {
name: "local-binary",
version: "1",
supports: (_kind: ViewerKind, ext: string) => Boolean(recipeFor(ext)),
outputType: (ext: string) => recipeFor(ext)?.mime,
toPreview: run,
toThumb: run,
async toText(input, meta) {
const recipe = recipeFor(meta.ext);
if (!recipe || !recipe.mime.startsWith("text/"))
throw new Error(`No text extraction for ${meta.ext}`);
return new TextDecoder().decode((await run(input, meta)).bytes);
},
};
}
+198
View File
@@ -0,0 +1,198 @@
import { createHash } from "node:crypto";
import { extensionOf, kindOfExtension } from "../kinds.ts";
import { conversionConfig } from "./config.ts";
import { sameOriginSource } from "./sources.ts";
import type { ConvertOperation, ConvertOutput, ResolvedSource } from "./types.ts";
const MAX_INPUT_BYTES = 64 * 1024 * 1024;
const CACHE_TTL_MS = 60 * 60 * 1000;
const MAX_CACHE_ENTRIES = 64;
interface CacheEntry extends ConvertOutput {
expires: number;
}
const cache = new Map<string, CacheEntry>();
function remember(key: string, value: ConvertOutput, ttlMs: number): void {
if (cache.size >= MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next();
if (!oldest.done) cache.delete(oldest.value);
}
cache.set(key, { ...value, expires: Date.now() + ttlMs });
}
function recall(key: string): ConvertOutput | undefined {
const entry = cache.get(key);
if (!entry) return undefined;
if (entry.expires < Date.now()) {
cache.delete(key);
return undefined;
}
return { mime: entry.mime, bytes: entry.bytes };
}
/** Caps the input before it reaches a binary parser. */
function bounded(stream: ReadableStream<Uint8Array>, cap: number): ReadableStream<Uint8Array> {
const reader = stream.getReader();
let seen = 0;
return new ReadableStream<Uint8Array>({
async pull(controller) {
const next = await reader.read();
if (next.done) {
controller.close();
return;
}
seen += next.value.length;
if (seen > cap) {
await reader.cancel().catch(() => {});
controller.error(new Error("Source exceeds the conversion limit"));
return;
}
controller.enqueue(next.value);
},
cancel: (reason) => reader.cancel(reason).catch(() => {}),
});
}
const problem = (status: number, message: string) =>
new Response(message, {
status,
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
});
/** `GET /api/viewer/capabilities` — what the client can ask to have converted. */
export function handleCapabilities(): Response {
const config = conversionConfig();
if (!config) return problem(404, "Conversion is not enabled");
const formats: Record<string, string> = {};
for (const converter of config.converters)
for (const ext of Object.keys(EXTENSIONS)) {
if (formats[ext]) continue;
const mime = converter.supports(kindOfExtension(ext), ext) && converter.outputType(ext);
if (mime) formats[ext] = mime;
}
return Response.json(
{ converters: config.converters.map((item) => item.name), formats },
{ headers: { "cache-control": "no-store" } },
);
}
/** Extensions the client may ask about. Keeps the probe response bounded. */
const EXTENSIONS: Record<string, true> = Object.fromEntries(
[
".doc",
".docx",
".odt",
".rtf",
".ppt",
".pptx",
".xls",
".xlsx",
".dotx",
".potx",
".ppsx",
".odp",
".ods",
".psd",
".ai",
".tif",
".tiff",
".avi",
".mkv",
".zip",
".rar",
".7z",
].map((ext) => [ext, true as const]),
);
export async function handleConvert(
request: Request,
operation: ConvertOperation,
): Promise<Response> {
const config = conversionConfig();
if (!config) return problem(404, "Conversion is not enabled");
const url = new URL(request.url);
const src = url.searchParams.get("src") ?? "";
const name = url.searchParams.get("name") ?? "";
if (!src || !name) return problem(400, "src and name are required");
const ext = extensionOf(name);
if (!EXTENSIONS[ext]) return problem(415, `Nothing converts ${ext || "this file"}`);
const kind = kindOfExtension(ext);
const converter = config.converters.find((item) => item.supports(kind, ext));
if (!converter) return problem(415, `No converter for ${ext}`);
// Resolve on every request, including cache hits, so revoked credentials cannot read cached output.
let source: ResolvedSource | undefined;
try {
source = await (config.resolveSource ?? sameOriginSource())(request, { src, name });
} catch {
return problem(403, "Source is not available for conversion");
}
if (!source) return problem(403, "Source is not available for conversion");
const maxInputBytes = config.maxInputBytes ?? MAX_INPUT_BYTES;
if (source.size !== undefined && source.size > maxInputBytes) {
await source.body.cancel().catch(() => {});
return problem(413, "Source exceeds the conversion limit");
}
const key = createHash("sha256")
.update(
// Encode the tuple so identity delimiters cannot cause key collisions.
JSON.stringify([
url.origin,
source.identity ?? src,
ext,
operation,
converter.name,
converter.version ?? "0",
]),
)
.digest("hex");
const cached = recall(key);
if (cached) {
await source.body.cancel().catch(() => {});
return output(cached, true);
}
const controller = new AbortController();
const abort = () => controller.abort();
request.signal.addEventListener("abort", abort, { once: true });
if (request.signal.aborted) controller.abort();
const meta = { name, ext, kind, size: source.size, signal: controller.signal };
const body = bounded(source.body, maxInputBytes);
try {
const result =
operation === "text" && converter.toText
? {
mime: "text/plain; charset=utf-8",
bytes: new TextEncoder().encode(await converter.toText(body, meta)),
}
: operation === "thumb" && converter.toThumb
? await converter.toThumb(body, meta)
: await converter.toPreview(body, meta);
remember(key, result, config.cacheTtlMs ?? CACHE_TTL_MS);
return output(result, false);
} catch (error) {
// The reason stays server-side: it can carry file paths and binary output.
console.warn(`[wrnexus] viewer conversion failed for ${name}:`, error);
return problem(422, "This file could not be converted");
} finally {
request.signal.removeEventListener("abort", abort);
if (!body.locked) await body.cancel().catch(() => {});
}
}
function output(result: ConvertOutput, hit: boolean): Response {
return new Response(result.bytes as unknown as BodyInit, {
headers: {
"content-type": result.mime,
"content-length": String(result.bytes.byteLength),
"content-disposition": "inline",
"x-content-type-options": "nosniff",
"cache-control": "private, no-store",
"x-viewer-cache": hit ? "hit" : "miss",
},
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { SourceResolver } from "./types.ts";
/** Tries policies in order. Undefined means not handled; errors fail closed. */
export function firstOf(...resolvers: SourceResolver[]): SourceResolver {
return async (request, params) => {
for (const resolve of resolvers) {
const source = await resolve(request, params);
if (source) return source;
}
return undefined;
};
}
/** Anonymous same-origin resources only. Redirects and viewer API recursion are refused. */
export function sameOriginSource(): SourceResolver {
return async (request, { src }) => {
const origin = new URL(request.url);
let target: URL;
try {
target = new URL(src, origin);
} catch {
return undefined;
}
if (
!["http:", "https:"].includes(target.protocol) ||
target.origin !== origin.origin ||
target.username ||
target.password ||
target.pathname.startsWith("/api/viewer/")
)
return undefined;
const response = await fetch(target, {
credentials: "omit",
redirect: "error",
signal: request.signal,
});
if (!response.ok || !response.body) {
await response.body?.cancel();
return undefined;
}
const length = response.headers.get("content-length");
return {
body: response.body,
size: length && /^\d+$/.test(length) ? Number(length) : undefined,
identity: target.href,
};
};
}
+60
View File
@@ -0,0 +1,60 @@
import type { ViewerKind } from "../types.ts";
export type ConvertOperation = "preview" | "thumb" | "text";
export interface ConvertMeta {
/** Original filename, used only for logging and the output extension. */
name: string;
/** Lowercased extension including the dot, e.g. `.docx`. */
ext: string;
kind: ViewerKind;
size?: number;
signal: AbortSignal;
}
export interface ConvertOutput {
mime: string;
bytes: Uint8Array;
}
/**
* Turns a format the browser cannot render into one it can. Implementations are
* server-side only and are never bundled into a client runtime.
*/
export interface ViewerConverter {
name: string;
/** Bumped when output changes; part of the cache key. */
version?: string;
supports(kind: ViewerKind, ext: string): boolean;
/** MIME this converter produces for the extension, for client-side planning. */
outputType(ext: string): string | undefined;
toPreview(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<ConvertOutput>;
toThumb?(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<ConvertOutput>;
toText?(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<string>;
}
export interface ResolvedSource {
body: ReadableStream<Uint8Array>;
size?: number;
/** Stable object identity, independent of credentials. Include a revision if mutable. */
identity?: string;
}
export type SourceResolver = (
request: Request,
params: { src: string; name: string },
) => Promise<ResolvedSource | undefined>;
export interface ConversionOptions {
converters: ViewerConverter[];
/**
* Produces the bytes for a requested source. The default accepts same-origin
* URLs only and fetches them without credentials; replace it to serve from
* private storage, and apply the app's own access checks inside it.
*/
resolveSource?: SourceResolver;
/** Refuse inputs larger than this. Default 64 MiB. */
maxInputBytes?: number;
/** Converted output time-to-live in the response cache. Default 1 hour. */
cacheTtlMs?: number;
}
+141
View File
@@ -0,0 +1,141 @@
import { mountViewer, type MountViewerOptions } from "./runtime.ts";
import { convertUrl, converterFormats } from "./convert/client.ts";
import { extensionOf, kindOfExtension } from "./kinds.ts";
import { readBounded, safeSourceUrl } from "./source.ts";
import { resolveKey } from "./uploads/client.ts";
import { resolve } from "./resolve.ts";
import { sniff } from "./sniff.ts";
import type { ViewerSource } from "./types.ts";
/** One active preview; switching always disposes its editor, media and pending work. */
export function mountFilmstrip(
host: HTMLElement,
files: ViewerSource[],
options: MountViewerOptions = {},
): () => void {
files = files.slice(0, 500);
const original = [...host.childNodes];
const doc = host.ownerDocument;
const templates = original.map((node) => node.cloneNode(true));
const controller = new AbortController();
const urls: string[] = [];
const stage = doc.createElement("div");
const strip = doc.createElement("div");
strip.className = "wrn-viewer-filmstrip";
strip.setAttribute("role", "group");
strip.setAttribute("aria-label", "Files to preview");
const status = doc.createElement("p");
status.setAttribute("role", "status");
host.replaceChildren(stage, status, strip);
let dispose: (() => void) | undefined;
let selected = -1;
const buttons: HTMLButtonElement[] = [];
const show = (index: number, focus = false) => {
if (!files.length || index === selected) return;
dispose?.();
selected = Math.max(0, Math.min(files.length - 1, index));
stage.replaceChildren(...templates.map((node) => node.cloneNode(true)));
dispose = mountViewer(stage, files[selected]!, options);
buttons.forEach((button, i) => {
button.setAttribute("aria-pressed", String(i === selected));
button.tabIndex = i === selected ? 0 : -1;
});
status.textContent = `${selected + 1} of ${files.length}: ${files[selected]!.name}`;
if (focus) buttons[selected]?.focus();
};
for (const [index, file] of files.slice(0, 500).entries()) {
const button = doc.createElement("button");
button.type = "button";
button.className = "wrn-viewer-file";
button.textContent = file.name || "File";
button.onclick = () => show(index);
button.onkeydown = (event) => {
const next =
event.key === "ArrowRight"
? Math.min(files.length - 1, selected + 1)
: event.key === "ArrowLeft"
? Math.max(0, selected - 1)
: event.key === "Home"
? 0
: event.key === "End"
? files.length - 1
: undefined;
if (next !== undefined) {
event.preventDefault();
show(next, true);
}
};
buttons.push(button);
strip.append(button);
}
if (files.length) show(0);
else status.textContent = "No files to preview.";
// Sequential thumbnail work bounds concurrent conversion and source requests.
void (async () => {
const formats = await converterFormats({ baseURI: doc.baseURI });
for (const [index, file] of files.slice(0, 500).entries()) {
if (controller.signal.aborted) return;
try {
const source =
file.key && !file.src
? await resolveKey(file, { baseURI: doc.baseURI, signal: controller.signal })
: file;
if (!source || controller.signal.aborted) continue;
let url: string | undefined;
if (kindOfExtension(extensionOf(source.name)) === "image") {
const normalized = source.src
? { ...source, src: new URL(source.src, doc.baseURI).href }
: source;
const plan = await resolve(normalized, {
baseURI: doc.baseURI,
signal: controller.signal,
});
if (
plan.kind !== "image" ||
plan.renderer.component === "DownloadCard" ||
controller.signal.aborted
)
continue;
if (source.blob) {
const type = sniff(new Uint8Array(await source.blob.slice(0, 4096).arrayBuffer())).mime;
if (controller.signal.aborted) return;
url = URL.createObjectURL(new Blob([source.blob], { type }));
urls.push(url);
} else if (normalized.src) url = safeSourceUrl(normalized.src);
} else if (formats[extensionOf(source.name)]) {
const endpoint = convertUrl(source, "thumb", doc.baseURI);
if (!endpoint) continue;
const response = await fetch(endpoint, { signal: controller.signal });
if (!/^image\/(png|jpeg|webp)$/.test(response.headers.get("content-type") ?? "")) {
await response.body?.cancel();
continue;
}
const bytes = await readBounded(response, 2 * 1024 * 1024);
if (controller.signal.aborted) return;
url = URL.createObjectURL(
new Blob([bytes as Uint8Array<ArrayBuffer>], {
type: response.headers.get("content-type")!,
}),
);
urls.push(url);
}
if (url && !controller.signal.aborted) {
const img = doc.createElement("img");
img.alt = "";
img.loading = "lazy";
img.src = url;
img.onerror = () => img.remove();
buttons[index]!.prepend(img);
}
} catch {
/* The filename remains a useful thumbnail fallback. */
}
}
})().catch(() => {});
return () => {
controller.abort();
dispose?.();
urls.forEach((url) => URL.revokeObjectURL(url));
host.replaceChildren(...original);
};
}
+20
View File
@@ -0,0 +1,20 @@
export type * from "./types.ts";
export { extensions, extensionOf, kindOfExtension, kindOfMime } from "./kinds.ts";
export { sniff } from "./sniff.ts";
export { resolve } from "./resolve.ts";
export { RendererRegistry, registerRenderer } from "./registry.ts";
export { parseDelimited, detectDelimiter } from "./parse/delimited.ts";
export type { DelimitedTable } from "./parse/delimited.ts";
export { sanitizeSvg } from "./parse/svg.ts";
export type { SvgSanitizerView } from "./parse/svg.ts";
export { parseTar, isTar, gzipName, gunzip } from "./parse/archive.ts";
export type { ArchiveEntry } from "./parse/archive.ts";
export { convertUrl, converterFormats, requireConverter } from "./convert/client.ts";
export { extractText } from "./text.ts";
export { sanitizeHtml } from "./parse/html.ts";
export { registerViewerAction, viewerActions, clearViewerActions } from "./actions.ts";
export type { ViewerAction, ViewerActionContext } from "./actions.ts";
export { canPrint, printViewer } from "./print.ts";
export { ICONS, icon, iconFor } from "./renderers/icons.ts";
export { openViewer, startViewerTriggers, sourceFromTrigger } from "./overlay.ts";
export type { OpenViewerOptions, OpenedViewer } from "./overlay.ts";
+68
View File
@@ -0,0 +1,68 @@
import type { ViewerKind } from "./types.ts";
/** Kind classification only; MIME ownership remains with uploader. */
export const extensions: Record<ViewerKind, readonly string[]> = {
image: ["jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "avif"],
vector: ["svg"],
pdf: ["pdf"],
code: [
"js",
"jsx",
"ts",
"tsx",
"py",
"java",
"cpp",
"c",
"h",
"cs",
"css",
"json",
"xml",
"sql",
"sh",
"yaml",
"yml",
],
text: ["txt", "md", "log"],
markup: ["html", "htm"],
sheet: ["csv", "tsv", "xls", "xlsx", "ods"],
doc: ["doc", "docx", "dotx", "odt", "rtf", "ppt", "pptx", "ppsx", "potx", "odp"],
audio: ["mp3", "wav", "aac", "flac", "ogg"],
video: ["mp4", "webm", "mov", "avi", "mkv"],
archive: ["zip", "rar", "7z", "tar", "gz", "tgz"],
binary: ["psd", "ai", "tif", "tiff", "exe", "dll"],
};
export function extensionOf(name: string): string {
const base = (name.split(/[?#]/)[0] ?? "").split(/[/\\]/).pop() ?? "";
const dot = base.lastIndexOf(".");
return dot < 0 ? "" : base.slice(dot).toLowerCase();
}
export function kindOfExtension(ext: string): ViewerKind {
return (
(Object.keys(extensions) as ViewerKind[]).find((kind) =>
extensions[kind].includes(ext.replace(/^\./, "").toLowerCase()),
) ?? "binary"
);
}
export function kindOfMime(mime: string): ViewerKind | undefined {
const type = mime.split(";")[0]!.trim().toLowerCase();
if (type === "image/svg+xml") return "vector";
if (type === "text/html" || type === "application/xhtml+xml") return "markup";
if (type === "application/pdf") return "pdf";
if (/^(application|text)\/(json|xml|javascript|x-javascript)$/.test(type) || type === "text/css")
return "code";
if (/^text\/(csv|tab-separated-values)$/.test(type) || /spreadsheet|ms-excel/.test(type))
return "sheet";
if (/opendocument\.spreadsheet/.test(type)) return "sheet";
if (/msword|wordprocessing|presentation|powerpoint|opendocument|rtf/.test(type)) return "doc";
if (/zip|gzip|x-tar|x-7z|rar/.test(type)) return "archive";
if (/^image\/(png|jpeg|gif|webp|bmp|x-icon|vnd.microsoft.icon|avif)$/.test(type)) return "image";
if (type.startsWith("audio/")) return "audio";
if (type.startsWith("video/")) return "video";
if (type.startsWith("text/")) return "text";
return undefined;
}
+19
View File
@@ -0,0 +1,19 @@
import { convertToHtml } from "mammoth";
/**
* Semantic HTML, not a faithful page layout: headers, footers, page breaks and
* exact positioning are lost. The output is derived from an untrusted file, so
* callers must render it inside a sandboxed frame.
*/
export async function docxToHtml(bytes: Uint8Array): Promise<string> {
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
// The browser build reads `arrayBuffer`, the Node build reads `buffer`.
// Supplying both keeps this module usable — and testable — in either.
const result = await convertToHtml({ arrayBuffer, buffer: bytes } as Parameters<
typeof convertToHtml
>[0]);
return result.value;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Optional parsers ship as separate plugin runtime assets, contributed only
* when their package resolves on the server. Importing one that was not
* contributed fails, which is how a renderer reports itself unavailable.
*/
export async function loadOptional<T>(
id: string,
baseURI?: string,
importModule: (url: string) => Promise<unknown> = (url) => import(/* @vite-ignore */ url),
): Promise<T> {
baseURI ??= typeof document === "undefined" ? undefined : document.baseURI;
if (!baseURI) throw new Error(`${id} needs a browser`);
const url = new URL(`/__wrnexus/assets/${id}.js`, baseURI).href;
const module = (await importModule(url)) as T;
return module;
}
+194
View File
@@ -0,0 +1,194 @@
import { unzip } from "fflate";
/**
* Office documents that are ZIP containers of XML: OOXML presentations
* (`.pptx`) and the whole OpenDocument family (`.odt`, `.ods`, `.odp`).
*
* Both are read here rather than through a converter so they preview with no
* binary on the host. What comes back is content and structure — headings,
* bullets, cells, slide order — not a faithful page layout. Layout fidelity is
* what the Tier 3 converter is for, and it stays preferred where it exists.
*/
export interface Slide {
title: string;
/** Text blocks in shape order, each already split into lines. */
blocks: string[][];
notes?: string;
}
export interface OfficeText {
type: "text";
/** Paragraphs with an outline level: 0 is body text, 1+ are headings. */
blocks: { level: number; text: string; list?: boolean }[];
}
export interface OfficeSheets {
type: "sheets";
sheets: { name: string; header: string[]; rows: string[][] }[];
}
export interface OfficeSlides {
type: "slides";
slides: Slide[];
}
export type OfficeDocument = OfficeText | OfficeSheets | OfficeSlides;
const MAX_SLIDES = 300;
const MAX_ROWS = 200_000;
/** A repeat count high enough to be a "rest of the row" filler, not real data. */
const REPEAT_LIMIT = 1024;
/** Reads only the entries a preview needs; media is never inflated. */
function unzipOnly(bytes: Uint8Array, wanted: RegExp): Promise<Record<string, Uint8Array>> {
return new Promise((resolve, reject) => {
unzip(bytes, { filter: (file) => wanted.test(file.name) }, (error, files) =>
error ? reject(error) : resolve(files),
);
});
}
const decoder = new TextDecoder();
function parse(xml: Uint8Array, view: { DOMParser: new () => DOMParser }): Document {
const document = new view.DOMParser().parseFromString(decoder.decode(xml), "application/xml");
if (document.getElementsByTagName("parsererror").length) throw new Error("Malformed office XML");
return document;
}
/** Local-name lookup: office XML is namespaced and prefixes are not fixed. */
function byTag(root: Element | Document, local: string): Element[] {
return [...root.getElementsByTagName("*")].filter(
(node) => node.localName === local || node.nodeName.split(":").pop() === local,
);
}
/** Slide and note files sort by their number, not their string name. */
function inOrder(names: string[]): string[] {
return names.sort((a, b) => {
const number = (value: string) => Number(/(\d+)\.xml$/.exec(value)?.[1] ?? 0);
return number(a) - number(b);
});
}
export async function readPptx(
bytes: Uint8Array,
view: { DOMParser: new () => DOMParser },
): Promise<OfficeSlides> {
const files = await unzipOnly(bytes, /^ppt\/(slides|notesSlides)\/[^/]+\.xml$/);
const slideNames = inOrder(Object.keys(files).filter((name) => name.startsWith("ppt/slides/")));
if (!slideNames.length) throw new Error("Presentation holds no slides");
const noteNames = inOrder(
Object.keys(files).filter((name) => name.startsWith("ppt/notesSlides/")),
);
const slides: Slide[] = [];
for (const name of slideNames.slice(0, MAX_SLIDES)) {
const document = parse(files[name]!, view);
// One block per shape, so a title and a bullet list stay separate.
const blocks: string[][] = [];
for (const shape of byTag(document, "sp")) {
const lines = byTag(shape, "p")
.map((paragraph) =>
byTag(paragraph, "t")
.map((run) => run.textContent ?? "")
.join("")
.trim(),
)
.filter(Boolean);
if (lines.length) blocks.push(lines);
}
const notesFile = noteNames.find(
(note) => /(\d+)\.xml$/.exec(note)?.[1] === /(\d+)\.xml$/.exec(name)?.[1],
);
const notes = notesFile
? byTag(parse(files[notesFile]!, view), "t")
.map((run) => run.textContent ?? "")
.join("")
.trim()
: "";
// The first shape carrying text is the title in every standard layout.
const title = blocks[0]?.[0] ?? "";
slides.push({ title, blocks, ...(notes ? { notes } : {}) });
}
return { type: "slides", slides };
}
function odfParagraphs(
root: Element | Document,
): { level: number; text: string; list?: boolean }[] {
const blocks: { level: number; text: string; list?: boolean }[] = [];
const walk = (node: Element, inList: boolean) => {
for (const child of [...node.children]) {
const local = child.localName ?? child.nodeName.split(":").pop() ?? "";
if (local === "h" || local === "p") {
const text = (child.textContent ?? "").trim();
const level =
local === "h" ? Number(child.getAttribute("text:outline-level") ?? 1) || 1 : 0;
if (text) blocks.push({ level, text, ...(inList && local === "p" ? { list: true } : {}) });
} else if (local === "list" || local === "list-item") walk(child, true);
else walk(child, inList);
}
};
walk(root as Element, false);
return blocks;
}
function odfSheet(table: Element): { name: string; header: string[]; rows: string[][] } {
const name = table.getAttribute("table:name") ?? "Sheet";
const grid: string[][] = [];
for (const row of byTag(table, "table-row")) {
const cells: string[] = [];
for (const cell of byTag(row, "table-cell")) {
const text = (cell.textContent ?? "").trim();
// ODF collapses runs of identical cells; a huge repeat is trailing padding.
const repeat = Number(cell.getAttribute("table:number-columns-repeated") ?? 1) || 1;
for (let i = 0; i < Math.min(repeat, REPEAT_LIMIT); i++) cells.push(text);
}
while (cells.length && cells[cells.length - 1] === "") cells.pop();
if (cells.length) grid.push(cells);
if (grid.length > MAX_ROWS) break;
}
const header = grid.shift() ?? [];
return { name, header, rows: grid };
}
export async function readOdf(
bytes: Uint8Array,
view: { DOMParser: new () => DOMParser },
): Promise<OfficeDocument> {
const files = await unzipOnly(bytes, /^(content\.xml|mimetype)$/);
const content = files["content.xml"];
if (!content) throw new Error("Not an OpenDocument file");
const mimetype = files["mimetype"] ? decoder.decode(files["mimetype"]) : "";
const document = parse(content, view);
if (mimetype.includes("spreadsheet") || byTag(document, "spreadsheet").length) {
const sheets = byTag(document, "table").map(odfSheet);
if (!sheets.length) throw new Error("Spreadsheet holds no sheets");
return { type: "sheets", sheets };
}
if (mimetype.includes("presentation") || byTag(document, "presentation").length) {
const slides: Slide[] = byTag(document, "page")
.slice(0, MAX_SLIDES)
.map((page) => {
const blocks = byTag(page, "frame")
.map((frame) =>
odfParagraphs(frame)
.map((block) => block.text)
.filter(Boolean),
)
.filter((lines) => lines.length);
return { title: blocks[0]?.[0] ?? page.getAttribute("draw:name") ?? "", blocks };
});
if (!slides.length) throw new Error("Presentation holds no slides");
return { type: "slides", slides };
}
const body = byTag(document, "text")[0] ?? document.documentElement;
const blocks = odfParagraphs(body);
if (!blocks.length) throw new Error("Document holds no readable text");
return { type: "text", blocks };
}
+126
View File
@@ -0,0 +1,126 @@
import type * as PdfJs from "pdfjs-dist";
export interface RichPdfOptions {
signal?: AbortSignal;
/** URL of the pdf.js ESM build, served verbatim by the plugin. */
libSrc: string;
/** URL of the pdf.js worker, served verbatim by the plugin. */
workerSrc: string;
/** Pages rendered before the viewer stops. */
maxPages?: number;
/** CSS pixels per PDF point at scale 1, before device pixel ratio. */
scale?: number;
/** Give up on a page that will not finish. Default 20s. */
timeoutMs?: number;
}
export interface RichPdfResult {
pages: number;
rendered: number;
destroy: () => void;
}
/**
* pdf.js drives rasterisation from `requestAnimationFrame`, which never fires
* while the document is hidden, so a page render can stay pending forever. A
* viewer must fall back rather than hang: exceeding this rejects, and the
* ladder moves on to the native frame or the download card.
*/
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
/**
* Rasterises pages into canvases appended to `parent`. Unlike the native
* viewer this gives page count, controlled sizing and consistent output across
* browsers, at the cost of downloading pdf.js.
*
* pdf.js is imported from its own published build at runtime rather than being
* bundled: putting its ESM entry through a bundler breaks the worker handshake,
* and `getDocument()` then never settles.
*/
export async function renderPdf(
parent: HTMLElement,
bytes: Uint8Array,
options: RichPdfOptions,
): Promise<RichPdfResult> {
const pdfjs = (await import(/* @vite-ignore */ options.libSrc)) as typeof PdfJs;
pdfjs.GlobalWorkerOptions.workerSrc = options.workerSrc;
const doc = parent.ownerDocument;
const timeoutMs = options.timeoutMs ?? 20_000;
const task = pdfjs.getDocument({ data: bytes, isEvalSupported: false });
let stopped = false;
let activeRender: { cancel(): void } | undefined;
const destroy = () => {
if (stopped) return;
stopped = true;
options.signal?.removeEventListener("abort", destroy);
activeRender?.cancel();
void task.destroy().catch(() => {});
};
options.signal?.addEventListener("abort", destroy, { once: true });
if (options.signal?.aborted) {
destroy();
throw new Error("Preview disposed");
}
try {
const pdf = await withTimeout(task.promise, timeoutMs, "The document could not be opened");
const limit = Math.min(pdf.numPages, options.maxPages ?? 50);
const ratio = Math.min(doc.defaultView?.devicePixelRatio ?? 1, 2);
let rendered = 0;
for (let number = 1; number <= limit; number++) {
if (stopped) throw new Error("Preview disposed");
const page = await withTimeout(pdf.getPage(number), timeoutMs, "Page could not be opened");
const viewport = page.getViewport({ scale: (options.scale ?? 1.4) * ratio });
const canvas = doc.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = `${Math.floor(viewport.width / ratio)}px`;
canvas.style.maxWidth = "100%";
canvas.className = "wrn-viewer-page";
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", `Page ${number} of ${pdf.numPages}`);
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas rendering is unavailable");
parent.append(canvas);
const render = page.render({ canvasContext: context, viewport });
activeRender = render;
try {
await withTimeout(render.promise, timeoutMs, "Page rendering did not complete");
} catch (error) {
render.cancel();
canvas.remove();
// A first page that will not draw means this renderer cannot be used.
if (rendered === 0) {
destroy();
throw error;
}
break;
}
rendered++;
}
return {
pages: pdf.numPages,
rendered,
destroy,
};
} catch (error) {
destroy();
throw error;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { read, utils } from "xlsx";
export interface SheetData {
name: string;
header: string[];
rows: string[][];
truncated: boolean;
}
const MAX_ROWS = 200_000;
/** Every sheet as strings; formulas arrive as their cached values. */
export function readWorkbook(bytes: Uint8Array): SheetData[] {
const workbook = read(bytes, { type: "array", cellFormula: false, cellHTML: false });
return workbook.SheetNames.map((name) => {
const sheet = workbook.Sheets[name];
const grid = sheet
? (utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" }) as unknown[][])
: [];
const rows = grid.map((row) => row.map((cell) => (cell == null ? "" : String(cell))));
const header = rows.shift() ?? [];
return {
name,
header,
rows: rows.slice(0, MAX_ROWS),
truncated: rows.length > MAX_ROWS,
};
});
}
+31
View File
@@ -0,0 +1,31 @@
import { unzip } from "fflate";
export interface ZipEntry {
name: string;
size: number;
directory: boolean;
}
/**
* Lists a zip without inflating it: fflate's filter runs per entry, and
* returning false skips decompression, so a 2 GB archive costs its directory.
*/
export function listZip(bytes: Uint8Array): Promise<ZipEntry[]> {
return new Promise((resolve, reject) => {
const entries: ZipEntry[] = [];
unzip(
bytes,
{
filter: (file) => {
entries.push({
name: file.name,
size: file.originalSize ?? 0,
directory: file.name.endsWith("/"),
});
return false;
},
},
(error) => (error ? reject(error) : resolve(entries)),
);
});
}
+127
View File
@@ -0,0 +1,127 @@
import { mountViewer, type MountViewerOptions } from "./runtime.ts";
import type { ViewerSource } from "./types.ts";
export interface OpenViewerOptions extends MountViewerOptions {
/** Document to open in. Defaults to the ambient one. */
document?: Document;
/** Called once the overlay has closed, however it was closed. */
onClose?: () => void;
/** Accessible name for the dialog. Defaults to the file's name. */
label?: string;
}
export interface OpenedViewer {
close: () => void;
dialog: HTMLElement;
}
/**
* Opens a file over the page, for the "click a button, preview the file" case.
*
* A native `<dialog>` does the work that is easy to get wrong by hand: it takes
* the top layer, traps focus, closes on Escape, and restores focus to whatever
* opened it. Closing also disposes the viewer, so a video stops and object URLs
* are revoked rather than leaking behind the overlay.
*/
export function openViewer(
source: ViewerSource | ViewerSource[],
options: OpenViewerOptions = {},
): OpenedViewer {
const doc = options.document ?? document;
const dialog = doc.createElement("dialog");
dialog.className = "wrn-viewer-dialog";
const first = Array.isArray(source) ? source[0] : source;
dialog.setAttribute("aria-label", options.label ?? first?.name ?? "File preview");
const host = doc.createElement("section");
host.className = "wrn-viewer";
dialog.append(host);
doc.body.append(dialog);
let closed = false;
// Assigned below; `close` may run before the viewer has finished mounting.
let dispose: (() => void) | undefined;
const close = (): void => {
if (closed) return;
closed = true;
dispose?.();
// `close()` on an open dialog fires `close`, which lands back here; the
// guard above makes that a no-op rather than a loop.
if (dialog.open) dialog.close();
dialog.remove();
options.onClose?.();
};
dialog.addEventListener("close", close);
// The dialog element fills the viewport, so a click that lands on it rather
// than on the viewer inside is a click on the backdrop.
dialog.addEventListener("click", (event) => {
if (event.target === dialog) close();
});
const stop = mountViewer(host, source, {
...options,
actions: [
{
id: "close",
label: "Close preview",
icon: "close",
placement: "actions",
run: close,
},
...(options.actions ?? []),
],
});
// If closing raced the mount, tear the viewer down rather than orphan it.
if (closed) stop();
else dispose = stop;
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
return { close, dialog };
}
/** Reads a trigger's data attributes into the source(s) it points at. */
export function sourceFromTrigger(trigger: HTMLElement): ViewerSource | ViewerSource[] | undefined {
const { src, key, store, name, mime, size, files } = trigger.dataset;
if (files) {
try {
const parsed = JSON.parse(files) as ViewerSource[];
if (Array.isArray(parsed) && parsed.length) return parsed;
} catch {
return undefined;
}
}
if (!src && !key) return undefined;
const bytes = Number(size);
return {
src,
key,
store,
name: name ?? "",
mime,
size: size && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined,
};
}
/**
* Any element carrying `data-viewer-open` opens a preview when clicked, so a
* plain button in a list of files needs no JavaScript of its own.
*/
export function startViewerTriggers(root: Document = document): () => void {
const onClick = (event: MouseEvent) => {
const target = event.target as Element | null;
const trigger = target?.closest?.<HTMLElement>("[data-viewer-open]");
if (!trigger) return;
const source = sourceFromTrigger(trigger);
if (!source) return;
event.preventDefault();
openViewer(source, {
document: root,
richPdf: trigger.dataset.richPdf === "true",
});
};
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
}
+86
View File
@@ -0,0 +1,86 @@
export interface ArchiveEntry {
name: string;
size: number;
directory: boolean;
}
const decoder = new TextDecoder();
/** Reads a NUL-padded fixed-width tar header field. */
function field(bytes: Uint8Array, offset: number, length: number): string {
const raw = bytes.subarray(offset, offset + length);
const end = raw.indexOf(0);
return decoder.decode(end < 0 ? raw : raw.subarray(0, end)).trim();
}
const octal = (value: string) => (/^[0-7]+$/.test(value) ? parseInt(value, 8) : 0);
/** USTAR/GNU tar listing. Entry data is skipped; only headers are read. */
export function parseTar(bytes: Uint8Array): ArchiveEntry[] {
const entries: ArchiveEntry[] = [];
let offset = 0;
let longName: string | undefined;
while (offset + 512 <= bytes.length) {
const header = bytes.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const size = octal(field(header, 124, 12));
const type = String.fromCharCode(header[156] ?? 0);
const blocks = Math.ceil(size / 512) * 512;
if (type === "L") {
// GNU long name: the following data block holds this entry's real name.
longName = field(bytes, offset + 512, size);
} else if (type !== "x" && type !== "g") {
const prefix = field(header, 345, 155);
const name = longName ?? [prefix, field(header, 0, 100)].filter(Boolean).join("/");
longName = undefined;
if (name) entries.push({ name, size, directory: type === "5" || name.endsWith("/") });
}
offset += 512 + blocks;
}
return entries;
}
/** True when the buffer carries the USTAR signature at its header offset. */
export function isTar(bytes: Uint8Array): boolean {
return field(bytes, 257, 5) === "ustar";
}
/** Original filename recorded in a gzip member header, when present. */
export function gzipName(bytes: Uint8Array): string | undefined {
const flags = bytes[3] ?? 0;
if (bytes[0] !== 0x1f || bytes[1] !== 0x8b || !(flags & 0x08)) return undefined;
let offset = 10;
if (flags & 0x04) offset += 2 + ((bytes[10] ?? 0) | ((bytes[11] ?? 0) << 8));
const end = bytes.indexOf(0, offset);
return end > offset ? decoder.decode(bytes.subarray(offset, end)) : undefined;
}
/** Gunzip through the platform stream; undefined when unsupported or invalid. */
export async function gunzip(file: Blob, cap: number): Promise<Uint8Array | undefined> {
if (typeof DecompressionStream === "undefined") return undefined;
try {
const reader = file.stream().pipeThrough(new DecompressionStream("gzip")).getReader();
const parts: Uint8Array[] = [];
let length = 0;
try {
while (length < cap) {
const next = await reader.read();
if (next.done) break;
const part = next.value.subarray(0, cap - length);
parts.push(part);
length += part.length;
}
} finally {
await reader.cancel().catch(() => {});
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
} catch {
return undefined;
}
}
+81
View File
@@ -0,0 +1,81 @@
export interface DelimitedTable {
header: string[];
rows: string[][];
delimiter: string;
/** True when the file held more rows than `maxRows`. */
truncated: boolean;
}
const CANDIDATES = [",", "\t", ";", "|"];
/** Delimiter used by the most fields on the first unquoted line. */
export function detectDelimiter(text: string): string {
const line = text.slice(0, 64 * 1024).split(/\r?\n/, 1)[0] ?? "";
let best = ",";
let bestCount = 0;
for (const candidate of CANDIDATES) {
let count = 0;
let quoted = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') quoted = !quoted;
else if (!quoted && char === candidate) count++;
}
if (count > bestCount) {
best = candidate;
bestCount = count;
}
}
return best;
}
/**
* RFC 4180 parsing: quoted fields may hold the delimiter, CR/LF, and `""`
* escapes. Rows are capped so a large file cannot exhaust memory in the tab.
*/
export function parseDelimited(
text: string,
options: { delimiter?: string; maxRows?: number } = {},
): DelimitedTable {
const delimiter = options.delimiter ?? detectDelimiter(text);
const maxRows = options.maxRows ?? 200_000;
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
let truncated = false;
const endField = () => {
row.push(field);
field = "";
};
const endRow = () => {
endField();
// A trailing newline must not produce a phantom row.
if (row.length > 1 || row[0] !== "") rows.push(row);
row = [];
if (rows.length >= maxRows) truncated = true;
};
for (let i = 0; i < text.length && !truncated; i++) {
const char = text[i]!;
if (quoted) {
if (char !== '"') field += char;
else if (text[i + 1] === '"') {
field += '"';
i++;
} else quoted = false;
continue;
}
if (char === '"' && field === "") quoted = true;
else if (char === delimiter) endField();
else if (char === "\n") endRow();
else if (char === "\r" && text[i + 1] === "\n") continue;
else if (char === "\r") endRow();
else field += char;
}
if (!truncated && (field !== "" || row.length)) endRow();
const header = rows.shift() ?? [];
return { header, rows, delimiter, truncated };
}
+48
View File
@@ -0,0 +1,48 @@
const TAGS = new Set(
"a abbr article aside b blockquote br caption code col colgroup dd del details div dl dt em figcaption figure footer h1 h2 h3 h4 h5 h6 header hr i img ins kbd li main mark nav ol p pre s section small span strong sub summary sup table tbody td tfoot th thead time tr u ul".split(
" ",
),
);
const DROP = new Set(
"script style iframe object embed link meta base form input button textarea select template svg math".split(
" ",
),
);
const escape = (text: string) =>
text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
/** Rebuilds an inert allowlisted body; never inserts file HTML into the host DOM. */
export function sanitizeHtml(html: string, view: { DOMParser: new () => DOMParser }): string {
const doc = new view.DOMParser().parseFromString(html, "text/html");
function serialize(node: Node): string {
if (node.nodeType === 3) return escape(node.textContent ?? "");
if (node.nodeType !== 1) return "";
const element = node as Element;
const tag = element.localName.toLowerCase();
if (DROP.has(tag)) return "";
const children = [...element.childNodes].map(serialize).join("");
if (!TAGS.has(tag)) return children;
const attrs: string[] = [];
for (const attr of [...element.attributes]) {
const name = attr.name.toLowerCase();
const value = attr.value;
if (
["title", "alt", "colspan", "rowspan", "width", "height", "dir", "lang", "id"].includes(
name,
) ||
(name === "href" && /^#[\w.-]+$/.test(value)) ||
(name === "src" &&
tag === "img" &&
/^data:image\/(png|jpeg|gif|webp);base64,[a-z\d+/=\s]+$/i.test(value)) ||
(name === "style" && !/url|expression|@|\\|behavior|binding/i.test(value))
)
attrs.push(`${name}="${escape(value)}"`);
}
return `<${tag}${attrs.length ? " " + attrs.join(" ") : ""}>${children}</${tag}>`;
}
return [...doc.body.childNodes].map(serialize).join("");
}
+162
View File
@@ -0,0 +1,162 @@
/**
* RTF to plain text. In-package and dependency-free, because RTF is a text
* format: the whole file is control words and braces, and everything a preview
* needs is the character data between them.
*
* This recovers text, paragraphs and tabs. Formatting, tables, images and
* embedded objects are deliberately dropped — a faithful RTF layout is what the
* converter is for.
*/
/** Groups whose entire contents are metadata, never document text. */
const SKIPPED = new Set([
"fonttbl",
"colortbl",
"stylesheet",
"listtable",
"listoverridetable",
"revtbl",
"rsidtbl",
"generator",
"info",
"pict",
"object",
"themedata",
"colorschememapping",
"latentstyles",
"datastore",
"xmlnstbl",
"filetbl",
"header",
"footer",
"headerl",
"headerr",
"footerl",
"footerr",
"footnote",
"annotation",
]);
const BREAKS: Record<string, string> = {
par: "\n",
line: "\n",
sect: "\n\n",
page: "\n\n",
tab: "\t",
cell: "\t",
row: "\n",
lquote: "‘",
rquote: "’",
ldblquote: "“",
rdblquote: "”",
emdash: "—",
endash: "–",
bullet: "•",
nbsp: " ",
"~": " ",
"-": "",
_: "-",
};
export function isRtf(source: string): boolean {
return source.trimStart().startsWith("{\\rtf");
}
export function rtfToText(source: string): string {
if (!isRtf(source)) throw new Error("Not an RTF document");
let out = "";
let depth = 0;
// Depth at which a metadata group started; everything deeper is discarded.
let skipFrom = -1;
// How many ANSI substitute characters trail each \uN, set by \ucN. It is
// group-scoped, so the stack restores the outer value when a group closes.
// The RTF default is 1, and \uc0 means a \uN stands alone.
let substitutes = 1;
const scopes: number[] = [];
let skipChars = 0;
for (let i = 0; i < source.length; i++) {
const char = source[i]!;
if (char === "{") {
scopes.push(substitutes);
depth++;
continue;
}
if (char === "}") {
if (skipFrom >= 0 && depth <= skipFrom) skipFrom = -1;
substitutes = scopes.pop() ?? 1;
depth--;
continue;
}
if (char === "\\") {
const next = source[i + 1] ?? "";
// \* marks an optional destination: skip the whole group.
if (next === "*") {
skipFrom = depth;
i++;
continue;
}
if (next === "'") {
const hex = source.slice(i + 2, i + 4);
i += 3;
if (skipFrom < 0 && /^[0-9a-f]{2}$/i.test(hex)) {
if (skipChars > 0) skipChars--;
else out += String.fromCharCode(parseInt(hex, 16));
}
continue;
}
if (!/[a-z]/i.test(next)) {
// A control symbol: one escaped literal character.
i++;
if (skipFrom < 0) out += BREAKS[next] ?? next;
continue;
}
const word = /^[a-z]+/i.exec(source.slice(i + 1))![0];
let cursor = i + 1 + word.length;
let parameter = "";
if (source[cursor] === "-") {
parameter = "-";
cursor++;
}
while (/[0-9]/.test(source[cursor] ?? "")) parameter += source[cursor++]!;
// A single trailing space is part of the control word, not the text.
if (source[cursor] === " ") cursor++;
i = cursor - 1;
if (SKIPPED.has(word.toLowerCase())) {
skipFrom = depth;
continue;
}
if (skipFrom >= 0) continue;
if (word === "u") {
const code = Number(parameter);
if (Number.isFinite(code)) out += String.fromCodePoint(code < 0 ? code + 65536 : code);
skipChars = substitutes;
continue;
}
if (word === "uc") {
const count = Number(parameter);
substitutes = Number.isFinite(count) && count >= 0 ? count : 1;
continue;
}
out += BREAKS[word] ?? "";
continue;
}
if (skipFrom >= 0) continue;
if (char === "\r" || char === "\n") continue;
if (skipChars > 0) {
skipChars--;
continue;
}
out += char;
}
return out
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
+107
View File
@@ -0,0 +1,107 @@
/**
* SVG sanitiser. This is the inner half of the defence: previews also render
* inside a sandboxed, CSP-restricted frame. Neither layer is trusted alone.
*/
const ELEMENTS = new Set([
"svg",
"g",
"defs",
"symbol",
"use",
"title",
"desc",
"switch",
"path",
"rect",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"text",
"tspan",
"textpath",
"marker",
"clippath",
"mask",
"pattern",
"lineargradient",
"radialgradient",
"stop",
"filter",
"style",
"fegaussianblur",
"feoffset",
"feblend",
"fecolormatrix",
"fecomposite",
"feflood",
"femerge",
"femergenode",
"femorphology",
"fedropshadow",
"feturbulence",
"fedisplacementmap",
"fespecularlighting",
"fediffuselighting",
"fepointlight",
"fedistantlight",
"fespotlight",
"fetile",
]);
export interface SvgSanitizerView {
DOMParser: new () => DOMParser;
XMLSerializer: new () => XMLSerializer;
}
/** Only same-document fragment references survive. */
function safeReference(value: string): boolean {
return value.trim().startsWith("#");
}
function safeStyle(value: string): boolean {
const text = value.toLowerCase();
if (text.includes("@import") || text.includes("javascript:") || text.includes("expression("))
return false;
// url() may only point at a fragment inside this same document.
return [...text.matchAll(/url\(\s*['"]?([^'")]*)/g)].every((match) =>
safeReference(match[1] ?? ""),
);
}
function scrub(element: Element): void {
for (const child of [...element.children]) {
if (ELEMENTS.has(child.tagName.toLowerCase())) scrub(child);
else child.remove();
}
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
const value = attribute.value;
const unsafe =
name.startsWith("on") ||
(/(^|:)href$/.test(name) && !safeReference(value)) ||
(name === "style" && !safeStyle(value)) ||
/javascript:/i.test(value);
if (unsafe) element.removeAttribute(attribute.name);
}
if (element.tagName.toLowerCase() === "style" && !safeStyle(element.textContent ?? ""))
element.textContent = "";
}
/** Returns sanitised SVG markup, or `undefined` when the source is not an SVG. */
export function sanitizeSvg(source: string, view: SvgSanitizerView): string | undefined {
const parsed = new view.DOMParser().parseFromString(source, "image/svg+xml");
const root = parsed.documentElement as Element | null;
if (!root || root.tagName.toLowerCase() !== "svg") return undefined;
if (parsed.getElementsByTagName("parsererror").length) return undefined;
scrub(root);
// A viewBox is what lets the frame's CSS size the graphic; without one the
// intrinsic size stands, and forcing 100% would stretch a small icon to fill.
if (root.getAttribute("viewBox")) {
root.removeAttribute("width");
root.removeAttribute("height");
root.setAttribute("width", "100%");
}
return new view.XMLSerializer().serializeToString(root);
}
+106
View File
@@ -0,0 +1,106 @@
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
import { configureConversion } from "./convert/config.ts";
import type { ConversionOptions } from "./convert/types.ts";
import { configureUploads } from "./uploads/config.ts";
import type { ViewerUploadsOptions } from "./uploads/types.ts";
export interface ViewerPluginOptions {
/**
* Server-side conversion for formats no browser can render. Omitted, the
* plugin mounts no routes and never runs an external process; the affected
* formats keep their download card.
*/
convert?: ConversionOptions;
/**
* Lets `<Viewer key="…">` resolve `@wrnexus/uploader` storage keys. Omitted,
* no routes are mounted and a `key` on its own produces the download card.
*/
uploads?: ViewerUploadsOptions;
}
const here = (path: string) =>
fileURLToPath(
new URL(import.meta.url.endsWith(".js") ? path.replace(/\.ts$/, ".js") : path, import.meta.url),
);
const required = createRequire(import.meta.url);
function resolves(specifier: string): string | undefined {
try {
return required.resolve(specifier);
} catch {
return undefined;
}
}
/**
* Tier 2 parsers with an optional peer dependency. Each is served as its own
* runtime asset and contributed only when its package resolves here, so an app
* that never installs `xlsx` neither builds nor ships it — and the matching
* renderer reports itself unavailable because the asset is not there.
*/
const OPTIONAL = [
{ id: "viewer-sheet", package: "xlsx", entry: "./optional/sheet.ts" },
{ id: "viewer-docx", package: "mammoth", entry: "./optional/docx.ts" },
{ id: "viewer-zip", package: "fflate", entry: "./optional/zip.ts" },
{ id: "viewer-office", package: "fflate", entry: "./optional/office.ts" },
{ id: "viewer-pdfjs", package: "pdfjs-dist", entry: "./optional/pdf.ts" },
];
function optionalRuntimes(): { id: string; entry: string; bundle?: boolean }[] {
const runtimes: { id: string; entry: string; bundle?: boolean }[] = [];
for (const item of OPTIONAL) {
if (!resolves(item.package)) continue;
runtimes.push({ id: item.id, entry: here(item.entry) });
}
// pdf.js is served from its own published build, not bundled: putting its ESM
// entry through a bundler breaks the worker handshake. Both files are already
// browser-ready ESM, and `.mjs` is served verbatim.
const library = resolves("pdfjs-dist/build/pdf.mjs");
const worker = resolves("pdfjs-dist/build/pdf.worker.mjs");
if (library && worker && runtimes.some((runtime) => runtime.id === "viewer-pdfjs")) {
runtimes.push({ id: "viewer-pdfjs-lib", entry: library, bundle: false });
runtimes.push({ id: "viewer-pdfjs-worker", entry: worker, bundle: false });
}
return runtimes;
}
const route = (path: string, entry: string) => ({
kind: "api" as const,
path: `/api/viewer/${path}`,
entry: here(`./routes/${entry}.ts`),
});
function routes(options: ViewerPluginOptions) {
return [
...(options.convert
? [
route("capabilities", "capabilities"),
route("preview", "preview"),
route("thumb", "thumb"),
route("text", "text"),
]
: []),
...(options.uploads ? [route("source", "source"), route("file", "file")] : []),
];
}
export function viewerPlugin(options: ViewerPluginOptions = {}) {
configureConversion(options.convert);
configureUploads(options.uploads);
return definePlugin({
name: "@wrnexus/viewer",
componentDirs: [here("../components")],
clientRuntimes: [
{ id: "viewer-code", entry: here("./code-editor.ts") },
{ id: "viewer", entry: here("./runtime.ts") },
...optionalRuntimes(),
],
styleSources: [{ id: "viewer", entry: here("../viewer.css") }],
// Each feature mounts only its own routes, so enabling one does not publish
// endpoints for the other that could only answer "not enabled".
...(routes(options).length ? { routeEntries: routes(options) } : {}),
});
}
export default viewerPlugin;
+92
View File
@@ -0,0 +1,92 @@
import type { ViewerActionContext } from "./actions.ts";
const ROOT = "data-viewer-print-root";
/**
* Printing what is on screen, not the whole page.
*
* The preview is cloned into a print-only container and every sibling is taken
* out of the printed flow. Hiding the page with `visibility` instead would keep
* its space and print blank pages around the preview.
*
* Three things do not survive a plain clone and are repaired here: a canvas
* loses its bitmap, a collapsed `<details>` hides its content, and the table
* view's spacer rows stand in for thousands of rows that are not in the DOM.
*/
export function canPrint(context: ViewerActionContext): boolean {
// Nothing rendered means nothing to print; the download card is not a preview.
return (
context.content.getAttribute("data-viewer-state") === "ready" &&
context.content.childElementCount > 0
);
}
function printableClone(content: HTMLElement): HTMLElement {
const doc = content.ownerDocument;
const clone = content.cloneNode(true) as HTMLElement;
const originals = content.querySelectorAll("canvas");
const copies = clone.querySelectorAll("canvas");
copies.forEach((canvas, index) => {
const original = originals[index];
if (!original) return;
try {
const image = doc.createElement("img");
image.src = original.toDataURL();
image.style.maxWidth = "100%";
canvas.replaceWith(image);
} catch {
canvas.remove();
}
});
for (const details of clone.querySelectorAll("details")) details.open = true;
for (const spacer of clone.querySelectorAll("[data-viewer-spacer]")) spacer.remove();
return clone;
}
export function printViewer(context: ViewerActionContext): void {
const doc = context.host.ownerDocument;
const view = doc.defaultView;
if (!view) return;
const frame = context.content.querySelector("iframe");
if (frame) {
try {
// Same-origin only: a sandboxed frame has an opaque origin and throws.
const inner = frame.contentWindow;
if (inner) {
inner.focus();
inner.print();
return;
}
} catch {
/* Fall through to printing a clone of the preview. */
}
}
const root = doc.createElement("div");
root.setAttribute(ROOT, "");
const heading = doc.createElement("p");
heading.className = "wrn-viewer-print-title";
heading.textContent = context.source.name || "File preview";
root.append(heading, printableClone(context.content));
doc.body.append(root);
doc.documentElement.setAttribute("data-viewer-printing-active", "");
let done = false;
const cleanup = () => {
if (done) return;
done = true;
root.remove();
doc.documentElement.removeAttribute("data-viewer-printing-active");
view.removeEventListener("afterprint", cleanup);
};
view.addEventListener("afterprint", cleanup);
try {
view.print();
} finally {
// Browsers that return before `afterprint` fires still need cleaning up.
view.setTimeout(cleanup, 1000);
}
}
+259
View File
@@ -0,0 +1,259 @@
import { requireConverter } from "./convert/client.ts";
import { mountArchive } from "./renderers/archive.ts";
import { mountConverted } from "./renderers/converted.ts";
import { mountDocx } from "./renderers/docx.ts";
import { mountOpenDocument, mountPresentation } from "./renderers/office.ts";
import { mountRtf } from "./renderers/rtf.ts";
import { mountCode } from "./renderers/code.ts";
import { mountHtml } from "./renderers/html.ts";
import { mountImage } from "./renderers/image.ts";
import { mountMedia } from "./renderers/media.ts";
import { mountJson } from "./renderers/json.ts";
import { mountPdf } from "./renderers/pdf.ts";
import { mountSvg } from "./renderers/svg.ts";
import { mountSheet } from "./renderers/sheet.ts";
import { mountTable } from "./renderers/table.ts";
import { mountXml } from "./renderers/xml.ts";
import { mountZip } from "./renderers/zip.ts";
import { loadOptional } from "./optional/load.ts";
import type { Renderer, ViewerKind } from "./types.ts";
const MB = 1024 * 1024;
/** Terminal rung of the ladder: metadata and a download link, never a preview. */
export const downloadRenderer: Renderer = {
kind: "binary",
exts: [],
tier: 1,
component: "DownloadCard",
maxInlineBytes: 0,
};
const builtins: Renderer[] = [
{
kind: "markup",
exts: [".html", ".htm"],
tier: 2,
component: "HtmlViewer",
maxInlineBytes: 2 * MB,
mount: mountHtml,
},
{
kind: "image",
exts: [],
tier: 1,
component: "ImageViewer",
maxInlineBytes: 32 * MB,
streaming: true,
mount: mountImage,
},
{
kind: "pdf",
exts: [],
tier: 1,
component: "PdfViewer",
maxInlineBytes: 32 * MB,
streaming: true,
mount: mountPdf,
},
...(["audio", "video"] as const).map((kind) => ({
kind,
exts: [],
tier: 1 as const,
component: "MediaViewer",
maxInlineBytes: 64 * MB,
streaming: true,
mount: mountMedia,
})),
// Tier 2: in-package parsers, no runtime dependency. Extension-specific, so a
// parse failure falls through to the source view below.
{
kind: "sheet" as const,
exts: [".csv", ".tsv"],
tier: 2 as const,
component: "TableViewer",
maxInlineBytes: 8 * MB,
mount: mountTable,
},
{
kind: "code" as const,
exts: [".json"],
tier: 2 as const,
component: "JsonViewer",
maxInlineBytes: 8 * MB,
mount: mountJson,
},
{
kind: "code" as const,
exts: [".xml"],
tier: 2 as const,
component: "XmlViewer",
maxInlineBytes: 8 * MB,
mount: mountXml,
},
{
kind: "vector" as const,
exts: [],
tier: 2 as const,
component: "SvgViewer",
maxInlineBytes: 2 * MB,
mount: mountSvg,
},
{
kind: "archive" as const,
exts: [".tar", ".gz", ".tgz"],
tier: 2 as const,
component: "ArchiveViewer",
maxInlineBytes: 64 * MB,
mount: mountArchive,
},
...(["text", "code", "markup"] as const).map((kind) => ({
kind,
exts: [],
tier: 1 as const,
component: "CodeViewer",
maxInlineBytes: 2 * MB,
mount: mountCode,
})),
];
/**
* Tier 2 renderers backed by an optional dependency. `load` imports the runtime
* asset the plugin only publishes when the package is installed, so a missing
* dependency is reported the same way a missing converter is: skip and move on.
*/
builtins.push(
{
kind: "sheet",
exts: [".xls", ".xlsx", ".ods"],
tier: 2,
component: "SheetViewer",
maxInlineBytes: 32 * MB,
load: (options) => loadOptional("viewer-sheet", options?.baseURI),
mount: mountSheet,
},
{
kind: "archive",
exts: [".zip"],
tier: 2,
component: "ZipViewer",
maxInlineBytes: 64 * MB,
load: (options) => loadOptional("viewer-zip", options?.baseURI),
mount: mountZip,
},
);
/**
* Tier 3: the browser cannot render these at all, so they are handed to a
* server-side converter. Each carries a `load` that asks the server once
* whether that format is actually convertible here; when conversion is not
* enabled the load fails and the ladder moves on to the download card.
*/
const CONVERTIBLE: [ViewerKind, string[]][] = [
["doc", [".doc", ".docx", ".dotx", ".odt", ".rtf", ".ppt", ".pptx", ".ppsx", ".potx", ".odp"]],
["sheet", [".xls", ".xlsx", ".ods"]],
["binary", [".psd", ".ai", ".tif", ".tiff"]],
["video", [".avi", ".mkv"]],
["archive", [".zip", ".rar", ".7z"]],
];
for (const [kind, exts] of CONVERTIBLE)
for (const ext of exts)
builtins.push({
kind,
exts: [ext],
tier: 3,
component: "ConvertedViewer",
// The preview comes from the conversion endpoint, so the original bytes
// are never needed here: streaming keeps the runtime from downloading a
// whole design file or video into memory only to discard it.
streaming: true,
maxInlineBytes: Number.POSITIVE_INFINITY,
load: (options) => requireConverter([ext], options),
mount: mountConverted,
});
/**
* In-package office readers. Each recovers content and structure, not a page
* layout, so all of them sit *behind* the converter's PDF and render only when
* no converter is reachable — the same trade the DOCX path already makes.
*
* `.ods` is the exception in spirit rather than order: a spreadsheet is data,
* and the table view is the better reading of it, so it is registered ahead of
* the converter alongside `.xlsx`.
*/
builtins.push(
{
kind: "doc",
exts: [".docx"],
tier: 2,
component: "DocxViewer",
maxInlineBytes: 32 * MB,
load: (options) => loadOptional("viewer-docx", options?.baseURI),
mount: mountDocx,
},
{
kind: "doc",
exts: [".pptx", ".ppsx", ".potx"],
tier: 2,
component: "SlidesViewer",
maxInlineBytes: 64 * MB,
load: (options) => loadOptional("viewer-office", options?.baseURI),
mount: mountPresentation,
},
{
kind: "doc",
// `.ods` is here as well as on the spreadsheet reader: SheetJS handles most
// OpenDocument workbooks, and this is the ladder's next rung when it cannot.
exts: [".odt", ".odp", ".ods"],
tier: 2,
component: "OpenDocumentViewer",
maxInlineBytes: 64 * MB,
load: (options) => loadOptional("viewer-office", options?.baseURI),
mount: mountOpenDocument,
},
{
// No dependency and no converter: RTF is text once the control words go.
kind: "doc",
exts: [".rtf"],
tier: 2,
component: "RtfViewer",
maxInlineBytes: 16 * MB,
mount: mountRtf,
},
);
export class RendererRegistry {
private renderers: Renderer[] = [...builtins];
fork(): RendererRegistry {
const registry = new RendererRegistry();
registry.renderers = [...this.renderers];
return registry;
}
register(renderer: Renderer): () => void {
const entry = {
...renderer,
exts: renderer.exts.map((ext) => "." + ext.replace(/^\./, "").toLowerCase()),
};
this.renderers.unshift(entry);
return () => {
this.renderers = this.renderers.filter((item) => item !== entry);
};
}
/** Extension-specific renderers outrank kind-wide ones. */
candidates(kind: ViewerKind, ext: string): Renderer[] {
return [
...this.renderers.filter((item) => item.exts.includes(ext)),
...this.renderers.filter((item) => item.kind === kind && item.exts.length === 0),
];
}
}
export const defaultRegistry = new RendererRegistry();
export function registerRenderer(renderer: Renderer): () => void {
return defaultRegistry.register(renderer);
}
+38
View File
@@ -0,0 +1,38 @@
import { gunzip, gzipName, isTar, parseTar, type ArchiveEntry } from "../parse/archive.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { entriesSummary, formatSize, renderEntries } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
const DECOMPRESS_CAP = 64 * 1024 * 1024;
export const mountArchive: ViewerMount = async ({ parent, file, source, signal }) => {
const element = elementFactory(parent.ownerDocument);
const input = requireFile(file);
let bytes: Uint8Array<ArrayBufferLike> = new Uint8Array(await input.arrayBuffer());
if (signal.aborted) return;
let note = "";
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
const inner = gzipName(bytes);
const expanded = await gunzip(input, DECOMPRESS_CAP);
if (signal.aborted) return;
if (!expanded) throw new Error("Compressed listing unavailable");
note = `gzip, ${formatSize(bytes.length)} compressed to ${formatSize(expanded.length)}`;
bytes = expanded;
if (!isTar(bytes)) {
// A plain gzipped file, not an archive: report what it holds.
parent.append(element("p", note));
parent.append(element("p", inner ?? "Single compressed file"));
return;
}
}
if (!isTar(bytes)) throw new Error("Unsupported archive format");
const entries: ArchiveEntry[] = parseTar(bytes);
if (!entries.length) throw new Error("Archive holds no readable entries");
parent.append(
element("p", entriesSummary(entries, note)),
renderEntries(parent.ownerDocument, entries, `Contents of ${source.name}`),
);
};
@@ -0,0 +1,188 @@
import { setButtonLabel } from "./dom.ts";
import { readSource } from "../source.ts";
import type { ViewerSource } from "../types.ts";
import { waveformPeaks } from "./waveform.ts";
const clock = (seconds: number) => {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const whole = Math.floor(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
};
/**
* One transport for audio instead of two.
*
* The native controls and a separate seek slider were both on screen, each able to
* move the same playhead. This is a single row — play, elapsed, a seekable
* track, duration, mute — with the waveform drawn *into* the track so the
* envelope and the progress are the same control rather than two stacked bars.
*/
export function mountAudioPlayer(
parent: HTMLElement,
audio: HTMLMediaElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const make = <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string) => {
const node = doc.createElement(tag);
if (className) node.className = className;
return node;
};
const shell = make("div", "wrn-viewer-player");
const transport = make("div", "wrn-viewer-player-transport");
const play = make("button", "wrn-viewer-action wrn-viewer-play");
play.type = "button";
setButtonLabel(play, "Play", "play");
const elapsed = make("span", "wrn-viewer-time");
elapsed.textContent = "0:00";
const duration = make("span", "wrn-viewer-time");
duration.textContent = "0:00";
const mute = make("button", "wrn-viewer-action");
mute.type = "button";
setButtonLabel(mute, "Mute", "volume");
const track = make("div", "wrn-viewer-track");
const canvas = make("canvas", "wrn-viewer-waveform");
canvas.setAttribute("aria-hidden", "true");
const progress = make("div", "wrn-viewer-progress");
const seek = make("input", "wrn-viewer-seek");
seek.type = "range";
seek.min = "0";
seek.max = "1000";
seek.value = "0";
seek.setAttribute("aria-label", `Seek ${source.name || "audio"}`);
track.append(canvas, progress, seek);
transport.append(play, elapsed, track, duration, mute);
const note = make("p", "wrn-viewer-note");
note.setAttribute("role", "status");
shell.append(transport, note);
parent.append(shell);
const BINS = 200;
let envelope: number[] = [];
/**
* Drawn at device resolution and redrawn on resize: a fixed bitmap stretched
* to the track is what made the bars look soft and blobby.
*/
const drawPeaks = (peaks = envelope) => {
envelope = peaks;
const box = canvas.getBoundingClientRect();
const ratio = Math.min(view?.devicePixelRatio ?? 1, 2);
const width = Math.max(1, Math.round(box.width * ratio));
const height = Math.max(1, Math.round(box.height * ratio));
if (!peaks.length || width < 2) return;
canvas.width = width;
canvas.height = height;
const draw = canvas.getContext("2d");
if (!draw) return;
const styles = view!.getComputedStyle(shell);
draw.fillStyle = styles.getPropertyValue("--wrn-color-primary").trim() || styles.color;
// Normalised against the loudest bin, so a quiet recording still reads as a
// waveform rather than a flat line down the middle.
const loudest = Math.max(...peaks, 0.0001);
const step = width / peaks.length;
const bar = Math.max(1 * ratio, Math.min(3 * ratio, step * 0.55));
const radius = bar / 2;
peaks.forEach((peak, i) => {
const size = Math.max(bar, (peak / loudest) * height * 0.9);
const x = i * step + (step - bar) / 2;
const y = (height - size) / 2;
draw.beginPath();
// Rounded caps read as a waveform; square blocks read as a bar chart.
if (draw.roundRect) draw.roundRect(x, y, bar, size, radius);
else draw.rect(x, y, bar, size);
draw.fill();
});
};
const setProgress = () => {
const ratio =
Number.isFinite(audio.duration) && audio.duration > 0
? audio.currentTime / audio.duration
: 0;
progress.style.width = `${ratio * 100}%`;
seek.value = String(Math.round(ratio * 1000));
elapsed.textContent = clock(audio.currentTime);
};
const setDuration = () => {
duration.textContent = clock(audio.duration);
};
const setPlayState = () => {
setButtonLabel(play, audio.paused ? "Play" : "Pause", audio.paused ? "play" : "pause");
};
play.addEventListener("click", () => {
if (audio.paused) void audio.play().catch(() => {});
else audio.pause();
});
mute.addEventListener("click", () => {
audio.muted = !audio.muted;
setButtonLabel(mute, audio.muted ? "Unmute" : "Mute", audio.muted ? "muted" : "volume");
});
seek.addEventListener("input", () => {
if (!Number.isFinite(audio.duration)) return;
audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
setProgress();
});
audio.addEventListener("timeupdate", setProgress);
audio.addEventListener("loadedmetadata", setDuration);
audio.addEventListener("durationchange", setDuration);
audio.addEventListener("play", setPlayState);
audio.addEventListener("pause", setPlayState);
audio.addEventListener("ended", setPlayState);
setDuration();
setProgress();
setPlayState();
// The envelope is drawn once metadata is in and decoding stays bounded; a
// file too long or too large simply keeps a flat track.
let disposed = false;
let context: AudioContext | undefined;
void (async () => {
try {
if (!view?.AudioContext) return;
await new Promise<void>((done) => {
if (Number.isFinite(audio.duration)) done();
else audio.addEventListener("loadedmetadata", () => done(), { once: true });
});
if (disposed || !Number.isFinite(audio.duration) || audio.duration > 600) return;
const file = await readSource(source, 8 * 1024 * 1024, { signal });
if (disposed) return;
context = new view.AudioContext();
const decoded = await context.decodeAudioData(await file.arrayBuffer());
if (disposed) return;
const peaks = waveformPeaks(
Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)),
BINS,
);
drawPeaks(peaks);
canvas.classList.add("is-ready");
} catch {
/* A flat track is a fine outcome; playback is unaffected. */
} finally {
await context?.close().catch(() => {});
context = undefined;
}
})();
const observer =
view && "ResizeObserver" in view ? new view.ResizeObserver(() => drawPeaks()) : undefined;
observer?.observe(track);
return () => {
disposed = true;
observer?.disconnect();
audio.removeEventListener("timeupdate", setProgress);
audio.removeEventListener("loadedmetadata", setDuration);
audio.removeEventListener("durationchange", setDuration);
audio.removeEventListener("play", setPlayState);
audio.removeEventListener("pause", setPlayState);
audio.removeEventListener("ended", setPlayState);
void context?.close().catch(() => {});
shell.remove();
};
}
+48
View File
@@ -0,0 +1,48 @@
import { elementFactory, requireFile } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/** Served separately by `viewerPlugin`; fetched only when a text preview opens. */
const editorAsset = "/__wrnexus/assets/viewer-code.js";
export const mountCode: ViewerMount = async ({ parent, file, source, button, signal }) => {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
// Plain text first: never a parse, never markup, and correct with no editor.
const plain = element("pre", text);
parent.append(plain);
try {
const { mountBuilderSourceEditor } = (await import(
new URL(editorAsset, doc.baseURI).href
)) as typeof import("@wrnexus/block-editor/source-editor");
if (signal.aborted) return;
const host = element("div");
host.className = "wrn-viewer-code";
parent.replaceChildren(host);
const editor = await mountBuilderSourceEditor(
host,
/\.(xml|html|htm|svg)$/i.test(source.name) ? "xml" : "text",
text,
{ label: source.name || "File source", theme: "tokens" },
);
if (signal.aborted) {
editor?.destroy();
return;
}
if (!editor) {
parent.replaceChildren(plain);
return;
}
let wrapped = false;
const wrap = button("Wrap lines", () => {
wrapped = !wrapped;
editor.wrap(wrapped);
wrap.setAttribute("aria-pressed", String(wrapped));
});
wrap.setAttribute("aria-pressed", "false");
return () => editor.destroy();
} catch {
if (!signal.aborted) parent.replaceChildren(plain);
}
};
@@ -0,0 +1,56 @@
import { convertUrl, requireConverter } from "../convert/client.ts";
import { extensionOf } from "../kinds.ts";
import { readBounded } from "../source.ts";
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
const MAX_TEXT_BYTES = 2 * 1024 * 1024;
/**
* Renders what the server produced rather than the original bytes. The output
* is same-origin, so the element points straight at the conversion endpoint and
* the browser streams it under the app's own policy.
*/
export const mountConverted: ViewerMount = async ({ parent, source, signal, degrade }) => {
const element = elementFactory(parent.ownerDocument);
const url = convertUrl(source, "preview", parent.ownerDocument.baseURI);
const mime = await requireConverter([extensionOf(source.name)], {
baseURI: parent.ownerDocument.baseURI,
});
if (!url) throw new Error("Conversion needs a URL source");
if (signal.aborted) return;
const note = (text: string) => {
const line = element("p", text);
line.className = "wrn-viewer-note";
return line;
};
if (mime.startsWith("text/")) {
// Listings are small and are shown inline. The cap is applied to the stream,
// not to the decoded string: slicing afterwards has already read it all.
const response = await fetch(url, { signal });
if (!response.ok) throw new Error("Conversion failed");
const text = new TextDecoder().decode(await readBounded(response, MAX_TEXT_BYTES, true));
if (signal.aborted) return;
parent.append(note(`Listed by the server from ${source.name}.`), element("pre", text));
return;
}
if (mime.startsWith("image/")) {
const image = element("img");
image.alt = `Converted preview of ${source.name}`;
image.src = url;
image.addEventListener("error", () => degrade("This file could not be converted"), {
once: true,
});
parent.append(image, note(`Rendered by the server from ${source.name}.`));
return;
}
const frame = element("iframe");
frame.src = url;
frame.className = "wrn-viewer-frame wrn-viewer-pdf";
frame.setAttribute("title", `Converted preview of ${source.name}`);
parent.append(frame, note("Converted by the server. Use Download for the original file."));
};
+36
View File
@@ -0,0 +1,36 @@
import { loadOptional } from "../optional/load.ts";
import type { docxToHtml } from "../optional/docx.ts";
import { requireFile } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
/** Mammoth may emit inline images as data URLs; nothing else may load. */
const POLICY = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
const CSS =
"body{padding:1.5rem;font:16px/1.6 system-ui,sans-serif}" +
"img{max-width:100%;height:auto}table{border-collapse:collapse}" +
"td,th{border:1px solid currentColor;padding:.25rem .5rem}";
export const mountDocx: ViewerMount = async ({ parent, file, source, signal }) => {
const { docxToHtml: convert } = await loadOptional<{ docxToHtml: typeof docxToHtml }>(
"viewer-docx",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const html = await convert(bytes);
if (signal.aborted) return;
if (!html.trim()) throw new Error("Document produced no readable content");
// Converted markup is derived from an untrusted file, so it is framed under a
// policy that permits no script and no network, exactly like the SVG preview.
parent.append(
sandboxFrame(parent, {
title: source.name || "Document preview",
policy: POLICY,
body: html,
css: CSS,
}),
);
};
+35
View File
@@ -0,0 +1,35 @@
import { icon, iconFor } from "./icons.ts";
/** Element factory bound to a host document; never parses strings into nodes. */
export function elementFactory(doc: Document) {
return <K extends keyof HTMLElementTagNameMap>(
tag: K,
text?: string,
): HTMLElementTagNameMap[K] => {
const node = doc.createElement(tag);
if (text !== undefined) node.textContent = text;
return node;
};
}
/** Renderers that parse bytes are never `streaming`, so the body is present. */
export function requireFile(file: Blob | undefined): Blob {
if (!file) throw new Error("This renderer needs the file body");
return file;
}
/**
* Toolbar buttons carry an icon plus a label that is read out but not shown, so
* assigning `textContent` would delete the icon. Toggling a label goes through
* here instead, which also swaps the icon to match.
*/
export function setButtonLabel(node: HTMLElement, label: string, iconName?: string): void {
const doc = node.ownerDocument;
node.setAttribute("aria-label", label);
node.setAttribute("data-tooltip", label);
node.replaceChildren(icon(doc, iconName ?? iconFor(label)));
const text = doc.createElement("span");
text.className = "wrn-viewer-sr";
text.textContent = label;
node.append(text);
}
+55
View File
@@ -0,0 +1,55 @@
import { elementFactory } from "./dom.ts";
export interface SandboxFrameOptions {
title: string;
/** Content-Security-Policy for the framed document. */
policy: string;
/** Markup placed inside the frame. Must already be sanitised. */
body: string;
/** Extra CSS for the framed document. */
css?: string;
}
const SAFE_COLOR = /^[#a-z0-9(),.%\s/-]+$/i;
/**
* A frame with every sandbox restriction on, carrying its own CSP.
*
* The framed document is a separate document, so theme tokens and
* `currentColor` do not reach it and Chrome paints its own opaque base
* background rather than letting the element's background show. Both the
* resolved text and surface colours are therefore copied in by value.
*/
export function sandboxFrame(host: HTMLElement, options: SandboxFrameOptions): HTMLIFrameElement {
const doc = host.ownerDocument;
const element = elementFactory(doc);
const view = doc.defaultView;
const computed = (node: Element) => view?.getComputedStyle?.(node);
const safe = (value: string, fallback: string) => (SAFE_COLOR.test(value) ? value : fallback);
const color = safe(computed(host)?.color ?? "", "#000");
let background = "#fff";
for (let node: Element | null = host; node; node = node.parentElement) {
const value = computed(node)?.backgroundColor ?? "";
if (value && !/^(transparent|rgba\(0, *0, *0, *0\))$/.test(value)) {
background = safe(value, "#fff");
break;
}
}
const frame = element("iframe");
frame.setAttribute("sandbox", "");
frame.setAttribute("title", options.title);
frame.className = "wrn-viewer-frame";
frame.setAttribute(
"srcdoc",
[
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${options.policy}">`,
`<style>html,body{margin:0;height:100%;color:${color};background:${background}}` +
`${options.css ?? ""}</style>`,
options.body,
].join(""),
);
return frame;
}
+28
View File
@@ -0,0 +1,28 @@
import { sanitizeHtml } from "../parse/html.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
export const mountHtml: ViewerMount = async ({ parent, file, source, button, signal }) => {
const text = await requireFile(file).text();
if (signal.aborted) return;
const view = parent.ownerDocument.defaultView;
if (!view) throw new Error("HTML preview requires a document");
const frame = sandboxFrame(parent, {
title: source.name || "HTML preview",
policy:
"default-src 'none'; base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:",
body: sanitizeHtml(text, view),
css: "body{padding:1rem;box-sizing:border-box;font:16px/1.5 system-ui}img{max-width:100%}",
});
const raw = elementFactory(parent.ownerDocument)("pre", text);
let sourceView = false;
const toggle = button("Source", () => {
sourceView = !sourceView;
setButtonLabel(toggle, sourceView ? "Preview" : "Source");
toggle.setAttribute("aria-pressed", String(sourceView));
parent.replaceChildren(sourceView ? raw : frame);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(frame);
};
+133
View File
@@ -0,0 +1,133 @@
/**
* Toolbar icons, drawn as SVG nodes rather than markup strings: the framework
* enables Trusted Types in production, where `innerHTML` is refused outright.
*
* One line-drawn family at 24×24 on `currentColor`, so every icon inherits the
* app's theme with no palette of its own.
*/
type Shape = [tag: string, attributes: Record<string, string>];
const path = (d: string): Shape => ["path", { d }];
export const ICONS: Record<string, Shape[]> = {
"zoom-in": [
["circle", { cx: "11", cy: "11", r: "7" }],
path("M21 21l-4.3-4.3"),
path("M8 11h6"),
path("M11 8v6"),
],
"zoom-out": [
["circle", { cx: "11", cy: "11", r: "7" }],
path("M21 21l-4.3-4.3"),
path("M8 11h6"),
],
rotate: [path("M21 12a9 9 0 1 1-3-6.7"), path("M21 4v5h-5")],
reset: [path("M3 12a9 9 0 1 0 3-6.7"), path("M3 4v5h5")],
fullscreen: [path("M8 3H3v5"), path("M21 8V3h-5"), path("M16 21h5v-5"), path("M3 16v5h5")],
download: [path("M12 3v12"), path("M7 12l5 5 5-5"), path("M4 21h16")],
print: [
path("M7 8V3h10v5"),
path("M7 18H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2"),
["rect", { x: "7", y: "15", width: "10", height: "6", rx: "1" }],
],
previous: [path("M15 5l-7 7 7 7")],
next: [path("M9 5l7 7-7 7")],
top: [path("M12 20V6"), path("M6 11l6-6 6 6")],
source: [path("M9 6l-6 6 6 6"), path("M15 6l6 6-6 6")],
preview: [
path("M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"),
["circle", { cx: "12", cy: "12", r: "3" }],
],
table: [
["rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }],
path("M3 10h18"),
path("M9 10v10"),
],
tree: [path("M5 4v12a2 2 0 0 0 2 2h12"), path("M9 8h11"), path("M13 18h7")],
raw: [path("M4 5h16"), path("M4 10h16"), path("M4 15h10"), path("M4 20h7")],
wrap: [
path("M4 6h16"),
path("M4 12h13a3 3 0 0 1 0 6h-4"),
path("M16 15l-3 3 3 3"),
path("M4 18h4"),
],
waveform: [path("M3 12h2"), path("M8 7v10"), path("M12 4v16"), path("M16 8v8"), path("M20 11h1")],
sheets: [
["rect", { x: "3", y: "3", width: "13", height: "13", rx: "2" }],
path("M8 21h11a2 2 0 0 0 2-2V8"),
],
text: [path("M5 4h14"), path("M12 4v16"), path("M8 20h8")],
play: [path("M7 4.5v15l12-7.5Z")],
close: [path("M6 6l12 12"), path("M18 6L6 18")],
expand: [path("M9 3H3v6"), path("M21 15v6h-6"), path("M3 3l7 7"), path("M21 21l-7-7")],
pause: [path("M9 4v16"), path("M15 4v16")],
volume: [
path("M11 5 6.5 9H3v6h3.5L11 19Z"),
path("M16 9.5a3.5 3.5 0 0 1 0 5"),
path("M18.5 7a7 7 0 0 1 0 10"),
],
muted: [path("M11 5 6.5 9H3v6h3.5L11 19Z"), path("M17 10l4 4"), path("M21 10l-4 4")],
file: [path("M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"), path("M14 3v5h5")],
slides: [
["rect", { x: "3", y: "4", width: "18", height: "12", rx: "2" }],
path("M12 16v4"),
path("M8 20h8"),
],
};
/** Toolbar labels are stable strings, so the icon can be derived from them. */
const BY_LABEL: Record<string, string> = {
"zoom in": "zoom-in",
"zoom out": "zoom-out",
rotate: "rotate",
reset: "reset",
fullscreen: "fullscreen",
download: "download",
print: "print",
previous: "previous",
next: "next",
top: "top",
source: "source",
preview: "preview",
table: "table",
raw: "raw",
tree: "tree",
text: "text",
"wrap lines": "wrap",
"show waveform": "waveform",
"hide waveform": "waveform",
sheets: "sheets",
play: "play",
pause: "pause",
close: "close",
expand: "expand",
collapse: "close",
mute: "volume",
unmute: "muted",
"extract text": "text",
"hide text": "text",
};
export function iconFor(label: string): string {
return BY_LABEL[label.trim().toLowerCase()] ?? "file";
}
export function icon(doc: Document, name: string): SVGSVGElement {
const NS = "http://www.w3.org/2000/svg";
const svg = doc.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.75");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.classList.add("wrn-viewer-icon");
for (const [tag, attributes] of ICONS[name] ?? ICONS.file!) {
const shape = doc.createElementNS(NS, tag);
for (const [key, value] of Object.entries(attributes)) shape.setAttribute(key, value);
svg.append(shape);
}
return svg;
}
+74
View File
@@ -0,0 +1,74 @@
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
export const mountImage: ViewerMount = async ({
parent,
source,
sourceUrl,
button,
degrade,
signal,
}) => {
const element = elementFactory(parent.ownerDocument);
const url = await sourceUrl();
if (signal.aborted) return;
const stage = element("div");
stage.className = "wrn-viewer-stage";
const image = element("img");
image.alt = source.name;
image.src = url;
image.draggable = false;
image.addEventListener("error", () => degrade("Image could not be displayed"), { once: true });
// A readout, because four zoom buttons with no feedback leave you guessing.
const readout = element("span", "100%");
readout.className = "wrn-viewer-zoom";
stage.append(image, readout);
parent.append(stage);
let zoom = 1;
let rotation = 0;
const update = () => {
image.style.width = zoom === 1 ? "" : `${zoom * 100}%`;
image.style.maxWidth = zoom === 1 ? "" : "none";
image.style.transform = rotation ? `rotate(${rotation}deg)` : "";
readout.textContent = `${Math.round(zoom * 100)}%${rotation ? ` · ${rotation}°` : ""}`;
};
button("Zoom in", () => {
zoom = Math.min(4, zoom + 0.25);
update();
});
button("Zoom out", () => {
zoom = Math.max(0.25, zoom - 0.25);
update();
});
button("Rotate", () => {
rotation = (rotation + 90) % 360;
update();
});
button("Reset", () => {
zoom = 1;
rotation = 0;
update();
});
// Native scrolling supports keyboard/touch panning; pointer drag supports mouse.
parent.tabIndex = 0;
let drag: { x: number; y: number; left: number; top: number } | undefined;
parent.onpointerdown = (event) => {
drag = { x: event.clientX, y: event.clientY, left: parent.scrollLeft, top: parent.scrollTop };
parent.setPointerCapture(event.pointerId);
};
parent.onpointermove = (event) => {
if (drag) {
parent.scrollLeft = drag.left - event.clientX + drag.x;
parent.scrollTop = drag.top - event.clientY + drag.y;
}
};
parent.onpointerup = parent.onpointercancel = () => {
drag = undefined;
};
return () => {
parent.onpointerdown = parent.onpointermove = parent.onpointerup = null;
parent.onpointercancel = null;
};
};
+56
View File
@@ -0,0 +1,56 @@
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { branch, leaf } from "./tree.ts";
import type { ViewerMount } from "../types.ts";
const preview = (value: unknown): string =>
Array.isArray(value)
? `[] ${value.length} item${value.length === 1 ? "" : "s"}`
: `{} ${Object.keys(value as object).length} key${
Object.keys(value as object).length === 1 ? "" : "s"
}`;
function render(doc: Document, key: string, value: unknown, depth: number): HTMLElement {
if (value === null || typeof value !== "object")
return leaf(
doc,
key,
typeof value === "string" ? JSON.stringify(value) : String(value),
`wrn-viewer-value wrn-viewer-${value === null ? "null" : typeof value}`,
);
const entries: [string, unknown][] = Array.isArray(value)
? value.map((item, index) => [String(index), item])
: Object.entries(value as object);
return branch(
doc,
`${key ? `${key} ` : ""}${preview(value)}`,
"wrn-viewer-key",
(into) => {
for (const [childKey, child] of entries) into.append(render(doc, childKey, child, depth + 1));
},
depth < 1,
);
}
export const mountJson: ViewerMount = async ({ parent, file, button, signal }) => {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
// A parse failure throws, and the ladder falls through to the source view.
const data = JSON.parse(text) as unknown;
const tree = element("div");
tree.className = "wrn-viewer-tree";
tree.append(render(doc, "", data, 0));
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Tree" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : tree);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(tree);
};
+41
View File
@@ -0,0 +1,41 @@
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
import { mountAudioPlayer } from "./audio-player.ts";
export const mountMedia: ViewerMount = async ({
parent,
source,
sourceUrl,
kind,
degrade,
signal,
}) => {
const element = elementFactory(parent.ownerDocument);
const url = await sourceUrl();
if (signal.aborted) return;
const media = element(kind === "audio" ? "audio" : "video");
// Audio gets the package's own transport, so the native controls would be a
// second set of the same buttons driving the same playhead.
media.controls = kind !== "audio";
media.preload = "metadata";
media.src = url;
media.setAttribute("aria-label", source.name);
media.addEventListener("error", () => degrade("This browser cannot play this file"), {
once: true,
});
if (kind === "video") {
// Video shares the image's stage: a bounded, framed surface rather than a
// bare element stretched into the panel.
const stage = element("div");
stage.className = "wrn-viewer-stage";
stage.append(media);
parent.append(stage);
} else parent.append(media);
const stopPlayer = kind === "audio" ? mountAudioPlayer(parent, media, source, signal) : undefined;
return () => {
stopPlayer?.();
media.pause();
media.removeAttribute("src");
media.load();
};
};
+76
View File
@@ -0,0 +1,76 @@
import { loadOptional } from "../optional/load.ts";
import type { OfficeDocument, readOdf, readPptx } from "../optional/office.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { renderWorkbook } from "./workbook.ts";
import { renderSlides, type SlideDeckView } from "./slides.ts";
import type { ViewerMount, ViewerMountContext } from "../types.ts";
interface OfficeModule {
readPptx: typeof readPptx;
readOdf: typeof readOdf;
}
function parser(parent: HTMLElement): { DOMParser: new () => DOMParser } {
const view = parent.ownerDocument.defaultView;
if (!view?.DOMParser) throw new Error("No XML parser available");
return view as unknown as { DOMParser: new () => DOMParser };
}
/** Shared presentation of a parsed office document. */
async function show(context: ViewerMountContext, document: OfficeDocument) {
const { parent, signal } = context;
const element = elementFactory(parent.ownerDocument);
if (signal.aborted) return;
if (document.type === "slides") {
// The deck carries its own pager, so toolbar arrows would be a second set
// of the same two controls — and they push the toolbar onto two rows.
const deck: SlideDeckView = renderSlides(parent.ownerDocument, document.slides);
parent.append(deck.node);
return () => deck.dispose();
}
if (document.type === "sheets") {
return renderWorkbook(parent, document.sheets);
}
const article = element("article");
article.className = "wrn-viewer-document";
for (const block of document.blocks) {
if (block.level > 0) {
const heading = element(
(["h1", "h2", "h3", "h4", "h5", "h6"] as const)[Math.min(block.level, 6) - 1]!,
block.text,
);
article.append(heading);
} else if (block.list) {
const previous = article.lastElementChild;
const list = previous?.tagName === "UL" ? previous : element("ul");
list.append(element("li", block.text));
if (list !== previous) article.append(list);
} else article.append(element("p", block.text));
}
parent.append(article);
}
export const mountPresentation: ViewerMount = async (context) => {
const { parent, file, signal } = context;
const { readPptx: read } = await loadOptional<OfficeModule>(
"viewer-office",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
return show(context, await read(bytes, parser(parent)));
};
export const mountOpenDocument: ViewerMount = async (context) => {
const { parent, file, signal } = context;
const { readOdf: read } = await loadOptional<OfficeModule>(
"viewer-office",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
return show(context, await read(bytes, parser(parent)));
};
+62
View File
@@ -0,0 +1,62 @@
import { loadOptional } from "../optional/load.ts";
import type { renderPdf } from "../optional/pdf.ts";
import { elementFactory, requireFile } from "./dom.ts";
import type { Renderer, ViewerMount } from "../types.ts";
const LIB_ASSET = "/__wrnexus/assets/viewer-pdfjs-lib.js";
const WORKER_ASSET = "/__wrnexus/assets/viewer-pdfjs-worker.js";
const mountRichPdf: ViewerMount = async ({ parent, file, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const { renderPdf: render } = await loadOptional<{ renderPdf: typeof renderPdf }>(
"viewer-pdfjs",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const pages = element("div");
pages.className = "wrn-viewer-pages";
parent.append(pages);
const base = parent.ownerDocument.baseURI;
const result = await render(pages, bytes, {
signal,
libSrc: new URL(LIB_ASSET, base).href,
workerSrc: new URL(WORKER_ASSET, base).href,
});
if (signal.aborted) {
result.destroy();
return;
}
const status = element(
"p",
result.rendered < result.pages
? `Showing ${result.rendered} of ${result.pages} pages. Use Download for the rest.`
: `${result.pages} page${result.pages === 1 ? "" : "s"}`,
);
status.className = "wrn-viewer-note";
parent.append(status);
button("Top", () => pages.firstElementChild?.scrollIntoView({ block: "start" }));
return result.destroy;
};
/**
* Opt-in pdf.js rendering. The native frame is the default because it costs
* nothing; this trades roughly a megabyte of script for page count, controlled
* sizing and consistent output across browsers.
*
* ```ts
* registerRenderer(richPdfRenderer());
* ```
*/
export function richPdfRenderer(options: { maxInlineBytes?: number } = {}): Renderer {
return {
kind: "pdf",
exts: [".pdf"],
tier: 2,
component: "RichPdfViewer",
maxInlineBytes: options.maxInlineBytes ?? 32 * 1024 * 1024,
load: (options) => loadOptional("viewer-pdfjs", options?.baseURI),
mount: mountRichPdf,
};
}
+25
View File
@@ -0,0 +1,25 @@
import { directUrl } from "../source.ts";
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/**
* A frame, not an `<object>`: `object-src` is commonly `'none'` while frames
* fall back to `default-src`, so a same-origin PDF loads under the app's own
* policy instead of being blocked.
*/
export const mountPdf: ViewerMount = async ({ parent, source, sourceUrl, signal }) => {
const element = elementFactory(parent.ownerDocument);
// The original URL wins over an object URL whenever there is one. Reaching
// here as a fallback — after a richer renderer failed — means a body was
// already read, and framing that `blob:` is refused by the common
// `default-src 'self'` policy, leaving an empty frame.
const url = directUrl(source) ?? (await sourceUrl());
if (signal.aborted) return;
const frame = element("iframe");
frame.src = url;
frame.className = "wrn-viewer-frame wrn-viewer-pdf";
frame.setAttribute("title", source.name || "PDF preview");
const note = element("p", "If the document does not appear, use Download to open it.");
note.className = "wrn-viewer-note";
parent.append(frame, note);
};
+28
View File
@@ -0,0 +1,28 @@
import { rtfToText } from "../parse/rtf.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/** RTF needs no dependency at all: it is text once the control words are gone. */
export const mountRtf: ViewerMount = async ({ parent, file, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const source = await requireFile(file).text();
if (signal.aborted) return;
const text = rtfToText(source);
if (!text) throw new Error("Document holds no readable text");
const article = element("article");
article.className = "wrn-viewer-document";
for (const paragraph of text.split(/\n{2,}/))
for (const line of paragraph.split("\n")) article.append(element("p", line));
const raw = element("pre", source);
let showingRaw = false;
const toggle = button("Source", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Text" : "Source");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : article);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(article);
};
+17
View File
@@ -0,0 +1,17 @@
import { loadOptional } from "../optional/load.ts";
import type { readWorkbook, SheetData } from "../optional/sheet.ts";
import { requireFile } from "./dom.ts";
import { renderWorkbook } from "./workbook.ts";
import type { ViewerMount } from "../types.ts";
export const mountSheet: ViewerMount = async ({ parent, file, signal }) => {
const { readWorkbook: parse } = await loadOptional<{ readWorkbook: typeof readWorkbook }>(
"viewer-sheet",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const sheets: SheetData[] = parse(bytes);
if (!sheets.length) throw new Error("Workbook holds no sheets");
return renderWorkbook(parent, sheets);
};
+141
View File
@@ -0,0 +1,141 @@
import { elementFactory } from "./dom.ts";
import { icon } from "./icons.ts";
import type { Slide } from "../optional/office.ts";
/**
* Slide-by-slide reader shared by `.pptx` and `.odp`. Content and order, not a
* faithful layout: the converter's PDF stays preferred wherever it is reachable.
*/
export interface SlideDeckView {
node: HTMLElement;
/** Toolbar label describing the deck, e.g. "3 of 12". */
status: () => string;
go: (index: number) => void;
next: (step: number) => void;
dispose: () => void;
}
/** Beyond this many slides a row of dots stops being readable. */
const MAX_DOTS = 20;
export function renderSlides(
doc: Document,
slides: Slide[],
/** Called after every move, however it was made. */
onChange?: (index: number) => void,
): SlideDeckView {
const element = elementFactory(doc);
const wrapper = element("div");
wrapper.className = "wrn-viewer-slides";
// A deck is one control: focus it once and the arrow keys drive it.
wrapper.tabIndex = 0;
wrapper.setAttribute("role", "group");
wrapper.setAttribute("aria-roledescription", "slide deck");
wrapper.setAttribute("aria-label", `${slides.length} slides. Use the arrow keys to move.`);
const stage = element("article");
stage.className = "wrn-viewer-slide";
stage.setAttribute("aria-live", "polite");
const pager = element("div");
pager.className = "wrn-viewer-pager";
const back = element("button");
back.type = "button";
back.className = "wrn-viewer-pager-step";
back.setAttribute("aria-label", "Previous slide");
back.append(icon(doc, "previous"));
const forward = element("button");
forward.type = "button";
forward.className = "wrn-viewer-pager-step";
forward.setAttribute("aria-label", "Next slide");
forward.append(icon(doc, "next"));
const dots = element("div");
dots.className = "wrn-viewer-dots";
const count = element("span");
count.className = "wrn-viewer-pager-count";
let current = 0;
const useDots = slides.length <= MAX_DOTS;
const marks = useDots
? slides.map((slide, index) => {
const dot = element("button");
dot.type = "button";
dot.className = "wrn-viewer-dot";
dot.title = slide.title || `Slide ${index + 1}`;
dot.setAttribute(
"aria-label",
`Slide ${index + 1}${slide.title ? `: ${slide.title}` : ""}`,
);
dot.addEventListener("click", () => go(index));
dots.append(dot);
return dot;
})
: [];
pager.append(back, useDots ? dots : count, forward);
if (useDots) pager.append(count);
function go(index: number): void {
current = Math.max(0, Math.min(slides.length - 1, index));
const slide = slides[current]!;
marks.forEach((dot, position) => {
dot.setAttribute("aria-current", String(position === current));
dot.tabIndex = position === current ? 0 : -1;
});
count.textContent = `${current + 1} / ${slides.length}`;
back.disabled = current === 0;
forward.disabled = current === slides.length - 1;
stage.replaceChildren();
stage.append(element("h2", slide.title || `Slide ${current + 1}`));
// The title shape is already the heading; render the rest as its body.
slide.blocks.forEach((lines, position) => {
const body = position === 0 ? lines.slice(1) : lines;
if (!body.length) return;
const list = element("ul");
for (const line of body) list.append(element("li", line));
stage.append(list);
});
if (slide.notes) {
const notes = element("details");
notes.append(element("summary", "Presenter notes"), element("p", slide.notes));
notes.className = "wrn-viewer-slide-notes";
stage.append(notes);
}
marks[current]?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
// Every route into the deck ends here, so a caller that subscribes cannot
// miss a move — keyboard navigation included.
onChange?.(current);
}
back.addEventListener("click", () => go(current - 1));
forward.addEventListener("click", () => go(current + 1));
const onKey = (event: KeyboardEvent) => {
const keys: Record<string, number> = {
ArrowRight: current + 1,
PageDown: current + 1,
ArrowDown: current + 1,
ArrowLeft: current - 1,
PageUp: current - 1,
ArrowUp: current - 1,
Home: 0,
End: slides.length - 1,
};
const target = keys[event.key];
if (target === undefined) return;
event.preventDefault();
go(target);
};
wrapper.addEventListener("keydown", onKey);
wrapper.append(stage, pager);
go(0);
return {
node: wrapper,
status: () => `${current + 1} of ${slides.length}`,
go,
next: (step) => go(current + step),
dispose: () => wrapper.removeEventListener("keydown", onKey),
};
}
+42
View File
@@ -0,0 +1,42 @@
import { sanitizeSvg } from "../parse/svg.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
/** No network, no scripts, no plugins: only inline styles may apply. */
const POLICY = "default-src 'none'; style-src 'unsafe-inline'";
export const mountSvg: ViewerMount = async ({ parent, file, source, button, signal }) => {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
if (!view?.DOMParser || !view.XMLSerializer) throw new Error("No XML parser available");
const safe = sanitizeSvg(text, view);
if (!safe) throw new Error("Not a valid SVG document");
// Sanitised markup still renders inside a sandboxed frame: two layers, so a
// gap in either one is not on its own enough to run anything.
const frame = sandboxFrame(parent, {
title: source.name || "SVG preview",
policy: POLICY,
body: safe,
// Centred and contained: a small icon should not be blown up to the full
// height of the frame just because the frame is tall.
css:
"html,body{display:flex;align-items:center;justify-content:center;padding:1rem;box-sizing:border-box}" +
"svg{display:block;max-width:100%;max-height:100%}",
});
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Source", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Preview" : "Source");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : frame);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(frame);
};
+181
View File
@@ -0,0 +1,181 @@
import { elementFactory } from "./dom.ts";
/** Must match `.wrn-viewer-sheet td` in viewer.css for windowing to line up. */
const ROW_HEIGHT = 28;
const OVERSCAN = 20;
export interface TableData {
header: string[];
rows: string[][];
truncated?: boolean;
}
export interface TableView {
/** Scroll container content: the windowed grid. */
node: HTMLElement;
caption: string;
/** Re-measure and redraw, e.g. after the view is shown again. */
refresh: () => void;
dispose: () => void;
}
/** Spreadsheet column labels: A, B … Z, AA, AB … */
export function columnLabel(index: number): string {
let label = "";
for (let n = index; n >= 0; n = Math.floor(n / 26) - 1)
label = String.fromCharCode(65 + (n % 26)) + label;
return label;
}
/**
* A spreadsheet grid, not a data table: lettered columns across the top,
* numbered rows down the side, and the file's first row kept as row 1 rather
* than promoted into a header — because in a spreadsheet it *is* row 1.
*
* Only the visible rows plus overscan exist in the DOM; two spacer rows stand
* in for everything above and below.
*/
export function renderTable(scroller: HTMLElement, data: TableData): TableView {
const element = elementFactory(scroller.ownerDocument);
// Row 1 of a sheet is data, so the parsed header rejoins the rows here.
const rows = data.header.length ? [data.header, ...data.rows] : data.rows;
const columns = rows.reduce((max, row) => Math.max(max, row.length), 0);
const caption = `${rows.length}${data.truncated ? "+" : ""} rows × ${columns} columns`;
// A column of numbers reads right-aligned; one of words does not. Row 1 is
// skipped because a label above a numeric column is still a label.
const numeric = Array.from({ length: columns }, (_, column) => {
const values = rows
.slice(1, 51)
.map((row) => (row[column] ?? "").trim())
.filter(Boolean);
return values.length > 0 && values.every((value) => /^-?[\d.,]+%?$/.test(value));
});
const wrapper = element("div");
wrapper.className = "wrn-viewer-sheet";
const grid = element("table");
const head = element("thead");
const headRow = element("tr");
const corner = element("th");
corner.setAttribute("data-viewer-index", "");
corner.setAttribute("aria-label", "Row numbers");
headRow.append(corner);
for (let i = 0; i < columns; i++) {
const cell = element("th", columnLabel(i));
cell.scope = "col";
headRow.append(cell);
}
head.append(headRow);
const body = element("tbody");
grid.append(head, body);
wrapper.append(grid);
const spacer = (height: number) => {
const row = element("tr");
row.setAttribute("data-viewer-spacer", "");
const cell = element("td");
cell.colSpan = columns + 1;
cell.style.height = `${height}px`;
cell.style.padding = "0";
row.append(cell);
return row;
};
let windowStart = -1;
const draw = () => {
const visible = Math.ceil((scroller.clientHeight || 400) / ROW_HEIGHT) + OVERSCAN * 2;
const start = Math.max(0, Math.floor(scroller.scrollTop / ROW_HEIGHT) - OVERSCAN);
if (start === windowStart) return;
windowStart = start;
const slice = rows.slice(start, start + visible);
const nodes: HTMLElement[] = [spacer(start * ROW_HEIGHT)];
slice.forEach((row, index) => {
const line = element("tr");
const number = element("th", String(start + index + 1));
number.setAttribute("data-viewer-index", "");
number.scope = "row";
line.append(number);
for (let i = 0; i < columns; i++) {
const cell = element("td", row[i] ?? "");
if (numeric[i]) cell.setAttribute("data-viewer-numeric", "");
line.append(cell);
}
// Row 1 usually carries the column names, so it is given some weight.
if (start + index === 0 && data.header.length) line.setAttribute("data-viewer-labels", "");
nodes.push(line);
});
nodes.push(spacer(Math.max(0, rows.length - start - slice.length) * ROW_HEIGHT));
body.replaceChildren(...nodes);
};
draw();
scroller.addEventListener("scroll", draw, { passive: true });
return {
node: wrapper,
caption,
refresh: () => {
windowStart = -1;
draw();
},
dispose: () => scroller.removeEventListener("scroll", draw),
};
}
export function formatSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
}
export interface ListedEntry {
name: string;
size: number;
directory: boolean;
}
/** Archive contents table, shared by the tar/gzip and zip previews. */
export function renderEntries(doc: Document, entries: ListedEntry[], label: string): HTMLElement {
const element = elementFactory(doc);
const wrapper = element("div");
wrapper.className = "wrn-viewer-table";
wrapper.setAttribute("aria-label", label);
const table = element("table");
const head = element("thead");
const headRow = element("tr");
const sizeHead = element("th", "Size");
sizeHead.setAttribute("data-viewer-numeric", "");
headRow.append(element("th", "Name"), sizeHead);
head.append(headRow);
const body = element("tbody");
for (const entry of entries) {
const row = element("tr");
const name = element("td", entry.name);
name.setAttribute("data-viewer-grow", "");
const size = element("td", entry.directory ? "" : formatSize(entry.size));
size.setAttribute("data-viewer-numeric", "");
row.append(name, size);
body.append(row);
}
table.append(head, body);
wrapper.append(table);
return wrapper;
}
/** "3 files, 1.2 MB uncompressed" — the line above an archive listing. */
export function entriesSummary(entries: ListedEntry[], note = ""): string {
const files = entries.filter((entry) => !entry.directory);
const total = files.reduce((sum, entry) => sum + entry.size, 0);
return [
`${files.length} file${files.length === 1 ? "" : "s"}`,
`${formatSize(total)} uncompressed`,
note,
]
.filter(Boolean)
.join(", ");
}
+32
View File
@@ -0,0 +1,32 @@
import { parseDelimited } from "../parse/delimited.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { renderTable } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
export const mountTable: ViewerMount = async ({ parent, file, source, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const text = await requireFile(file).text();
if (signal.aborted) return;
const table = parseDelimited(text, {
delimiter: /\.tsv$/i.test(source.name) ? "\t" : undefined,
});
// A single column means no delimiter was found: show it as text instead.
if (table.header.length < 2) throw new Error("Not a delimited table");
const view = renderTable(parent, table);
const caption = element("p", view.caption);
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Table" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(caption, showingRaw ? raw : view.node);
if (!showingRaw) view.refresh();
});
toggle.setAttribute("aria-pressed", "false");
parent.append(caption, view.node);
return view.dispose;
};
+45
View File
@@ -0,0 +1,45 @@
import { elementFactory } from "./dom.ts";
/**
* Shared collapsible tree used by the JSON and XML previews. Children are built
* on first expand, so a deep document costs nothing until it is opened.
*/
export function branch(
doc: Document,
label: string,
summaryClass: string,
fill: (into: HTMLElement) => void,
open = false,
): HTMLElement {
const element = elementFactory(doc);
const details = element("details");
details.open = open;
const summary = element("summary", label);
summary.className = summaryClass;
const children = element("div");
children.className = "wrn-viewer-branch";
details.append(summary, children);
let filled = false;
const build = () => {
if (filled || !details.open) return;
filled = true;
fill(children);
};
details.addEventListener("toggle", build);
build();
return details;
}
export function leaf(doc: Document, label: string, value: string, valueClass: string): HTMLElement {
const element = elementFactory(doc);
const line = element("div");
line.className = "wrn-viewer-leaf";
if (label) {
line.append(element("span", label));
line.append(doc.createTextNode(": "));
}
const rendered = element("span", value);
rendered.className = valueClass;
line.append(rendered);
return line;
}
+108
View File
@@ -0,0 +1,108 @@
import { setButtonLabel } from "./dom.ts";
import { readSource } from "../source.ts";
import type { ViewerSource } from "../types.ts";
/** Peak envelope in bounded bins, including every channel. */
export function waveformPeaks(channels: Float32Array[], bins = 160): number[] {
const length = Math.max(0, ...channels.map((channel) => channel.length));
return Array.from({ length: bins }, (_, i) => {
let peak = 0;
for (const channel of channels)
for (
let n = Math.floor((i * length) / bins);
n < Math.min(channel.length, Math.ceil(((i + 1) * length) / bins));
n++
)
peak = Math.max(peak, Math.abs(channel[n] ?? 0));
return Math.min(1, peak);
});
}
/** User-triggered decoding preserves streaming playback until a waveform is requested. */
export function mountWaveform(
parent: HTMLElement,
audio: HTMLMediaElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const button = doc.createElement("button");
button.type = "button";
button.className = "wrn-viewer-action";
setButtonLabel(button, "Show waveform");
const note = doc.createElement("p");
note.setAttribute("role", "status");
const canvas = doc.createElement("canvas");
canvas.width = 640;
canvas.height = 96;
canvas.className = "wrn-viewer-waveform";
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", "Audio waveform");
const seek = doc.createElement("input");
seek.type = "range";
seek.min = "0";
seek.max = "1000";
seek.value = "0";
seek.setAttribute("aria-label", "Seek audio");
let context: AudioContext | undefined;
let disposed = false;
const update = () => {
if (Number.isFinite(audio.duration) && audio.duration > 0)
seek.value = String(Math.round((audio.currentTime / audio.duration) * 1000));
};
seek.oninput = () => {
if (Number.isFinite(audio.duration))
audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
};
audio.addEventListener("timeupdate", update);
button.onclick = async () => {
button.disabled = true;
note.textContent = "Building waveform…";
parent.append(note);
try {
if (!view?.AudioContext) throw new Error("No decoder");
// 8 MiB encoded and 10 minutes of audio bound this optional analysis.
if (!Number.isFinite(audio.duration) || audio.duration > 600)
throw new Error("Duration unavailable or too long");
const file = await readSource(source, 8 * 1024 * 1024, { signal });
if (disposed) return;
context = new view.AudioContext();
const decoded = await context.decodeAudioData(await file.arrayBuffer());
if (disposed) return;
const peaks = waveformPeaks(
Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)),
);
const draw = canvas.getContext("2d");
if (!draw) throw new Error("No canvas");
draw.fillStyle =
view.getComputedStyle(parent).getPropertyValue("--wrn-color-primary") ||
view.getComputedStyle(parent).color;
peaks.forEach((peak, i) => {
const h = Math.max(1, peak * 90);
draw.fillRect(i * 4, (96 - h) / 2, 3, h);
});
note.textContent = "";
button.remove();
parent.append(canvas, seek);
} catch {
if (!disposed) {
note.textContent = "Waveform unavailable for this file. Audio controls remain available.";
button.disabled = false;
}
} finally {
await context?.close().catch(() => {});
context = undefined;
}
};
parent.append(button);
return () => {
disposed = true;
audio.removeEventListener("timeupdate", update);
void context?.close().catch(() => {});
button.remove();
note.remove();
canvas.remove();
seek.remove();
};
}
+75
View File
@@ -0,0 +1,75 @@
import { elementFactory } from "./dom.ts";
import { renderTable, type TableData, type TableView } from "./table-view.ts";
export interface WorkbookSheet extends TableData {
name: string;
}
/**
* A workbook: the grid scrolls, and the sheet bar stays beneath it with the
* active tab joined to the sheet — where a spreadsheet keeps it.
*
* Shared by `.xlsx` and `.ods` so the same file looks the same whichever
* reader opened it.
*/
export function renderWorkbook(parent: HTMLElement, sheets: WorkbookSheet[]): () => void {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const shell = element("div");
shell.className = "wrn-viewer-workbook";
const surface = element("div");
surface.className = "wrn-viewer-workbook-surface";
const bar = element("div");
bar.className = "wrn-viewer-sheetbar";
bar.setAttribute("role", "tablist");
bar.setAttribute("aria-label", "Sheets");
const status = element("span");
status.className = "wrn-viewer-sheetbar-status";
let view: TableView | undefined;
let current = 0;
const show = (index: number) => {
current = Math.max(0, Math.min(sheets.length - 1, index));
const sheet = sheets[current]!;
view?.dispose();
view = renderTable(surface, sheet);
surface.replaceChildren(view.node);
status.textContent = view.caption;
tabs.forEach((tab, position) => {
const selected = position === current;
tab.setAttribute("aria-selected", String(selected));
tab.tabIndex = selected ? 0 : -1;
});
view.refresh();
};
const tabs = sheets.map((sheet, index) => {
const tab = element("button", sheet.name);
tab.type = "button";
tab.setAttribute("role", "tab");
tab.className = "wrn-viewer-sheettab";
tab.title = sheet.name;
tab.addEventListener("click", () => show(index));
return tab;
});
// Arrow keys walk the bar, as a tablist should.
bar.addEventListener("keydown", (event) => {
const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
event.preventDefault();
const next = (current + step + sheets.length) % sheets.length;
show(next);
tabs[next]?.focus();
});
bar.append(...tabs, status);
// One sheet needs no tabs, but its size is still worth stating.
if (sheets.length === 1) bar.classList.add("is-single");
shell.append(surface, bar);
parent.append(shell);
show(0);
return () => view?.dispose();
}
+62
View File
@@ -0,0 +1,62 @@
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { branch, leaf } from "./tree.ts";
import type { ViewerMount } from "../types.ts";
function label(node: Element): string {
const attributes = [...node.attributes]
.map((attribute) => `${attribute.name}="${attribute.value}"`)
.join(" ");
return `<${node.tagName}${attributes ? ` ${attributes}` : ""}>`;
}
function render(doc: Document, node: Element, depth: number): HTMLElement {
const children = [...node.childNodes].filter(
(child) => child.nodeType === 1 || (child.textContent ?? "").trim(),
);
const elements = children.filter((child) => child.nodeType === 1) as Element[];
if (!elements.length)
return leaf(doc, label(node), (node.textContent ?? "").trim(), "wrn-viewer-value");
return branch(
doc,
label(node),
"wrn-viewer-key",
(into) => {
for (const child of children)
into.append(
child.nodeType === 1
? render(doc, child as Element, depth + 1)
: leaf(doc, "", (child.textContent ?? "").trim(), "wrn-viewer-value"),
);
},
depth < 2,
);
}
export const mountXml: ViewerMount = async ({ parent, file, button, signal }) => {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
if (!view?.DOMParser) throw new Error("No XML parser available");
const parsed = new view.DOMParser().parseFromString(text, "application/xml");
const root = parsed.documentElement;
// Malformed XML throws, and the ladder falls through to the source view.
if (!root || parsed.getElementsByTagName("parsererror").length)
throw new Error("Malformed XML document");
const tree = element("div");
tree.className = "wrn-viewer-tree";
tree.append(render(doc, root, 0));
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Tree" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : tree);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(tree);
};
+22
View File
@@ -0,0 +1,22 @@
import { loadOptional } from "../optional/load.ts";
import type { listZip } from "../optional/zip.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { entriesSummary, renderEntries } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
export const mountZip: ViewerMount = async ({ parent, file, source, signal }) => {
const element = elementFactory(parent.ownerDocument);
const { listZip: list } = await loadOptional<{ listZip: typeof listZip }>(
"viewer-zip",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const entries = await list(bytes);
if (signal.aborted) return;
if (!entries.length) throw new Error("Archive holds no readable entries");
parent.append(
element("p", entriesSummary(entries)),
renderEntries(parent.ownerDocument, entries, `Contents of ${source.name}`),
);
};
+95
View File
@@ -0,0 +1,95 @@
import { extensionOf, kindOfExtension, kindOfMime } from "./kinds.ts";
import { defaultRegistry, downloadRenderer } from "./registry.ts";
import { sniff } from "./sniff.ts";
import { readBounded, safeSourceUrl } from "./source.ts";
import type { ResolveOptions, ViewerKind, ViewerPlan, ViewerSource } from "./types.ts";
export async function resolve(
source: ViewerSource,
options: ResolveOptions = {},
): Promise<ViewerPlan> {
const ext = extensionOf(source.name);
let kind = kindOfMime(source.mime ?? source.blob?.type ?? "") ?? kindOfExtension(ext);
const fallback = (reason: string): ViewerPlan => ({
kind,
renderer: downloadRenderer,
fallback: [],
needsConvert: false,
sandboxed: false,
reason,
});
if ([".exe", ".dll"].includes(ext)) return fallback("Executable files cannot be previewed");
const registry = options.registry ?? defaultRegistry;
const candidates = (k: ViewerKind, extension = ext) => registry.candidates(k, extension);
// No renderer for the kind is not an oversized file: leave the cap open so the
// ladder reports why nothing can display it rather than "exceeds preview limit".
const capFor = (k: ViewerKind) => {
const caps = candidates(k).map((r) => r.maxInlineBytes ?? 2 * 1024 * 1024);
return caps.length ? Math.max(...caps) : Infinity;
};
try {
let size = source.blob?.size ?? source.size;
let bytes: Uint8Array;
if (source.blob) {
bytes = new Uint8Array(await source.blob.slice(0, 4096).arrayBuffer());
} else {
const url = source.src && safeSourceUrl(source.src);
if (!url) return fallback("No readable file source");
const request = options.fetch ?? fetch;
const head = await request(url, { method: "HEAD", signal: options.signal });
if (head.ok) {
const length = head.headers.get("content-length");
if (length !== null && /^\d+$/.test(length)) size = Math.max(size ?? 0, Number(length));
kind = kindOfMime(head.headers.get("content-type") ?? "") ?? kind;
}
if (size !== undefined && size > capFor(kind)) return fallback("File exceeds preview limit");
const response = await request(url, {
headers: { Range: "bytes=0-4095" },
signal: options.signal,
});
bytes = await readBounded(response, 4096, true);
}
const detected = sniff(bytes);
if (detected.executable) return fallback("Executable files cannot be previewed");
const previous = kind;
// Plain UTF-8 retains code classification; active markup and signatures win.
// RTF is the one office format that is genuinely text, so `doc` survives a
// text signature too — otherwise a .rtf loses its extension and its reader.
// A generic container signature (OLE, ZIP, unrecognised binary) says only
// "not text", so a .docx or .psd keeps the kind its extension implies.
const generic = detected.kind === "binary" || detected.kind === "archive";
const keepsExtensionKind =
(detected.kind === "text" && ["text", "code", "markup", "sheet", "doc"].includes(kind)) ||
(generic && ["doc", "sheet", "archive", "binary"].includes(kind));
kind = keepsExtensionKind ? kind : detected.kind;
// An extension override must not bypass conflicting signatures.
const selectedExt =
kind === previous || (detected.kind === "text" && previous === "binary") ? ext : "";
const available = [];
let needsConvert = false;
for (const renderer of candidates(kind, selectedExt)) {
// A tier 3 renderer needs a converter, and `load` is how it proves one is
// reachable. Without that check it could never report itself unavailable.
if (renderer.tier === 3 && !renderer.load) continue;
if (size !== undefined && size > (renderer.maxInlineBytes ?? 2 * 1024 * 1024)) continue;
try {
await renderer.load?.(options);
if (renderer.tier === 3) needsConvert = true;
available.push(renderer);
} catch {
/* Optional dependency unavailable. */
}
}
const renderer = available.shift();
if (!renderer) return fallback("Preview unavailable for this file");
return {
kind,
renderer,
fallback: [...available, downloadRenderer],
needsConvert: needsConvert && renderer.tier === 3,
sandboxed: kind === "markup" || kind === "vector",
};
} catch {
return fallback("File could not be loaded");
}
}
@@ -0,0 +1,5 @@
import { handleCapabilities } from "../convert/service.ts";
export function GET(): Response {
return handleCapabilities();
}
+9
View File
@@ -0,0 +1,9 @@
import { handleFile } from "../uploads/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleFile(ctx.req);
}
export async function HEAD(ctx: { req: Request }): Promise<Response> {
return handleFile(ctx.req);
}
+5
View File
@@ -0,0 +1,5 @@
import { handleConvert } from "../convert/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleConvert(ctx.req, "preview");
}
+5
View File
@@ -0,0 +1,5 @@
import { handleSource } from "../uploads/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleSource(ctx.req);
}
+5
View File
@@ -0,0 +1,5 @@
import { handleConvert } from "../convert/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleConvert(ctx.req, "text");
}
+5
View File
@@ -0,0 +1,5 @@
import { handleConvert } from "../convert/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleConvert(ctx.req, "thumb");
}
+468
View File
@@ -0,0 +1,468 @@
import { mountFilmstrip } from "./filmstrip.ts";
import { richPdfRenderer } from "./renderers/pdf-rich.ts";
import { mountExtractedText } from "./text.ts";
import { resolve } from "./resolve.ts";
import { defaultRegistry, downloadRenderer } from "./registry.ts";
import { elementFactory, setButtonLabel } from "./renderers/dom.ts";
import { viewerActions, type ViewerAction, type ViewerActionContext } from "./actions.ts";
import { canPrint, printViewer } from "./print.ts";
import { sniff } from "./sniff.ts";
import { directUrl, readSource, safeSourceUrl } from "./source.ts";
import { resolveKey } from "./uploads/client.ts";
import { startViewerTriggers } from "./overlay.ts";
import type { Renderer, ViewerKind, ViewerSource } from "./types.ts";
const DEFAULT_CAP = 2 * 1024 * 1024;
function formatBytes(bytes: number): string {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
}
/** Mount a preview, including local File/Blob sources. Dispose before reusing the host. */
export interface MountViewerOptions {
richPdf?: boolean;
/**
* Opens covering the page, with a Close control that returns it inline. This
* is a layered overlay rather than the browser's Fullscreen API, so it needs
* no user gesture and can be the state a viewer mounts in.
*/
fullScreen?: boolean;
/** `page` drops the card chrome and fills its container, for a preview route. */
layout?: "inline" | "page";
/** Actions for this viewer only, offered after the globally registered ones. */
actions?: ViewerAction[];
/** Drops the built-in Print action when an app has its own. */
print?: boolean;
}
export function mountViewer(
host: HTMLElement,
input: ViewerSource | ViewerSource[],
options: MountViewerOptions = {},
): () => void {
if (Array.isArray(input)) return mountFilmstrip(host, input, options);
const original = [...host.childNodes].map((node) => node.cloneNode(true));
const registry = defaultRegistry.fork();
if (options.richPdf) registry.register(richPdfRenderer());
const controller = new AbortController();
// Reassigned once a storage key has been exchanged for a URL.
let source = input;
const doc = host.ownerDocument;
const element = elementFactory(doc);
let disposed = false;
let cleanup: (() => void) | undefined;
const urls: string[] = [];
const objectUrl = (blob: Blob) => {
const url = URL.createObjectURL(blob);
urls.push(url);
return url;
};
host.classList.add("wrn-viewer");
if (options.layout === "page") host.classList.add("wrn-viewer-as-page");
host.setAttribute("aria-label", source.name || "File preview");
let expanded = options.fullScreen === true;
const setExpanded = (next: boolean) => {
expanded = next;
host.classList.toggle("wrn-viewer-expanded", expanded);
// The control swaps between Close and Expand, so re-offer the toolbar.
offerActions(lastRenderer, lastKind);
};
if (expanded) host.classList.add("wrn-viewer-expanded");
const toolbar = host.querySelector<HTMLElement>("[data-viewer-toolbar]") ?? element("div");
toolbar.classList.add("wrn-viewer-toolbar");
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", "File preview controls");
const title =
toolbar.querySelector<HTMLElement>("[data-viewer-title]") ??
element("span", source.name || "File");
title.textContent = source.name || "File";
if (!toolbar.contains(title)) toolbar.append(title);
const controls = toolbar.querySelector<HTMLElement>("[data-viewer-controls]") ?? element("span");
const actions = toolbar.querySelector<HTMLElement>("[data-viewer-actions]") ?? element("span");
if (!toolbar.contains(actions)) toolbar.append(actions);
if (!toolbar.contains(controls)) toolbar.append(controls);
const content = host.querySelector<HTMLElement>("[data-viewer-content]") ?? element("div");
content.classList.add("wrn-viewer-content");
const status = element("p", "Loading preview…");
status.setAttribute("role", "status");
content.replaceChildren(status);
if (!host.contains(toolbar)) host.append(toolbar);
if (!host.contains(content)) host.append(content);
const button = (label: string, action: () => void, iconName?: string) => {
const node = element("button");
node.type = "button";
node.className = "wrn-viewer-action";
setButtonLabel(node, label, iconName);
node.addEventListener("click", action);
controls.append(node);
return node;
};
const actionButton = (label: string, run: () => void, iconName?: string) => {
const node = element("button");
node.type = "button";
node.className = "wrn-viewer-action";
setButtonLabel(node, label, iconName);
node.addEventListener("click", run);
actions.append(node);
return node;
};
if (host.requestFullscreen)
actionButton("Fullscreen", () => {
const promise =
doc.fullscreenElement === host ? doc.exitFullscreen() : host.requestFullscreen();
promise.catch(() => {});
});
const downloadUrl = source.blob
? objectUrl(source.blob)
: source.src && safeSourceUrl(source.src);
const downloadLink = (url: string) => {
const link = element("a");
link.href = url;
link.download = source.name;
link.rel = "noopener noreferrer";
link.className = "wrn-viewer-action";
setButtonLabel(link, "Download");
actions.append(link);
};
if (downloadUrl) downloadLink(downloadUrl);
const showDownload = (reason = "Preview unavailable for this file") => {
cleanup?.();
cleanup = undefined;
controls.replaceChildren();
const template = host.querySelector<HTMLTemplateElement>(
'[data-viewer-template="DownloadCard"]',
);
content.replaceChildren(template ? template.content.cloneNode(true) : element("p", reason));
const reasonNode = content.querySelector("[data-viewer-reason]");
if (reasonNode) reasonNode.textContent = reason;
const bytes = source.blob?.size ?? source.size;
const meta = [source.name, bytes === undefined ? "" : formatBytes(bytes)]
.filter(Boolean)
.join(" · ");
const metaNode = content.querySelector("[data-viewer-size]");
if (metaNode) metaNode.textContent = meta;
else if (meta) content.append(element("p", meta));
content.setAttribute("data-viewer-state", "fallback");
// An app action may still apply to a file the viewer cannot render.
offerActions();
};
let lastRenderer: Renderer | undefined;
let lastKind: ViewerKind | undefined;
async function render(renderer: Renderer, blob: Blob | undefined, kind: ViewerKind) {
if (renderer === downloadRenderer) {
showDownload();
return;
}
if (!renderer.mount)
throw new Error(`Renderer "${renderer.component}" has no browser mount adapter`);
controls.replaceChildren();
const aliases: Record<string, string> = {
SvgViewer: "VectorViewer",
HtmlViewer: "DocViewer",
DocxViewer: "DocViewer",
ConvertedViewer: "DocViewer",
TableViewer: "SheetViewer",
JsonViewer: "CodeViewer",
XmlViewer: "CodeViewer",
ZipViewer: "ArchiveViewer",
RichPdfViewer: "PdfViewer",
};
const template = [...host.querySelectorAll<HTMLTemplateElement>("[data-viewer-template]")].find(
(node) => node.dataset.viewerTemplate === (aliases[renderer.component] ?? renderer.component),
);
content.replaceChildren(...(template ? [template.content.cloneNode(true)] : []));
const parent = content.querySelector<HTMLElement>("[data-viewer-mount]") ?? content;
content.setAttribute("data-viewer-state", "ready");
const dispose = await renderer.mount({
parent,
file: blob,
sourceUrl: async () => {
if (!blob) {
const url = directUrl(source);
if (!url) throw new Error("No readable file source");
return url;
}
// Serving the body under its sniffed type keeps a mislabelled response
// from choosing how the element interprets it.
const detected = sniff(new Uint8Array(await blob.slice(0, 4096).arrayBuffer()));
return objectUrl(new Blob([blob], { type: detected.mime ?? "application/octet-stream" }));
},
source,
kind,
button,
objectUrl,
degrade: showDownload,
signal: controller.signal,
});
if (disposed) {
dispose?.();
return;
}
cleanup = dispose || undefined;
lastRenderer = renderer;
lastKind = kind;
offerActions(renderer, kind);
}
/**
* Built-in and app-supplied actions, offered after every render so a `when`
* predicate can look at what actually drew — printing is meaningless on a
* download card, and an app action may only suit one kind.
*/
function offerActions(renderer?: Renderer, kind?: ViewerKind) {
// Re-offered after every render, so anything from the previous one goes
// first; the actions slot is not cleared by the render itself.
for (const node of host.querySelectorAll("[data-viewer-action]")) node.remove();
const context: ViewerActionContext = { host, content, source, renderer, kind };
const builtIn: ViewerAction[] = [
...(options.print === false
? []
: [
{
id: "print",
label: "Print",
icon: "print",
placement: "actions" as const,
when: canPrint,
run: printViewer,
},
]),
// Offered only once the viewer is covering the page, so an inline viewer
// is not cluttered with a control that would do nothing.
...(expanded
? [
{
id: "close",
label: "Close",
icon: "close",
placement: "actions" as const,
run: () => setExpanded(false),
},
]
: []),
];
for (const action of [...builtIn, ...viewerActions(), ...(options.actions ?? [])]) {
if (action.when && !action.when(context)) continue;
const node = element("button");
node.type = "button";
node.className = "wrn-viewer-action";
setButtonLabel(node, action.label, action.icon);
node.dataset.viewerAction = action.id;
node.addEventListener("click", () => void action.run(context));
(action.placement === "controls" ? controls : actions).append(node);
}
}
void (async () => {
if (source.key && !source.src && !source.blob) {
const resolved = await resolveKey(source, {
signal: controller.signal,
baseURI: doc.baseURI,
});
if (disposed) return;
if (!resolved) {
showDownload("This file is not available to preview");
return;
}
source = resolved;
title.textContent = source.name || "File";
host.setAttribute("aria-label", source.name || "File preview");
const url = safeSourceUrl(source.src ?? "");
// The download control could not be built before the link existed.
if (url) downloadLink(url);
}
if (source.src) source = { ...source, src: new URL(source.src, doc.baseURI).href };
const stopText = mountExtractedText(actions, source, controller.signal);
controller.signal.addEventListener("abort", stopText, { once: true });
const plan = await resolve(source, {
signal: controller.signal,
baseURI: doc.baseURI,
registry: options.richPdf ? registry : undefined,
});
if (disposed) return;
if (plan.renderer === downloadRenderer) {
showDownload(plan.reason);
return;
}
try {
// A streaming renderer is pointed at the original URL: the browser fetches
// and decodes it, so nothing is buffered here and playback can start early.
// Trying every leading streaming candidate before touching the body keeps
// the ladder intact — a failed conversion still falls through to the
// in-package reader rather than straight to the download card.
const streamable = directUrl(source) ? [plan.renderer, ...plan.fallback] : [];
for (const renderer of streamable) {
if (!renderer.streaming) break;
if (disposed) return;
try {
await render(renderer, undefined, plan.kind);
return;
} catch {
cleanup?.();
cleanup = undefined;
}
}
// The body only serves renderers that actually read it, so the cap comes
// from those. A streaming renderer's open-ended limit must never become
// the size of an unbounded download.
const readers = [plan.renderer, ...plan.fallback].filter(
(renderer) => renderer !== downloadRenderer && !(renderer.streaming && directUrl(source)),
);
const cap = readers.reduce(
(limit, renderer) => Math.max(limit, renderer.maxInlineBytes ?? DEFAULT_CAP),
0,
);
const blob = await readSource(source, Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_CAP, {
signal: controller.signal,
});
// Resolve the actual body again: a URL may change between HEAD/range/body.
const verified = await resolve(
{ ...source, blob },
{
signal: controller.signal,
baseURI: doc.baseURI,
registry: options.richPdf ? registry : undefined,
},
);
for (const renderer of [verified.renderer, ...verified.fallback]) {
if (disposed) return;
if (renderer !== downloadRenderer && blob.size > (renderer.maxInlineBytes ?? DEFAULT_CAP))
continue;
try {
await render(renderer, blob, verified.kind);
return;
} catch {
cleanup?.();
cleanup = undefined;
}
}
if (!disposed) showDownload();
} catch {
if (!disposed) showDownload("File could not be loaded within the preview limit");
}
})().catch(() => {
if (!disposed) showDownload("File could not be loaded");
});
return () => {
disposed = true;
controller.abort();
cleanup?.();
for (const url of urls) URL.revokeObjectURL(url);
host.replaceChildren(...original);
};
}
function parseFiles(value?: string): ViewerSource[] | undefined {
if (!value) return;
try {
const files: unknown = JSON.parse(value);
if (
Array.isArray(files) &&
files.length &&
files.length <= 500 &&
files.every(
(item) =>
item &&
typeof item === "object" &&
typeof item.name === "string" &&
(typeof item.src === "string" || typeof item.key === "string"),
)
)
return files;
} catch {
/* Invalid serialized file lists fall back to the single source. */
}
}
/** Observe framework navigation and changed source attributes. */
export function startViewerRuntime(root: Document = document): () => void {
const mounted = new Map<HTMLElement, { signature: string; dispose: () => void }>();
const scan = () => {
for (const [host, state] of mounted)
if (!root.contains(host)) {
state.dispose();
mounted.delete(host);
}
for (const host of root.querySelectorAll<HTMLElement>('[data-wrnexus-runtime="viewer"]')) {
const signature = [
host.dataset.files,
host.dataset.richPdf,
host.dataset.fullScreen,
host.dataset.layout,
host.dataset.src,
host.dataset.key,
host.dataset.store,
host.dataset.name,
host.dataset.mime,
host.dataset.size,
].join("\n");
if (mounted.get(host)?.signature === signature) continue;
mounted.get(host)?.dispose();
const size = Number(host.dataset.size);
mounted.set(host, {
signature,
dispose: mountViewer(
host,
parseFiles(host.dataset.files) ?? {
src: host.dataset.src,
key: host.dataset.key,
store: host.dataset.store,
name: host.dataset.name ?? "",
mime: host.dataset.mime,
size: host.dataset.size && Number.isFinite(size) && size >= 0 ? size : undefined,
},
{
richPdf: host.dataset.richPdf === "true",
fullScreen: host.dataset.fullScreen === "true",
layout: host.dataset.layout === "page" ? "page" : "inline",
},
),
});
}
};
const stopTriggers = startViewerTriggers(root);
const Observer = root.defaultView?.MutationObserver ?? MutationObserver;
const observer = new Observer(scan);
scan();
observer.observe(root.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
"data-full-screen",
"data-layout",
"data-files",
"data-rich-pdf",
"data-src",
"data-key",
"data-store",
"data-name",
"data-mime",
"data-size",
],
});
return () => {
stopTriggers();
observer.disconnect();
for (const item of mounted.values()) item.dispose();
mounted.clear();
};
}
if (typeof document !== "undefined") {
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", () => startViewerRuntime(), { once: true });
else startViewerRuntime();
}
+59
View File
@@ -0,0 +1,59 @@
import type { ViewerKind } from "./types.ts";
export interface SniffResult {
kind: ViewerKind;
mime?: string;
executable?: boolean;
}
/** Bounded signature inspection. Unknown bytes must never become active markup. */
export function sniff(bytes: Uint8Array): SniffResult {
const at = (value: string, offset = 0) =>
[...value].every((ch, i) => bytes[offset + i] === ch.charCodeAt(0));
const hex = (...values: number[]) => values.every((value, i) => bytes[i] === value);
if (at("MZ") || hex(0x7f, 0x45, 0x4c, 0x46)) return { kind: "binary", executable: true };
if (hex(137, 80, 78, 71, 13, 10, 26, 10)) return { kind: "image", mime: "image/png" };
if (hex(255, 216, 255)) return { kind: "image", mime: "image/jpeg" };
if (at("GIF87a") || at("GIF89a")) return { kind: "image", mime: "image/gif" };
if (at("RIFF") && at("WEBP", 8)) return { kind: "image", mime: "image/webp" };
if (at("BM")) return { kind: "image", mime: "image/bmp" };
if (hex(0, 0, 1, 0)) return { kind: "image", mime: "image/x-icon" };
if (at("%PDF-")) return { kind: "pdf", mime: "application/pdf" };
// Design and print formats: recognised so they reach the download card (and,
// later, a converter) instead of being mistaken for text.
if (at("8BPS")) return { kind: "binary", mime: "image/vnd.adobe.photoshop" };
if (hex(0x49, 0x49, 0x2a, 0x00) || hex(0x4d, 0x4d, 0x00, 0x2a))
return { kind: "binary", mime: "image/tiff" };
if (at("ftyp", 4)) {
if (at("avif", 8) || at("avis", 8)) return { kind: "image", mime: "image/avif" };
return { kind: "video", mime: at("qt ", 8) ? "video/quicktime" : "video/mp4" };
}
if (at("RIFF") && at("WAVE", 8)) return { kind: "audio", mime: "audio/wav" };
if (at("OggS")) return { kind: "audio", mime: "audio/ogg" };
if (at("fLaC")) return { kind: "audio", mime: "audio/flac" };
if (bytes[0] === 255 && ((bytes[1] ?? 0) & 0xf6) === 0xf0)
return { kind: "audio", mime: "audio/aac" };
if (at("ID3") || (bytes[0] === 255 && ((bytes[1] ?? 0) & 0xe0) === 0xe0))
return { kind: "audio", mime: "audio/mpeg" };
if (hex(0x1a, 0x45, 0xdf, 0xa3)) return { kind: "video", mime: "video/webm" };
if (at("PK") || hex(0x1f, 0x8b) || at("Rar!") || at("7z") || at("ustar", 257))
return { kind: "archive" };
try {
const text = new TextDecoder("utf-8", { fatal: true })
.decode(bytes, { stream: true })
.replace(/^\uFEFF/, "")
.trimStart();
if (
[...text].some((char) => {
const code = char.charCodeAt(0);
return code <= 8 || (code >= 14 && code <= 31);
})
)
return { kind: "binary" };
if (/<svg(?:\s|>)/i.test(text)) return { kind: "vector", mime: "image/svg+xml" };
if (/<(?:!doctype\s+html|html|head|body|script|iframe)(?:\s|>)/i.test(text))
return { kind: "markup", mime: "text/html" };
return { kind: "text", mime: "text/plain" };
} catch {
return { kind: "binary" };
}
}
+68
View File
@@ -0,0 +1,68 @@
import type { ResolveOptions, ViewerSource } from "./types.ts";
export function safeSourceUrl(src: string): string | undefined {
try {
const url = new URL(src, typeof location === "undefined" ? "http://localhost" : location.href);
return ["http:", "https:", "blob:", "data:"].includes(url.protocol) ? url.href : undefined;
} catch {
return undefined;
}
}
/** The source's own http(s) URL, when it has one that an element can load. */
export function directUrl(source: ViewerSource): string | undefined {
if (source.blob || !source.src) return undefined;
const url = safeSourceUrl(source.src);
return url && /^https?:/i.test(url) ? url : undefined;
}
export async function readBounded(
response: Response,
cap: number,
prefixOnly = false,
): Promise<Uint8Array> {
if (!response.ok || !response.body) throw new Error("File could not be read");
const reader = response.body.getReader();
const parts: Uint8Array[] = [];
let length = 0;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
if (!prefixOnly && length + next.value.length > cap)
throw new Error("File exceeds preview limit");
const part = next.value.subarray(0, cap - length);
parts.push(part);
length += part.length;
if (prefixOnly && length >= cap) break;
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
}
export async function readSource(
source: ViewerSource,
cap: number,
options: ResolveOptions = {},
): Promise<Blob> {
if (source.blob) {
if (source.blob.size > cap) throw new Error("File exceeds preview limit");
return source.blob;
}
const url = source.src && safeSourceUrl(source.src);
if (!url) throw new Error("No readable file source");
const response = await (options.fetch ?? fetch)(url, { signal: options.signal });
const bytes = await readBounded(response, cap);
return new Blob([bytes as Uint8Array<ArrayBuffer>], {
type: response.headers.get("content-type") ?? source.mime ?? "",
});
}
+66
View File
@@ -0,0 +1,66 @@
import { setButtonLabel } from "./renderers/dom.ts";
import { convertUrl, converterFormats } from "./convert/client.ts";
import { extensionOf } from "./kinds.ts";
import { readBounded } from "./source.ts";
import type { ViewerSource } from "./types.ts";
/** Bounded plain text for search/indexing consumers; the server rechecks source access. */
export async function extractText(
source: ViewerSource,
options: { baseURI?: string; signal?: AbortSignal; fetch?: typeof fetch } = {},
): Promise<string> {
const url = convertUrl(source, "text", options.baseURI);
if (!url) throw new Error("Text extraction needs a URL");
const response = await (options.fetch ?? fetch)(url, { signal: options.signal });
if (!response.headers.get("content-type")?.startsWith("text/plain")) {
await response.body?.cancel();
throw new Error("Text extraction unavailable");
}
return new TextDecoder().decode(await readBounded(response, 2 * 1024 * 1024));
}
export function mountExtractedText(
parent: HTMLElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const button = doc.createElement("button");
button.type = "button";
button.className = "wrn-viewer-action";
setButtonLabel(button, "Extract text");
const details = doc.createElement("details");
const summary = doc.createElement("summary");
summary.textContent = "Extracted text";
const text = doc.createElement("pre");
details.append(summary, text);
let disposed = false;
void converterFormats({ baseURI: doc.baseURI })
.then((formats) => {
if (!disposed && source.src && formats[extensionOf(source.name)]) parent.append(button);
})
.catch(() => {});
button.onclick = async () => {
button.disabled = true;
try {
const result = await extractText(source, { baseURI: doc.baseURI, signal });
if (disposed) return;
text.textContent = result || "No text was found.";
details.open = true;
parent.append(details);
} catch {
if (!disposed) {
text.textContent = "Text extraction is unavailable.";
details.open = true;
parent.append(details);
}
} finally {
if (!disposed) button.disabled = false;
}
};
return () => {
disposed = true;
button.remove();
details.remove();
};
}
+93
View File
@@ -0,0 +1,93 @@
export type ViewerKind =
| "image"
| "vector"
| "pdf"
| "code"
| "text"
| "markup"
| "sheet"
| "doc"
| "audio"
| "video"
| "archive"
| "binary";
export interface ViewerSource {
src?: string;
blob?: Blob | File;
/** `@wrnexus/uploader` object key, resolved by `/api/viewer/source`. */
key?: string;
/** Store holding the key. Omit for the app's default store. */
store?: string;
name: string;
mime?: string;
size?: number;
}
/**
* Everything a renderer may touch. Built-in and app-defined renderers receive
* the same object, so `component` stays a plain label rather than a value the
* runtime switches on.
*/
export interface ViewerMountContext {
/** Scroll container the renderer owns. Already emptied. */
parent: HTMLElement;
/**
* The bounded, re-classified body. Absent only for `streaming` renderers,
* which are handed a URL instead of the bytes.
*/
file?: Blob;
/**
* Element `src` for this file: the original http(s) URL for a streaming
* renderer, otherwise an object URL serving the body under its sniffed type.
*/
sourceUrl: () => Promise<string>;
source: ViewerSource;
kind: ViewerKind;
/** Append a toolbar control. Removed with the rest of the chrome on dispose. */
button: (label: string, action: () => void) => HTMLButtonElement;
/** Object URL revoked when the viewer is disposed. */
objectUrl: (blob: Blob) => string;
/** Abandon this renderer and show the download card instead. */
degrade: (reason?: string) => void;
signal: AbortSignal;
}
export type ViewerMount = (
context: ViewerMountContext,
) => void | (() => void) | Promise<void | (() => void)>;
export interface Renderer {
kind: ViewerKind;
exts: string[];
tier: 1 | 2 | 3;
/** Display name, and the `.wrn` component name where one exists. */
component: string;
load?: (options?: ResolveOptions) => Promise<unknown>;
maxInlineBytes?: number;
/**
* Renders from a URL rather than from bytes. For an http(s) source the
* element is pointed at the original URL, so the browser streams it and the
* app's own CSP (`media-src`/`frame-src` falling back to `default-src`)
* applies to a same-origin request instead of to a `blob:` URL.
*/
streaming?: boolean;
/** Browser adapter. A renderer without one can never be selected for display. */
mount?: ViewerMount;
}
export interface ViewerPlan {
kind: ViewerKind;
renderer: Renderer;
fallback: Renderer[];
needsConvert: boolean;
sandboxed: boolean;
reason?: string;
}
export interface ResolveOptions {
baseURI?: string;
fetch?: typeof fetch;
signal?: AbortSignal;
registry?: import("./registry.ts").RendererRegistry;
}
+43
View File
@@ -0,0 +1,43 @@
import type { ViewerSource } from "../types.ts";
const SOURCE_ROUTE = "/api/viewer/source";
interface SourceDescriptor {
src: string;
name: string;
mime?: string;
}
/**
* Trades a storage key for something loadable. The signing, and the app's own
* access check, happen on the server: the browser only ever sees the resulting
* short-lived URL.
*
* Returns undefined when uploader sources are not enabled or the caller may not
* see this object, which leaves the ladder to show the download card.
*/
export async function resolveKey(
source: ViewerSource,
init: { signal?: AbortSignal; baseURI?: string } = {},
): Promise<ViewerSource | undefined> {
const base = init.baseURI ?? (typeof document === "undefined" ? undefined : document.baseURI);
if (!source.key || !base) return undefined;
const url = new URL(SOURCE_ROUTE, base);
url.searchParams.set("key", source.key);
if (source.store) url.searchParams.set("store", source.store);
try {
const response = await fetch(url.href, { signal: init.signal, credentials: "same-origin" });
if (!response.ok) return undefined;
const descriptor = (await response.json()) as SourceDescriptor;
if (!descriptor?.src) return undefined;
return {
...source,
src: descriptor.src,
// An explicit name on the component wins over the key's basename.
name: source.name || descriptor.name,
mime: source.mime || descriptor.mime,
};
} catch {
return undefined;
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { ViewerUploadsOptions } from "./types.ts";
const KEY = "__wrnexusViewerUploads";
/** Arms uploader-backed sources. See `configureConversion` for why this is a global. */
export function configureUploads(options: ViewerUploadsOptions | undefined): void {
(globalThis as Record<string, unknown>)[KEY] = options;
}
export function uploadsConfig(): ViewerUploadsOptions | undefined {
return (globalThis as Record<string, unknown>)[KEY] as ViewerUploadsOptions | undefined;
}
+3
View File
@@ -0,0 +1,3 @@
export { configureUploads, uploadsConfig } from "./config.ts";
export { handleFile, handleSource, uploadedConversionSource, FILE_ROUTE } from "./service.ts";
export type { ViewerUploadsOptions } from "./types.ts";
+190
View File
@@ -0,0 +1,190 @@
import {
contentTypeOf,
createSignedFileToken,
getStore,
hasStorage,
storedUrl,
verifySignedFileToken,
} from "@wrnexus/uploader";
import { uploadsConfig } from "./config.ts";
import type { ResolvedSource } from "../convert/types.ts";
const DEFAULT_TTL_MS = 5 * 60 * 1000;
export const FILE_ROUTE = "/api/viewer/file";
const problem = (status: number, message: string) =>
new Response(message, {
status,
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
});
/** Keys are opaque object paths; anything that could escape one is refused. */
function safeKey(key: string): boolean {
return (
key.length > 0 &&
key.length <= 1024 &&
!key.startsWith("/") &&
!key.includes("..") &&
!key.includes("\\") &&
// No control characters: keys reach a filesystem or an S3 request line.
![...key].some((character) => character.charCodeAt(0) < 0x20)
);
}
function allowed(store: string): boolean {
const config = uploadsConfig();
if (!config) return false;
if (!hasStorage(store)) return false;
return !config.stores || config.stores.includes(store);
}
/**
* `GET /api/viewer/source` — turns a storage key into what the browser needs:
* a URL it can load plus whatever metadata is free to produce.
*
* Public objects answer with their own URL. Private ones answer with a
* short-lived signed link to the file route, and only after the app's own
* `authorize` hook agrees.
*/
export async function handleSource(request: Request): Promise<Response> {
const config = uploadsConfig();
if (!config) return problem(404, "Uploader sources are not enabled");
const url = new URL(request.url);
const key = url.searchParams.get("key") ?? "";
const storeName = url.searchParams.get("store") ?? "";
if (!key || !safeKey(key)) return problem(400, "A valid key is required");
if (storeName && !allowed(storeName)) return problem(403, "That store is not viewable");
let store;
try {
store = getStore(storeName || undefined);
} catch {
return problem(404, "No such store");
}
if (!allowed(store.name)) return problem(403, "That store is not viewable");
const name = key.split("/").pop() || key;
// The MIME comes from the uploader's own extension table rather than a second
// copy of it here; the viewer still sniffs the bytes before rendering.
const mime = contentTypeOf(name, "");
if (store.access === "public") {
const src = storedUrl(store, key);
if (!src) return problem(404, "Not available");
return Response.json({ src, name, mime }, { headers: { "cache-control": "no-store" } });
}
if (!config.secret) return problem(403, "Private stores need a signing secret");
if (!config.authorize) return problem(403, "Private stores need an authorize hook");
if (!(await config.authorize(request, { store: store.name, key })))
return problem(403, "Not allowed");
const token = await createSignedFileToken(
{
store: store.name,
key,
expiresAt: Date.now() + (config.ttlMs ?? DEFAULT_TTL_MS),
disposition: "inline",
},
config.secret,
);
const src = `${FILE_ROUTE}?token=${encodeURIComponent(token)}`;
return Response.json({ src, name, mime }, { headers: { "cache-control": "no-store" } });
}
async function openToken(token: string) {
const config = uploadsConfig();
if (!config?.secret) return undefined;
const claim = await verifySignedFileToken(token, config.secret);
if (!claim || !allowed(claim.store) || !safeKey(claim.key)) return undefined;
const store = getStore(claim.store);
const object = await store.driver.get(claim.key);
return object ? { claim, object } : undefined;
}
function fileHeaders(contentType: string, size?: number): Headers {
const headers = new Headers({
"content-type": contentType || "application/octet-stream",
"content-disposition": "inline",
"x-content-type-options": "nosniff",
// A signed link is per-viewer and short-lived; it must not enter a shared cache.
"cache-control": "private, no-store",
"accept-ranges": "bytes",
});
if (size !== undefined) headers.set("content-length", String(size));
return headers;
}
/** `GET /api/viewer/file` — streams a private object for a valid signed link. */
export async function handleFile(request: Request): Promise<Response> {
const url = new URL(request.url);
const token = url.searchParams.get("token") ?? "";
if (!token) return problem(400, "A token is required");
const opened = await openToken(token);
// An expired, forged or revoked link is indistinguishable from a missing one.
if (!opened) return problem(404, "Not Found");
const { object } = opened;
if (request.method === "HEAD")
return new Response(null, { headers: fileHeaders(object.contentType, object.size) });
const range = request.headers.get("range");
const buffered = object.body instanceof Uint8Array ? object.body : undefined;
const match = buffered && /^bytes=(\d*)-(\d*)$/.exec(range ?? "");
if (buffered && match) {
// Seeking in audio and video needs ranges; only buffered bodies can serve
// them without reading the whole object back through memory.
const total = buffered.byteLength;
const start = match[1] ? Number(match[1]) : Math.max(0, total - Number(match[2] || 0));
const end = match[1] && match[2] ? Math.min(Number(match[2]), total - 1) : total - 1;
if (!Number.isFinite(start) || start >= total || end < start)
return new Response(null, {
status: 416,
headers: { "content-range": `bytes */${total}` },
});
const slice = buffered.subarray(start, end + 1);
const headers = fileHeaders(object.contentType, slice.byteLength);
headers.set("content-range", `bytes ${start}-${end}/${total}`);
return new Response(slice as unknown as BodyInit, { status: 206, headers });
}
return new Response(object.body as unknown as BodyInit, {
headers: fileHeaders(object.contentType, object.size),
});
}
/**
* Conversion source policy for uploader-backed apps: a converter may only open
* what a valid signed link already grants, so converted output inherits exactly
* the access check of its input.
*/
export function uploadedConversionSource() {
return async (
request: Request,
params: { src: string; name: string },
): Promise<ResolvedSource | undefined> => {
let token: string | null;
try {
const url = new URL(params.src, request.url);
if (url.origin !== new URL(request.url).origin || url.pathname !== FILE_ROUTE)
return undefined;
token = url.searchParams.get("token");
} catch {
return undefined;
}
if (!token) return undefined;
const opened = await openToken(token);
if (!opened) return undefined;
const { object } = opened;
const body =
object.body instanceof Uint8Array
? new Response(object.body as unknown as BodyInit).body!
: object.body;
return {
body,
size: object.size,
identity: JSON.stringify([opened.claim.store, opened.claim.key]),
};
};
}
+23
View File
@@ -0,0 +1,23 @@
export interface ViewerUploadsOptions {
/**
* Stores the viewer may serve from. Omit to allow every configured store.
* Naming them is the cheapest way to keep an unrelated bucket out of reach.
*/
stores?: string[];
/**
* Secret used to sign short-lived links to private objects. Without it,
* private stores are refused outright — nothing is served unsigned.
*/
secret?: string;
/** Lifetime of a signed link. Default 5 minutes. */
ttlMs?: number;
/**
* The app's own access check, called before a private link is minted. There is
* no safe default for "may this viewer see this object", so a private store
* without this hook is refused.
*/
authorize?: (
request: Request,
params: { store: string; key: string },
) => boolean | Promise<boolean>;
}
+208
View File
@@ -0,0 +1,208 @@
import { afterEach, describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { clearViewerActions, registerViewerAction } from "../src/actions.ts";
import { mountViewer } from "../src/runtime.ts";
import { iconFor } from "../src/renderers/icons.ts";
async function settled(ticks = 30) {
for (let i = 0; i < ticks; i++) await new Promise((done) => setTimeout(done, 5));
}
function fixture() {
const window = new Window({ url: "https://app.test/" });
const host = window.document.createElement("section") as unknown as HTMLElement;
window.document.body.append(host as never);
return { window, host };
}
const csv = () => ({ name: "data.csv", blob: new Blob(["a,b\n1,2\n"]) });
const labels = (host: HTMLElement) =>
[...host.querySelectorAll(".wrn-viewer-action")].map((node) => node.getAttribute("aria-label"));
afterEach(() => clearViewerActions());
describe("icon buttons", () => {
test("every control is an icon button with an accessible name and a tooltip", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, csv());
await settled();
const buttons = [...host.querySelectorAll(".wrn-viewer-action")];
expect(buttons.length).toBeGreaterThan(0);
for (const node of buttons) {
const label = node.getAttribute("aria-label") ?? "";
expect(label).toBeTruthy();
// The tooltip shows the same name the assistive label reads out.
expect(node.getAttribute("data-tooltip")).toBe(label);
expect(node.querySelector("svg")).not.toBeNull();
// The name stays in the DOM for screen readers, visually hidden.
expect(node.querySelector(".wrn-viewer-sr")!.textContent).toBe(label);
}
dispose();
await window.happyDOM.close();
});
test("toggling a label swaps the icon instead of deleting it", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, csv());
await settled();
const toggle = [...host.querySelectorAll<HTMLButtonElement>(".wrn-viewer-action")].find(
(node) => node.getAttribute("aria-label") === "Raw",
)!;
toggle.click();
expect(toggle.getAttribute("aria-label")).toBe("Table");
expect(toggle.getAttribute("data-tooltip")).toBe("Table");
expect(toggle.querySelector("svg")).not.toBeNull();
dispose();
await window.happyDOM.close();
});
test("an unknown label still gets an icon rather than nothing", () => {
expect(iconFor("Zoom in")).toBe("zoom-in");
expect(iconFor("Something bespoke")).toBe("file");
});
});
describe("custom actions", () => {
test("a globally registered action appears and runs with the render context", async () => {
const { window, host } = fixture();
const seen: string[] = [];
registerViewerAction({
id: "open-in-tab",
label: "Open in new tab",
run: ({ source, renderer }) => {
seen.push(`${source.name}:${renderer?.component}`);
},
});
const dispose = mountViewer(host, csv());
await settled();
const action = host.querySelector<HTMLButtonElement>('[data-viewer-action="open-in-tab"]')!;
expect(action).not.toBeNull();
expect(action.getAttribute("aria-label")).toBe("Open in new tab");
action.click();
expect(seen).toEqual(["data.csv:TableViewer"]);
dispose();
await window.happyDOM.close();
});
test("`when` decides, and per-viewer actions are offered too", async () => {
const { window, host } = fixture();
registerViewerAction({
id: "images-only",
label: "Crop",
when: ({ kind }) => kind === "image",
run: () => {},
});
const dispose = mountViewer(host, csv(), {
actions: [{ id: "local", label: "Send", placement: "controls", run: () => {} }],
});
await settled();
expect(host.querySelector('[data-viewer-action="images-only"]')).toBeNull();
expect(host.querySelector('[data-viewer-action="local"]')).not.toBeNull();
dispose();
await window.happyDOM.close();
});
test("actions survive a re-render without piling up", async () => {
const { window, host } = fixture();
registerViewerAction({ id: "once", label: "Once", run: () => {} });
const dispose = mountViewer(host, csv());
await settled();
const toggle = [...host.querySelectorAll<HTMLButtonElement>(".wrn-viewer-action")].find(
(node) => node.getAttribute("aria-label") === "Raw",
)!;
toggle.click();
expect(host.querySelectorAll('[data-viewer-action="once"]')).toHaveLength(1);
dispose();
await window.happyDOM.close();
});
test("an app action is still offered for a file nothing can preview", async () => {
const { window, host } = fixture();
registerViewerAction({ id: "anywhere", label: "Report", run: () => {} });
const dispose = mountViewer(host, {
name: "thing.bin",
blob: new Blob([new Uint8Array([0, 1, 2])]),
});
await settled();
expect(host.querySelector('[data-viewer-action="anywhere"]')).not.toBeNull();
// Printing a download card would print nothing, so it is withheld.
expect(host.querySelector('[data-viewer-action="print"]')).toBeNull();
dispose();
await window.happyDOM.close();
});
});
describe("print", () => {
test("a rendered preview offers Print; it is opt-out", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, csv());
await settled();
expect(labels(host)).toContain("Print");
dispose();
const second = fixture();
const disposeSecond = mountViewer(second.host, csv(), { print: false });
await settled();
expect(labels(second.host)).not.toContain("Print");
disposeSecond();
await second.window.happyDOM.close();
await window.happyDOM.close();
});
test("printing isolates a repaired clone of the preview", async () => {
const { window, host } = fixture();
let printed = 0;
(window as unknown as { print: () => void }).print = () => {
printed++;
const doc = window.document;
// At the moment of printing, a clone of the preview stands alone in the
// body and the page is marked so everything else drops out of the flow.
expect(doc.documentElement.hasAttribute("data-viewer-printing-active")).toBe(true);
const root = doc.querySelector("[data-viewer-print-root]")!;
expect(root).not.toBeNull();
expect(root.textContent).toContain("data.csv");
expect(root.querySelector("table")).not.toBeNull();
// Spacer rows stand in for rows that were never in the DOM.
expect(root.querySelectorAll("[data-viewer-spacer]")).toHaveLength(0);
};
const dispose = mountViewer(host, csv());
await settled();
host.querySelector<HTMLButtonElement>('[data-viewer-action="print"]')!.click();
expect(printed).toBe(1);
// Cleanup waits for the browser to finish, then leaves the page as found.
window.dispatchEvent(new window.Event("afterprint") as never);
expect(window.document.querySelector("[data-viewer-print-root]")).toBeNull();
expect(window.document.documentElement.hasAttribute("data-viewer-printing-active")).toBe(false);
dispose();
await window.happyDOM.close();
});
});
describe("audio transport", () => {
test("one set of controls drives the playhead, not two", async () => {
const { window, host } = fixture();
const wav = new Uint8Array(64);
wav.set(new TextEncoder().encode("RIFF"));
wav.set(new TextEncoder().encode("WAVE"), 8);
const dispose = mountViewer(host, { name: "clip.wav", blob: new Blob([wav as never]) });
await settled();
// The native controls are gone: they were a second play/seek for the same
// element, sitting above the package's own transport.
expect(host.querySelector("audio")!.hasAttribute("controls")).toBe(false);
const player = host.querySelector(".wrn-viewer-player")!;
expect(player).not.toBeNull();
expect(
[...player.querySelectorAll(".wrn-viewer-action")].map((n) => n.getAttribute("aria-label")),
).toEqual(["Play", "Mute"]);
// Exactly one seek control, and it is labelled.
const seeks = host.querySelectorAll('input[type="range"]');
expect(seeks).toHaveLength(1);
expect(seeks[0]!.getAttribute("aria-label")).toContain("clip.wav");
const play = player.querySelector<HTMLButtonElement>(".wrn-viewer-play")!;
expect(play.querySelector("svg")).not.toBeNull();
dispose();
await window.happyDOM.close();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { expect, test } from "bun:test";
import { readFileSync, readdirSync } from "node:fs";
import { createRequire } from "node:module";
import { renderComponent } from "../../test/src/index.ts";
import { viewerPlugin } from "../src/plugin.ts";
import { compileWrnFile } from "../../compiler/src/index.ts";
import {
mergePluginAssets,
readPluginAsset,
runtimeScriptsForMarkup,
} from "../../dev-server/src/plugin-assets.ts";
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
for (const filename of readdirSync(new URL("../components", import.meta.url))) {
test(`${filename} renders its presentation contract`, async () => {
const source = readFileSync(new URL(`../components/${filename}`, import.meta.url), "utf8");
const html = await renderComponent(source, { class: "custom-presentation" });
expect(html).toContain("custom-presentation");
expect(html).toContain(filename === "Viewer.wrn" ? "data-wrnexus-runtime" : "data-viewer-");
});
}
test("Viewer renders escaped source attributes and runtime marker", async () => {
const source = readFileSync(new URL("../components/Viewer.wrn", import.meta.url), "utf8");
const html = await renderComponent(source, {
name: "<script>alert(1)</script>",
src: '/file?x="',
});
expect(html).toContain('data-wrnexus-runtime="viewer"');
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain('data-component="ViewerShell"');
});
test("framework bundles editor separately and injects only the viewer runtime", async () => {
const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
const assets = mergePluginAssets(runtimes, []);
const viewer = assets.find((asset) => asset.id === "runtime:viewer")!;
const editor = assets.find((asset) => asset.id === "runtime:viewer-code")!;
const decode = (body: string | ArrayBuffer) =>
typeof body === "string" ? body : new TextDecoder().decode(body);
const main = decode(await readPluginAsset(viewer));
expect(main).not.toContain("class EditorView");
expect(main).toContain("/__wrnexus/assets/viewer-code.js");
expect(decode(await readPluginAsset(editor))).toContain("mountBuilderSourceEditor");
expect(
runtimeScriptsForMarkup('<section data-wrnexus-runtime="viewer"></section>', runtimes),
).toHaveLength(1);
});
test("example page compiles with explicit Viewer import", () => {
const path = new URL("../../../examples/basic-app/app/pages/file-viewer.wrn", import.meta.url);
expect(compileWrnFile(readFileSync(path, "utf8"), path.pathname)).toContain("Viewer");
});
test("plugin contributes existing browser assets and mounts no conversion routes", () => {
const plugin = viewerPlugin();
expect(plugin.routeEntries).toBeUndefined();
expect(Array.isArray(plugin.componentDirs)).toBe(true);
for (const file of [
new URL("../src/runtime.ts", import.meta.url),
new URL("../viewer.css", import.meta.url),
])
expect(readFileSync(file, "utf8").length).toBeGreaterThan(0);
// The two core runtimes, plus one per installed optional parser (and the
// pdf.js worker). Only the core viewer runtime is ever injected into a page.
const ids = (plugin.clientRuntimes as ClientRuntimeDefinition[]).map((runtime) => runtime.id);
expect(ids.slice(0, 2)).toEqual(["viewer-code", "viewer"]);
for (const id of ids.slice(2)) expect(id.startsWith("viewer-")).toBe(true);
expect(plugin.styleSources).toHaveLength(1);
});
test("optional parsers are contributed only when their package resolves", () => {
const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
const ids = new Set(runtimes.map((runtime) => runtime.id));
const resolvable = (specifier: string) => {
try {
createRequire(new URL("../src/plugin.ts", import.meta.url)).resolve(specifier);
return true;
} catch {
return false;
}
};
for (const [id, specifier] of [
["viewer-sheet", "xlsx"],
["viewer-docx", "mammoth"],
["viewer-zip", "fflate"],
["viewer-pdfjs", "pdfjs-dist"],
] as const)
expect(ids.has(id)).toBe(resolvable(specifier));
// pdf.js is useless without its worker asset.
expect(ids.has("viewer-pdfjs-worker")).toBe(ids.has("viewer-pdfjs"));
});
test("optional runtimes are served but never injected into a page", () => {
const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
const markup = '<section data-wrnexus-runtime="viewer"></section>';
expect(runtimeScriptsForMarkup(markup, runtimes)).toHaveLength(1);
const assets = mergePluginAssets(runtimes, []);
for (const id of ["viewer-sheet", "viewer-zip", "viewer-docx", "viewer-pdfjs"]) {
if (!runtimes.some((runtime) => runtime.id === id)) continue;
const asset = assets.find((item) => item.id === `runtime:${id}`)!;
expect(asset.publicPath).toBe(`/__wrnexus/assets/${id}.js`);
}
});
test("each optional runtime bundles for the browser and exports its parser", async () => {
const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
const assets = mergePluginAssets(runtimes, []);
const decode = (body: string | ArrayBuffer) =>
typeof body === "string" ? body : new TextDecoder().decode(body);
for (const [id, exported] of [
["viewer-sheet", "readWorkbook"],
["viewer-docx", "docxToHtml"],
["viewer-zip", "listZip"],
["viewer-pdfjs", "renderPdf"],
] as const) {
const asset = assets.find((item) => item.id === `runtime:${id}`);
if (!asset) continue;
const code = decode(await readPluginAsset(asset));
expect(code.length).toBeGreaterThan(0);
expect(code).toContain(exported);
}
}, 120_000);
+203
View File
@@ -0,0 +1,203 @@
import { afterEach, describe, expect, test } from "bun:test";
import { configureConversion } from "../src/convert/config.ts";
import { handleCapabilities, handleConvert } from "../src/convert/service.ts";
import { localBinaryConverter } from "../src/convert/local.ts";
import { resetConverterProbe } from "../src/convert/client.ts";
import { viewerPlugin } from "../src/plugin.ts";
import { resolve } from "../src/resolve.ts";
import type { ViewerConverter } from "../src/convert/types.ts";
const encoder = new TextEncoder();
function fakeConverter(overrides: Partial<ViewerConverter> = {}): ViewerConverter {
return {
name: "fake",
version: "1",
supports: (_kind, ext) => ext === ".docx",
outputType: (ext) => (ext === ".docx" ? "application/pdf" : undefined),
toPreview: async () => ({ mime: "application/pdf", bytes: encoder.encode("%PDF-1.7 fake") }),
...overrides,
};
}
const request = (path: string) => new Request(`https://app.test${path}`);
const convertRequest = (src: string, name: string) =>
request(`/api/viewer/preview?src=${encodeURIComponent(src)}&name=${encodeURIComponent(name)}`);
afterEach(() => {
configureConversion(undefined);
resetConverterProbe();
});
describe("conversion routes", () => {
test("everything 404s until conversion is configured", async () => {
expect(handleCapabilities().status).toBe(404);
expect((await handleConvert(convertRequest("/a.docx", "a.docx"), "preview")).status).toBe(404);
});
test("capabilities advertise only what a converter claims", async () => {
configureConversion({ converters: [fakeConverter()] });
const body = (await handleCapabilities().json()) as {
converters: string[];
formats: Record<string, string>;
};
expect(body.converters).toEqual(["fake"]);
expect(body.formats[".docx"]).toBe("application/pdf");
expect(body.formats[".psd"]).toBeUndefined();
});
test("unknown extensions and unsupported formats are refused, not attempted", async () => {
configureConversion({ converters: [fakeConverter()] });
expect((await handleConvert(convertRequest("/a.foo", "a.foo"), "preview")).status).toBe(415);
expect((await handleConvert(convertRequest("/a.psd", "a.psd"), "preview")).status).toBe(415);
expect((await handleConvert(request("/api/viewer/preview"), "preview")).status).toBe(400);
});
test("the default source policy refuses cross-origin URLs without fetching", async () => {
let fetched = false;
const original = globalThis.fetch;
globalThis.fetch = (async () => {
fetched = true;
return new Response("x");
}) as unknown as typeof fetch;
try {
configureConversion({ converters: [fakeConverter()] });
const response = await handleConvert(
convertRequest("https://evil.test/a.docx", "a.docx"),
"preview",
);
expect(response.status).toBe(403);
expect(fetched).toBe(false);
} finally {
globalThis.fetch = original;
}
});
test("a converted result is returned, then served from cache", async () => {
let conversions = 0;
configureConversion({
converters: [
fakeConverter({
toPreview: async () => {
conversions++;
return { mime: "application/pdf", bytes: encoder.encode("%PDF-1.7 fake") };
},
}),
],
resolveSource: async () => ({
body: new Response(encoder.encode("docx bytes")).body!,
size: 10,
}),
});
const first = await handleConvert(convertRequest("/files/a.docx", "a.docx"), "preview");
expect(first.status).toBe(200);
expect(first.headers.get("content-type")).toBe("application/pdf");
expect(first.headers.get("x-content-type-options")).toBe("nosniff");
expect(first.headers.get("x-viewer-cache")).toBe("miss");
expect(await first.text()).toContain("%PDF");
const second = await handleConvert(convertRequest("/files/a.docx", "a.docx"), "preview");
expect(second.headers.get("x-viewer-cache")).toBe("hit");
expect(conversions).toBe(1);
});
test("oversized sources are refused before the converter runs", async () => {
let ran = false;
configureConversion({
maxInputBytes: 8,
converters: [
fakeConverter({
toPreview: async () => {
ran = true;
return { mime: "application/pdf", bytes: new Uint8Array() };
},
}),
],
resolveSource: async () => ({ body: new Response("x").body!, size: 999 }),
});
expect((await handleConvert(convertRequest("/a.docx", "a.docx"), "preview")).status).toBe(413);
expect(ran).toBe(false);
});
test("a converter failure reports a generic error, never its own message", async () => {
configureConversion({
converters: [
fakeConverter({
toPreview: async () => {
throw new Error("soffice crashed at C:/secret/path");
},
}),
],
resolveSource: async () => ({ body: new Response("x").body! }),
});
const response = await handleConvert(convertRequest("/a.docx", "a.docx"), "preview");
expect(response.status).toBe(422);
expect(await response.text()).not.toContain("secret");
});
});
describe("plugin wiring", () => {
test("routes appear only when conversion is configured", () => {
expect(viewerPlugin().routeEntries).toBeUndefined();
const routes = viewerPlugin({ convert: { converters: [fakeConverter()] } }).routeEntries as {
path: string;
}[];
expect(routes.map((route) => route.path)).toEqual([
"/api/viewer/capabilities",
"/api/viewer/preview",
"/api/viewer/thumb",
"/api/viewer/text",
]);
// Configuring the plugin is what arms the handlers.
expect(handleCapabilities().status).toBe(200);
});
});
describe("local binary converter", () => {
test("a disabled binary makes its formats unsupported", () => {
const converter = localBinaryConverter({
binaries: { soffice: false, magick: false, ffmpeg: false, "7z": false },
});
expect(converter.supports("doc", ".docx")).toBe(false);
expect(converter.outputType(".psd")).toBeUndefined();
});
test("an unknown extension is never routed to a binary", () => {
expect(localBinaryConverter().supports("binary", ".xyz")).toBe(false);
});
test("conversion of an unsupported format rejects rather than spawning", async () => {
const converter = localBinaryConverter({ binaries: { soffice: false } });
await expect(
converter.toPreview(new Response("x").body!, {
name: "a.docx",
ext: ".docx",
kind: "doc",
signal: new AbortController().signal,
}),
).rejects.toThrow("No local converter");
});
});
describe("tier 3 planning", () => {
test("office and design files keep their extension kind despite generic bytes", async () => {
// A .docx is a ZIP and a .psd is opaque binary; neither signature identifies
// the content, so the extension must still choose the converter.
const docx = await resolve({
name: "report.docx",
blob: new Blob([new Uint8Array([0x50, 0x4b, 3, 4, 0, 0])]),
});
expect(docx.kind).toBe("doc");
const psd = await resolve({
name: "art.psd",
blob: new Blob([new Uint8Array([0x38, 0x42, 0x50, 0x53, 0, 1])]),
});
expect(psd.kind).toBe("binary");
});
test("without a reachable converter they fall back and ask for no conversion", async () => {
const plan = await resolve({
name: "report.docx",
blob: new Blob([new Uint8Array([0x50, 0x4b, 3, 4, 0, 0])]),
});
expect(plan.renderer.component).toBe("DownloadCard");
expect(plan.needsConvert).toBe(false);
});
});
+259
View File
@@ -0,0 +1,259 @@
import { afterEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { firstOf, sameOriginSource } from "../src/convert/sources.ts";
import { configureConversion } from "../src/convert/config.ts";
import { handleConvert } from "../src/convert/service.ts";
import { converterFormats, convertUrl, resetConverterProbe } from "../src/convert/client.ts";
import { loadOptional } from "../src/optional/load.ts";
import { sanitizeHtml } from "../src/parse/html.ts";
import { waveformPeaks } from "../src/renderers/waveform.ts";
import { mountViewer } from "../src/runtime.ts";
import { registerRenderer } from "../src/registry.ts";
import { extractText } from "../src/text.ts";
afterEach(() => {
configureConversion(undefined);
resetConverterProbe();
});
const tick = () => new Promise((resolve) => setTimeout(resolve, 40));
test("runtime preserves overridden chrome and mounts inside the renderer presentation", async () => {
const window = new Window();
const host = window.document.createElement("section");
window.document.body.append(host);
// Trusted test fixture representing SSR output from an overridden component.
host.innerHTML =
'<header class="custom-toolbar" data-viewer-toolbar><span data-viewer-title></span><span data-viewer-controls></span><span data-viewer-actions></span></header><main class="custom-content" data-viewer-content></main><template data-viewer-template="ImageViewer"><article class="custom-image"><div data-viewer-mount></div></article></template>';
const before = host.innerHTML;
const dispose = mountViewer(host as unknown as HTMLElement, {
name: "photo.png",
blob: new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])]),
});
await tick();
expect(host.querySelector(".custom-toolbar [data-viewer-title]")?.textContent).toBe("photo.png");
expect(
host.querySelector(".custom-content .custom-image [data-viewer-mount] img"),
).not.toBeNull();
dispose();
expect(host.innerHTML).toBe(before);
await window.happyDOM.close();
});
test("filmstrip consumes the thumbnail endpoint and releases its object URLs", async () => {
const window = new Window({ url: "https://thumb.test/demo" });
const host = window.document.createElement("section");
window.document.body.append(host);
const saved = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (async (input, init) => {
const url = new URL(String(input));
requests.push(url.pathname);
if (url.pathname.endsWith("/capabilities"))
return Response.json({ formats: { ".psd": "image/png" } });
if (url.pathname.endsWith("/thumb"))
return new Response(new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), {
headers: { "content-type": "image/png" },
});
if (init?.method === "HEAD") return new Response(null, { headers: { "content-length": "8" } });
return new Response(new Uint8Array([56, 66, 80, 83, 0, 0, 0, 0]));
}) as typeof fetch;
let dispose: (() => void) | undefined;
try {
dispose = mountViewer(host as unknown as HTMLElement, [
{ name: "art.psd", src: "https://thumb.test/art.psd" },
]);
await tick();
expect(requests).toContain("/api/viewer/thumb");
const image = host.querySelector(".wrn-viewer-file img") as unknown as HTMLImageElement;
expect(image).not.toBeNull();
const url = image.src;
dispose();
dispose = undefined;
await expect(saved(url)).rejects.toThrow();
} finally {
dispose?.();
globalThis.fetch = saved;
await window.happyDOM.close();
}
});
test("rotating links hit one stable cache entry, but denied access never reads it", async () => {
let allowed = true;
let conversions = 0;
let cancellations = 0;
configureConversion({
converters: [
{
name: "stable-test",
supports: () => true,
outputType: () => "application/pdf",
toPreview: async () => {
conversions++;
return { mime: "application/pdf", bytes: new TextEncoder().encode("%PDF") };
},
},
],
resolveSource: async () =>
allowed
? {
identity: "reports:key:revision1",
body: new ReadableStream({
cancel() {
cancellations++;
},
}),
}
: undefined,
});
const request = (token: string) =>
new Request(
`https://app.test/api/viewer/preview?name=a.docx&src=${encodeURIComponent("/api/viewer/file?token=" + token)}`,
);
expect((await handleConvert(request("first"), "preview")).headers.get("x-viewer-cache")).toBe(
"miss",
);
expect((await handleConvert(request("rotated"), "preview")).headers.get("x-viewer-cache")).toBe(
"hit",
);
expect(conversions).toBe(1);
expect(cancellations).toBeGreaterThanOrEqual(1);
allowed = false;
expect((await handleConvert(request("rotated"), "preview")).status).toBe(403);
});
test("source composition falls through on undefined and stops on errors", async () => {
const seen: string[] = [];
const result = await firstOf(
async () => {
seen.push("upload");
return undefined;
},
async () => {
seen.push("static");
return { body: new Response("static").body!, identity: "static" };
},
)(new Request("https://app.test"), { src: "/a", name: "a" });
expect(result?.identity).toBe("static");
expect(seen).toEqual(["upload", "static"]);
await expect(
firstOf(
async () => {
throw new Error("denied");
},
async () => {
throw new Error("must not run");
},
)(new Request("https://app.test"), { src: "/a", name: "a" }),
).rejects.toThrow("denied");
});
test("same-origin policy refuses viewer API links instead of bypassing private policy", async () => {
const source = sameOriginSource();
for (const src of [
"/api/viewer/file?token=bad",
"https://other.test/a",
"https://user:pass@app.test/a",
])
expect(await source(new Request("https://app.test"), { src, name: "a.docx" })).toBeUndefined();
});
test("client helpers use the supplied document origin without global document", async () => {
const fetched: string[] = [];
const request = (async (url: string) => {
fetched.push(String(url));
return Response.json({ formats: { ".docx": "application/pdf" } });
}) as unknown as typeof fetch;
expect(await converterFormats({ baseURI: "https://one.test/page", fetch: request })).toEqual({
".docx": "application/pdf",
});
expect(fetched).toEqual(["https://one.test/api/viewer/capabilities"]);
expect(convertUrl({ src: "/file", name: "a.docx" }, "text", "https://two.test/page")).toContain(
"https://two.test/api/viewer/text",
);
expect(
await loadOptional<{ ok: boolean }>("viewer-sheet", "https://three.test/page", async (url) => {
fetched.push(url);
return { ok: true };
}),
).toEqual({ ok: true });
expect(fetched[1]).toBe("https://three.test/__wrnexus/assets/viewer-sheet.js");
});
test("HTML sanitizer preserves text and layout while removing active and external content", async () => {
const window = new Window();
const html = sanitizeHtml(
'<base href="https://evil.test"><script>alert(1)</script><h1 onclick="x()">Hello</h1><iframe src="//evil"></iframe><a href="javascript:x()">link</a><img src="https://evil" onerror="x()"><p style="background:url(https://evil)">safe</p>',
window as unknown as { DOMParser: new () => DOMParser },
);
expect(html).toContain("<h1>Hello</h1>");
for (const bad of [
"script",
"onclick",
"iframe",
"javascript:",
"https://evil",
"onerror",
"<base",
])
expect(html).not.toContain(bad);
await window.happyDOM.close();
});
test("waveform envelope includes stereo channels and clamps amplitudes", () => {
expect(
waveformPeaks([new Float32Array([0, 0.5, 0, 0]), new Float32Array([0, 0, -2, 0])], 2),
).toEqual([0.5, 1]);
expect(waveformPeaks([new Float32Array(4)], 2)).toEqual([0, 0]);
});
test("filmstrip switching disposes each active renderer and supports keyboard selection", async () => {
const window = new Window();
const host = window.document.createElement("section");
window.document.body.append(host);
const clean: string[] = [];
const unregister = registerRenderer({
kind: "text",
exts: [".txt"],
tier: 2,
component: "TextTest",
mount: ({ parent, source }) => {
parent.textContent = source.name;
return () => {
clean.push(source.name);
};
},
});
const dispose = mountViewer(host as unknown as HTMLElement, [
{ name: "one.txt", blob: new Blob(["one"]) },
{ name: "two.txt", blob: new Blob(["two"]) },
]);
await tick();
const buttons = host.querySelectorAll(".wrn-viewer-file");
(buttons[1] as unknown as HTMLButtonElement).click();
await tick();
expect(clean).toEqual(["one.txt"]);
expect(buttons[1]!.getAttribute("aria-pressed")).toBe("true");
buttons[1]!.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
await tick();
expect(clean).toEqual(["one.txt", "two.txt"]);
expect(buttons[0]!.getAttribute("aria-pressed")).toBe("true");
dispose();
expect(clean).toEqual(["one.txt", "two.txt", "one.txt"]);
expect(host.children.length).toBe(0);
unregister();
await window.happyDOM.close();
});
test("text extraction rejects non-text and enforces bounded output", async () => {
const source = { src: "/a.docx", name: "a.docx" };
expect(
await extractText(source, {
baseURI: "https://app.test",
fetch: (async () =>
new Response("search words", {
headers: { "content-type": "text/plain" },
})) as unknown as typeof fetch,
}),
).toBe("search words");
await expect(
extractText(source, {
baseURI: "https://app.test",
fetch: (async () =>
new Response("%PDF", {
headers: { "content-type": "application/pdf" },
})) as unknown as typeof fetch,
}),
).rejects.toThrow("unavailable");
});
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { zipSync, strToU8 } from "fflate";
import { readOdf, readPptx } from "../src/optional/office.ts";
import { isRtf, rtfToText } from "../src/parse/rtf.ts";
import { resolve } from "../src/resolve.ts";
import { RendererRegistry } from "../src/registry.ts";
import { kindOfExtension } from "../src/kinds.ts";
const view = new Window() as unknown as { DOMParser: new () => DOMParser };
const A = "http://schemas.openxmlformats.org/drawingml/2006/main";
const P = "http://schemas.openxmlformats.org/presentationml/2006/main";
const OFFICE = "urn:oasis:names:tc:opendocument:xmlns:office:1.0";
const TEXT = "urn:oasis:names:tc:opendocument:xmlns:text:1.0";
const TABLE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0";
const DRAW = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0";
const file = (name: string, bytes: Uint8Array, mime = "") => ({
name,
blob: new Blob([bytes as unknown as BlobPart], { type: mime }),
});
/** A slide with one title shape and one body shape, as PowerPoint writes them. */
const slideXml = (title: string, bullets: string[]) =>
`<?xml version="1.0"?><p:sld xmlns:p="${P}" xmlns:a="${A}"><p:cSld><p:spTree>` +
`<p:sp><p:txBody><a:p><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp>` +
`<p:sp><p:txBody>${bullets
.map((line) => `<a:p><a:r><a:t>${line}</a:t></a:r></a:p>`)
.join("")}</p:txBody></p:sp>` +
"</p:spTree></p:cSld></p:sld>";
const pptx = zipSync({
// Written out of order on purpose: slide 10 must not sort before slide 2.
"ppt/slides/slide10.xml": strToU8(slideXml("Last slide", ["The end"])),
"ppt/slides/slide1.xml": strToU8(slideXml("Quarterly review", ["Revenue up", "Costs flat"])),
"ppt/slides/slide2.xml": strToU8(slideXml("Regions", ["North", "South"])),
"ppt/notesSlides/notesSlide1.xml": strToU8(
`<?xml version="1.0"?><p:notes xmlns:p="${P}" xmlns:a="${A}"><a:p><a:r><a:t>Say hello first</a:t></a:r></a:p></p:notes>`,
),
// Media must never be inflated for a listing-style read.
"ppt/media/image1.png": new Uint8Array(2048),
});
const odf = (mimetype: string, body: string) =>
zipSync({
mimetype: strToU8(mimetype),
"content.xml": strToU8(
`<?xml version="1.0"?><office:document-content xmlns:office="${OFFICE}" ` +
`xmlns:text="${TEXT}" xmlns:table="${TABLE}" xmlns:draw="${DRAW}">${body}` +
"</office:document-content>",
),
});
describe("PowerPoint", () => {
test("slides come back in numeric order with titles and bullets", async () => {
const deck = await readPptx(pptx, view);
expect(deck.type).toBe("slides");
expect(deck.slides.map((slide) => slide.title)).toEqual([
"Quarterly review",
"Regions",
"Last slide",
]);
expect(deck.slides[0]!.blocks[1]).toEqual(["Revenue up", "Costs flat"]);
expect(deck.slides[0]!.notes).toBe("Say hello first");
expect(deck.slides[1]!.notes).toBeUndefined();
});
test("a file with no slides rejects rather than showing an empty deck", async () => {
await expect(readPptx(zipSync({ "docProps/app.xml": strToU8("<x/>") }), view)).rejects.toThrow(
"no slides",
);
});
});
describe("OpenDocument", () => {
test("text keeps heading levels and list membership", async () => {
const bytes = odf(
"application/vnd.oasis.opendocument.text",
"<office:body><office:text>" +
'<text:h text:outline-level="1">Annual report</text:h>' +
"<text:p>Opening paragraph.</text:p>" +
'<text:h text:outline-level="2">Detail</text:h>' +
"<text:list><text:list-item><text:p>First point</text:p></text:list-item>" +
"<text:list-item><text:p>Second point</text:p></text:list-item></text:list>" +
"</office:text></office:body>",
);
const document = await readOdf(bytes, view);
expect(document.type).toBe("text");
if (document.type !== "text") throw new Error("unreachable");
expect(document.blocks[0]).toEqual({ level: 1, text: "Annual report" });
expect(document.blocks[1]).toEqual({ level: 0, text: "Opening paragraph." });
expect(document.blocks[2]!.level).toBe(2);
expect(document.blocks[3]).toEqual({ level: 0, text: "First point", list: true });
});
test("spreadsheets expand repeated cells but not trailing padding", async () => {
const bytes = odf(
"application/vnd.oasis.opendocument.spreadsheet",
"<office:body><office:spreadsheet>" +
'<table:table table:name="Revenue">' +
"<table:table-row><table:table-cell><text:p>Region</text:p></table:table-cell>" +
"<table:table-cell><text:p>Q1</text:p></table:table-cell></table:table-row>" +
"<table:table-row><table:table-cell><text:p>North</text:p></table:table-cell>" +
'<table:table-cell table:number-columns-repeated="2"><text:p>10</text:p></table:table-cell>' +
'<table:table-cell table:number-columns-repeated="16384"/></table:table-row>' +
"</table:table></office:spreadsheet></office:body>",
);
const document = await readOdf(bytes, view);
if (document.type !== "sheets") throw new Error("expected sheets");
expect(document.sheets[0]!.name).toBe("Revenue");
expect(document.sheets[0]!.header).toEqual(["Region", "Q1"]);
// The repeat of 2 is real data; the 16384 empty tail is dropped.
expect(document.sheets[0]!.rows).toEqual([["North", "10", "10"]]);
});
test("presentations become slides", async () => {
const bytes = odf(
"application/vnd.oasis.opendocument.presentation",
"<office:body><office:presentation>" +
'<draw:page draw:name="page1"><draw:frame><text:p>Kickoff</text:p></draw:frame>' +
"<draw:frame><text:p>Agenda</text:p><text:p>Timeline</text:p></draw:frame></draw:page>" +
"</office:presentation></office:body>",
);
const document = await readOdf(bytes, view);
if (document.type !== "slides") throw new Error("expected slides");
expect(document.slides[0]!.title).toBe("Kickoff");
expect(document.slides[0]!.blocks[1]).toEqual(["Agenda", "Timeline"]);
});
test("a zip that is not an OpenDocument file rejects", async () => {
await expect(readOdf(zipSync({ "a.txt": strToU8("hi") }), view)).rejects.toThrow(
"Not an OpenDocument",
);
});
});
describe("RTF", () => {
test("control words, escapes and unicode become text", () => {
const source =
"{\\rtf1\\ansi{\\fonttbl{\\f0 Times;}}{\\colortbl;\\red0\\green0\\blue0;}" +
"\\f0\\fs24 Hello\\tab world\\par Second line\\par " +
"Caf\\'e9 and \\u8212? dashes\\par}";
const text = rtfToText(source);
expect(text).toContain("Hello\tworld");
expect(text).toContain("Second line");
// Font and colour tables must not leak into the output.
expect(text).not.toContain("Times");
expect(text).not.toContain("red0");
expect(text).toContain("Café");
expect(text).toContain("—");
});
test("optional destinations are skipped whole", () => {
const text = rtfToText(
"{\\rtf1{\\*\\generator Word 16;}Visible text{\\*\\bkmkstart hidden}\\par}",
);
expect(text).toContain("Visible text");
expect(text).not.toContain("Word 16");
expect(text).not.toContain("hidden");
});
test("non-RTF input is rejected", () => {
expect(isRtf("hello")).toBe(false);
expect(() => rtfToText("hello")).toThrow("Not an RTF");
});
});
describe("routing", () => {
// Optional modules cannot load outside a browser, so `resolve` would skip
// every renderer that needs one. Registration and precedence are the units
// under test here; the mounted result is verified in the example app.
const registry = new RendererRegistry();
const components = (ext: string) =>
registry.candidates(kindOfExtension(ext), ext).map((renderer) => renderer.component);
test("presentations and documents prefer the converter, with a local reader behind it", () => {
expect(components(".pptx")).toEqual(["ConvertedViewer", "SlidesViewer"]);
expect(components(".odt")).toEqual(["ConvertedViewer", "OpenDocumentViewer"]);
expect(components(".odp")).toEqual(["ConvertedViewer", "OpenDocumentViewer"]);
expect(components(".docx")).toEqual(["ConvertedViewer", "DocxViewer"]);
});
test("spreadsheets prefer the local reader: the data matters, not the layout", () => {
expect(components(".ods")[0]).toBe("SheetViewer");
expect(components(".xlsx")[0]).toBe("SheetViewer");
// A workbook SheetJS cannot open still has the OpenDocument reader behind it.
expect(components(".ods")).toContain("OpenDocumentViewer");
});
test("legacy binary formats stay converter-only", () => {
expect(components(".doc")).toEqual(["ConvertedViewer"]);
expect(components(".ppt")).toEqual(["ConvertedViewer"]);
});
test("RTF needs no optional module and no converter", async () => {
expect(components(".rtf")[0]).toBe("ConvertedViewer");
const source = String.raw`{\rtf1\ansi Hello\par}`;
expect(isRtf(source)).toBe(true);
const plan = await resolve(file("letter.rtf", strToU8(source)));
expect(plan.renderer.component).toBe("RtfViewer");
expect(rtfToText(source)).toBe("Hello");
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test";
import { docxToHtml } from "../src/optional/docx.ts";
import { readWorkbook } from "../src/optional/sheet.ts";
import { listZip } from "../src/optional/zip.ts";
import { zipSync, strToU8 } from "fflate";
/**
* These exercise the parsers themselves. The browser wiring — one runtime asset
* per installed package, fetched on first use — is covered in component.test.ts.
*/
describe("zip listing", () => {
const archive = zipSync({
"docs/readme.md": strToU8("hello world\n".repeat(20)),
"src/index.ts": strToU8("export const value = 1;\n"),
});
test("entries are listed with their uncompressed sizes", async () => {
const entries = await listZip(archive);
expect(entries.map((entry) => entry.name).sort()).toEqual(["docs/readme.md", "src/index.ts"]);
expect(entries.find((entry) => entry.name === "docs/readme.md")!.size).toBe(240);
});
test("a corrupt archive rejects rather than resolving empty", async () => {
await expect(listZip(new Uint8Array([0x50, 0x4b, 3, 4, 9, 9, 9]))).rejects.toThrow();
});
});
describe("workbook reading", () => {
// Built with the same shape as examples/basic-app/public/viewer/sample.xlsx.
const NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
const R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const sheet = (rows: (string | number)[][]) =>
`<?xml version="1.0"?><worksheet xmlns="${NS}"><sheetData>${rows
.map(
(row, rowIndex) =>
`<row r="${rowIndex + 1}">${row
.map((value, index) => {
const ref = `${String.fromCharCode(65 + index)}${rowIndex + 1}`;
return typeof value === "number"
? `<c r="${ref}"><v>${value}</v></c>`
: `<c r="${ref}" t="inlineStr"><is><t>${value}</t></is></c>`;
})
.join("")}</row>`,
)
.join("")}</sheetData></worksheet>`;
const workbook = zipSync({
"[Content_Types].xml": strToU8(
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
'<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
"</Types>",
),
"_rels/.rels": strToU8(
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${R}/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
),
"xl/workbook.xml": strToU8(
`<?xml version="1.0"?><workbook xmlns="${NS}" xmlns:r="${R}"><sheets>` +
'<sheet name="Revenue" sheetId="1" r:id="rId1"/>' +
'<sheet name="Headcount" sheetId="2" r:id="rId2"/>' +
"</sheets></workbook>",
),
"xl/_rels/workbook.xml.rels": strToU8(
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="${R}/worksheet" Target="worksheets/sheet1.xml"/>` +
`<Relationship Id="rId2" Type="${R}/worksheet" Target="worksheets/sheet2.xml"/>` +
"</Relationships>",
),
"xl/worksheets/sheet1.xml": strToU8(
sheet([
["Region", "Q1"],
["North", 15400],
["South", 11200],
]),
),
"xl/worksheets/sheet2.xml": strToU8(
sheet([
["Team", "People"],
["Engineering", 42],
]),
),
});
test("every sheet comes back named, with a header and string cells", () => {
const sheets = readWorkbook(workbook);
expect(sheets.map((item) => item.name)).toEqual(["Revenue", "Headcount"]);
expect(sheets[0]!.header).toEqual(["Region", "Q1"]);
expect(sheets[0]!.rows).toEqual([
["North", "15400"],
["South", "11200"],
]);
expect(sheets[1]!.rows).toEqual([["Engineering", "42"]]);
});
});
describe("document conversion", () => {
const W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const paragraph = (text: string, style?: string) =>
`<w:p>${style ? `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>` : ""}` +
`<w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>`;
const document = zipSync({
"[Content_Types].xml": strToU8(
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
"</Types>",
),
"_rels/.rels": strToU8(
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${R}/officeDocument" Target="word/document.xml"/></Relationships>`,
),
"word/document.xml": strToU8(
`<?xml version="1.0"?><w:document xmlns:w="${W}"><w:body>` +
paragraph("Quarterly summary", "Heading1") +
paragraph("Bodies of text survive; page layout does not.") +
"</w:body></w:document>",
),
});
test("a docx becomes semantic HTML", async () => {
const html = await docxToHtml(document);
expect(html).toContain("<h1>Quarterly summary</h1>");
expect(html).toContain("Bodies of text survive");
// Semantic output only: no document chrome, and nothing executable.
expect(html).not.toContain("<script");
});
test("a file that is not a docx rejects", async () => {
await expect(docxToHtml(strToU8("not a document"))).rejects.toThrow();
});
});
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { openViewer, sourceFromTrigger, startViewerTriggers } from "../src/overlay.ts";
import { mountViewer, startViewerRuntime } from "../src/runtime.ts";
async function settled(ticks = 30) {
for (let i = 0; i < ticks; i++) await new Promise((done) => setTimeout(done, 5));
}
function page() {
const window = new Window({ url: "https://app.test/" });
return { window, doc: window.document as unknown as Document };
}
const csv = { name: "data.csv", blob: new Blob(["a,b\n1,2\n"]) };
describe("opening a preview over the page", () => {
test("the overlay mounts a viewer and offers a way out", async () => {
const { window, doc } = page();
const opened = openViewer(csv, { document: doc });
await settled();
const dialog = doc.querySelector(".wrn-viewer-dialog")!;
expect(dialog).not.toBeNull();
expect(dialog.querySelector(".wrn-viewer-content")!.getAttribute("data-viewer-state")).toBe(
"ready",
);
expect(dialog.querySelector('[data-viewer-action="close"]')).not.toBeNull();
opened.close();
expect(doc.querySelector(".wrn-viewer-dialog")).toBeNull();
await window.happyDOM.close();
});
test("closing disposes the viewer rather than leaving it behind the overlay", async () => {
const { window, doc } = page();
const opened = openViewer(csv, { document: doc });
await settled();
const host = doc.querySelector(".wrn-viewer-dialog .wrn-viewer")!;
expect(host.querySelector("table")).not.toBeNull();
opened.close();
// The host went with the dialog, so nothing keeps running off-screen.
expect(host.isConnected).toBe(false);
await window.happyDOM.close();
});
test("closing twice, or from the dialog's own close event, is safe", async () => {
const { window, doc } = page();
let closes = 0;
const opened = openViewer(csv, { document: doc, onClose: () => closes++ });
await settled();
doc.querySelector(".wrn-viewer-dialog")!.dispatchEvent(new window.Event("close") as never);
opened.close();
expect(closes).toBe(1);
await window.happyDOM.close();
});
test("a click on the dialog itself is a backdrop click", async () => {
const { window, doc } = page();
openViewer(csv, { document: doc });
await settled();
const dialog = doc.querySelector(".wrn-viewer-dialog")!;
// A click inside the viewer must not close it.
dialog
.querySelector(".wrn-viewer")!
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }) as never);
expect(doc.querySelector(".wrn-viewer-dialog")).not.toBeNull();
dialog.dispatchEvent(new window.MouseEvent("click", { bubbles: true }) as never);
expect(doc.querySelector(".wrn-viewer-dialog")).toBeNull();
await window.happyDOM.close();
});
});
describe("triggers", () => {
test("a data attribute is read into a source", () => {
const { window, doc } = page();
const button = doc.createElement("button");
button.dataset.viewerOpen = "";
button.dataset.src = "/files/a.pdf";
button.dataset.name = "a.pdf";
button.dataset.size = "120";
expect(sourceFromTrigger(button)).toEqual({
src: "/files/a.pdf",
key: undefined,
store: undefined,
name: "a.pdf",
mime: undefined,
size: 120,
});
// Nothing to open is not an error; the click is simply ignored.
expect(sourceFromTrigger(doc.createElement("button"))).toBeUndefined();
void window.happyDOM.close();
});
test("clicking an element with data-viewer-open opens the preview", async () => {
const { window, doc } = page();
const stop = startViewerTriggers(doc);
const button = doc.createElement("button");
button.dataset.viewerOpen = "";
button.dataset.src = "https://app.test/notes.csv";
button.dataset.name = "notes.csv";
doc.body.append(button);
const original = globalThis.fetch;
globalThis.fetch = (async () =>
new Response("a,b\n1,2\n", { headers: { "content-type": "text/csv" } })) as never;
try {
button.dispatchEvent(new window.MouseEvent("click", { bubbles: true }) as never);
await settled();
expect(doc.querySelector(".wrn-viewer-dialog")).not.toBeNull();
expect(doc.querySelector(".wrn-viewer-dialog table")).not.toBeNull();
} finally {
globalThis.fetch = original;
stop();
}
await window.happyDOM.close();
});
});
describe("layout modes", () => {
test("fullScreen mounts covering the page, and Close returns it inline", async () => {
const { window, doc } = page();
const host = doc.createElement("section");
doc.body.append(host);
const dispose = mountViewer(host as never, csv, { fullScreen: true });
await settled();
expect(host.classList.contains("wrn-viewer-expanded")).toBe(true);
const close = host.querySelector('[data-viewer-action="close"]') as HTMLButtonElement;
expect(close).not.toBeNull();
close.click();
expect(host.classList.contains("wrn-viewer-expanded")).toBe(false);
// The control goes with the state it exits.
expect(host.querySelector('[data-viewer-action="close"]')).toBeNull();
dispose();
await window.happyDOM.close();
});
test("an inline viewer offers no Close, because there is nothing to close", async () => {
const { window, doc } = page();
const host = doc.createElement("section");
doc.body.append(host);
const dispose = mountViewer(host as never, csv);
await settled();
expect(host.querySelector('[data-viewer-action="close"]')).toBeNull();
dispose();
await window.happyDOM.close();
});
test("layout=page drops the card chrome", async () => {
const { window, doc } = page();
const host = doc.createElement("section");
doc.body.append(host);
const dispose = mountViewer(host as never, csv, { layout: "page" });
await settled();
expect(host.classList.contains("wrn-viewer-as-page")).toBe(true);
dispose();
await window.happyDOM.close();
});
test("the runtime re-mounts when the layout attributes change", async () => {
const { window, doc } = page();
const host = doc.createElement("section");
host.dataset.wrnexusRuntime = "viewer";
host.dataset.name = "data.csv";
host.dataset.src = "https://app.test/data.csv";
host.dataset.fullScreen = "false";
doc.body.append(host);
const original = globalThis.fetch;
globalThis.fetch = (async () =>
new Response("a,b\n1,2\n", { headers: { "content-type": "text/csv" } })) as never;
const stop = startViewerRuntime(doc);
try {
await settled();
expect(host.classList.contains("wrn-viewer-expanded")).toBe(false);
// Without this in the remount signature the attribute change is ignored.
host.dataset.fullScreen = "true";
await settled();
expect(host.classList.contains("wrn-viewer-expanded")).toBe(true);
} finally {
globalThis.fetch = original;
stop();
}
await window.happyDOM.close();
});
});
+167
View File
@@ -0,0 +1,167 @@
import { describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { rtfToText } from "../src/parse/rtf.ts";
import { renderSlides } from "../src/renderers/slides.ts";
import { mountViewer } from "../src/runtime.ts";
import { registerRenderer } from "../src/registry.ts";
import { resetConverterProbe } from "../src/convert/client.ts";
import { mountPdf } from "../src/renderers/pdf.ts";
async function settled(ticks = 40) {
for (let i = 0; i < ticks; i++) await new Promise((done) => setTimeout(done, 5));
}
function fixture() {
const window = new Window({ url: "https://app.test/" });
const host = window.document.createElement("section") as unknown as HTMLElement;
window.document.body.append(host as never);
return { window, host };
}
describe("RTF unicode substitution", () => {
// \ucN says how many ANSI characters stand in for each \uN. Assuming 1 ate a
// real character after \uc0, and left a stray "?" behind after \uc2.
test("uc0 means the escape stands alone", () => {
expect(rtfToText(String.raw`{\rtf1\ansi\uc0舒 Hello world\par}`)).toBe("—Hello world");
});
test("uc2 skips exactly two substitutes", () => {
expect(rtfToText(String.raw`{\rtf1\ansi\uc2舒?? Hello\par}`)).toBe("— Hello");
});
test("the count is group-scoped and restored on close", () => {
expect(rtfToText(String.raw`{\rtf1\ansi{\uc0舒}舒? tail\par}`)).toBe("—— tail");
});
});
describe("slide navigation", () => {
// The counter used to be refreshed from a click listener, so arrow keys moved
// the deck without moving the label.
test("every move notifies, keyboard included", () => {
const { window } = fixture();
const doc = window.document as unknown as Document;
const seen: number[] = [];
const deck = renderSlides(
doc,
[
{ title: "One", blocks: [["One"]] },
{ title: "Two", blocks: [["Two"]] },
{ title: "Three", blocks: [["Three"]] },
],
(index) => seen.push(index),
);
doc.body.append(deck.node as never);
deck.next(1);
deck.node.dispatchEvent(
new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }) as never,
);
expect(seen).toEqual([0, 1, 2]);
expect(deck.status()).toBe("3 of 3");
});
});
describe("streaming renderers and the body", () => {
function stubFetch(requests: string[], body: Uint8Array) {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const range = new Headers(init?.headers).get("range");
requests.push(`${init?.method ?? "GET"}${range ? " ranged" : ""} ${new URL(url).pathname}`);
if (url.includes("/api/viewer/capabilities"))
return Response.json({ converters: ["fake"], formats: { ".psd": "image/png" } });
return new Response(body as unknown as BodyInit, {
headers: {
"content-length": String(body.byteLength),
"content-type": "image/vnd.adobe.photoshop",
},
});
}) as unknown as typeof fetch;
return () => {
globalThis.fetch = original;
};
}
test("a converted preview never downloads the original body", async () => {
resetConverterProbe();
const { window, host } = fixture();
const requests: string[] = [];
// 40 MB of design file: reading it into a blob only to discard it was the bug.
const body = new Uint8Array(40 * 1024 * 1024);
body.set([0x38, 0x42, 0x50, 0x53]);
const restore = stubFetch(requests, body);
try {
const dispose = mountViewer(host, { src: "https://app.test/art.psd", name: "art.psd" });
await settled();
expect(requests.some((entry) => entry.includes("/api/viewer/capabilities"))).toBe(true);
expect(
requests.filter((entry) => entry === "GET /art.psd"),
// Only the bounded ranged sniff may touch the source.
).toEqual([]);
dispose();
} finally {
restore();
resetConverterProbe();
}
await window.happyDOM.close();
});
test("a streaming renderer that fails still falls through to a buffered one", async () => {
const { window, host } = fixture();
// Registered first so it sits *behind* the streaming renderer in the ladder.
const unregisterBuffered = registerRenderer({
kind: "text",
exts: ["probe"],
tier: 2,
component: "BufferedProbe",
async mount({ parent, file }) {
parent.textContent = `buffered:${await file!.text()}`;
},
});
const unregisterStreaming = registerRenderer({
kind: "text",
exts: ["probe"],
tier: 2,
component: "StreamingProbe",
streaming: true,
mount() {
throw new Error("streaming renderer failed");
},
});
const requests: string[] = [];
const restore = stubFetch(requests, new TextEncoder().encode("hello"));
try {
const dispose = mountViewer(host, { src: "https://app.test/a.probe", name: "a.probe" });
await settled();
expect(host.textContent).toContain("buffered:hello");
dispose();
} finally {
restore();
unregisterStreaming();
unregisterBuffered();
}
await window.happyDOM.close();
});
});
describe("PDF framing", () => {
// Reaching the native frame as a fallback means a body was already read. The
// object URL for it cannot be framed under a `default-src 'self'` policy, so
// the original URL has to win whenever there is one.
test("the frame uses the original URL even when a body is in hand", async () => {
const { window, host } = fixture();
const blob = new Blob(["%PDF-1.7"], { type: "application/pdf" });
await mountPdf({
parent: host,
file: blob,
source: { src: "https://app.test/report.pdf", name: "report.pdf" },
sourceUrl: async () => "blob:https://app.test/should-not-be-used",
kind: "pdf",
button: () => window.document.createElement("button") as never,
objectUrl: () => "blob:https://app.test/should-not-be-used",
degrade: () => {},
signal: new AbortController().signal,
});
expect(host.querySelector("iframe")!.getAttribute("src")).toBe("https://app.test/report.pdf");
await window.happyDOM.close();
});
});
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, test } from "bun:test";
import { extensions, kindOfExtension } from "../src/kinds.ts";
import { RendererRegistry } from "../src/registry.ts";
import { resolve } from "../src/resolve.ts";
import { readBounded } from "../src/source.ts";
import { sniff } from "../src/sniff.ts";
const file = (name: string, bytes: string | number[], mime = "") => ({
name,
blob: new Blob([typeof bytes === "string" ? bytes : new Uint8Array(bytes)], { type: mime }),
});
describe("classification and signatures", () => {
for (const [kind, exts] of Object.entries(extensions))
for (const ext of exts) {
test(`classifies .${ext}`, () =>
expect(String(kindOfExtension(`.${ext.toUpperCase()}`))).toBe(kind));
}
const signatures: [string, string | number[], string][] = [
["png", [137, 80, 78, 71, 13, 10, 26, 10], "ImageViewer"],
["jpeg", [255, 216, 255], "ImageViewer"],
["gif", "GIF89a", "ImageViewer"],
["webp", "RIFF0000WEBP", "ImageViewer"],
["bmp", "BM00", "ImageViewer"],
["ico", [0, 0, 1, 0], "ImageViewer"],
["avif", "0000ftypavif", "ImageViewer"],
["pdf", "%PDF-1.7", "PdfViewer"],
["wav", "RIFF0000WAVE", "MediaViewer"],
["mp3", "ID3", "MediaViewer"],
["aac", [255, 241, 80, 128], "MediaViewer"],
["flac", "fLaC", "MediaViewer"],
["ogg", "OggS", "MediaViewer"],
["mp4", "0000ftypisom", "MediaViewer"],
["mov", "0000ftypqt ", "MediaViewer"],
["webm", [0x1a, 0x45, 0xdf, 0xa3], "MediaViewer"],
["txt", "hello", "CodeViewer"],
["js", "const value = 1;", "CodeViewer"],
["html", "<html><script>alert(1)</script></html>", "HtmlViewer"],
];
for (const [ext, bytes, component] of signatures)
test(`resolves ${ext}`, async () => {
expect((await resolve(file(`test.${ext}`, bytes))).renderer.component).toBe(component);
});
test("HTML masquerading as PNG is shown as inert source", async () => {
const plan = await resolve(
file("fake.png", "<!doctype html><script>alert(1)</script>", "image/png"),
);
expect(plan.kind).toBe("markup");
expect(plan.renderer.component).toBe("CodeViewer");
});
test("SVG masquerading as image never reaches native image rendering", async () => {
const plan = await resolve(file("fake.png", '<svg onload="alert(1)"></svg>', "image/png"));
expect(plan.kind).toBe("vector");
expect(plan.sandboxed).toBe(true);
expect(plan.renderer.component).toBe("SvgViewer");
});
test("binary or executables cannot become text through MIME", async () => {
expect((await resolve(file("fake.txt", [0, 1, 2], "text/plain"))).renderer.component).toBe(
"DownloadCard",
);
expect((await resolve(file("fake.png", "MZ123", "image/png"))).renderer.component).toBe(
"DownloadCard",
);
expect((await resolve(file("fake.exe", "hello"))).renderer.component).toBe("DownloadCard");
});
test("UTF-8 truncated at sniff boundary is still text", () => {
expect(sniff(new Uint8Array([97, 0xe2, 0x82])).kind).toBe("text");
});
});
describe("degradation and network limits", () => {
test("missing optional dependencies fall through to native renderer", async () => {
const registry = new RendererRegistry();
registry.register({
kind: "pdf",
exts: [".pdf"],
tier: 2,
component: "RichPdf",
load: async () => {
throw new Error("missing");
},
});
expect((await resolve(file("test.pdf", "%PDF-1.7"), { registry })).renderer.component).toBe(
"PdfViewer",
);
});
test("overrides can be removed and conflicting signatures bypass them", async () => {
const registry = new RendererRegistry();
const unregister = registry.register({
kind: "text",
exts: ["txt"],
tier: 2,
component: "Custom",
});
expect((await resolve(file("test.txt", "hi"), { registry })).renderer.component).toBe("Custom");
expect((await resolve(file("test.txt", "%PDF-1.7"), { registry })).renderer.component).toBe(
"PdfViewer",
);
unregister();
expect((await resolve(file("test.txt", "hi"), { registry })).renderer.component).toBe(
"CodeViewer",
);
});
test("unavailable converters degrade", async () => {
const registry = new RendererRegistry();
registry.register({ kind: "binary", exts: [".psd"], tier: 3, component: "Convert" });
expect((await resolve(file("image.psd", [0, 0, 0]), { registry })).renderer.component).toBe(
"DownloadCard",
);
});
test("oversized HEAD prevents body fetch", async () => {
const methods: string[] = [];
const request = (async (_url, init) => {
methods.push(init?.method ?? "GET");
return new Response(null, { headers: { "content-length": "400000000" } });
}) as typeof fetch;
const plan = await resolve(
{ name: "large.mp4", src: "https://example.test/file" },
{ fetch: request },
);
expect(plan.renderer.component).toBe("DownloadCard");
expect(methods).toEqual(["HEAD"]);
});
test("unknown-length or ignored Range stream is cancelled at prefix cap", async () => {
let cancelled = false;
const bytes = await readBounded(
new Response(
new ReadableStream({
start(c) {
c.enqueue(new Uint8Array(8192));
},
cancel() {
cancelled = true;
},
}),
),
4096,
true,
);
expect(bytes.length).toBe(4096);
expect(cancelled).toBe(true);
});
test("full reads enforce actual bytes despite dishonest metadata", async () => {
await expect(
readBounded(new Response("123456", { headers: { "content-length": "1" } }), 3),
).rejects.toThrow("limit");
});
test("HEAD without support still performs bounded sniff", async () => {
const request = (async (_url, init) =>
init?.method === "HEAD"
? new Response(null, { status: 405 })
: new Response("hello")) as typeof fetch;
expect(
(await resolve({ name: "text.txt", src: "/file" }, { fetch: request })).renderer.component,
).toBe("CodeViewer");
});
test("invalid URLs, rejected fetches and key-only sources degrade", async () => {
expect((await resolve({ name: "a.txt", src: "javascript:alert(1)" })).renderer.component).toBe(
"DownloadCard",
);
expect((await resolve({ name: "a.txt", key: "private" })).renderer.component).toBe(
"DownloadCard",
);
const request = (async () => {
throw new Error("offline");
}) as unknown as typeof fetch;
expect(
(await resolve({ name: "a.txt", src: "/file" }, { fetch: request })).renderer.component,
).toBe("DownloadCard");
});
});
describe("fallback reasons and unrenderable signatures", () => {
test("a kind with no renderer reports why, not a size limit", async () => {
const request = (async () =>
new Response("PK", {
headers: { "content-length": "512", "content-type": "application/zip" },
})) as unknown as typeof fetch;
const plan = await resolve(
{ name: "bundle.zip", src: "https://example.test/bundle.zip" },
{ fetch: request },
);
expect(plan.kind).toBe("archive");
expect(plan.reason).toBe("Preview unavailable for this file");
});
test("design formats with ASCII-clean headers are not mistaken for text", async () => {
const ascii = (signature: number[]) => {
const bytes = new Uint8Array(4096).fill(0x41);
bytes.set(signature);
return bytes;
};
expect(sniff(ascii([0x38, 0x42, 0x50, 0x53])).kind).toBe("binary");
expect(sniff(ascii([0x49, 0x49, 0x2a, 0x00])).kind).toBe("binary");
expect(sniff(ascii([0x4d, 0x4d, 0x00, 0x2a])).kind).toBe("binary");
expect(
(await resolve({ name: "art.psd", blob: new Blob([ascii([0x38, 0x42, 0x50, 0x53])]) }))
.renderer.component,
).toBe("DownloadCard");
});
});
+147
View File
@@ -0,0 +1,147 @@
import { expect, test } from "bun:test";
import { Window } from "happy-dom";
import { mountViewer } from "../src/runtime.ts";
import { registerRenderer } from "../src/registry.ts";
async function settled() {
for (let i = 0; i < 15; i++) await new Promise((resolve) => setTimeout(resolve, 5));
}
function fixture() {
const window = new Window();
const host = window.document.createElement("section");
window.document.body.append(host);
return { window, host: host as unknown as HTMLElement };
}
test("image controls, failure fallback, and teardown", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, {
name: "<script>bad</script>.png",
blob: new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])]),
});
await settled();
expect(host.querySelector("script")).toBeNull();
const image = host.querySelector("img")!;
expect(image).not.toBeNull();
const zoom = [...host.querySelectorAll("button")].find(
(button) => button.textContent === "Zoom in",
)!;
zoom.click();
expect(image.style.width).toBe("125%");
const url = image.src;
image.dispatchEvent(new window.Event("error") as unknown as Event);
expect(host.textContent).toContain("Image could not be displayed");
dispose();
expect(host.children.length).toBe(0);
await expect(fetch(url)).rejects.toThrow();
await window.happyDOM.close();
});
test("unsafe links are omitted and unavailable files retain metadata", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, { name: "file", src: "javascript:alert(1)", size: 123 });
await settled();
expect(host.querySelector("a")).toBeNull();
// The fallback card keeps the name and a human-readable size.
expect(host.textContent).toContain("file");
expect(host.textContent).toContain("123 B");
dispose();
await window.happyDOM.close();
});
test("markup source never creates executable DOM", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, {
name: "hostile.html",
blob: new Blob(['<html><script>alert(1)</script><img onerror="alert(1)"></html>']),
});
await settled();
expect(host.querySelector("script, img")).toBeNull();
const frame = host.querySelector("iframe")!;
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.srcdoc).not.toContain("<script>");
expect(frame.srcdoc).not.toContain("onerror");
[...host.querySelectorAll("button")].find((button) => button.textContent === "Source")!.click();
expect(host.textContent).toContain("<script>alert(1)</script>");
dispose();
await window.happyDOM.close();
});
test("disposing a pending custom renderer runs its eventual cleanup", async () => {
const { window, host } = fixture();
let finish!: () => void;
let cleaned = false;
const ready = new Promise<void>((resolve) => {
finish = resolve;
});
const unregister = registerRenderer({
kind: "text",
exts: ["txt"],
tier: 2,
component: "Custom",
mount: async () => {
await ready;
return () => {
cleaned = true;
};
},
});
const dispose = mountViewer(host, { name: "a.txt", blob: new Blob(["hello"]) });
await settled();
dispose();
finish();
await settled();
expect(cleaned).toBe(true);
expect(host.children.length).toBe(0);
unregister();
await window.happyDOM.close();
});
test("a renderer without a mount adapter never blocks the preview", async () => {
const { window, host } = fixture();
const unregister = registerRenderer({
kind: "text",
exts: ["txt"],
tier: 2,
component: "NoMount",
});
const dispose = mountViewer(host, { name: "a.txt", blob: new Blob(["plain body"]) });
await settled();
expect(host.textContent).toContain("plain body");
dispose();
unregister();
await window.happyDOM.close();
});
test("an http source streams from its own URL instead of a blob copy", async () => {
const { window, host } = fixture();
let bodyRequests = 0;
const original = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const method = init?.method ?? "GET";
if (method === "HEAD") return new Response(null, { headers: { "content-type": "video/mp4" } });
bodyRequests++;
return new Response("\0\0\0\0ftypisom");
}) as unknown as typeof fetch;
try {
const dispose = mountViewer(host, { name: "clip.mp4", src: "https://cdn.test/clip.mp4" });
await settled();
const video = host.querySelector("video")!;
expect(video).not.toBeNull();
// The element points at the original URL: no blob copy, no full download.
expect(video.getAttribute("src")).toBe("https://cdn.test/clip.mp4");
expect(bodyRequests).toBe(1); // the bounded sniff only
dispose();
} finally {
globalThis.fetch = original;
}
await window.happyDOM.close();
});
test("a local file still renders from an object URL under its sniffed type", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, {
name: "shot.png",
blob: new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])]),
});
await settled();
expect(host.querySelector("img")!.src.startsWith("blob:")).toBe(true);
dispose();
await window.happyDOM.close();
});
+261
View File
@@ -0,0 +1,261 @@
import { describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { detectDelimiter, parseDelimited } from "../src/parse/delimited.ts";
import { gzipName, isTar, parseTar } from "../src/parse/archive.ts";
import { sanitizeSvg } from "../src/parse/svg.ts";
import { resolve } from "../src/resolve.ts";
import { mountViewer } from "../src/runtime.ts";
const file = (name: string, bytes: string | Uint8Array, mime = "") => ({
name,
blob: new Blob([bytes as string], { type: mime }),
});
async function settled() {
for (let i = 0; i < 15; i++) await new Promise((done) => setTimeout(done, 5));
}
function fixture() {
const window = new Window();
const host = window.document.createElement("section");
window.document.body.append(host);
return { window, host: host as unknown as HTMLElement };
}
/** Minimal USTAR archive: one header block plus padded content. */
function tar(entries: { name: string; body: string }[]): Uint8Array<ArrayBuffer> {
const blocks: Uint8Array<ArrayBuffer>[] = [];
const encoder = new TextEncoder();
for (const entry of entries) {
const header = new Uint8Array(512);
header.set(encoder.encode(entry.name), 0);
header.set(encoder.encode(entry.body.length.toString(8).padStart(11, "0")), 124);
header[156] = "0".charCodeAt(0);
header.set(encoder.encode("ustar"), 257);
blocks.push(header);
const content = new Uint8Array(Math.ceil(entry.body.length / 512) * 512);
content.set(encoder.encode(entry.body));
blocks.push(content);
}
blocks.push(new Uint8Array(1024));
const total = blocks.reduce((sum, block) => sum + block.length, 0);
const bytes = new Uint8Array(total);
let offset = 0;
for (const block of blocks) {
bytes.set(block, offset);
offset += block.length;
}
return bytes;
}
describe("delimited parsing", () => {
test("quotes may hold the delimiter, newlines and escaped quotes", () => {
const table = parseDelimited('a,b\r\n"x,1","says ""hi""\nagain"\r\n');
expect(table.header).toEqual(["a", "b"]);
expect(table.rows).toEqual([["x,1", 'says "hi"\nagain']]);
expect(table.delimiter).toBe(",");
});
test("tabs and semicolons are detected, quoted ones ignored", () => {
expect(detectDelimiter("a\tb\tc")).toBe("\t");
expect(detectDelimiter("a;b;c")).toBe(";");
expect(detectDelimiter('"a,b,c,d";e')).toBe(";");
});
test("a trailing newline does not add a row and caps are honoured", () => {
expect(parseDelimited("a,b\n1,2\n").rows).toHaveLength(1);
const capped = parseDelimited("a,b\n1,2\n3,4\n5,6\n", { maxRows: 2 });
expect(capped.truncated).toBe(true);
expect(capped.rows).toHaveLength(1);
});
});
describe("SVG sanitising", () => {
const window = new Window();
const view = window as unknown as {
DOMParser: new () => DOMParser;
XMLSerializer: new () => XMLSerializer;
};
const clean = (source: string) => sanitizeSvg(source, view) ?? "";
test("scripts, handlers and foreign content are removed", () => {
const output = clean(
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">' +
"<script>alert(1)</script>" +
"<foreignObject><body>x</body></foreignObject>" +
'<rect width="10" height="10" onclick="alert(1)"/>' +
"</svg>",
);
expect(output).not.toContain("script");
expect(output).not.toContain("foreignObject");
expect(output).not.toContain("onload");
expect(output).not.toContain("onclick");
expect(output).toContain("rect");
});
test("only same-document references survive", () => {
const output = clean(
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">' +
'<use xlink:href="https://evil.test/x.svg#a"/>' +
'<use href="#local"/>' +
'<a href="javascript:alert(1)"><text>go</text></a>' +
"</svg>",
);
expect(output).not.toContain("evil.test");
expect(output).not.toContain("javascript:");
expect(output).toContain("#local");
});
test("stylesheets may not import or fetch", () => {
expect(
clean('<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(x)</style></svg>'),
).not.toContain("@import");
expect(
clean(
'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(https://evil.test/a)"/></svg>',
),
).not.toContain("evil.test");
});
test("non-SVG input is rejected outright", () => {
expect(sanitizeSvg("<html><body>hi</body></html>", view)).toBeUndefined();
expect(sanitizeSvg("not markup at all", view)).toBeUndefined();
});
});
describe("archive listing", () => {
test("USTAR entries are listed with sizes", () => {
const bytes = tar([
{ name: "readme.txt", body: "hello" },
{ name: "src/index.ts", body: "export {};" },
]);
expect(isTar(bytes)).toBe(true);
expect(parseTar(bytes).map((entry) => entry.name)).toEqual(["readme.txt", "src/index.ts"]);
expect(parseTar(bytes)[0]!.size).toBe(5);
});
test("gzip member names are read when present", () => {
const header = new Uint8Array([0x1f, 0x8b, 8, 0x08, 0, 0, 0, 0, 0, 3]);
const name = new TextEncoder().encode("payload.tar\0");
const bytes = new Uint8Array(header.length + name.length);
bytes.set(header);
bytes.set(name, header.length);
expect(gzipName(bytes)).toBe("payload.tar");
expect(gzipName(new Uint8Array([0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3]))).toBeUndefined();
});
});
describe("tier 2 routing", () => {
const cases: [string, string, string][] = [
["data.csv", "a,b\n1,2\n", "TableViewer"],
["data.tsv", "a\tb\n1\t2\n", "TableViewer"],
["data.json", '{"a":1}', "JsonViewer"],
["data.xml", "<root><a/></root>", "XmlViewer"],
["logo.svg", '<svg xmlns="http://www.w3.org/2000/svg"></svg>', "SvgViewer"],
];
for (const [name, body, component] of cases)
test(`routes ${name}`, async () =>
expect((await resolve(file(name, body))).renderer.component).toBe(component));
test("a tar archive is listed rather than downloaded", async () => {
const bytes = tar([{ name: "a.txt", body: "x" }]);
const plan = await resolve({ name: "bundle.tar", blob: new Blob([bytes]) });
expect(plan.kind).toBe("archive");
expect(plan.renderer.component).toBe("ArchiveViewer");
});
test("tier 2 renderers stay behind the source view for their kind", async () => {
const plan = await resolve(file("notes.json", '{"a":1}'));
expect(plan.fallback.map((renderer) => renderer.component)).toContain("CodeViewer");
});
});
describe("tier 2 rendering", () => {
test("a delimited file becomes a spreadsheet grid", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, file("data.csv", "name,size\nalpha,10\nbeta,20\n"));
await settled();
expect(host.querySelectorAll("table")).toHaveLength(1);
// The first line of the file is row 1 of the sheet, not a promoted header,
// so all three rows are counted and the columns are lettered.
expect(host.textContent).toContain("3 rows × 2 columns");
expect([...host.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual([
"",
"A",
"B",
]);
expect(
[...host.querySelectorAll("tbody [data-viewer-index]")].map((cell) => cell.textContent),
).toEqual(["1", "2", "3"]);
expect(host.textContent).toContain("alpha");
// A column of numbers is right-aligned; one of words is not.
expect(host.querySelectorAll("tbody [data-viewer-numeric]")).toHaveLength(3);
dispose();
await window.happyDOM.close();
});
test("malformed JSON falls through to the source view", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, file("broken.json", "{ not json"));
await settled();
expect(host.querySelector(".wrn-viewer-tree")).toBeNull();
expect(host.textContent).toContain("{ not json");
dispose();
await window.happyDOM.close();
});
test("JSON renders a lazy tree with nested values", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, file("data.json", '{"tag":"alpha","list":[1,2]}'));
await settled();
const tree = host.querySelector(".wrn-viewer-tree")!;
expect(tree).not.toBeNull();
expect(tree.textContent).toContain("tag");
// The nested array is a closed branch until it is opened.
const nested = host.querySelector<HTMLDetailsElement>(".wrn-viewer-branch details")!;
expect(nested.textContent).toContain("list");
expect(nested.open).toBe(false);
nested.open = true;
nested.dispatchEvent(new window.Event("toggle") as unknown as Event);
expect(nested.textContent).toContain("2");
dispose();
await window.happyDOM.close();
});
test("a JSON file carrying markup is shown as inert source, not a tree", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, file("data.json", '{"tag":"<script>alert(1)</script>"}'));
await settled();
expect(host.querySelector("script")).toBeNull();
expect(host.querySelector(".wrn-viewer-tree")).toBeNull();
expect(host.textContent).toContain("<script>alert(1)</script>");
dispose();
await window.happyDOM.close();
});
test("SVG renders sandboxed, with the sanitised document inside the frame", async () => {
const { window, host } = fixture();
const dispose = mountViewer(
host,
file(
"logo.svg",
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(1)</script><rect width="4" height="4"/></svg>',
),
);
await settled();
const frame = host.querySelector("iframe")!;
expect(frame).not.toBeNull();
expect(frame.getAttribute("sandbox")).toBe("");
const srcdoc = frame.getAttribute("srcdoc")!;
expect(srcdoc).toContain("default-src 'none'");
expect(srcdoc).not.toContain("onload");
expect(srcdoc).not.toContain("<script");
expect(srcdoc).toContain("rect");
// The frame is its own document: theme tokens and currentColor cannot reach
// it, and its base background is opaque, so both are carried across.
expect(srcdoc).toMatch(/html,body\{[^}]*color:[^;}]+/);
expect(srcdoc).toMatch(/html,body\{[^}]*background:[^;}]+/);
dispose();
await window.happyDOM.close();
});
test("an archive lists its entries", async () => {
const { window, host } = fixture();
const dispose = mountViewer(host, {
name: "bundle.tar",
blob: new Blob([tar([{ name: "docs/readme.md", body: "hello" }])]),
});
await settled();
expect(host.textContent).toContain("docs/readme.md");
expect(host.textContent).toContain("1 file");
dispose();
await window.happyDOM.close();
});
});
+250
View File
@@ -0,0 +1,250 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { configureStorage } from "@wrnexus/uploader";
import { configureUploads } from "../src/uploads/config.ts";
import { handleFile, handleSource, uploadedConversionSource } from "../src/uploads/service.ts";
import { viewerPlugin } from "../src/plugin.ts";
import { Window } from "happy-dom";
import { mountViewer } from "../src/runtime.ts";
const SECRET = "test-secret-value-32-chars-minimum!!";
const root = mkdtempSync(join(tmpdir(), "wrn-viewer-uploads-"));
function storage() {
for (const dir of ["public", "private"])
mkdirSync(join(root, "uploads", dir), { recursive: true });
writeFileSync(join(root, "uploads", "public", "logo.png"), Buffer.from([137, 80, 78, 71]));
writeFileSync(join(root, "uploads", "private", "report.pdf"), Buffer.from("%PDF-1.7 private"));
configureStorage(
{
default: "public",
stores: {
public: { driver: "local", access: "public", dir: "uploads/public" },
private: { driver: "local", access: "private", dir: "uploads/private" },
secret: { driver: "local", access: "private", dir: "uploads/private" },
},
},
root,
);
}
storage();
const ask = (query: string) => new Request(`https://app.test/api/viewer/source?${query}`);
const body = async (response: Response) =>
(await response.json()) as { src: string; name: string; mime?: string };
afterEach(() => configureUploads(undefined));
describe("resolving a storage key", () => {
test("nothing resolves until uploads are enabled", async () => {
expect((await handleSource(ask("key=logo.png"))).status).toBe(404);
});
test("a public object answers with its own URL, no token", async () => {
configureUploads({ stores: ["public"] });
const response = await handleSource(ask("key=logo.png&store=public"));
expect(response.status).toBe(200);
const descriptor = await body(response);
expect(descriptor.src).toContain("logo.png");
expect(descriptor.src).not.toContain("token=");
expect(descriptor.name).toBe("logo.png");
// The MIME comes from the uploader's table, not a second copy of it.
expect(descriptor.mime).toBe("image/png");
});
test("stores outside the allow-list are refused", async () => {
configureUploads({ stores: ["public"], secret: SECRET, authorize: () => true });
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
});
test("keys that try to escape the store are refused", async () => {
configureUploads({ stores: ["public"] });
for (const key of ["../secrets.env", "/etc/passwd", "a/../../b"])
expect((await handleSource(ask(`key=${encodeURIComponent(key)}&store=public`))).status).toBe(
400,
);
});
test("a private object needs both a secret and an authorize hook", async () => {
configureUploads({ stores: ["private"] });
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
configureUploads({ stores: ["private"], secret: SECRET });
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
});
test("authorize decides, and a refusal mints nothing", async () => {
const seen: { store: string; key: string }[] = [];
configureUploads({
stores: ["private"],
secret: SECRET,
authorize: (_request, params) => {
seen.push(params);
return false;
},
});
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
expect(seen).toEqual([{ store: "private", key: "report.pdf" }]);
});
});
describe("serving a signed link", () => {
const signedFor = async (key: string, store = "private") => {
configureUploads({ stores: [store], secret: SECRET, authorize: () => true });
const descriptor = await body(await handleSource(ask(`key=${key}&store=${store}`)));
return new URL(descriptor.src, "https://app.test").searchParams.get("token")!;
};
const fetchFile = (token: string, init: RequestInit = {}) =>
handleFile(
new Request(`https://app.test/api/viewer/file?token=${encodeURIComponent(token)}`, init),
);
test("a valid link streams the object, uncacheable and inline", async () => {
const response = await fetchFile(await signedFor("report.pdf"));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("private, no-store");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("content-disposition")).toBe("inline");
expect(await response.text()).toContain("%PDF");
});
test("HEAD answers with the same headers and no body", async () => {
const response = await fetchFile(await signedFor("report.pdf"), { method: "HEAD" });
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("16");
expect(await response.text()).toBe("");
});
test("ranges are served so media can seek", async () => {
const response = await fetchFile(await signedFor("report.pdf"), {
headers: { range: "bytes=0-3" },
});
expect(response.status).toBe(206);
expect(response.headers.get("content-range")).toBe("bytes 0-3/16");
expect(await response.text()).toBe("%PDF");
});
test("a tampered, unsigned or expired link is indistinguishable from a miss", async () => {
const token = await signedFor("report.pdf");
expect((await fetchFile(token.slice(0, -2) + "xx")).status).toBe(404);
expect((await fetchFile("not-a-token")).status).toBe(404);
expect((await handleFile(new Request("https://app.test/api/viewer/file"))).status).toBe(400);
// A link signed with a different secret must not open under this one.
configureUploads({
stores: ["private"],
secret: "another-secret-entirely-x",
authorize: () => true,
});
expect((await fetchFile(token)).status).toBe(404);
});
test("a link for one store cannot be replayed after that store is withdrawn", async () => {
const token = await signedFor("report.pdf");
configureUploads({ stores: ["public"], secret: SECRET, authorize: () => true });
expect((await fetchFile(token)).status).toBe(404);
});
});
describe("conversion inherits the same check", () => {
test("only a valid signed link opens an object for a converter", async () => {
configureUploads({ stores: ["private"], secret: SECRET, authorize: () => true });
const descriptor = await body(await handleSource(ask("key=report.pdf&store=private")));
const open = uploadedConversionSource();
const request = new Request("https://app.test/api/viewer/preview");
const allowed = await open(request, { src: descriptor.src, name: "report.pdf" });
expect(allowed).toBeDefined();
expect(allowed!.size).toBe(16);
// A bare key, a foreign URL, or a forged token opens nothing.
expect(await open(request, { src: "report.pdf", name: "report.pdf" })).toBeUndefined();
expect(
await open(request, { src: "https://evil.test/x?token=abc", name: "report.pdf" }),
).toBeUndefined();
});
});
describe("plugin wiring", () => {
test("uploader routes appear only when uploads are configured", () => {
const paths = (plugin: ReturnType<typeof viewerPlugin>) =>
((plugin.routeEntries ?? []) as { path: string }[]).map((route) => route.path);
expect(paths(viewerPlugin())).toEqual([]);
expect(paths(viewerPlugin({ uploads: { stores: ["public"] } }))).toEqual([
"/api/viewer/source",
"/api/viewer/file",
]);
// Enabling one feature must not publish the other's endpoints.
expect(paths(viewerPlugin({ uploads: { stores: ["public"] } }))).not.toContain(
"/api/viewer/preview",
);
});
});
describe("the browser side of a key", () => {
async function mountKeyed(descriptor: unknown, body: string, componentName = "") {
const window = new Window({ url: "https://app.test/" });
const host = window.document.createElement("section") as unknown as HTMLElement;
window.document.body.append(host as never);
const asked: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input);
asked.push(url);
if (url.includes("/api/viewer/source"))
return descriptor ? Response.json(descriptor) : new Response("no", { status: 403 });
return new Response(body, { headers: { "content-type": "text/csv" } });
}) as unknown as typeof fetch;
try {
const dispose = mountViewer(host, {
key: "regional-figures.csv",
store: "reports",
name: componentName,
});
for (let i = 0; i < 20; i++) await new Promise((done) => setTimeout(done, 5));
return { host, asked, dispose, window };
} finally {
globalThis.fetch = original;
}
}
test("a key is exchanged for a link, and the store travels with it", async () => {
const { host, asked, dispose, window } = await mountKeyed(
{ src: "/api/viewer/file?token=abc", name: "regional-figures.csv", mime: "text/csv" },
"region,units\nNorth,120\n",
);
const source = asked.find((url) => url.includes("/api/viewer/source"))!;
expect(source).toContain("key=regional-figures.csv");
expect(source).toContain("store=reports");
// The descriptor's filename must reach classification: without the extension
// the renderer for this kind cannot be found at all.
expect(host.querySelector("table")).not.toBeNull();
expect(host.querySelector(".wrn-viewer-toolbar span")!.textContent).toBe(
"regional-figures.csv",
);
dispose();
await window.happyDOM.close();
});
test("an explicit name on the component still wins", async () => {
const { host, dispose, window } = await mountKeyed(
{ src: "/api/viewer/file?token=abc", name: "regional-figures.csv", mime: "text/csv" },
"region,units\nNorth,120\n",
"Q3 figures.csv",
);
expect(host.querySelector(".wrn-viewer-toolbar span")!.textContent).toBe("Q3 figures.csv");
dispose();
await window.happyDOM.close();
});
test("a refused key shows the fallback, and no file is fetched", async () => {
const { host, asked, dispose, window } = await mountKeyed(undefined, "");
expect(host.textContent).toContain("This file is not available to preview");
expect(asked.every((url) => url.includes("/api/viewer/source"))).toBe(true);
dispose();
await window.happyDOM.close();
});
});
process.on("exit", () => rmSync(root, { recursive: true, force: true }));
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+923
View File
@@ -0,0 +1,923 @@
.wrn-viewer {
color: var(--wrn-color-text);
background: var(--wrn-color-surface);
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
overflow: hidden;
min-width: 0;
}
.wrn-viewer-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-bottom: 1px solid var(--wrn-color-border);
}
.wrn-viewer-toolbar > span:first-child {
/* Wide enough not to be crushed to one character per line by a full control
set; it wraps onto its own row before that happens. */
flex: 1 1 10rem;
min-width: 8rem;
overflow-wrap: anywhere;
}
.wrn-viewer-toolbar > span {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
/* The generic control look applies only to controls the package has not styled
itself; without this it outranks every specific rule below. */
.wrn-viewer button:not([class*="wrn-viewer-"]),
.wrn-viewer a:not([class*="wrn-viewer-"]) {
font: inherit;
color: var(--wrn-color-primary);
background: var(--wrn-color-surface);
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
padding: 0.25rem 0.5rem;
cursor: pointer;
}
.wrn-viewer :focus-visible {
outline: 2px solid var(--wrn-color-primary);
outline-offset: 2px;
}
.wrn-viewer-content {
overflow: auto;
max-height: 70vh;
min-height: 8rem;
padding: 1rem;
}
.wrn-viewer-content img,
.wrn-viewer-content video {
display: block;
max-width: 100%;
margin: auto;
transform-origin: center;
}
.wrn-viewer-content audio {
width: 100%;
}
.wrn-viewer-pdf {
height: 65vh;
}
.wrn-viewer-note {
margin: 0.5rem 0 0;
font-size: 0.875em;
color: var(--wrn-color-muted, inherit);
}
.wrn-viewer-content > pre {
white-space: pre;
margin: 0;
}
.wrn-viewer-code {
height: 55vh;
}
.wrn-viewer:fullscreen .wrn-viewer-content {
max-height: calc(100vh - 5rem);
}
.wrn-viewer-table {
overflow: auto;
max-width: 100%;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
}
.wrn-viewer-table table {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.wrn-viewer-table th,
.wrn-viewer-table td {
height: 28px;
padding: 0 0.75rem;
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--wrn-color-border) 55%, transparent);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 24rem;
}
.wrn-viewer-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--wrn-color-surface);
border-bottom: 1px solid var(--wrn-color-border);
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--wrn-color-muted, inherit);
}
.wrn-viewer-table tbody tr:nth-child(even) td,
.wrn-viewer-table tbody tr:nth-child(even) th {
background: color-mix(in srgb, var(--wrn-color-text) 4%, transparent);
}
.wrn-viewer-table tbody tr:hover td,
.wrn-viewer-table tbody tr:hover th {
background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent);
}
/* The row-number column is scaffolding, not data. */
.wrn-viewer-table [data-viewer-index] {
width: 1%;
color: color-mix(in srgb, var(--wrn-color-text) 45%, transparent);
font-weight: normal;
text-align: right;
text-transform: none;
font-variant-numeric: tabular-nums;
}
/* Numbers line up on the right so magnitudes can be compared down a column. */
.wrn-viewer-table [data-viewer-numeric] {
text-align: right;
font-variant-numeric: tabular-nums;
}
.wrn-viewer-table [data-viewer-grow] {
width: 100%;
white-space: normal;
overflow-wrap: anywhere;
}
.wrn-viewer-table tbody tr:last-child td,
.wrn-viewer-table tbody tr:last-child th {
border-bottom: 0;
}
.wrn-viewer-tree {
font-family: var(--wrn-font-mono, ui-monospace, monospace);
line-height: 1.6;
}
.wrn-viewer-branch {
padding-left: 1rem;
border-left: 1px solid var(--wrn-color-border);
margin-left: 0.35rem;
}
.wrn-viewer-tree summary {
cursor: pointer;
}
.wrn-viewer-key {
color: var(--wrn-color-primary);
}
.wrn-viewer-leaf > span:first-child {
color: var(--wrn-color-primary);
}
.wrn-viewer-string {
color: var(--wrn-color-success, var(--wrn-color-text));
}
.wrn-viewer-number,
.wrn-viewer-boolean,
.wrn-viewer-null {
color: var(--wrn-color-accent, var(--wrn-color-text));
}
.wrn-viewer-frame {
display: block;
width: 100%;
height: 60vh;
border: 0;
background: var(--wrn-color-surface);
}
.wrn-viewer-tabs {
display: inline-flex;
flex-wrap: wrap;
gap: 0.125rem;
margin-bottom: 0.75rem;
padding: 0.187rem;
border-radius: calc(var(--wrn-radius) + 0.187rem);
background: color-mix(in srgb, var(--wrn-color-text) 6%, transparent);
}
.wrn-viewer-tabs button {
border: 0;
background: transparent;
color: var(--wrn-color-muted, inherit);
padding: 0.3rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
}
.wrn-viewer-tabs button:hover {
color: var(--wrn-color-text);
}
.wrn-viewer-tabs button[aria-selected="true"] {
background: var(--wrn-color-primary);
color: var(--wrn-color-on-primary, #fff);
font-weight: 600;
}
.wrn-viewer-pages {
display: grid;
gap: 1rem;
justify-items: center;
}
.wrn-viewer-page {
display: block;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
}
.wrn-viewer-filmstrip {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 0.75rem;
border-top: 1px solid var(--wrn-color-border);
}
.wrn-viewer-file {
flex: 0 0 9rem;
display: grid;
gap: 0.5rem;
overflow-wrap: anywhere;
}
.wrn-viewer-file[aria-pressed="true"] {
outline: 2px solid var(--wrn-color-primary);
outline-offset: -2px;
}
.wrn-viewer-file img {
width: 100%;
height: 5rem;
object-fit: contain;
}
.wrn-viewer-waveform {
width: 100%;
max-height: 6rem;
display: block;
margin-top: 0.75rem;
}
.wrn-viewer input[type="range"] {
width: 100%;
accent-color: var(--wrn-color-primary);
}
.wrn-viewer-toolbar details {
flex-basis: 100%;
max-width: 100%;
}
.wrn-viewer-toolbar details pre {
max-height: 20rem;
overflow: auto;
white-space: pre-wrap;
}
.wrn-viewer [data-viewer-mount] {
min-width: 0;
}
.wrn-viewer-slides {
display: grid;
gap: 0.875rem;
outline: none;
}
.wrn-viewer-slides:focus-visible .wrn-viewer-slide {
outline: 2px solid var(--wrn-color-primary);
outline-offset: 3px;
}
.wrn-viewer-slide {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 16rem;
padding: 2rem 2.25rem;
border: 1px solid var(--wrn-color-border);
border-radius: calc(var(--wrn-radius) * 1.5);
background: color-mix(in srgb, var(--wrn-color-text) 3%, var(--wrn-color-surface));
}
.wrn-viewer-slide h2 {
margin: 0;
font-size: 1.5rem;
line-height: 1.25;
letter-spacing: -0.01em;
}
.wrn-viewer-slide h2::after {
content: "";
display: block;
width: 2.5rem;
height: 3px;
margin-top: 0.6rem;
border-radius: 2px;
background: var(--wrn-color-primary);
}
.wrn-viewer-slide ul {
margin: 0;
padding-left: 1.35rem;
display: grid;
gap: 0.4rem;
font-size: 1.0625rem;
line-height: 1.5;
list-style: disc outside;
}
.wrn-viewer-slide li {
list-style: inherit;
}
.wrn-viewer-slide li::marker {
color: var(--wrn-color-primary);
}
.wrn-viewer-slide-notes {
margin-top: 1rem;
border-top: 1px solid var(--wrn-color-border);
padding-top: 0.5rem;
}
.wrn-viewer-document {
max-width: 46rem;
margin: 0 auto;
line-height: 1.6;
}
.wrn-viewer-document p {
margin: 0 0 0.75rem;
}
.wrn-viewer-document ul {
margin: 0 0 0.75rem;
padding-left: 1.35rem;
list-style: disc outside;
}
.wrn-viewer-document li {
list-style: inherit;
margin-bottom: 0.25rem;
}
.wrn-viewer-document li::marker {
color: var(--wrn-color-primary);
}
/* Heading levels have to read as levels; the document is the only structure. */
.wrn-viewer-document h1 {
margin: 0 0 0.75rem;
font-size: 1.75rem;
line-height: 1.2;
letter-spacing: -0.01em;
}
.wrn-viewer-document h2 {
margin: 1.75rem 0 0.6rem;
font-size: 1.25rem;
line-height: 1.3;
}
.wrn-viewer-document h3 {
margin: 1.25rem 0 0.5rem;
font-size: 1.0625rem;
color: var(--wrn-color-muted, inherit);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.wrn-viewer-document h4,
.wrn-viewer-document h5,
.wrn-viewer-document h6 {
margin: 1rem 0 0.4rem;
font-size: 1rem;
}
.wrn-viewer-document h1:first-child,
.wrn-viewer-document h2:first-child {
margin-top: 0;
}
.wrn-viewer-action {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
box-sizing: border-box;
width: 2rem;
height: 2rem;
padding: 0;
/* A link and a button must sit on the same line: page styles commonly give
one of them a margin the other does not have. */
margin: 0;
line-height: 1;
text-decoration: none;
position: relative;
color: var(--wrn-color-text);
background: transparent;
border: 1px solid transparent;
border-radius: var(--wrn-radius);
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.wrn-viewer-action:hover,
.wrn-viewer-action:focus-visible {
color: var(--wrn-color-primary);
border-color: var(--wrn-color-border);
background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent);
}
.wrn-viewer-action[aria-pressed="true"] {
color: var(--wrn-color-on-primary, var(--wrn-color-surface));
background: var(--wrn-color-primary);
border-color: var(--wrn-color-primary);
}
.wrn-viewer-icon {
width: 1.125rem;
height: 1.125rem;
}
.wrn-viewer-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.wrn-viewer-action[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 0.4rem);
left: 50%;
transform: translateX(-50%) translateY(-0.15rem);
z-index: 5;
padding: 0.2rem 0.45rem;
border-radius: var(--wrn-radius);
background: var(--wrn-color-text);
color: var(--wrn-color-surface);
font-size: 0.75rem;
line-height: 1.3;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.wrn-viewer-action[data-tooltip]:hover::after,
.wrn-viewer-action[data-tooltip]:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.wrn-viewer-action,
.wrn-viewer-action[data-tooltip]::after {
transition: none;
}
}
.wrn-viewer-download {
display: grid;
place-items: center;
gap: 0.75rem;
min-height: 14rem;
padding: 2rem 1rem;
text-align: center;
}
.wrn-viewer-download-art {
display: grid;
place-items: center;
width: 4rem;
height: 4rem;
border-radius: 50%;
color: var(--wrn-color-primary);
background: color-mix(in srgb, var(--wrn-color-primary) 12%, transparent);
}
.wrn-viewer-download-art svg {
width: 2rem;
height: 2rem;
}
.wrn-viewer-download-reason {
margin: 0;
font-size: 1.0625rem;
font-weight: 600;
}
.wrn-viewer-download-meta {
margin: 0;
font-size: 0.875rem;
color: var(--wrn-color-muted, inherit);
overflow-wrap: anywhere;
}
.wrn-viewer-player {
display: grid;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--wrn-color-border);
border-radius: calc(var(--wrn-radius) * 1.5);
background: color-mix(in srgb, var(--wrn-color-text) 3%, var(--wrn-color-surface));
}
.wrn-viewer-player-transport {
display: flex;
align-items: center;
gap: 0.75rem;
}
.wrn-viewer-play {
width: 2.75rem;
height: 2.75rem;
flex: 0 0 auto;
color: var(--wrn-color-on-primary, var(--wrn-color-surface));
background: var(--wrn-color-primary);
border-color: var(--wrn-color-primary);
}
.wrn-viewer-play:hover,
.wrn-viewer-play:focus-visible {
color: var(--wrn-color-on-primary, var(--wrn-color-surface));
background: color-mix(in srgb, var(--wrn-color-primary) 85%, black);
border-color: transparent;
}
.wrn-viewer-play .wrn-viewer-icon {
width: 1.375rem;
height: 1.375rem;
}
.wrn-viewer-time {
flex: 0 0 auto;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--wrn-color-muted, inherit);
}
/* The waveform is the track: envelope and progress are one control. */
.wrn-viewer-track {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 2.75rem;
border-radius: var(--wrn-radius);
background: color-mix(in srgb, var(--wrn-color-text) 8%, transparent);
overflow: hidden;
}
.wrn-viewer-track .wrn-viewer-waveform {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.25s ease;
}
.wrn-viewer-track .wrn-viewer-waveform.is-ready {
opacity: 0.5;
}
/* The played part of the envelope reads at full strength. */
.wrn-viewer-progress .wrn-viewer-waveform {
opacity: 1;
}
.wrn-viewer-progress {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: color-mix(in srgb, var(--wrn-color-primary) 22%, transparent);
border-right: 2px solid var(--wrn-color-primary);
pointer-events: none;
}
.wrn-viewer-track:hover .wrn-viewer-progress {
background: color-mix(in srgb, var(--wrn-color-primary) 30%, transparent);
}
.wrn-viewer-seek {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.wrn-viewer-seek:focus-visible {
opacity: 1;
}
.wrn-viewer-print-title {
margin: 0 0 1rem;
font-size: 1.125rem;
font-weight: 600;
}
[data-viewer-print-root] {
display: none;
}
@media print {
[data-viewer-printing-active] body > *:not([data-viewer-print-root]) {
display: none !important;
}
[data-viewer-print-root] {
display: block;
}
[data-viewer-print-root] .wrn-viewer-content,
[data-viewer-print-root] .wrn-viewer-table {
max-height: none;
overflow: visible;
border: 0;
}
[data-viewer-print-root] .wrn-viewer-slide-strip,
[data-viewer-print-root] .wrn-viewer-tabs,
[data-viewer-print-root] .wrn-viewer-player-transport {
display: none;
}
}
/* Caption above a table or listing: context, not content. */
.wrn-viewer-content > p:first-child:not(.wrn-viewer-note):not(.wrn-viewer-download-reason) {
margin: 0 0 0.6rem;
font-size: 0.8125rem;
color: var(--wrn-color-muted, inherit);
}
/* Pager: a dot per slide, arrows either side, position on the right. */
.wrn-viewer-pager {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.wrn-viewer-pager-step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.875rem;
height: 1.875rem;
padding: 0;
margin: 0;
color: var(--wrn-color-text);
background: transparent;
border: 1px solid var(--wrn-color-border);
border-radius: 50%;
cursor: pointer;
}
.wrn-viewer-pager-step:hover:not(:disabled) {
color: var(--wrn-color-primary);
border-color: var(--wrn-color-primary);
}
.wrn-viewer-pager-step:disabled {
opacity: 0.35;
cursor: default;
}
.wrn-viewer-pager-step .wrn-viewer-icon {
width: 1rem;
height: 1rem;
}
.wrn-viewer-dots {
display: flex;
align-items: center;
gap: 0.375rem;
max-width: 60%;
overflow-x: auto;
padding: 0.25rem;
}
.wrn-viewer-dot {
flex: 0 0 auto;
width: 0.5rem;
height: 0.5rem;
padding: 0;
margin: 0;
border: 0;
border-radius: 50%;
background: color-mix(in srgb, var(--wrn-color-text) 25%, transparent);
cursor: pointer;
transition:
width 0.18s ease,
background-color 0.18s ease;
}
.wrn-viewer-dot:hover {
background: color-mix(in srgb, var(--wrn-color-primary) 60%, transparent);
}
.wrn-viewer-dot[aria-current="true"] {
width: 1.5rem;
border-radius: 0.25rem;
background: var(--wrn-color-primary);
}
.wrn-viewer-pager-count {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--wrn-color-muted, inherit);
}
@media (prefers-reduced-motion: reduce) {
.wrn-viewer-dot {
transition: none;
}
}
/* Workbook: a scrolling grid with the sheet bar fixed beneath it. */
.wrn-viewer-workbook {
display: grid;
grid-template-rows: 1fr auto;
min-height: 0;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
overflow: hidden;
}
.wrn-viewer-workbook-surface {
overflow: auto;
max-height: 60vh;
}
.wrn-viewer-sheetbar {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.5rem;
overflow-x: auto;
border-top: 1px solid var(--wrn-color-border);
background: color-mix(in srgb, var(--wrn-color-text) 6%, transparent);
}
.wrn-viewer-sheetbar.is-single .wrn-viewer-sheettab {
display: none;
}
.wrn-viewer-sheettab {
flex: 0 0 auto;
margin: 0;
padding: 0.3rem 0.85rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
color: var(--wrn-color-muted, inherit);
background: transparent;
border: 1px solid transparent;
border-bottom: 0;
border-radius: var(--wrn-radius) var(--wrn-radius) 0 0;
cursor: pointer;
}
.wrn-viewer-sheettab:hover {
color: var(--wrn-color-text);
}
/* The active tab joins the sheet above it, as a spreadsheet tab does. */
.wrn-viewer-sheettab[aria-selected="true"] {
color: var(--wrn-color-text);
font-weight: 600;
background: var(--wrn-color-surface);
border-color: var(--wrn-color-border);
box-shadow: 0 -2px 0 0 var(--wrn-color-primary) inset;
}
.wrn-viewer-sheetbar-status {
margin-left: auto;
padding-left: 0.75rem;
font-size: 0.75rem;
white-space: nowrap;
color: var(--wrn-color-muted, inherit);
}
/* The spreadsheet grid: lettered columns, numbered rows, ruled cells. */
.wrn-viewer-sheet table {
border-collapse: separate;
border-spacing: 0;
width: 100%;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}
.wrn-viewer-sheet th,
.wrn-viewer-sheet td {
height: 28px;
padding: 0 0.6rem;
border-right: 1px solid color-mix(in srgb, var(--wrn-color-border) 60%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--wrn-color-border) 60%, transparent);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 22rem;
}
.wrn-viewer-sheet thead th {
position: sticky;
top: 0;
z-index: 2;
height: 24px;
font-size: 0.75rem;
font-weight: 500;
text-align: center;
color: var(--wrn-color-muted, inherit);
background: color-mix(in srgb, var(--wrn-color-text) 8%, var(--wrn-color-surface));
border-bottom: 1px solid var(--wrn-color-border);
}
/* The row-number gutter stays put while the grid scrolls sideways. */
.wrn-viewer-sheet [data-viewer-index] {
position: sticky;
left: 0;
z-index: 1;
width: 1%;
min-width: 2.75rem;
text-align: center;
font-size: 0.75rem;
font-weight: 500;
color: var(--wrn-color-muted, inherit);
background: color-mix(in srgb, var(--wrn-color-text) 8%, var(--wrn-color-surface));
border-right: 1px solid var(--wrn-color-border);
}
.wrn-viewer-sheet thead [data-viewer-index] {
z-index: 3;
}
.wrn-viewer-sheet [data-viewer-numeric] {
text-align: right;
}
.wrn-viewer-sheet [data-viewer-labels] td {
font-weight: 600;
background: color-mix(in srgb, var(--wrn-color-text) 4%, transparent);
}
.wrn-viewer-sheet tbody tr:hover td {
background: color-mix(in srgb, var(--wrn-color-primary) 9%, transparent);
}
/* Image and video sit on a stage, so the media's bounds are visible. */
.wrn-viewer-stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 12rem;
padding: 1rem;
border-radius: var(--wrn-radius);
background:
linear-gradient(
45deg,
color-mix(in srgb, var(--wrn-color-text) 7%, transparent) 25%,
transparent 25%
),
linear-gradient(
-45deg,
color-mix(in srgb, var(--wrn-color-text) 7%, transparent) 25%,
transparent 25%
),
linear-gradient(
45deg,
transparent 75%,
color-mix(in srgb, var(--wrn-color-text) 7%, transparent) 75%
),
linear-gradient(
-45deg,
transparent 75%,
color-mix(in srgb, var(--wrn-color-text) 7%, transparent) 75%
);
background-size: 18px 18px;
background-position:
0 0,
0 9px,
9px -9px,
-9px 0;
}
.wrn-viewer-stage img {
display: block;
max-width: 100%;
box-shadow: 0 1px 12px rgb(0 0 0 / 0.35);
}
.wrn-viewer-stage video {
display: block;
max-width: 100%;
max-height: 60vh;
border-radius: var(--wrn-radius);
background: #000;
}
.wrn-viewer-zoom {
position: absolute;
right: 0.5rem;
bottom: 0.5rem;
padding: 0.15rem 0.45rem;
border-radius: var(--wrn-radius);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--wrn-color-surface);
background: color-mix(in srgb, var(--wrn-color-text) 75%, transparent);
pointer-events: none;
}
/* A preview opened over the page. The dialog itself is the backdrop area, so a
click that lands on it rather than on the viewer closes the overlay. */
.wrn-viewer-dialog {
width: min(72rem, 94vw);
max-width: none;
max-height: 92vh;
padding: 0;
border: 0;
background: transparent;
overflow: visible;
}
.wrn-viewer-dialog::backdrop {
background: rgb(0 0 0 / 0.65);
backdrop-filter: blur(2px);
}
.wrn-viewer-dialog .wrn-viewer {
max-height: 92vh;
display: flex;
flex-direction: column;
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.5);
}
/* Covering the page without the Fullscreen API, so it needs no gesture. */
.wrn-viewer-expanded {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
flex-direction: column;
margin: 0;
border-radius: 0;
border: 0;
background: var(--wrn-color-surface);
}
/* A whole page given over to one preview: no card, fill what is available. */
.wrn-viewer-as-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.wrn-viewer-as-page .wrn-viewer-toolbar {
padding-left: 0;
padding-right: 0;
}
/* `Viewer.wrn` renders the toolbar and content inside a shell, so the growing
box is two levels down: every wrapper between has to flex for the content to
fill the space these modes hand it. */
.wrn-viewer-dialog .wrn-viewer > *:not(noscript),
.wrn-viewer-expanded > *:not(noscript),
.wrn-viewer-as-page > *:not(noscript),
.wrn-viewer-dialog .wrn-viewer-shell,
.wrn-viewer-expanded .wrn-viewer-shell,
.wrn-viewer-as-page .wrn-viewer-shell {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.wrn-viewer-dialog .wrn-viewer-content,
.wrn-viewer-expanded .wrn-viewer-content,
.wrn-viewer-as-page .wrn-viewer-content {
flex: 1 1 auto;
max-height: none;
min-height: 0;
}