Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Separately served by viewerPlugin; downloaded only when a text preview opens. */
|
||||
export { mountBuilderSourceEditor } from "@wrnexus/block-editor/source-editor";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
|
||||
/** 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("");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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."));
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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)));
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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}`),
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleConvert } from "../convert/service.ts";
|
||||
|
||||
export async function GET(ctx: { req: Request }): Promise<Response> {
|
||||
return handleConvert(ctx.req, "preview");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleSource } from "../uploads/service.ts";
|
||||
|
||||
export async function GET(ctx: { req: Request }): Promise<Response> {
|
||||
return handleSource(ctx.req);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleConvert } from "../convert/service.ts";
|
||||
|
||||
export async function GET(ctx: { req: Request }): Promise<Response> {
|
||||
return handleConvert(ctx.req, "text");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { handleConvert } from "../convert/service.ts";
|
||||
|
||||
export async function GET(ctx: { req: Request }): Promise<Response> {
|
||||
return handleConvert(ctx.req, "thumb");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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]),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user