Ship file viewer, editor improvements, and private package releases

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

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
import type { Renderer, ViewerSource } from "./types.ts";
/**
* What an action can see when it is offered and when it runs. `content` is the
* element the renderer drew into, so an action can read or print what is on
* screen rather than re-fetching the file.
*/
export interface ViewerActionContext {
host: HTMLElement;
content: HTMLElement;
source: ViewerSource;
/** Absent while the preview is still resolving, or when nothing could render. */
renderer?: Renderer;
kind?: string;
}
export interface ViewerAction {
/** Stable identifier; registering the same id again replaces the earlier one. */
id: string;
label: string;
/** Name from the built-in icon set. Falls back to one derived from the label. */
icon?: string;
/** Toolbar side: alongside the renderer's own controls, or with download. */
placement?: "controls" | "actions";
/** Offered only when this returns true. Defaults to always. */
when?: (context: ViewerActionContext) => boolean;
run: (context: ViewerActionContext) => void | Promise<void>;
}
const registered = new Map<string, ViewerAction>();
/**
* Adds a toolbar action to every viewer in the page. Returns an unregister
* function, so a component can add one for as long as it is mounted.
*
* ```ts
* registerViewerAction({
* id: "open-in-tab",
* label: "Open in new tab",
* when: ({ source }) => Boolean(source.src),
* run: ({ source }) => window.open(source.src, "_blank", "noopener"),
* });
* ```
*/
export function registerViewerAction(action: ViewerAction): () => void {
registered.set(action.id, action);
return () => {
if (registered.get(action.id) === action) registered.delete(action.id);
};
}
/** Globally registered actions, in registration order. */
export function viewerActions(): ViewerAction[] {
return [...registered.values()];
}
/** Testing helper: drops every globally registered action. */
export function clearViewerActions(): void {
registered.clear();
}
+2
View File
@@ -0,0 +1,2 @@
/** Separately served by viewerPlugin; downloaded only when a text preview opens. */
export { mountBuilderSourceEditor } from "@wrnexus/block-editor/source-editor";
+52
View File
@@ -0,0 +1,52 @@
import type { ViewerSource } from "../types.ts";
export const CONVERT_BASE = "/api/viewer";
export interface ConverterClientOptions {
baseURI?: string;
fetch?: typeof fetch;
}
const probes = new Map<string, Promise<Record<string, string>>>();
const base = (value?: string) =>
value ?? (typeof document === "undefined" ? undefined : document.baseURI);
export async function converterFormats(
options: ConverterClientOptions = {},
): Promise<Record<string, string>> {
const baseURI = base(options.baseURI);
if (!baseURI) return Promise.resolve({});
let url: string;
try {
url = new URL(`${CONVERT_BASE}/capabilities`, baseURI).href;
} catch {
return {};
}
const request = () =>
(options.fetch ?? fetch)(url)
.then((response) => (response.ok ? response.json() : { formats: {} }))
.then((body: { formats?: Record<string, string> }) => body.formats ?? {})
.catch(() => ({}));
if (options.fetch) return request();
if (!probes.has(url)) probes.set(url, request());
return probes.get(url)!;
}
export async function requireConverter(
exts: string[],
options: ConverterClientOptions = {},
): Promise<string> {
const formats = await converterFormats(options);
for (const ext of exts) if (formats[ext]) return formats[ext]!;
throw new Error(`No converter for ${exts.join(", ")}`);
}
export function convertUrl(
source: ViewerSource,
operation: "preview" | "thumb" | "text" = "preview",
baseURI?: string,
): string | undefined {
const origin = base(baseURI);
if (!source.src || !origin) return undefined;
const url = new URL(`${CONVERT_BASE}/${operation}`, origin);
url.searchParams.set("src", new URL(source.src, origin).href);
url.searchParams.set("name", source.name);
return url.href;
}
export function resetConverterProbe(): void {
probes.clear();
}
+16
View File
@@ -0,0 +1,16 @@
import type { ConversionOptions } from "./types.ts";
const KEY = "__wrnexusViewerConversion";
/**
* Route modules are loaded by the router, not by the plugin factory, so the
* configuration is handed over through a global — the same arrangement
* `@wrnexus/graphql` uses for its handler.
*/
export function configureConversion(options: ConversionOptions | undefined): void {
(globalThis as Record<string, unknown>)[KEY] = options;
}
export function conversionConfig(): ConversionOptions | undefined {
return (globalThis as Record<string, unknown>)[KEY] as ConversionOptions | undefined;
}
+14
View File
@@ -0,0 +1,14 @@
export { configureConversion, conversionConfig } from "./config.ts";
export { handleCapabilities, handleConvert } from "./service.ts";
export { localBinaryConverter } from "./local.ts";
export { firstOf, sameOriginSource } from "./sources.ts";
export type { LocalConverterOptions } from "./local.ts";
export type {
ConversionOptions,
ConvertMeta,
ConvertOperation,
ConvertOutput,
ResolvedSource,
SourceResolver,
ViewerConverter,
} from "./types.ts";
+254
View File
@@ -0,0 +1,254 @@
import { spawn, spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConvertMeta, ConvertOutput, ViewerConverter } from "./types.ts";
import type { ViewerKind } from "../types.ts";
type Binary = "soffice" | "magick" | "ffmpeg" | "7z";
interface Recipe {
binary: Binary;
mime: string;
/** Output is read from this file, or from stdout when omitted. */
output?: string;
args: (input: string, dir: string) => string[];
}
const DOCUMENT: Recipe = {
binary: "soffice",
mime: "application/pdf",
output: "input.pdf",
args: (input, dir) => [
"--headless",
"--norestore",
"--nolockcheck",
`-env:UserInstallation=file://${dir.replace(/\\/g, "/")}/profile`,
"--convert-to",
"pdf",
"--outdir",
dir,
input,
],
};
const RASTER: Recipe = {
binary: "magick",
mime: "image/png",
output: "output.png",
// [0] takes the first layer/page only, which bounds the work for huge files.
args: (input, dir) => [`${input}[0]`, "-flatten", join(dir, "output.png")],
};
const POSTER: Recipe = {
binary: "ffmpeg",
mime: "image/png",
output: "output.png",
args: (input, dir) => [
"-nostdin",
"-y",
"-i",
input,
"-frames:v",
"1",
"-vf",
"scale='min(1280,iw)':-2",
join(dir, "output.png"),
],
};
const LISTING: Recipe = {
binary: "7z",
mime: "text/plain; charset=utf-8",
args: (input) => ["l", "-ba", "-p", input],
};
const RECIPES: Record<string, Recipe> = {
".doc": DOCUMENT,
".docx": DOCUMENT,
".odt": DOCUMENT,
".rtf": DOCUMENT,
".ppt": DOCUMENT,
".pptx": DOCUMENT,
".xls": DOCUMENT,
".xlsx": DOCUMENT,
".dotx": DOCUMENT,
".potx": DOCUMENT,
".ppsx": DOCUMENT,
".odp": DOCUMENT,
".ods": DOCUMENT,
".psd": RASTER,
".ai": RASTER,
".tif": RASTER,
".tiff": RASTER,
".avi": POSTER,
".mkv": POSTER,
".zip": LISTING,
".rar": LISTING,
".7z": LISTING,
};
export interface LocalConverterOptions {
/** Wall-clock limit for one conversion. Default 30s. */
timeoutMs?: number;
/** Refuse output larger than this. Default 64 MiB. */
maxOutputBytes?: number;
/** Override or disable a binary, e.g. `{ ffmpeg: false }`. */
binaries?: Partial<Record<Binary, string | false>>;
/** Parent directory for per-job scratch space. Defaults to the system temp. */
tempDir?: string;
}
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const parts: Uint8Array[] = [];
let length = 0;
while (true) {
const next = await reader.read();
if (next.done) break;
parts.push(next.value);
length += next.value.length;
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
}
function probe(binary: string): boolean {
try {
const finder = process.platform === "win32" ? "where" : "which";
return spawnSync(finder, [binary], { stdio: "ignore" }).status === 0;
} catch {
return false;
}
}
/**
* Runs LibreOffice, ImageMagick, ffmpeg and 7-Zip on the host.
*
* These are large C/C++ parsers with a long history of memory-safety bugs, and
* they are being pointed at untrusted files, so every run is given a hard
* timeout, a private scratch directory as its only writable path, a stripped
* environment, no stdin, and a cap on how much output is read back. A missing
* binary is not an error: the format simply reports as unsupported and the
* viewer falls back to its download card.
*/
export function localBinaryConverter(options: LocalConverterOptions = {}): ViewerConverter {
const timeoutMs = options.timeoutMs ?? 30_000;
const maxOutputBytes = options.maxOutputBytes ?? 64 * 1024 * 1024;
const available = new Map<Binary, string | false>();
const resolveBinary = (binary: Binary): string | false => {
const cached = available.get(binary);
if (cached !== undefined) return cached;
const override = options.binaries?.[binary];
const command = override === false ? false : (override ?? binary);
const found = command === false ? false : probe(command) ? command : false;
available.set(binary, found);
return found;
};
const recipeFor = (ext: string): Recipe | undefined => {
const recipe = RECIPES[ext];
return recipe && resolveBinary(recipe.binary) ? recipe : undefined;
};
async function run(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<ConvertOutput> {
const recipe = recipeFor(meta.ext);
const command = recipe && resolveBinary(recipe.binary);
if (!recipe || !command) throw new Error(`No local converter for ${meta.ext}`);
const dir = await mkdtemp(join(options.tempDir ?? tmpdir(), "wrn-viewer-"));
try {
const inputPath = join(dir, `input${meta.ext}`);
await writeFile(inputPath, await collect(input));
const bytes = await execute(command, recipe.args(inputPath, dir), dir, meta.signal);
if (recipe.output) {
const produced = await readFile(join(dir, recipe.output));
if (produced.byteLength > maxOutputBytes) throw new Error("Converted output too large");
return { mime: recipe.mime, bytes: new Uint8Array(produced) };
}
return { mime: recipe.mime, bytes };
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
async function execute(
command: string,
args: string[],
dir: string,
signal: AbortSignal,
): Promise<Uint8Array> {
return new Promise<Uint8Array>((resolve, reject) => {
const child = spawn(command, args, {
cwd: dir,
stdio: ["ignore", "pipe", "ignore"],
// A stripped environment: no proxies, credentials or user profile leak
// into a process parsing an untrusted file.
env: {
PATH: process.env.PATH ?? "",
HOME: dir,
TMPDIR: dir,
TEMP: dir,
TMP: dir,
SystemRoot: process.env.SystemRoot ?? "",
},
});
const parts: Buffer[] = [];
let length = 0;
let settled = false;
const finish = (error?: Error, value?: Uint8Array) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
if (error) {
child.kill("SIGKILL");
reject(error);
} else resolve(value!);
};
const timer = setTimeout(
() => finish(new Error(`Conversion timed out after ${timeoutMs}ms`)),
timeoutMs,
);
const onAbort = () => finish(new Error("Conversion aborted"));
signal.addEventListener("abort", onAbort, { once: true });
child.stdout.on("data", (chunk: Buffer) => {
length += chunk.byteLength;
if (length > maxOutputBytes) {
finish(new Error("Converted output too large"));
return;
}
parts.push(chunk);
});
child.on("error", (error) => finish(error));
child.on("close", (code) =>
code === 0
? finish(undefined, new Uint8Array(Buffer.concat(parts)))
: finish(new Error(`${command} exited with ${code}`)),
);
});
}
return {
name: "local-binary",
version: "1",
supports: (_kind: ViewerKind, ext: string) => Boolean(recipeFor(ext)),
outputType: (ext: string) => recipeFor(ext)?.mime,
toPreview: run,
toThumb: run,
async toText(input, meta) {
const recipe = recipeFor(meta.ext);
if (!recipe || !recipe.mime.startsWith("text/"))
throw new Error(`No text extraction for ${meta.ext}`);
return new TextDecoder().decode((await run(input, meta)).bytes);
},
};
}
+198
View File
@@ -0,0 +1,198 @@
import { createHash } from "node:crypto";
import { extensionOf, kindOfExtension } from "../kinds.ts";
import { conversionConfig } from "./config.ts";
import { sameOriginSource } from "./sources.ts";
import type { ConvertOperation, ConvertOutput, ResolvedSource } from "./types.ts";
const MAX_INPUT_BYTES = 64 * 1024 * 1024;
const CACHE_TTL_MS = 60 * 60 * 1000;
const MAX_CACHE_ENTRIES = 64;
interface CacheEntry extends ConvertOutput {
expires: number;
}
const cache = new Map<string, CacheEntry>();
function remember(key: string, value: ConvertOutput, ttlMs: number): void {
if (cache.size >= MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next();
if (!oldest.done) cache.delete(oldest.value);
}
cache.set(key, { ...value, expires: Date.now() + ttlMs });
}
function recall(key: string): ConvertOutput | undefined {
const entry = cache.get(key);
if (!entry) return undefined;
if (entry.expires < Date.now()) {
cache.delete(key);
return undefined;
}
return { mime: entry.mime, bytes: entry.bytes };
}
/** Caps the input before it reaches a binary parser. */
function bounded(stream: ReadableStream<Uint8Array>, cap: number): ReadableStream<Uint8Array> {
const reader = stream.getReader();
let seen = 0;
return new ReadableStream<Uint8Array>({
async pull(controller) {
const next = await reader.read();
if (next.done) {
controller.close();
return;
}
seen += next.value.length;
if (seen > cap) {
await reader.cancel().catch(() => {});
controller.error(new Error("Source exceeds the conversion limit"));
return;
}
controller.enqueue(next.value);
},
cancel: (reason) => reader.cancel(reason).catch(() => {}),
});
}
const problem = (status: number, message: string) =>
new Response(message, {
status,
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
});
/** `GET /api/viewer/capabilities` — what the client can ask to have converted. */
export function handleCapabilities(): Response {
const config = conversionConfig();
if (!config) return problem(404, "Conversion is not enabled");
const formats: Record<string, string> = {};
for (const converter of config.converters)
for (const ext of Object.keys(EXTENSIONS)) {
if (formats[ext]) continue;
const mime = converter.supports(kindOfExtension(ext), ext) && converter.outputType(ext);
if (mime) formats[ext] = mime;
}
return Response.json(
{ converters: config.converters.map((item) => item.name), formats },
{ headers: { "cache-control": "no-store" } },
);
}
/** Extensions the client may ask about. Keeps the probe response bounded. */
const EXTENSIONS: Record<string, true> = Object.fromEntries(
[
".doc",
".docx",
".odt",
".rtf",
".ppt",
".pptx",
".xls",
".xlsx",
".dotx",
".potx",
".ppsx",
".odp",
".ods",
".psd",
".ai",
".tif",
".tiff",
".avi",
".mkv",
".zip",
".rar",
".7z",
].map((ext) => [ext, true as const]),
);
export async function handleConvert(
request: Request,
operation: ConvertOperation,
): Promise<Response> {
const config = conversionConfig();
if (!config) return problem(404, "Conversion is not enabled");
const url = new URL(request.url);
const src = url.searchParams.get("src") ?? "";
const name = url.searchParams.get("name") ?? "";
if (!src || !name) return problem(400, "src and name are required");
const ext = extensionOf(name);
if (!EXTENSIONS[ext]) return problem(415, `Nothing converts ${ext || "this file"}`);
const kind = kindOfExtension(ext);
const converter = config.converters.find((item) => item.supports(kind, ext));
if (!converter) return problem(415, `No converter for ${ext}`);
// Resolve on every request, including cache hits, so revoked credentials cannot read cached output.
let source: ResolvedSource | undefined;
try {
source = await (config.resolveSource ?? sameOriginSource())(request, { src, name });
} catch {
return problem(403, "Source is not available for conversion");
}
if (!source) return problem(403, "Source is not available for conversion");
const maxInputBytes = config.maxInputBytes ?? MAX_INPUT_BYTES;
if (source.size !== undefined && source.size > maxInputBytes) {
await source.body.cancel().catch(() => {});
return problem(413, "Source exceeds the conversion limit");
}
const key = createHash("sha256")
.update(
// Encode the tuple so identity delimiters cannot cause key collisions.
JSON.stringify([
url.origin,
source.identity ?? src,
ext,
operation,
converter.name,
converter.version ?? "0",
]),
)
.digest("hex");
const cached = recall(key);
if (cached) {
await source.body.cancel().catch(() => {});
return output(cached, true);
}
const controller = new AbortController();
const abort = () => controller.abort();
request.signal.addEventListener("abort", abort, { once: true });
if (request.signal.aborted) controller.abort();
const meta = { name, ext, kind, size: source.size, signal: controller.signal };
const body = bounded(source.body, maxInputBytes);
try {
const result =
operation === "text" && converter.toText
? {
mime: "text/plain; charset=utf-8",
bytes: new TextEncoder().encode(await converter.toText(body, meta)),
}
: operation === "thumb" && converter.toThumb
? await converter.toThumb(body, meta)
: await converter.toPreview(body, meta);
remember(key, result, config.cacheTtlMs ?? CACHE_TTL_MS);
return output(result, false);
} catch (error) {
// The reason stays server-side: it can carry file paths and binary output.
console.warn(`[wrnexus] viewer conversion failed for ${name}:`, error);
return problem(422, "This file could not be converted");
} finally {
request.signal.removeEventListener("abort", abort);
if (!body.locked) await body.cancel().catch(() => {});
}
}
function output(result: ConvertOutput, hit: boolean): Response {
return new Response(result.bytes as unknown as BodyInit, {
headers: {
"content-type": result.mime,
"content-length": String(result.bytes.byteLength),
"content-disposition": "inline",
"x-content-type-options": "nosniff",
"cache-control": "private, no-store",
"x-viewer-cache": hit ? "hit" : "miss",
},
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { SourceResolver } from "./types.ts";
/** Tries policies in order. Undefined means not handled; errors fail closed. */
export function firstOf(...resolvers: SourceResolver[]): SourceResolver {
return async (request, params) => {
for (const resolve of resolvers) {
const source = await resolve(request, params);
if (source) return source;
}
return undefined;
};
}
/** Anonymous same-origin resources only. Redirects and viewer API recursion are refused. */
export function sameOriginSource(): SourceResolver {
return async (request, { src }) => {
const origin = new URL(request.url);
let target: URL;
try {
target = new URL(src, origin);
} catch {
return undefined;
}
if (
!["http:", "https:"].includes(target.protocol) ||
target.origin !== origin.origin ||
target.username ||
target.password ||
target.pathname.startsWith("/api/viewer/")
)
return undefined;
const response = await fetch(target, {
credentials: "omit",
redirect: "error",
signal: request.signal,
});
if (!response.ok || !response.body) {
await response.body?.cancel();
return undefined;
}
const length = response.headers.get("content-length");
return {
body: response.body,
size: length && /^\d+$/.test(length) ? Number(length) : undefined,
identity: target.href,
};
};
}
+60
View File
@@ -0,0 +1,60 @@
import type { ViewerKind } from "../types.ts";
export type ConvertOperation = "preview" | "thumb" | "text";
export interface ConvertMeta {
/** Original filename, used only for logging and the output extension. */
name: string;
/** Lowercased extension including the dot, e.g. `.docx`. */
ext: string;
kind: ViewerKind;
size?: number;
signal: AbortSignal;
}
export interface ConvertOutput {
mime: string;
bytes: Uint8Array;
}
/**
* Turns a format the browser cannot render into one it can. Implementations are
* server-side only and are never bundled into a client runtime.
*/
export interface ViewerConverter {
name: string;
/** Bumped when output changes; part of the cache key. */
version?: string;
supports(kind: ViewerKind, ext: string): boolean;
/** MIME this converter produces for the extension, for client-side planning. */
outputType(ext: string): string | undefined;
toPreview(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<ConvertOutput>;
toThumb?(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<ConvertOutput>;
toText?(input: ReadableStream<Uint8Array>, meta: ConvertMeta): Promise<string>;
}
export interface ResolvedSource {
body: ReadableStream<Uint8Array>;
size?: number;
/** Stable object identity, independent of credentials. Include a revision if mutable. */
identity?: string;
}
export type SourceResolver = (
request: Request,
params: { src: string; name: string },
) => Promise<ResolvedSource | undefined>;
export interface ConversionOptions {
converters: ViewerConverter[];
/**
* Produces the bytes for a requested source. The default accepts same-origin
* URLs only and fetches them without credentials; replace it to serve from
* private storage, and apply the app's own access checks inside it.
*/
resolveSource?: SourceResolver;
/** Refuse inputs larger than this. Default 64 MiB. */
maxInputBytes?: number;
/** Converted output time-to-live in the response cache. Default 1 hour. */
cacheTtlMs?: number;
}
+141
View File
@@ -0,0 +1,141 @@
import { mountViewer, type MountViewerOptions } from "./runtime.ts";
import { convertUrl, converterFormats } from "./convert/client.ts";
import { extensionOf, kindOfExtension } from "./kinds.ts";
import { readBounded, safeSourceUrl } from "./source.ts";
import { resolveKey } from "./uploads/client.ts";
import { resolve } from "./resolve.ts";
import { sniff } from "./sniff.ts";
import type { ViewerSource } from "./types.ts";
/** One active preview; switching always disposes its editor, media and pending work. */
export function mountFilmstrip(
host: HTMLElement,
files: ViewerSource[],
options: MountViewerOptions = {},
): () => void {
files = files.slice(0, 500);
const original = [...host.childNodes];
const doc = host.ownerDocument;
const templates = original.map((node) => node.cloneNode(true));
const controller = new AbortController();
const urls: string[] = [];
const stage = doc.createElement("div");
const strip = doc.createElement("div");
strip.className = "wrn-viewer-filmstrip";
strip.setAttribute("role", "group");
strip.setAttribute("aria-label", "Files to preview");
const status = doc.createElement("p");
status.setAttribute("role", "status");
host.replaceChildren(stage, status, strip);
let dispose: (() => void) | undefined;
let selected = -1;
const buttons: HTMLButtonElement[] = [];
const show = (index: number, focus = false) => {
if (!files.length || index === selected) return;
dispose?.();
selected = Math.max(0, Math.min(files.length - 1, index));
stage.replaceChildren(...templates.map((node) => node.cloneNode(true)));
dispose = mountViewer(stage, files[selected]!, options);
buttons.forEach((button, i) => {
button.setAttribute("aria-pressed", String(i === selected));
button.tabIndex = i === selected ? 0 : -1;
});
status.textContent = `${selected + 1} of ${files.length}: ${files[selected]!.name}`;
if (focus) buttons[selected]?.focus();
};
for (const [index, file] of files.slice(0, 500).entries()) {
const button = doc.createElement("button");
button.type = "button";
button.className = "wrn-viewer-file";
button.textContent = file.name || "File";
button.onclick = () => show(index);
button.onkeydown = (event) => {
const next =
event.key === "ArrowRight"
? Math.min(files.length - 1, selected + 1)
: event.key === "ArrowLeft"
? Math.max(0, selected - 1)
: event.key === "Home"
? 0
: event.key === "End"
? files.length - 1
: undefined;
if (next !== undefined) {
event.preventDefault();
show(next, true);
}
};
buttons.push(button);
strip.append(button);
}
if (files.length) show(0);
else status.textContent = "No files to preview.";
// Sequential thumbnail work bounds concurrent conversion and source requests.
void (async () => {
const formats = await converterFormats({ baseURI: doc.baseURI });
for (const [index, file] of files.slice(0, 500).entries()) {
if (controller.signal.aborted) return;
try {
const source =
file.key && !file.src
? await resolveKey(file, { baseURI: doc.baseURI, signal: controller.signal })
: file;
if (!source || controller.signal.aborted) continue;
let url: string | undefined;
if (kindOfExtension(extensionOf(source.name)) === "image") {
const normalized = source.src
? { ...source, src: new URL(source.src, doc.baseURI).href }
: source;
const plan = await resolve(normalized, {
baseURI: doc.baseURI,
signal: controller.signal,
});
if (
plan.kind !== "image" ||
plan.renderer.component === "DownloadCard" ||
controller.signal.aborted
)
continue;
if (source.blob) {
const type = sniff(new Uint8Array(await source.blob.slice(0, 4096).arrayBuffer())).mime;
if (controller.signal.aborted) return;
url = URL.createObjectURL(new Blob([source.blob], { type }));
urls.push(url);
} else if (normalized.src) url = safeSourceUrl(normalized.src);
} else if (formats[extensionOf(source.name)]) {
const endpoint = convertUrl(source, "thumb", doc.baseURI);
if (!endpoint) continue;
const response = await fetch(endpoint, { signal: controller.signal });
if (!/^image\/(png|jpeg|webp)$/.test(response.headers.get("content-type") ?? "")) {
await response.body?.cancel();
continue;
}
const bytes = await readBounded(response, 2 * 1024 * 1024);
if (controller.signal.aborted) return;
url = URL.createObjectURL(
new Blob([bytes as Uint8Array<ArrayBuffer>], {
type: response.headers.get("content-type")!,
}),
);
urls.push(url);
}
if (url && !controller.signal.aborted) {
const img = doc.createElement("img");
img.alt = "";
img.loading = "lazy";
img.src = url;
img.onerror = () => img.remove();
buttons[index]!.prepend(img);
}
} catch {
/* The filename remains a useful thumbnail fallback. */
}
}
})().catch(() => {});
return () => {
controller.abort();
dispose?.();
urls.forEach((url) => URL.revokeObjectURL(url));
host.replaceChildren(...original);
};
}
+20
View File
@@ -0,0 +1,20 @@
export type * from "./types.ts";
export { extensions, extensionOf, kindOfExtension, kindOfMime } from "./kinds.ts";
export { sniff } from "./sniff.ts";
export { resolve } from "./resolve.ts";
export { RendererRegistry, registerRenderer } from "./registry.ts";
export { parseDelimited, detectDelimiter } from "./parse/delimited.ts";
export type { DelimitedTable } from "./parse/delimited.ts";
export { sanitizeSvg } from "./parse/svg.ts";
export type { SvgSanitizerView } from "./parse/svg.ts";
export { parseTar, isTar, gzipName, gunzip } from "./parse/archive.ts";
export type { ArchiveEntry } from "./parse/archive.ts";
export { convertUrl, converterFormats, requireConverter } from "./convert/client.ts";
export { extractText } from "./text.ts";
export { sanitizeHtml } from "./parse/html.ts";
export { registerViewerAction, viewerActions, clearViewerActions } from "./actions.ts";
export type { ViewerAction, ViewerActionContext } from "./actions.ts";
export { canPrint, printViewer } from "./print.ts";
export { ICONS, icon, iconFor } from "./renderers/icons.ts";
export { openViewer, startViewerTriggers, sourceFromTrigger } from "./overlay.ts";
export type { OpenViewerOptions, OpenedViewer } from "./overlay.ts";
+68
View File
@@ -0,0 +1,68 @@
import type { ViewerKind } from "./types.ts";
/** Kind classification only; MIME ownership remains with uploader. */
export const extensions: Record<ViewerKind, readonly string[]> = {
image: ["jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "avif"],
vector: ["svg"],
pdf: ["pdf"],
code: [
"js",
"jsx",
"ts",
"tsx",
"py",
"java",
"cpp",
"c",
"h",
"cs",
"css",
"json",
"xml",
"sql",
"sh",
"yaml",
"yml",
],
text: ["txt", "md", "log"],
markup: ["html", "htm"],
sheet: ["csv", "tsv", "xls", "xlsx", "ods"],
doc: ["doc", "docx", "dotx", "odt", "rtf", "ppt", "pptx", "ppsx", "potx", "odp"],
audio: ["mp3", "wav", "aac", "flac", "ogg"],
video: ["mp4", "webm", "mov", "avi", "mkv"],
archive: ["zip", "rar", "7z", "tar", "gz", "tgz"],
binary: ["psd", "ai", "tif", "tiff", "exe", "dll"],
};
export function extensionOf(name: string): string {
const base = (name.split(/[?#]/)[0] ?? "").split(/[/\\]/).pop() ?? "";
const dot = base.lastIndexOf(".");
return dot < 0 ? "" : base.slice(dot).toLowerCase();
}
export function kindOfExtension(ext: string): ViewerKind {
return (
(Object.keys(extensions) as ViewerKind[]).find((kind) =>
extensions[kind].includes(ext.replace(/^\./, "").toLowerCase()),
) ?? "binary"
);
}
export function kindOfMime(mime: string): ViewerKind | undefined {
const type = mime.split(";")[0]!.trim().toLowerCase();
if (type === "image/svg+xml") return "vector";
if (type === "text/html" || type === "application/xhtml+xml") return "markup";
if (type === "application/pdf") return "pdf";
if (/^(application|text)\/(json|xml|javascript|x-javascript)$/.test(type) || type === "text/css")
return "code";
if (/^text\/(csv|tab-separated-values)$/.test(type) || /spreadsheet|ms-excel/.test(type))
return "sheet";
if (/opendocument\.spreadsheet/.test(type)) return "sheet";
if (/msword|wordprocessing|presentation|powerpoint|opendocument|rtf/.test(type)) return "doc";
if (/zip|gzip|x-tar|x-7z|rar/.test(type)) return "archive";
if (/^image\/(png|jpeg|gif|webp|bmp|x-icon|vnd.microsoft.icon|avif)$/.test(type)) return "image";
if (type.startsWith("audio/")) return "audio";
if (type.startsWith("video/")) return "video";
if (type.startsWith("text/")) return "text";
return undefined;
}
+19
View File
@@ -0,0 +1,19 @@
import { convertToHtml } from "mammoth";
/**
* Semantic HTML, not a faithful page layout: headers, footers, page breaks and
* exact positioning are lost. The output is derived from an untrusted file, so
* callers must render it inside a sandboxed frame.
*/
export async function docxToHtml(bytes: Uint8Array): Promise<string> {
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
// The browser build reads `arrayBuffer`, the Node build reads `buffer`.
// Supplying both keeps this module usable — and testable — in either.
const result = await convertToHtml({ arrayBuffer, buffer: bytes } as Parameters<
typeof convertToHtml
>[0]);
return result.value;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Optional parsers ship as separate plugin runtime assets, contributed only
* when their package resolves on the server. Importing one that was not
* contributed fails, which is how a renderer reports itself unavailable.
*/
export async function loadOptional<T>(
id: string,
baseURI?: string,
importModule: (url: string) => Promise<unknown> = (url) => import(/* @vite-ignore */ url),
): Promise<T> {
baseURI ??= typeof document === "undefined" ? undefined : document.baseURI;
if (!baseURI) throw new Error(`${id} needs a browser`);
const url = new URL(`/__wrnexus/assets/${id}.js`, baseURI).href;
const module = (await importModule(url)) as T;
return module;
}
+194
View File
@@ -0,0 +1,194 @@
import { unzip } from "fflate";
/**
* Office documents that are ZIP containers of XML: OOXML presentations
* (`.pptx`) and the whole OpenDocument family (`.odt`, `.ods`, `.odp`).
*
* Both are read here rather than through a converter so they preview with no
* binary on the host. What comes back is content and structure — headings,
* bullets, cells, slide order — not a faithful page layout. Layout fidelity is
* what the Tier 3 converter is for, and it stays preferred where it exists.
*/
export interface Slide {
title: string;
/** Text blocks in shape order, each already split into lines. */
blocks: string[][];
notes?: string;
}
export interface OfficeText {
type: "text";
/** Paragraphs with an outline level: 0 is body text, 1+ are headings. */
blocks: { level: number; text: string; list?: boolean }[];
}
export interface OfficeSheets {
type: "sheets";
sheets: { name: string; header: string[]; rows: string[][] }[];
}
export interface OfficeSlides {
type: "slides";
slides: Slide[];
}
export type OfficeDocument = OfficeText | OfficeSheets | OfficeSlides;
const MAX_SLIDES = 300;
const MAX_ROWS = 200_000;
/** A repeat count high enough to be a "rest of the row" filler, not real data. */
const REPEAT_LIMIT = 1024;
/** Reads only the entries a preview needs; media is never inflated. */
function unzipOnly(bytes: Uint8Array, wanted: RegExp): Promise<Record<string, Uint8Array>> {
return new Promise((resolve, reject) => {
unzip(bytes, { filter: (file) => wanted.test(file.name) }, (error, files) =>
error ? reject(error) : resolve(files),
);
});
}
const decoder = new TextDecoder();
function parse(xml: Uint8Array, view: { DOMParser: new () => DOMParser }): Document {
const document = new view.DOMParser().parseFromString(decoder.decode(xml), "application/xml");
if (document.getElementsByTagName("parsererror").length) throw new Error("Malformed office XML");
return document;
}
/** Local-name lookup: office XML is namespaced and prefixes are not fixed. */
function byTag(root: Element | Document, local: string): Element[] {
return [...root.getElementsByTagName("*")].filter(
(node) => node.localName === local || node.nodeName.split(":").pop() === local,
);
}
/** Slide and note files sort by their number, not their string name. */
function inOrder(names: string[]): string[] {
return names.sort((a, b) => {
const number = (value: string) => Number(/(\d+)\.xml$/.exec(value)?.[1] ?? 0);
return number(a) - number(b);
});
}
export async function readPptx(
bytes: Uint8Array,
view: { DOMParser: new () => DOMParser },
): Promise<OfficeSlides> {
const files = await unzipOnly(bytes, /^ppt\/(slides|notesSlides)\/[^/]+\.xml$/);
const slideNames = inOrder(Object.keys(files).filter((name) => name.startsWith("ppt/slides/")));
if (!slideNames.length) throw new Error("Presentation holds no slides");
const noteNames = inOrder(
Object.keys(files).filter((name) => name.startsWith("ppt/notesSlides/")),
);
const slides: Slide[] = [];
for (const name of slideNames.slice(0, MAX_SLIDES)) {
const document = parse(files[name]!, view);
// One block per shape, so a title and a bullet list stay separate.
const blocks: string[][] = [];
for (const shape of byTag(document, "sp")) {
const lines = byTag(shape, "p")
.map((paragraph) =>
byTag(paragraph, "t")
.map((run) => run.textContent ?? "")
.join("")
.trim(),
)
.filter(Boolean);
if (lines.length) blocks.push(lines);
}
const notesFile = noteNames.find(
(note) => /(\d+)\.xml$/.exec(note)?.[1] === /(\d+)\.xml$/.exec(name)?.[1],
);
const notes = notesFile
? byTag(parse(files[notesFile]!, view), "t")
.map((run) => run.textContent ?? "")
.join("")
.trim()
: "";
// The first shape carrying text is the title in every standard layout.
const title = blocks[0]?.[0] ?? "";
slides.push({ title, blocks, ...(notes ? { notes } : {}) });
}
return { type: "slides", slides };
}
function odfParagraphs(
root: Element | Document,
): { level: number; text: string; list?: boolean }[] {
const blocks: { level: number; text: string; list?: boolean }[] = [];
const walk = (node: Element, inList: boolean) => {
for (const child of [...node.children]) {
const local = child.localName ?? child.nodeName.split(":").pop() ?? "";
if (local === "h" || local === "p") {
const text = (child.textContent ?? "").trim();
const level =
local === "h" ? Number(child.getAttribute("text:outline-level") ?? 1) || 1 : 0;
if (text) blocks.push({ level, text, ...(inList && local === "p" ? { list: true } : {}) });
} else if (local === "list" || local === "list-item") walk(child, true);
else walk(child, inList);
}
};
walk(root as Element, false);
return blocks;
}
function odfSheet(table: Element): { name: string; header: string[]; rows: string[][] } {
const name = table.getAttribute("table:name") ?? "Sheet";
const grid: string[][] = [];
for (const row of byTag(table, "table-row")) {
const cells: string[] = [];
for (const cell of byTag(row, "table-cell")) {
const text = (cell.textContent ?? "").trim();
// ODF collapses runs of identical cells; a huge repeat is trailing padding.
const repeat = Number(cell.getAttribute("table:number-columns-repeated") ?? 1) || 1;
for (let i = 0; i < Math.min(repeat, REPEAT_LIMIT); i++) cells.push(text);
}
while (cells.length && cells[cells.length - 1] === "") cells.pop();
if (cells.length) grid.push(cells);
if (grid.length > MAX_ROWS) break;
}
const header = grid.shift() ?? [];
return { name, header, rows: grid };
}
export async function readOdf(
bytes: Uint8Array,
view: { DOMParser: new () => DOMParser },
): Promise<OfficeDocument> {
const files = await unzipOnly(bytes, /^(content\.xml|mimetype)$/);
const content = files["content.xml"];
if (!content) throw new Error("Not an OpenDocument file");
const mimetype = files["mimetype"] ? decoder.decode(files["mimetype"]) : "";
const document = parse(content, view);
if (mimetype.includes("spreadsheet") || byTag(document, "spreadsheet").length) {
const sheets = byTag(document, "table").map(odfSheet);
if (!sheets.length) throw new Error("Spreadsheet holds no sheets");
return { type: "sheets", sheets };
}
if (mimetype.includes("presentation") || byTag(document, "presentation").length) {
const slides: Slide[] = byTag(document, "page")
.slice(0, MAX_SLIDES)
.map((page) => {
const blocks = byTag(page, "frame")
.map((frame) =>
odfParagraphs(frame)
.map((block) => block.text)
.filter(Boolean),
)
.filter((lines) => lines.length);
return { title: blocks[0]?.[0] ?? page.getAttribute("draw:name") ?? "", blocks };
});
if (!slides.length) throw new Error("Presentation holds no slides");
return { type: "slides", slides };
}
const body = byTag(document, "text")[0] ?? document.documentElement;
const blocks = odfParagraphs(body);
if (!blocks.length) throw new Error("Document holds no readable text");
return { type: "text", blocks };
}
+126
View File
@@ -0,0 +1,126 @@
import type * as PdfJs from "pdfjs-dist";
export interface RichPdfOptions {
signal?: AbortSignal;
/** URL of the pdf.js ESM build, served verbatim by the plugin. */
libSrc: string;
/** URL of the pdf.js worker, served verbatim by the plugin. */
workerSrc: string;
/** Pages rendered before the viewer stops. */
maxPages?: number;
/** CSS pixels per PDF point at scale 1, before device pixel ratio. */
scale?: number;
/** Give up on a page that will not finish. Default 20s. */
timeoutMs?: number;
}
export interface RichPdfResult {
pages: number;
rendered: number;
destroy: () => void;
}
/**
* pdf.js drives rasterisation from `requestAnimationFrame`, which never fires
* while the document is hidden, so a page render can stay pending forever. A
* viewer must fall back rather than hang: exceeding this rejects, and the
* ladder moves on to the native frame or the download card.
*/
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
/**
* Rasterises pages into canvases appended to `parent`. Unlike the native
* viewer this gives page count, controlled sizing and consistent output across
* browsers, at the cost of downloading pdf.js.
*
* pdf.js is imported from its own published build at runtime rather than being
* bundled: putting its ESM entry through a bundler breaks the worker handshake,
* and `getDocument()` then never settles.
*/
export async function renderPdf(
parent: HTMLElement,
bytes: Uint8Array,
options: RichPdfOptions,
): Promise<RichPdfResult> {
const pdfjs = (await import(/* @vite-ignore */ options.libSrc)) as typeof PdfJs;
pdfjs.GlobalWorkerOptions.workerSrc = options.workerSrc;
const doc = parent.ownerDocument;
const timeoutMs = options.timeoutMs ?? 20_000;
const task = pdfjs.getDocument({ data: bytes, isEvalSupported: false });
let stopped = false;
let activeRender: { cancel(): void } | undefined;
const destroy = () => {
if (stopped) return;
stopped = true;
options.signal?.removeEventListener("abort", destroy);
activeRender?.cancel();
void task.destroy().catch(() => {});
};
options.signal?.addEventListener("abort", destroy, { once: true });
if (options.signal?.aborted) {
destroy();
throw new Error("Preview disposed");
}
try {
const pdf = await withTimeout(task.promise, timeoutMs, "The document could not be opened");
const limit = Math.min(pdf.numPages, options.maxPages ?? 50);
const ratio = Math.min(doc.defaultView?.devicePixelRatio ?? 1, 2);
let rendered = 0;
for (let number = 1; number <= limit; number++) {
if (stopped) throw new Error("Preview disposed");
const page = await withTimeout(pdf.getPage(number), timeoutMs, "Page could not be opened");
const viewport = page.getViewport({ scale: (options.scale ?? 1.4) * ratio });
const canvas = doc.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = `${Math.floor(viewport.width / ratio)}px`;
canvas.style.maxWidth = "100%";
canvas.className = "wrn-viewer-page";
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", `Page ${number} of ${pdf.numPages}`);
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas rendering is unavailable");
parent.append(canvas);
const render = page.render({ canvasContext: context, viewport });
activeRender = render;
try {
await withTimeout(render.promise, timeoutMs, "Page rendering did not complete");
} catch (error) {
render.cancel();
canvas.remove();
// A first page that will not draw means this renderer cannot be used.
if (rendered === 0) {
destroy();
throw error;
}
break;
}
rendered++;
}
return {
pages: pdf.numPages,
rendered,
destroy,
};
} catch (error) {
destroy();
throw error;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { read, utils } from "xlsx";
export interface SheetData {
name: string;
header: string[];
rows: string[][];
truncated: boolean;
}
const MAX_ROWS = 200_000;
/** Every sheet as strings; formulas arrive as their cached values. */
export function readWorkbook(bytes: Uint8Array): SheetData[] {
const workbook = read(bytes, { type: "array", cellFormula: false, cellHTML: false });
return workbook.SheetNames.map((name) => {
const sheet = workbook.Sheets[name];
const grid = sheet
? (utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" }) as unknown[][])
: [];
const rows = grid.map((row) => row.map((cell) => (cell == null ? "" : String(cell))));
const header = rows.shift() ?? [];
return {
name,
header,
rows: rows.slice(0, MAX_ROWS),
truncated: rows.length > MAX_ROWS,
};
});
}
+31
View File
@@ -0,0 +1,31 @@
import { unzip } from "fflate";
export interface ZipEntry {
name: string;
size: number;
directory: boolean;
}
/**
* Lists a zip without inflating it: fflate's filter runs per entry, and
* returning false skips decompression, so a 2 GB archive costs its directory.
*/
export function listZip(bytes: Uint8Array): Promise<ZipEntry[]> {
return new Promise((resolve, reject) => {
const entries: ZipEntry[] = [];
unzip(
bytes,
{
filter: (file) => {
entries.push({
name: file.name,
size: file.originalSize ?? 0,
directory: file.name.endsWith("/"),
});
return false;
},
},
(error) => (error ? reject(error) : resolve(entries)),
);
});
}
+127
View File
@@ -0,0 +1,127 @@
import { mountViewer, type MountViewerOptions } from "./runtime.ts";
import type { ViewerSource } from "./types.ts";
export interface OpenViewerOptions extends MountViewerOptions {
/** Document to open in. Defaults to the ambient one. */
document?: Document;
/** Called once the overlay has closed, however it was closed. */
onClose?: () => void;
/** Accessible name for the dialog. Defaults to the file's name. */
label?: string;
}
export interface OpenedViewer {
close: () => void;
dialog: HTMLElement;
}
/**
* Opens a file over the page, for the "click a button, preview the file" case.
*
* A native `<dialog>` does the work that is easy to get wrong by hand: it takes
* the top layer, traps focus, closes on Escape, and restores focus to whatever
* opened it. Closing also disposes the viewer, so a video stops and object URLs
* are revoked rather than leaking behind the overlay.
*/
export function openViewer(
source: ViewerSource | ViewerSource[],
options: OpenViewerOptions = {},
): OpenedViewer {
const doc = options.document ?? document;
const dialog = doc.createElement("dialog");
dialog.className = "wrn-viewer-dialog";
const first = Array.isArray(source) ? source[0] : source;
dialog.setAttribute("aria-label", options.label ?? first?.name ?? "File preview");
const host = doc.createElement("section");
host.className = "wrn-viewer";
dialog.append(host);
doc.body.append(dialog);
let closed = false;
// Assigned below; `close` may run before the viewer has finished mounting.
let dispose: (() => void) | undefined;
const close = (): void => {
if (closed) return;
closed = true;
dispose?.();
// `close()` on an open dialog fires `close`, which lands back here; the
// guard above makes that a no-op rather than a loop.
if (dialog.open) dialog.close();
dialog.remove();
options.onClose?.();
};
dialog.addEventListener("close", close);
// The dialog element fills the viewport, so a click that lands on it rather
// than on the viewer inside is a click on the backdrop.
dialog.addEventListener("click", (event) => {
if (event.target === dialog) close();
});
const stop = mountViewer(host, source, {
...options,
actions: [
{
id: "close",
label: "Close preview",
icon: "close",
placement: "actions",
run: close,
},
...(options.actions ?? []),
],
});
// If closing raced the mount, tear the viewer down rather than orphan it.
if (closed) stop();
else dispose = stop;
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
return { close, dialog };
}
/** Reads a trigger's data attributes into the source(s) it points at. */
export function sourceFromTrigger(trigger: HTMLElement): ViewerSource | ViewerSource[] | undefined {
const { src, key, store, name, mime, size, files } = trigger.dataset;
if (files) {
try {
const parsed = JSON.parse(files) as ViewerSource[];
if (Array.isArray(parsed) && parsed.length) return parsed;
} catch {
return undefined;
}
}
if (!src && !key) return undefined;
const bytes = Number(size);
return {
src,
key,
store,
name: name ?? "",
mime,
size: size && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined,
};
}
/**
* Any element carrying `data-viewer-open` opens a preview when clicked, so a
* plain button in a list of files needs no JavaScript of its own.
*/
export function startViewerTriggers(root: Document = document): () => void {
const onClick = (event: MouseEvent) => {
const target = event.target as Element | null;
const trigger = target?.closest?.<HTMLElement>("[data-viewer-open]");
if (!trigger) return;
const source = sourceFromTrigger(trigger);
if (!source) return;
event.preventDefault();
openViewer(source, {
document: root,
richPdf: trigger.dataset.richPdf === "true",
});
};
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
}
+86
View File
@@ -0,0 +1,86 @@
export interface ArchiveEntry {
name: string;
size: number;
directory: boolean;
}
const decoder = new TextDecoder();
/** Reads a NUL-padded fixed-width tar header field. */
function field(bytes: Uint8Array, offset: number, length: number): string {
const raw = bytes.subarray(offset, offset + length);
const end = raw.indexOf(0);
return decoder.decode(end < 0 ? raw : raw.subarray(0, end)).trim();
}
const octal = (value: string) => (/^[0-7]+$/.test(value) ? parseInt(value, 8) : 0);
/** USTAR/GNU tar listing. Entry data is skipped; only headers are read. */
export function parseTar(bytes: Uint8Array): ArchiveEntry[] {
const entries: ArchiveEntry[] = [];
let offset = 0;
let longName: string | undefined;
while (offset + 512 <= bytes.length) {
const header = bytes.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const size = octal(field(header, 124, 12));
const type = String.fromCharCode(header[156] ?? 0);
const blocks = Math.ceil(size / 512) * 512;
if (type === "L") {
// GNU long name: the following data block holds this entry's real name.
longName = field(bytes, offset + 512, size);
} else if (type !== "x" && type !== "g") {
const prefix = field(header, 345, 155);
const name = longName ?? [prefix, field(header, 0, 100)].filter(Boolean).join("/");
longName = undefined;
if (name) entries.push({ name, size, directory: type === "5" || name.endsWith("/") });
}
offset += 512 + blocks;
}
return entries;
}
/** True when the buffer carries the USTAR signature at its header offset. */
export function isTar(bytes: Uint8Array): boolean {
return field(bytes, 257, 5) === "ustar";
}
/** Original filename recorded in a gzip member header, when present. */
export function gzipName(bytes: Uint8Array): string | undefined {
const flags = bytes[3] ?? 0;
if (bytes[0] !== 0x1f || bytes[1] !== 0x8b || !(flags & 0x08)) return undefined;
let offset = 10;
if (flags & 0x04) offset += 2 + ((bytes[10] ?? 0) | ((bytes[11] ?? 0) << 8));
const end = bytes.indexOf(0, offset);
return end > offset ? decoder.decode(bytes.subarray(offset, end)) : undefined;
}
/** Gunzip through the platform stream; undefined when unsupported or invalid. */
export async function gunzip(file: Blob, cap: number): Promise<Uint8Array | undefined> {
if (typeof DecompressionStream === "undefined") return undefined;
try {
const reader = file.stream().pipeThrough(new DecompressionStream("gzip")).getReader();
const parts: Uint8Array[] = [];
let length = 0;
try {
while (length < cap) {
const next = await reader.read();
if (next.done) break;
const part = next.value.subarray(0, cap - length);
parts.push(part);
length += part.length;
}
} finally {
await reader.cancel().catch(() => {});
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
} catch {
return undefined;
}
}
+81
View File
@@ -0,0 +1,81 @@
export interface DelimitedTable {
header: string[];
rows: string[][];
delimiter: string;
/** True when the file held more rows than `maxRows`. */
truncated: boolean;
}
const CANDIDATES = [",", "\t", ";", "|"];
/** Delimiter used by the most fields on the first unquoted line. */
export function detectDelimiter(text: string): string {
const line = text.slice(0, 64 * 1024).split(/\r?\n/, 1)[0] ?? "";
let best = ",";
let bestCount = 0;
for (const candidate of CANDIDATES) {
let count = 0;
let quoted = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') quoted = !quoted;
else if (!quoted && char === candidate) count++;
}
if (count > bestCount) {
best = candidate;
bestCount = count;
}
}
return best;
}
/**
* RFC 4180 parsing: quoted fields may hold the delimiter, CR/LF, and `""`
* escapes. Rows are capped so a large file cannot exhaust memory in the tab.
*/
export function parseDelimited(
text: string,
options: { delimiter?: string; maxRows?: number } = {},
): DelimitedTable {
const delimiter = options.delimiter ?? detectDelimiter(text);
const maxRows = options.maxRows ?? 200_000;
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
let truncated = false;
const endField = () => {
row.push(field);
field = "";
};
const endRow = () => {
endField();
// A trailing newline must not produce a phantom row.
if (row.length > 1 || row[0] !== "") rows.push(row);
row = [];
if (rows.length >= maxRows) truncated = true;
};
for (let i = 0; i < text.length && !truncated; i++) {
const char = text[i]!;
if (quoted) {
if (char !== '"') field += char;
else if (text[i + 1] === '"') {
field += '"';
i++;
} else quoted = false;
continue;
}
if (char === '"' && field === "") quoted = true;
else if (char === delimiter) endField();
else if (char === "\n") endRow();
else if (char === "\r" && text[i + 1] === "\n") continue;
else if (char === "\r") endRow();
else field += char;
}
if (!truncated && (field !== "" || row.length)) endRow();
const header = rows.shift() ?? [];
return { header, rows, delimiter, truncated };
}
+48
View File
@@ -0,0 +1,48 @@
const TAGS = new Set(
"a abbr article aside b blockquote br caption code col colgroup dd del details div dl dt em figcaption figure footer h1 h2 h3 h4 h5 h6 header hr i img ins kbd li main mark nav ol p pre s section small span strong sub summary sup table tbody td tfoot th thead time tr u ul".split(
" ",
),
);
const DROP = new Set(
"script style iframe object embed link meta base form input button textarea select template svg math".split(
" ",
),
);
const escape = (text: string) =>
text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
/** Rebuilds an inert allowlisted body; never inserts file HTML into the host DOM. */
export function sanitizeHtml(html: string, view: { DOMParser: new () => DOMParser }): string {
const doc = new view.DOMParser().parseFromString(html, "text/html");
function serialize(node: Node): string {
if (node.nodeType === 3) return escape(node.textContent ?? "");
if (node.nodeType !== 1) return "";
const element = node as Element;
const tag = element.localName.toLowerCase();
if (DROP.has(tag)) return "";
const children = [...element.childNodes].map(serialize).join("");
if (!TAGS.has(tag)) return children;
const attrs: string[] = [];
for (const attr of [...element.attributes]) {
const name = attr.name.toLowerCase();
const value = attr.value;
if (
["title", "alt", "colspan", "rowspan", "width", "height", "dir", "lang", "id"].includes(
name,
) ||
(name === "href" && /^#[\w.-]+$/.test(value)) ||
(name === "src" &&
tag === "img" &&
/^data:image\/(png|jpeg|gif|webp);base64,[a-z\d+/=\s]+$/i.test(value)) ||
(name === "style" && !/url|expression|@|\\|behavior|binding/i.test(value))
)
attrs.push(`${name}="${escape(value)}"`);
}
return `<${tag}${attrs.length ? " " + attrs.join(" ") : ""}>${children}</${tag}>`;
}
return [...doc.body.childNodes].map(serialize).join("");
}
+162
View File
@@ -0,0 +1,162 @@
/**
* RTF to plain text. In-package and dependency-free, because RTF is a text
* format: the whole file is control words and braces, and everything a preview
* needs is the character data between them.
*
* This recovers text, paragraphs and tabs. Formatting, tables, images and
* embedded objects are deliberately dropped — a faithful RTF layout is what the
* converter is for.
*/
/** Groups whose entire contents are metadata, never document text. */
const SKIPPED = new Set([
"fonttbl",
"colortbl",
"stylesheet",
"listtable",
"listoverridetable",
"revtbl",
"rsidtbl",
"generator",
"info",
"pict",
"object",
"themedata",
"colorschememapping",
"latentstyles",
"datastore",
"xmlnstbl",
"filetbl",
"header",
"footer",
"headerl",
"headerr",
"footerl",
"footerr",
"footnote",
"annotation",
]);
const BREAKS: Record<string, string> = {
par: "\n",
line: "\n",
sect: "\n\n",
page: "\n\n",
tab: "\t",
cell: "\t",
row: "\n",
lquote: "‘",
rquote: "’",
ldblquote: "“",
rdblquote: "”",
emdash: "—",
endash: "–",
bullet: "•",
nbsp: " ",
"~": " ",
"-": "",
_: "-",
};
export function isRtf(source: string): boolean {
return source.trimStart().startsWith("{\\rtf");
}
export function rtfToText(source: string): string {
if (!isRtf(source)) throw new Error("Not an RTF document");
let out = "";
let depth = 0;
// Depth at which a metadata group started; everything deeper is discarded.
let skipFrom = -1;
// How many ANSI substitute characters trail each \uN, set by \ucN. It is
// group-scoped, so the stack restores the outer value when a group closes.
// The RTF default is 1, and \uc0 means a \uN stands alone.
let substitutes = 1;
const scopes: number[] = [];
let skipChars = 0;
for (let i = 0; i < source.length; i++) {
const char = source[i]!;
if (char === "{") {
scopes.push(substitutes);
depth++;
continue;
}
if (char === "}") {
if (skipFrom >= 0 && depth <= skipFrom) skipFrom = -1;
substitutes = scopes.pop() ?? 1;
depth--;
continue;
}
if (char === "\\") {
const next = source[i + 1] ?? "";
// \* marks an optional destination: skip the whole group.
if (next === "*") {
skipFrom = depth;
i++;
continue;
}
if (next === "'") {
const hex = source.slice(i + 2, i + 4);
i += 3;
if (skipFrom < 0 && /^[0-9a-f]{2}$/i.test(hex)) {
if (skipChars > 0) skipChars--;
else out += String.fromCharCode(parseInt(hex, 16));
}
continue;
}
if (!/[a-z]/i.test(next)) {
// A control symbol: one escaped literal character.
i++;
if (skipFrom < 0) out += BREAKS[next] ?? next;
continue;
}
const word = /^[a-z]+/i.exec(source.slice(i + 1))![0];
let cursor = i + 1 + word.length;
let parameter = "";
if (source[cursor] === "-") {
parameter = "-";
cursor++;
}
while (/[0-9]/.test(source[cursor] ?? "")) parameter += source[cursor++]!;
// A single trailing space is part of the control word, not the text.
if (source[cursor] === " ") cursor++;
i = cursor - 1;
if (SKIPPED.has(word.toLowerCase())) {
skipFrom = depth;
continue;
}
if (skipFrom >= 0) continue;
if (word === "u") {
const code = Number(parameter);
if (Number.isFinite(code)) out += String.fromCodePoint(code < 0 ? code + 65536 : code);
skipChars = substitutes;
continue;
}
if (word === "uc") {
const count = Number(parameter);
substitutes = Number.isFinite(count) && count >= 0 ? count : 1;
continue;
}
out += BREAKS[word] ?? "";
continue;
}
if (skipFrom >= 0) continue;
if (char === "\r" || char === "\n") continue;
if (skipChars > 0) {
skipChars--;
continue;
}
out += char;
}
return out
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
+107
View File
@@ -0,0 +1,107 @@
/**
* SVG sanitiser. This is the inner half of the defence: previews also render
* inside a sandboxed, CSP-restricted frame. Neither layer is trusted alone.
*/
const ELEMENTS = new Set([
"svg",
"g",
"defs",
"symbol",
"use",
"title",
"desc",
"switch",
"path",
"rect",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"text",
"tspan",
"textpath",
"marker",
"clippath",
"mask",
"pattern",
"lineargradient",
"radialgradient",
"stop",
"filter",
"style",
"fegaussianblur",
"feoffset",
"feblend",
"fecolormatrix",
"fecomposite",
"feflood",
"femerge",
"femergenode",
"femorphology",
"fedropshadow",
"feturbulence",
"fedisplacementmap",
"fespecularlighting",
"fediffuselighting",
"fepointlight",
"fedistantlight",
"fespotlight",
"fetile",
]);
export interface SvgSanitizerView {
DOMParser: new () => DOMParser;
XMLSerializer: new () => XMLSerializer;
}
/** Only same-document fragment references survive. */
function safeReference(value: string): boolean {
return value.trim().startsWith("#");
}
function safeStyle(value: string): boolean {
const text = value.toLowerCase();
if (text.includes("@import") || text.includes("javascript:") || text.includes("expression("))
return false;
// url() may only point at a fragment inside this same document.
return [...text.matchAll(/url\(\s*['"]?([^'")]*)/g)].every((match) =>
safeReference(match[1] ?? ""),
);
}
function scrub(element: Element): void {
for (const child of [...element.children]) {
if (ELEMENTS.has(child.tagName.toLowerCase())) scrub(child);
else child.remove();
}
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
const value = attribute.value;
const unsafe =
name.startsWith("on") ||
(/(^|:)href$/.test(name) && !safeReference(value)) ||
(name === "style" && !safeStyle(value)) ||
/javascript:/i.test(value);
if (unsafe) element.removeAttribute(attribute.name);
}
if (element.tagName.toLowerCase() === "style" && !safeStyle(element.textContent ?? ""))
element.textContent = "";
}
/** Returns sanitised SVG markup, or `undefined` when the source is not an SVG. */
export function sanitizeSvg(source: string, view: SvgSanitizerView): string | undefined {
const parsed = new view.DOMParser().parseFromString(source, "image/svg+xml");
const root = parsed.documentElement as Element | null;
if (!root || root.tagName.toLowerCase() !== "svg") return undefined;
if (parsed.getElementsByTagName("parsererror").length) return undefined;
scrub(root);
// A viewBox is what lets the frame's CSS size the graphic; without one the
// intrinsic size stands, and forcing 100% would stretch a small icon to fill.
if (root.getAttribute("viewBox")) {
root.removeAttribute("width");
root.removeAttribute("height");
root.setAttribute("width", "100%");
}
return new view.XMLSerializer().serializeToString(root);
}
+106
View File
@@ -0,0 +1,106 @@
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
import { configureConversion } from "./convert/config.ts";
import type { ConversionOptions } from "./convert/types.ts";
import { configureUploads } from "./uploads/config.ts";
import type { ViewerUploadsOptions } from "./uploads/types.ts";
export interface ViewerPluginOptions {
/**
* Server-side conversion for formats no browser can render. Omitted, the
* plugin mounts no routes and never runs an external process; the affected
* formats keep their download card.
*/
convert?: ConversionOptions;
/**
* Lets `<Viewer key="…">` resolve `@wrnexus/uploader` storage keys. Omitted,
* no routes are mounted and a `key` on its own produces the download card.
*/
uploads?: ViewerUploadsOptions;
}
const here = (path: string) =>
fileURLToPath(
new URL(import.meta.url.endsWith(".js") ? path.replace(/\.ts$/, ".js") : path, import.meta.url),
);
const required = createRequire(import.meta.url);
function resolves(specifier: string): string | undefined {
try {
return required.resolve(specifier);
} catch {
return undefined;
}
}
/**
* Tier 2 parsers with an optional peer dependency. Each is served as its own
* runtime asset and contributed only when its package resolves here, so an app
* that never installs `xlsx` neither builds nor ships it — and the matching
* renderer reports itself unavailable because the asset is not there.
*/
const OPTIONAL = [
{ id: "viewer-sheet", package: "xlsx", entry: "./optional/sheet.ts" },
{ id: "viewer-docx", package: "mammoth", entry: "./optional/docx.ts" },
{ id: "viewer-zip", package: "fflate", entry: "./optional/zip.ts" },
{ id: "viewer-office", package: "fflate", entry: "./optional/office.ts" },
{ id: "viewer-pdfjs", package: "pdfjs-dist", entry: "./optional/pdf.ts" },
];
function optionalRuntimes(): { id: string; entry: string; bundle?: boolean }[] {
const runtimes: { id: string; entry: string; bundle?: boolean }[] = [];
for (const item of OPTIONAL) {
if (!resolves(item.package)) continue;
runtimes.push({ id: item.id, entry: here(item.entry) });
}
// pdf.js is served from its own published build, not bundled: putting its ESM
// entry through a bundler breaks the worker handshake. Both files are already
// browser-ready ESM, and `.mjs` is served verbatim.
const library = resolves("pdfjs-dist/build/pdf.mjs");
const worker = resolves("pdfjs-dist/build/pdf.worker.mjs");
if (library && worker && runtimes.some((runtime) => runtime.id === "viewer-pdfjs")) {
runtimes.push({ id: "viewer-pdfjs-lib", entry: library, bundle: false });
runtimes.push({ id: "viewer-pdfjs-worker", entry: worker, bundle: false });
}
return runtimes;
}
const route = (path: string, entry: string) => ({
kind: "api" as const,
path: `/api/viewer/${path}`,
entry: here(`./routes/${entry}.ts`),
});
function routes(options: ViewerPluginOptions) {
return [
...(options.convert
? [
route("capabilities", "capabilities"),
route("preview", "preview"),
route("thumb", "thumb"),
route("text", "text"),
]
: []),
...(options.uploads ? [route("source", "source"), route("file", "file")] : []),
];
}
export function viewerPlugin(options: ViewerPluginOptions = {}) {
configureConversion(options.convert);
configureUploads(options.uploads);
return definePlugin({
name: "@wrnexus/viewer",
componentDirs: [here("../components")],
clientRuntimes: [
{ id: "viewer-code", entry: here("./code-editor.ts") },
{ id: "viewer", entry: here("./runtime.ts") },
...optionalRuntimes(),
],
styleSources: [{ id: "viewer", entry: here("../viewer.css") }],
// Each feature mounts only its own routes, so enabling one does not publish
// endpoints for the other that could only answer "not enabled".
...(routes(options).length ? { routeEntries: routes(options) } : {}),
});
}
export default viewerPlugin;
+92
View File
@@ -0,0 +1,92 @@
import type { ViewerActionContext } from "./actions.ts";
const ROOT = "data-viewer-print-root";
/**
* Printing what is on screen, not the whole page.
*
* The preview is cloned into a print-only container and every sibling is taken
* out of the printed flow. Hiding the page with `visibility` instead would keep
* its space and print blank pages around the preview.
*
* Three things do not survive a plain clone and are repaired here: a canvas
* loses its bitmap, a collapsed `<details>` hides its content, and the table
* view's spacer rows stand in for thousands of rows that are not in the DOM.
*/
export function canPrint(context: ViewerActionContext): boolean {
// Nothing rendered means nothing to print; the download card is not a preview.
return (
context.content.getAttribute("data-viewer-state") === "ready" &&
context.content.childElementCount > 0
);
}
function printableClone(content: HTMLElement): HTMLElement {
const doc = content.ownerDocument;
const clone = content.cloneNode(true) as HTMLElement;
const originals = content.querySelectorAll("canvas");
const copies = clone.querySelectorAll("canvas");
copies.forEach((canvas, index) => {
const original = originals[index];
if (!original) return;
try {
const image = doc.createElement("img");
image.src = original.toDataURL();
image.style.maxWidth = "100%";
canvas.replaceWith(image);
} catch {
canvas.remove();
}
});
for (const details of clone.querySelectorAll("details")) details.open = true;
for (const spacer of clone.querySelectorAll("[data-viewer-spacer]")) spacer.remove();
return clone;
}
export function printViewer(context: ViewerActionContext): void {
const doc = context.host.ownerDocument;
const view = doc.defaultView;
if (!view) return;
const frame = context.content.querySelector("iframe");
if (frame) {
try {
// Same-origin only: a sandboxed frame has an opaque origin and throws.
const inner = frame.contentWindow;
if (inner) {
inner.focus();
inner.print();
return;
}
} catch {
/* Fall through to printing a clone of the preview. */
}
}
const root = doc.createElement("div");
root.setAttribute(ROOT, "");
const heading = doc.createElement("p");
heading.className = "wrn-viewer-print-title";
heading.textContent = context.source.name || "File preview";
root.append(heading, printableClone(context.content));
doc.body.append(root);
doc.documentElement.setAttribute("data-viewer-printing-active", "");
let done = false;
const cleanup = () => {
if (done) return;
done = true;
root.remove();
doc.documentElement.removeAttribute("data-viewer-printing-active");
view.removeEventListener("afterprint", cleanup);
};
view.addEventListener("afterprint", cleanup);
try {
view.print();
} finally {
// Browsers that return before `afterprint` fires still need cleaning up.
view.setTimeout(cleanup, 1000);
}
}
+259
View File
@@ -0,0 +1,259 @@
import { requireConverter } from "./convert/client.ts";
import { mountArchive } from "./renderers/archive.ts";
import { mountConverted } from "./renderers/converted.ts";
import { mountDocx } from "./renderers/docx.ts";
import { mountOpenDocument, mountPresentation } from "./renderers/office.ts";
import { mountRtf } from "./renderers/rtf.ts";
import { mountCode } from "./renderers/code.ts";
import { mountHtml } from "./renderers/html.ts";
import { mountImage } from "./renderers/image.ts";
import { mountMedia } from "./renderers/media.ts";
import { mountJson } from "./renderers/json.ts";
import { mountPdf } from "./renderers/pdf.ts";
import { mountSvg } from "./renderers/svg.ts";
import { mountSheet } from "./renderers/sheet.ts";
import { mountTable } from "./renderers/table.ts";
import { mountXml } from "./renderers/xml.ts";
import { mountZip } from "./renderers/zip.ts";
import { loadOptional } from "./optional/load.ts";
import type { Renderer, ViewerKind } from "./types.ts";
const MB = 1024 * 1024;
/** Terminal rung of the ladder: metadata and a download link, never a preview. */
export const downloadRenderer: Renderer = {
kind: "binary",
exts: [],
tier: 1,
component: "DownloadCard",
maxInlineBytes: 0,
};
const builtins: Renderer[] = [
{
kind: "markup",
exts: [".html", ".htm"],
tier: 2,
component: "HtmlViewer",
maxInlineBytes: 2 * MB,
mount: mountHtml,
},
{
kind: "image",
exts: [],
tier: 1,
component: "ImageViewer",
maxInlineBytes: 32 * MB,
streaming: true,
mount: mountImage,
},
{
kind: "pdf",
exts: [],
tier: 1,
component: "PdfViewer",
maxInlineBytes: 32 * MB,
streaming: true,
mount: mountPdf,
},
...(["audio", "video"] as const).map((kind) => ({
kind,
exts: [],
tier: 1 as const,
component: "MediaViewer",
maxInlineBytes: 64 * MB,
streaming: true,
mount: mountMedia,
})),
// Tier 2: in-package parsers, no runtime dependency. Extension-specific, so a
// parse failure falls through to the source view below.
{
kind: "sheet" as const,
exts: [".csv", ".tsv"],
tier: 2 as const,
component: "TableViewer",
maxInlineBytes: 8 * MB,
mount: mountTable,
},
{
kind: "code" as const,
exts: [".json"],
tier: 2 as const,
component: "JsonViewer",
maxInlineBytes: 8 * MB,
mount: mountJson,
},
{
kind: "code" as const,
exts: [".xml"],
tier: 2 as const,
component: "XmlViewer",
maxInlineBytes: 8 * MB,
mount: mountXml,
},
{
kind: "vector" as const,
exts: [],
tier: 2 as const,
component: "SvgViewer",
maxInlineBytes: 2 * MB,
mount: mountSvg,
},
{
kind: "archive" as const,
exts: [".tar", ".gz", ".tgz"],
tier: 2 as const,
component: "ArchiveViewer",
maxInlineBytes: 64 * MB,
mount: mountArchive,
},
...(["text", "code", "markup"] as const).map((kind) => ({
kind,
exts: [],
tier: 1 as const,
component: "CodeViewer",
maxInlineBytes: 2 * MB,
mount: mountCode,
})),
];
/**
* Tier 2 renderers backed by an optional dependency. `load` imports the runtime
* asset the plugin only publishes when the package is installed, so a missing
* dependency is reported the same way a missing converter is: skip and move on.
*/
builtins.push(
{
kind: "sheet",
exts: [".xls", ".xlsx", ".ods"],
tier: 2,
component: "SheetViewer",
maxInlineBytes: 32 * MB,
load: (options) => loadOptional("viewer-sheet", options?.baseURI),
mount: mountSheet,
},
{
kind: "archive",
exts: [".zip"],
tier: 2,
component: "ZipViewer",
maxInlineBytes: 64 * MB,
load: (options) => loadOptional("viewer-zip", options?.baseURI),
mount: mountZip,
},
);
/**
* Tier 3: the browser cannot render these at all, so they are handed to a
* server-side converter. Each carries a `load` that asks the server once
* whether that format is actually convertible here; when conversion is not
* enabled the load fails and the ladder moves on to the download card.
*/
const CONVERTIBLE: [ViewerKind, string[]][] = [
["doc", [".doc", ".docx", ".dotx", ".odt", ".rtf", ".ppt", ".pptx", ".ppsx", ".potx", ".odp"]],
["sheet", [".xls", ".xlsx", ".ods"]],
["binary", [".psd", ".ai", ".tif", ".tiff"]],
["video", [".avi", ".mkv"]],
["archive", [".zip", ".rar", ".7z"]],
];
for (const [kind, exts] of CONVERTIBLE)
for (const ext of exts)
builtins.push({
kind,
exts: [ext],
tier: 3,
component: "ConvertedViewer",
// The preview comes from the conversion endpoint, so the original bytes
// are never needed here: streaming keeps the runtime from downloading a
// whole design file or video into memory only to discard it.
streaming: true,
maxInlineBytes: Number.POSITIVE_INFINITY,
load: (options) => requireConverter([ext], options),
mount: mountConverted,
});
/**
* In-package office readers. Each recovers content and structure, not a page
* layout, so all of them sit *behind* the converter's PDF and render only when
* no converter is reachable — the same trade the DOCX path already makes.
*
* `.ods` is the exception in spirit rather than order: a spreadsheet is data,
* and the table view is the better reading of it, so it is registered ahead of
* the converter alongside `.xlsx`.
*/
builtins.push(
{
kind: "doc",
exts: [".docx"],
tier: 2,
component: "DocxViewer",
maxInlineBytes: 32 * MB,
load: (options) => loadOptional("viewer-docx", options?.baseURI),
mount: mountDocx,
},
{
kind: "doc",
exts: [".pptx", ".ppsx", ".potx"],
tier: 2,
component: "SlidesViewer",
maxInlineBytes: 64 * MB,
load: (options) => loadOptional("viewer-office", options?.baseURI),
mount: mountPresentation,
},
{
kind: "doc",
// `.ods` is here as well as on the spreadsheet reader: SheetJS handles most
// OpenDocument workbooks, and this is the ladder's next rung when it cannot.
exts: [".odt", ".odp", ".ods"],
tier: 2,
component: "OpenDocumentViewer",
maxInlineBytes: 64 * MB,
load: (options) => loadOptional("viewer-office", options?.baseURI),
mount: mountOpenDocument,
},
{
// No dependency and no converter: RTF is text once the control words go.
kind: "doc",
exts: [".rtf"],
tier: 2,
component: "RtfViewer",
maxInlineBytes: 16 * MB,
mount: mountRtf,
},
);
export class RendererRegistry {
private renderers: Renderer[] = [...builtins];
fork(): RendererRegistry {
const registry = new RendererRegistry();
registry.renderers = [...this.renderers];
return registry;
}
register(renderer: Renderer): () => void {
const entry = {
...renderer,
exts: renderer.exts.map((ext) => "." + ext.replace(/^\./, "").toLowerCase()),
};
this.renderers.unshift(entry);
return () => {
this.renderers = this.renderers.filter((item) => item !== entry);
};
}
/** Extension-specific renderers outrank kind-wide ones. */
candidates(kind: ViewerKind, ext: string): Renderer[] {
return [
...this.renderers.filter((item) => item.exts.includes(ext)),
...this.renderers.filter((item) => item.kind === kind && item.exts.length === 0),
];
}
}
export const defaultRegistry = new RendererRegistry();
export function registerRenderer(renderer: Renderer): () => void {
return defaultRegistry.register(renderer);
}
+38
View File
@@ -0,0 +1,38 @@
import { gunzip, gzipName, isTar, parseTar, type ArchiveEntry } from "../parse/archive.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { entriesSummary, formatSize, renderEntries } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
const DECOMPRESS_CAP = 64 * 1024 * 1024;
export const mountArchive: ViewerMount = async ({ parent, file, source, signal }) => {
const element = elementFactory(parent.ownerDocument);
const input = requireFile(file);
let bytes: Uint8Array<ArrayBufferLike> = new Uint8Array(await input.arrayBuffer());
if (signal.aborted) return;
let note = "";
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
const inner = gzipName(bytes);
const expanded = await gunzip(input, DECOMPRESS_CAP);
if (signal.aborted) return;
if (!expanded) throw new Error("Compressed listing unavailable");
note = `gzip, ${formatSize(bytes.length)} compressed to ${formatSize(expanded.length)}`;
bytes = expanded;
if (!isTar(bytes)) {
// A plain gzipped file, not an archive: report what it holds.
parent.append(element("p", note));
parent.append(element("p", inner ?? "Single compressed file"));
return;
}
}
if (!isTar(bytes)) throw new Error("Unsupported archive format");
const entries: ArchiveEntry[] = parseTar(bytes);
if (!entries.length) throw new Error("Archive holds no readable entries");
parent.append(
element("p", entriesSummary(entries, note)),
renderEntries(parent.ownerDocument, entries, `Contents of ${source.name}`),
);
};
@@ -0,0 +1,188 @@
import { setButtonLabel } from "./dom.ts";
import { readSource } from "../source.ts";
import type { ViewerSource } from "../types.ts";
import { waveformPeaks } from "./waveform.ts";
const clock = (seconds: number) => {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const whole = Math.floor(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
};
/**
* One transport for audio instead of two.
*
* The native controls and a separate seek slider were both on screen, each able to
* move the same playhead. This is a single row — play, elapsed, a seekable
* track, duration, mute — with the waveform drawn *into* the track so the
* envelope and the progress are the same control rather than two stacked bars.
*/
export function mountAudioPlayer(
parent: HTMLElement,
audio: HTMLMediaElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const make = <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string) => {
const node = doc.createElement(tag);
if (className) node.className = className;
return node;
};
const shell = make("div", "wrn-viewer-player");
const transport = make("div", "wrn-viewer-player-transport");
const play = make("button", "wrn-viewer-action wrn-viewer-play");
play.type = "button";
setButtonLabel(play, "Play", "play");
const elapsed = make("span", "wrn-viewer-time");
elapsed.textContent = "0:00";
const duration = make("span", "wrn-viewer-time");
duration.textContent = "0:00";
const mute = make("button", "wrn-viewer-action");
mute.type = "button";
setButtonLabel(mute, "Mute", "volume");
const track = make("div", "wrn-viewer-track");
const canvas = make("canvas", "wrn-viewer-waveform");
canvas.setAttribute("aria-hidden", "true");
const progress = make("div", "wrn-viewer-progress");
const seek = make("input", "wrn-viewer-seek");
seek.type = "range";
seek.min = "0";
seek.max = "1000";
seek.value = "0";
seek.setAttribute("aria-label", `Seek ${source.name || "audio"}`);
track.append(canvas, progress, seek);
transport.append(play, elapsed, track, duration, mute);
const note = make("p", "wrn-viewer-note");
note.setAttribute("role", "status");
shell.append(transport, note);
parent.append(shell);
const BINS = 200;
let envelope: number[] = [];
/**
* Drawn at device resolution and redrawn on resize: a fixed bitmap stretched
* to the track is what made the bars look soft and blobby.
*/
const drawPeaks = (peaks = envelope) => {
envelope = peaks;
const box = canvas.getBoundingClientRect();
const ratio = Math.min(view?.devicePixelRatio ?? 1, 2);
const width = Math.max(1, Math.round(box.width * ratio));
const height = Math.max(1, Math.round(box.height * ratio));
if (!peaks.length || width < 2) return;
canvas.width = width;
canvas.height = height;
const draw = canvas.getContext("2d");
if (!draw) return;
const styles = view!.getComputedStyle(shell);
draw.fillStyle = styles.getPropertyValue("--wrn-color-primary").trim() || styles.color;
// Normalised against the loudest bin, so a quiet recording still reads as a
// waveform rather than a flat line down the middle.
const loudest = Math.max(...peaks, 0.0001);
const step = width / peaks.length;
const bar = Math.max(1 * ratio, Math.min(3 * ratio, step * 0.55));
const radius = bar / 2;
peaks.forEach((peak, i) => {
const size = Math.max(bar, (peak / loudest) * height * 0.9);
const x = i * step + (step - bar) / 2;
const y = (height - size) / 2;
draw.beginPath();
// Rounded caps read as a waveform; square blocks read as a bar chart.
if (draw.roundRect) draw.roundRect(x, y, bar, size, radius);
else draw.rect(x, y, bar, size);
draw.fill();
});
};
const setProgress = () => {
const ratio =
Number.isFinite(audio.duration) && audio.duration > 0
? audio.currentTime / audio.duration
: 0;
progress.style.width = `${ratio * 100}%`;
seek.value = String(Math.round(ratio * 1000));
elapsed.textContent = clock(audio.currentTime);
};
const setDuration = () => {
duration.textContent = clock(audio.duration);
};
const setPlayState = () => {
setButtonLabel(play, audio.paused ? "Play" : "Pause", audio.paused ? "play" : "pause");
};
play.addEventListener("click", () => {
if (audio.paused) void audio.play().catch(() => {});
else audio.pause();
});
mute.addEventListener("click", () => {
audio.muted = !audio.muted;
setButtonLabel(mute, audio.muted ? "Unmute" : "Mute", audio.muted ? "muted" : "volume");
});
seek.addEventListener("input", () => {
if (!Number.isFinite(audio.duration)) return;
audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
setProgress();
});
audio.addEventListener("timeupdate", setProgress);
audio.addEventListener("loadedmetadata", setDuration);
audio.addEventListener("durationchange", setDuration);
audio.addEventListener("play", setPlayState);
audio.addEventListener("pause", setPlayState);
audio.addEventListener("ended", setPlayState);
setDuration();
setProgress();
setPlayState();
// The envelope is drawn once metadata is in and decoding stays bounded; a
// file too long or too large simply keeps a flat track.
let disposed = false;
let context: AudioContext | undefined;
void (async () => {
try {
if (!view?.AudioContext) return;
await new Promise<void>((done) => {
if (Number.isFinite(audio.duration)) done();
else audio.addEventListener("loadedmetadata", () => done(), { once: true });
});
if (disposed || !Number.isFinite(audio.duration) || audio.duration > 600) return;
const file = await readSource(source, 8 * 1024 * 1024, { signal });
if (disposed) return;
context = new view.AudioContext();
const decoded = await context.decodeAudioData(await file.arrayBuffer());
if (disposed) return;
const peaks = waveformPeaks(
Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)),
BINS,
);
drawPeaks(peaks);
canvas.classList.add("is-ready");
} catch {
/* A flat track is a fine outcome; playback is unaffected. */
} finally {
await context?.close().catch(() => {});
context = undefined;
}
})();
const observer =
view && "ResizeObserver" in view ? new view.ResizeObserver(() => drawPeaks()) : undefined;
observer?.observe(track);
return () => {
disposed = true;
observer?.disconnect();
audio.removeEventListener("timeupdate", setProgress);
audio.removeEventListener("loadedmetadata", setDuration);
audio.removeEventListener("durationchange", setDuration);
audio.removeEventListener("play", setPlayState);
audio.removeEventListener("pause", setPlayState);
audio.removeEventListener("ended", setPlayState);
void context?.close().catch(() => {});
shell.remove();
};
}
+48
View File
@@ -0,0 +1,48 @@
import { elementFactory, requireFile } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/** Served separately by `viewerPlugin`; fetched only when a text preview opens. */
const editorAsset = "/__wrnexus/assets/viewer-code.js";
export const mountCode: ViewerMount = async ({ parent, file, source, button, signal }) => {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
// Plain text first: never a parse, never markup, and correct with no editor.
const plain = element("pre", text);
parent.append(plain);
try {
const { mountBuilderSourceEditor } = (await import(
new URL(editorAsset, doc.baseURI).href
)) as typeof import("@wrnexus/block-editor/source-editor");
if (signal.aborted) return;
const host = element("div");
host.className = "wrn-viewer-code";
parent.replaceChildren(host);
const editor = await mountBuilderSourceEditor(
host,
/\.(xml|html|htm|svg)$/i.test(source.name) ? "xml" : "text",
text,
{ label: source.name || "File source", theme: "tokens" },
);
if (signal.aborted) {
editor?.destroy();
return;
}
if (!editor) {
parent.replaceChildren(plain);
return;
}
let wrapped = false;
const wrap = button("Wrap lines", () => {
wrapped = !wrapped;
editor.wrap(wrapped);
wrap.setAttribute("aria-pressed", String(wrapped));
});
wrap.setAttribute("aria-pressed", "false");
return () => editor.destroy();
} catch {
if (!signal.aborted) parent.replaceChildren(plain);
}
};
@@ -0,0 +1,56 @@
import { convertUrl, requireConverter } from "../convert/client.ts";
import { extensionOf } from "../kinds.ts";
import { readBounded } from "../source.ts";
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
const MAX_TEXT_BYTES = 2 * 1024 * 1024;
/**
* Renders what the server produced rather than the original bytes. The output
* is same-origin, so the element points straight at the conversion endpoint and
* the browser streams it under the app's own policy.
*/
export const mountConverted: ViewerMount = async ({ parent, source, signal, degrade }) => {
const element = elementFactory(parent.ownerDocument);
const url = convertUrl(source, "preview", parent.ownerDocument.baseURI);
const mime = await requireConverter([extensionOf(source.name)], {
baseURI: parent.ownerDocument.baseURI,
});
if (!url) throw new Error("Conversion needs a URL source");
if (signal.aborted) return;
const note = (text: string) => {
const line = element("p", text);
line.className = "wrn-viewer-note";
return line;
};
if (mime.startsWith("text/")) {
// Listings are small and are shown inline. The cap is applied to the stream,
// not to the decoded string: slicing afterwards has already read it all.
const response = await fetch(url, { signal });
if (!response.ok) throw new Error("Conversion failed");
const text = new TextDecoder().decode(await readBounded(response, MAX_TEXT_BYTES, true));
if (signal.aborted) return;
parent.append(note(`Listed by the server from ${source.name}.`), element("pre", text));
return;
}
if (mime.startsWith("image/")) {
const image = element("img");
image.alt = `Converted preview of ${source.name}`;
image.src = url;
image.addEventListener("error", () => degrade("This file could not be converted"), {
once: true,
});
parent.append(image, note(`Rendered by the server from ${source.name}.`));
return;
}
const frame = element("iframe");
frame.src = url;
frame.className = "wrn-viewer-frame wrn-viewer-pdf";
frame.setAttribute("title", `Converted preview of ${source.name}`);
parent.append(frame, note("Converted by the server. Use Download for the original file."));
};
+36
View File
@@ -0,0 +1,36 @@
import { loadOptional } from "../optional/load.ts";
import type { docxToHtml } from "../optional/docx.ts";
import { requireFile } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
/** Mammoth may emit inline images as data URLs; nothing else may load. */
const POLICY = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
const CSS =
"body{padding:1.5rem;font:16px/1.6 system-ui,sans-serif}" +
"img{max-width:100%;height:auto}table{border-collapse:collapse}" +
"td,th{border:1px solid currentColor;padding:.25rem .5rem}";
export const mountDocx: ViewerMount = async ({ parent, file, source, signal }) => {
const { docxToHtml: convert } = await loadOptional<{ docxToHtml: typeof docxToHtml }>(
"viewer-docx",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const html = await convert(bytes);
if (signal.aborted) return;
if (!html.trim()) throw new Error("Document produced no readable content");
// Converted markup is derived from an untrusted file, so it is framed under a
// policy that permits no script and no network, exactly like the SVG preview.
parent.append(
sandboxFrame(parent, {
title: source.name || "Document preview",
policy: POLICY,
body: html,
css: CSS,
}),
);
};
+35
View File
@@ -0,0 +1,35 @@
import { icon, iconFor } from "./icons.ts";
/** Element factory bound to a host document; never parses strings into nodes. */
export function elementFactory(doc: Document) {
return <K extends keyof HTMLElementTagNameMap>(
tag: K,
text?: string,
): HTMLElementTagNameMap[K] => {
const node = doc.createElement(tag);
if (text !== undefined) node.textContent = text;
return node;
};
}
/** Renderers that parse bytes are never `streaming`, so the body is present. */
export function requireFile(file: Blob | undefined): Blob {
if (!file) throw new Error("This renderer needs the file body");
return file;
}
/**
* Toolbar buttons carry an icon plus a label that is read out but not shown, so
* assigning `textContent` would delete the icon. Toggling a label goes through
* here instead, which also swaps the icon to match.
*/
export function setButtonLabel(node: HTMLElement, label: string, iconName?: string): void {
const doc = node.ownerDocument;
node.setAttribute("aria-label", label);
node.setAttribute("data-tooltip", label);
node.replaceChildren(icon(doc, iconName ?? iconFor(label)));
const text = doc.createElement("span");
text.className = "wrn-viewer-sr";
text.textContent = label;
node.append(text);
}
+55
View File
@@ -0,0 +1,55 @@
import { elementFactory } from "./dom.ts";
export interface SandboxFrameOptions {
title: string;
/** Content-Security-Policy for the framed document. */
policy: string;
/** Markup placed inside the frame. Must already be sanitised. */
body: string;
/** Extra CSS for the framed document. */
css?: string;
}
const SAFE_COLOR = /^[#a-z0-9(),.%\s/-]+$/i;
/**
* A frame with every sandbox restriction on, carrying its own CSP.
*
* The framed document is a separate document, so theme tokens and
* `currentColor` do not reach it and Chrome paints its own opaque base
* background rather than letting the element's background show. Both the
* resolved text and surface colours are therefore copied in by value.
*/
export function sandboxFrame(host: HTMLElement, options: SandboxFrameOptions): HTMLIFrameElement {
const doc = host.ownerDocument;
const element = elementFactory(doc);
const view = doc.defaultView;
const computed = (node: Element) => view?.getComputedStyle?.(node);
const safe = (value: string, fallback: string) => (SAFE_COLOR.test(value) ? value : fallback);
const color = safe(computed(host)?.color ?? "", "#000");
let background = "#fff";
for (let node: Element | null = host; node; node = node.parentElement) {
const value = computed(node)?.backgroundColor ?? "";
if (value && !/^(transparent|rgba\(0, *0, *0, *0\))$/.test(value)) {
background = safe(value, "#fff");
break;
}
}
const frame = element("iframe");
frame.setAttribute("sandbox", "");
frame.setAttribute("title", options.title);
frame.className = "wrn-viewer-frame";
frame.setAttribute(
"srcdoc",
[
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${options.policy}">`,
`<style>html,body{margin:0;height:100%;color:${color};background:${background}}` +
`${options.css ?? ""}</style>`,
options.body,
].join(""),
);
return frame;
}
+28
View File
@@ -0,0 +1,28 @@
import { sanitizeHtml } from "../parse/html.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
export const mountHtml: ViewerMount = async ({ parent, file, source, button, signal }) => {
const text = await requireFile(file).text();
if (signal.aborted) return;
const view = parent.ownerDocument.defaultView;
if (!view) throw new Error("HTML preview requires a document");
const frame = sandboxFrame(parent, {
title: source.name || "HTML preview",
policy:
"default-src 'none'; base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:",
body: sanitizeHtml(text, view),
css: "body{padding:1rem;box-sizing:border-box;font:16px/1.5 system-ui}img{max-width:100%}",
});
const raw = elementFactory(parent.ownerDocument)("pre", text);
let sourceView = false;
const toggle = button("Source", () => {
sourceView = !sourceView;
setButtonLabel(toggle, sourceView ? "Preview" : "Source");
toggle.setAttribute("aria-pressed", String(sourceView));
parent.replaceChildren(sourceView ? raw : frame);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(frame);
};
+133
View File
@@ -0,0 +1,133 @@
/**
* Toolbar icons, drawn as SVG nodes rather than markup strings: the framework
* enables Trusted Types in production, where `innerHTML` is refused outright.
*
* One line-drawn family at 24×24 on `currentColor`, so every icon inherits the
* app's theme with no palette of its own.
*/
type Shape = [tag: string, attributes: Record<string, string>];
const path = (d: string): Shape => ["path", { d }];
export const ICONS: Record<string, Shape[]> = {
"zoom-in": [
["circle", { cx: "11", cy: "11", r: "7" }],
path("M21 21l-4.3-4.3"),
path("M8 11h6"),
path("M11 8v6"),
],
"zoom-out": [
["circle", { cx: "11", cy: "11", r: "7" }],
path("M21 21l-4.3-4.3"),
path("M8 11h6"),
],
rotate: [path("M21 12a9 9 0 1 1-3-6.7"), path("M21 4v5h-5")],
reset: [path("M3 12a9 9 0 1 0 3-6.7"), path("M3 4v5h5")],
fullscreen: [path("M8 3H3v5"), path("M21 8V3h-5"), path("M16 21h5v-5"), path("M3 16v5h5")],
download: [path("M12 3v12"), path("M7 12l5 5 5-5"), path("M4 21h16")],
print: [
path("M7 8V3h10v5"),
path("M7 18H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2"),
["rect", { x: "7", y: "15", width: "10", height: "6", rx: "1" }],
],
previous: [path("M15 5l-7 7 7 7")],
next: [path("M9 5l7 7-7 7")],
top: [path("M12 20V6"), path("M6 11l6-6 6 6")],
source: [path("M9 6l-6 6 6 6"), path("M15 6l6 6-6 6")],
preview: [
path("M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"),
["circle", { cx: "12", cy: "12", r: "3" }],
],
table: [
["rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }],
path("M3 10h18"),
path("M9 10v10"),
],
tree: [path("M5 4v12a2 2 0 0 0 2 2h12"), path("M9 8h11"), path("M13 18h7")],
raw: [path("M4 5h16"), path("M4 10h16"), path("M4 15h10"), path("M4 20h7")],
wrap: [
path("M4 6h16"),
path("M4 12h13a3 3 0 0 1 0 6h-4"),
path("M16 15l-3 3 3 3"),
path("M4 18h4"),
],
waveform: [path("M3 12h2"), path("M8 7v10"), path("M12 4v16"), path("M16 8v8"), path("M20 11h1")],
sheets: [
["rect", { x: "3", y: "3", width: "13", height: "13", rx: "2" }],
path("M8 21h11a2 2 0 0 0 2-2V8"),
],
text: [path("M5 4h14"), path("M12 4v16"), path("M8 20h8")],
play: [path("M7 4.5v15l12-7.5Z")],
close: [path("M6 6l12 12"), path("M18 6L6 18")],
expand: [path("M9 3H3v6"), path("M21 15v6h-6"), path("M3 3l7 7"), path("M21 21l-7-7")],
pause: [path("M9 4v16"), path("M15 4v16")],
volume: [
path("M11 5 6.5 9H3v6h3.5L11 19Z"),
path("M16 9.5a3.5 3.5 0 0 1 0 5"),
path("M18.5 7a7 7 0 0 1 0 10"),
],
muted: [path("M11 5 6.5 9H3v6h3.5L11 19Z"), path("M17 10l4 4"), path("M21 10l-4 4")],
file: [path("M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"), path("M14 3v5h5")],
slides: [
["rect", { x: "3", y: "4", width: "18", height: "12", rx: "2" }],
path("M12 16v4"),
path("M8 20h8"),
],
};
/** Toolbar labels are stable strings, so the icon can be derived from them. */
const BY_LABEL: Record<string, string> = {
"zoom in": "zoom-in",
"zoom out": "zoom-out",
rotate: "rotate",
reset: "reset",
fullscreen: "fullscreen",
download: "download",
print: "print",
previous: "previous",
next: "next",
top: "top",
source: "source",
preview: "preview",
table: "table",
raw: "raw",
tree: "tree",
text: "text",
"wrap lines": "wrap",
"show waveform": "waveform",
"hide waveform": "waveform",
sheets: "sheets",
play: "play",
pause: "pause",
close: "close",
expand: "expand",
collapse: "close",
mute: "volume",
unmute: "muted",
"extract text": "text",
"hide text": "text",
};
export function iconFor(label: string): string {
return BY_LABEL[label.trim().toLowerCase()] ?? "file";
}
export function icon(doc: Document, name: string): SVGSVGElement {
const NS = "http://www.w3.org/2000/svg";
const svg = doc.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.75");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.classList.add("wrn-viewer-icon");
for (const [tag, attributes] of ICONS[name] ?? ICONS.file!) {
const shape = doc.createElementNS(NS, tag);
for (const [key, value] of Object.entries(attributes)) shape.setAttribute(key, value);
svg.append(shape);
}
return svg;
}
+74
View File
@@ -0,0 +1,74 @@
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
export const mountImage: ViewerMount = async ({
parent,
source,
sourceUrl,
button,
degrade,
signal,
}) => {
const element = elementFactory(parent.ownerDocument);
const url = await sourceUrl();
if (signal.aborted) return;
const stage = element("div");
stage.className = "wrn-viewer-stage";
const image = element("img");
image.alt = source.name;
image.src = url;
image.draggable = false;
image.addEventListener("error", () => degrade("Image could not be displayed"), { once: true });
// A readout, because four zoom buttons with no feedback leave you guessing.
const readout = element("span", "100%");
readout.className = "wrn-viewer-zoom";
stage.append(image, readout);
parent.append(stage);
let zoom = 1;
let rotation = 0;
const update = () => {
image.style.width = zoom === 1 ? "" : `${zoom * 100}%`;
image.style.maxWidth = zoom === 1 ? "" : "none";
image.style.transform = rotation ? `rotate(${rotation}deg)` : "";
readout.textContent = `${Math.round(zoom * 100)}%${rotation ? ` · ${rotation}°` : ""}`;
};
button("Zoom in", () => {
zoom = Math.min(4, zoom + 0.25);
update();
});
button("Zoom out", () => {
zoom = Math.max(0.25, zoom - 0.25);
update();
});
button("Rotate", () => {
rotation = (rotation + 90) % 360;
update();
});
button("Reset", () => {
zoom = 1;
rotation = 0;
update();
});
// Native scrolling supports keyboard/touch panning; pointer drag supports mouse.
parent.tabIndex = 0;
let drag: { x: number; y: number; left: number; top: number } | undefined;
parent.onpointerdown = (event) => {
drag = { x: event.clientX, y: event.clientY, left: parent.scrollLeft, top: parent.scrollTop };
parent.setPointerCapture(event.pointerId);
};
parent.onpointermove = (event) => {
if (drag) {
parent.scrollLeft = drag.left - event.clientX + drag.x;
parent.scrollTop = drag.top - event.clientY + drag.y;
}
};
parent.onpointerup = parent.onpointercancel = () => {
drag = undefined;
};
return () => {
parent.onpointerdown = parent.onpointermove = parent.onpointerup = null;
parent.onpointercancel = null;
};
};
+56
View File
@@ -0,0 +1,56 @@
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { branch, leaf } from "./tree.ts";
import type { ViewerMount } from "../types.ts";
const preview = (value: unknown): string =>
Array.isArray(value)
? `[] ${value.length} item${value.length === 1 ? "" : "s"}`
: `{} ${Object.keys(value as object).length} key${
Object.keys(value as object).length === 1 ? "" : "s"
}`;
function render(doc: Document, key: string, value: unknown, depth: number): HTMLElement {
if (value === null || typeof value !== "object")
return leaf(
doc,
key,
typeof value === "string" ? JSON.stringify(value) : String(value),
`wrn-viewer-value wrn-viewer-${value === null ? "null" : typeof value}`,
);
const entries: [string, unknown][] = Array.isArray(value)
? value.map((item, index) => [String(index), item])
: Object.entries(value as object);
return branch(
doc,
`${key ? `${key} ` : ""}${preview(value)}`,
"wrn-viewer-key",
(into) => {
for (const [childKey, child] of entries) into.append(render(doc, childKey, child, depth + 1));
},
depth < 1,
);
}
export const mountJson: ViewerMount = async ({ parent, file, button, signal }) => {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
// A parse failure throws, and the ladder falls through to the source view.
const data = JSON.parse(text) as unknown;
const tree = element("div");
tree.className = "wrn-viewer-tree";
tree.append(render(doc, "", data, 0));
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Tree" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : tree);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(tree);
};
+41
View File
@@ -0,0 +1,41 @@
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
import { mountAudioPlayer } from "./audio-player.ts";
export const mountMedia: ViewerMount = async ({
parent,
source,
sourceUrl,
kind,
degrade,
signal,
}) => {
const element = elementFactory(parent.ownerDocument);
const url = await sourceUrl();
if (signal.aborted) return;
const media = element(kind === "audio" ? "audio" : "video");
// Audio gets the package's own transport, so the native controls would be a
// second set of the same buttons driving the same playhead.
media.controls = kind !== "audio";
media.preload = "metadata";
media.src = url;
media.setAttribute("aria-label", source.name);
media.addEventListener("error", () => degrade("This browser cannot play this file"), {
once: true,
});
if (kind === "video") {
// Video shares the image's stage: a bounded, framed surface rather than a
// bare element stretched into the panel.
const stage = element("div");
stage.className = "wrn-viewer-stage";
stage.append(media);
parent.append(stage);
} else parent.append(media);
const stopPlayer = kind === "audio" ? mountAudioPlayer(parent, media, source, signal) : undefined;
return () => {
stopPlayer?.();
media.pause();
media.removeAttribute("src");
media.load();
};
};
+76
View File
@@ -0,0 +1,76 @@
import { loadOptional } from "../optional/load.ts";
import type { OfficeDocument, readOdf, readPptx } from "../optional/office.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { renderWorkbook } from "./workbook.ts";
import { renderSlides, type SlideDeckView } from "./slides.ts";
import type { ViewerMount, ViewerMountContext } from "../types.ts";
interface OfficeModule {
readPptx: typeof readPptx;
readOdf: typeof readOdf;
}
function parser(parent: HTMLElement): { DOMParser: new () => DOMParser } {
const view = parent.ownerDocument.defaultView;
if (!view?.DOMParser) throw new Error("No XML parser available");
return view as unknown as { DOMParser: new () => DOMParser };
}
/** Shared presentation of a parsed office document. */
async function show(context: ViewerMountContext, document: OfficeDocument) {
const { parent, signal } = context;
const element = elementFactory(parent.ownerDocument);
if (signal.aborted) return;
if (document.type === "slides") {
// The deck carries its own pager, so toolbar arrows would be a second set
// of the same two controls — and they push the toolbar onto two rows.
const deck: SlideDeckView = renderSlides(parent.ownerDocument, document.slides);
parent.append(deck.node);
return () => deck.dispose();
}
if (document.type === "sheets") {
return renderWorkbook(parent, document.sheets);
}
const article = element("article");
article.className = "wrn-viewer-document";
for (const block of document.blocks) {
if (block.level > 0) {
const heading = element(
(["h1", "h2", "h3", "h4", "h5", "h6"] as const)[Math.min(block.level, 6) - 1]!,
block.text,
);
article.append(heading);
} else if (block.list) {
const previous = article.lastElementChild;
const list = previous?.tagName === "UL" ? previous : element("ul");
list.append(element("li", block.text));
if (list !== previous) article.append(list);
} else article.append(element("p", block.text));
}
parent.append(article);
}
export const mountPresentation: ViewerMount = async (context) => {
const { parent, file, signal } = context;
const { readPptx: read } = await loadOptional<OfficeModule>(
"viewer-office",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
return show(context, await read(bytes, parser(parent)));
};
export const mountOpenDocument: ViewerMount = async (context) => {
const { parent, file, signal } = context;
const { readOdf: read } = await loadOptional<OfficeModule>(
"viewer-office",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
return show(context, await read(bytes, parser(parent)));
};
+62
View File
@@ -0,0 +1,62 @@
import { loadOptional } from "../optional/load.ts";
import type { renderPdf } from "../optional/pdf.ts";
import { elementFactory, requireFile } from "./dom.ts";
import type { Renderer, ViewerMount } from "../types.ts";
const LIB_ASSET = "/__wrnexus/assets/viewer-pdfjs-lib.js";
const WORKER_ASSET = "/__wrnexus/assets/viewer-pdfjs-worker.js";
const mountRichPdf: ViewerMount = async ({ parent, file, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const { renderPdf: render } = await loadOptional<{ renderPdf: typeof renderPdf }>(
"viewer-pdfjs",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const pages = element("div");
pages.className = "wrn-viewer-pages";
parent.append(pages);
const base = parent.ownerDocument.baseURI;
const result = await render(pages, bytes, {
signal,
libSrc: new URL(LIB_ASSET, base).href,
workerSrc: new URL(WORKER_ASSET, base).href,
});
if (signal.aborted) {
result.destroy();
return;
}
const status = element(
"p",
result.rendered < result.pages
? `Showing ${result.rendered} of ${result.pages} pages. Use Download for the rest.`
: `${result.pages} page${result.pages === 1 ? "" : "s"}`,
);
status.className = "wrn-viewer-note";
parent.append(status);
button("Top", () => pages.firstElementChild?.scrollIntoView({ block: "start" }));
return result.destroy;
};
/**
* Opt-in pdf.js rendering. The native frame is the default because it costs
* nothing; this trades roughly a megabyte of script for page count, controlled
* sizing and consistent output across browsers.
*
* ```ts
* registerRenderer(richPdfRenderer());
* ```
*/
export function richPdfRenderer(options: { maxInlineBytes?: number } = {}): Renderer {
return {
kind: "pdf",
exts: [".pdf"],
tier: 2,
component: "RichPdfViewer",
maxInlineBytes: options.maxInlineBytes ?? 32 * 1024 * 1024,
load: (options) => loadOptional("viewer-pdfjs", options?.baseURI),
mount: mountRichPdf,
};
}
+25
View File
@@ -0,0 +1,25 @@
import { directUrl } from "../source.ts";
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/**
* A frame, not an `<object>`: `object-src` is commonly `'none'` while frames
* fall back to `default-src`, so a same-origin PDF loads under the app's own
* policy instead of being blocked.
*/
export const mountPdf: ViewerMount = async ({ parent, source, sourceUrl, signal }) => {
const element = elementFactory(parent.ownerDocument);
// The original URL wins over an object URL whenever there is one. Reaching
// here as a fallback — after a richer renderer failed — means a body was
// already read, and framing that `blob:` is refused by the common
// `default-src 'self'` policy, leaving an empty frame.
const url = directUrl(source) ?? (await sourceUrl());
if (signal.aborted) return;
const frame = element("iframe");
frame.src = url;
frame.className = "wrn-viewer-frame wrn-viewer-pdf";
frame.setAttribute("title", source.name || "PDF preview");
const note = element("p", "If the document does not appear, use Download to open it.");
note.className = "wrn-viewer-note";
parent.append(frame, note);
};
+28
View File
@@ -0,0 +1,28 @@
import { rtfToText } from "../parse/rtf.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/** RTF needs no dependency at all: it is text once the control words are gone. */
export const mountRtf: ViewerMount = async ({ parent, file, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const source = await requireFile(file).text();
if (signal.aborted) return;
const text = rtfToText(source);
if (!text) throw new Error("Document holds no readable text");
const article = element("article");
article.className = "wrn-viewer-document";
for (const paragraph of text.split(/\n{2,}/))
for (const line of paragraph.split("\n")) article.append(element("p", line));
const raw = element("pre", source);
let showingRaw = false;
const toggle = button("Source", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Text" : "Source");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : article);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(article);
};
+17
View File
@@ -0,0 +1,17 @@
import { loadOptional } from "../optional/load.ts";
import type { readWorkbook, SheetData } from "../optional/sheet.ts";
import { requireFile } from "./dom.ts";
import { renderWorkbook } from "./workbook.ts";
import type { ViewerMount } from "../types.ts";
export const mountSheet: ViewerMount = async ({ parent, file, signal }) => {
const { readWorkbook: parse } = await loadOptional<{ readWorkbook: typeof readWorkbook }>(
"viewer-sheet",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const sheets: SheetData[] = parse(bytes);
if (!sheets.length) throw new Error("Workbook holds no sheets");
return renderWorkbook(parent, sheets);
};
+141
View File
@@ -0,0 +1,141 @@
import { elementFactory } from "./dom.ts";
import { icon } from "./icons.ts";
import type { Slide } from "../optional/office.ts";
/**
* Slide-by-slide reader shared by `.pptx` and `.odp`. Content and order, not a
* faithful layout: the converter's PDF stays preferred wherever it is reachable.
*/
export interface SlideDeckView {
node: HTMLElement;
/** Toolbar label describing the deck, e.g. "3 of 12". */
status: () => string;
go: (index: number) => void;
next: (step: number) => void;
dispose: () => void;
}
/** Beyond this many slides a row of dots stops being readable. */
const MAX_DOTS = 20;
export function renderSlides(
doc: Document,
slides: Slide[],
/** Called after every move, however it was made. */
onChange?: (index: number) => void,
): SlideDeckView {
const element = elementFactory(doc);
const wrapper = element("div");
wrapper.className = "wrn-viewer-slides";
// A deck is one control: focus it once and the arrow keys drive it.
wrapper.tabIndex = 0;
wrapper.setAttribute("role", "group");
wrapper.setAttribute("aria-roledescription", "slide deck");
wrapper.setAttribute("aria-label", `${slides.length} slides. Use the arrow keys to move.`);
const stage = element("article");
stage.className = "wrn-viewer-slide";
stage.setAttribute("aria-live", "polite");
const pager = element("div");
pager.className = "wrn-viewer-pager";
const back = element("button");
back.type = "button";
back.className = "wrn-viewer-pager-step";
back.setAttribute("aria-label", "Previous slide");
back.append(icon(doc, "previous"));
const forward = element("button");
forward.type = "button";
forward.className = "wrn-viewer-pager-step";
forward.setAttribute("aria-label", "Next slide");
forward.append(icon(doc, "next"));
const dots = element("div");
dots.className = "wrn-viewer-dots";
const count = element("span");
count.className = "wrn-viewer-pager-count";
let current = 0;
const useDots = slides.length <= MAX_DOTS;
const marks = useDots
? slides.map((slide, index) => {
const dot = element("button");
dot.type = "button";
dot.className = "wrn-viewer-dot";
dot.title = slide.title || `Slide ${index + 1}`;
dot.setAttribute(
"aria-label",
`Slide ${index + 1}${slide.title ? `: ${slide.title}` : ""}`,
);
dot.addEventListener("click", () => go(index));
dots.append(dot);
return dot;
})
: [];
pager.append(back, useDots ? dots : count, forward);
if (useDots) pager.append(count);
function go(index: number): void {
current = Math.max(0, Math.min(slides.length - 1, index));
const slide = slides[current]!;
marks.forEach((dot, position) => {
dot.setAttribute("aria-current", String(position === current));
dot.tabIndex = position === current ? 0 : -1;
});
count.textContent = `${current + 1} / ${slides.length}`;
back.disabled = current === 0;
forward.disabled = current === slides.length - 1;
stage.replaceChildren();
stage.append(element("h2", slide.title || `Slide ${current + 1}`));
// The title shape is already the heading; render the rest as its body.
slide.blocks.forEach((lines, position) => {
const body = position === 0 ? lines.slice(1) : lines;
if (!body.length) return;
const list = element("ul");
for (const line of body) list.append(element("li", line));
stage.append(list);
});
if (slide.notes) {
const notes = element("details");
notes.append(element("summary", "Presenter notes"), element("p", slide.notes));
notes.className = "wrn-viewer-slide-notes";
stage.append(notes);
}
marks[current]?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
// Every route into the deck ends here, so a caller that subscribes cannot
// miss a move — keyboard navigation included.
onChange?.(current);
}
back.addEventListener("click", () => go(current - 1));
forward.addEventListener("click", () => go(current + 1));
const onKey = (event: KeyboardEvent) => {
const keys: Record<string, number> = {
ArrowRight: current + 1,
PageDown: current + 1,
ArrowDown: current + 1,
ArrowLeft: current - 1,
PageUp: current - 1,
ArrowUp: current - 1,
Home: 0,
End: slides.length - 1,
};
const target = keys[event.key];
if (target === undefined) return;
event.preventDefault();
go(target);
};
wrapper.addEventListener("keydown", onKey);
wrapper.append(stage, pager);
go(0);
return {
node: wrapper,
status: () => `${current + 1} of ${slides.length}`,
go,
next: (step) => go(current + step),
dispose: () => wrapper.removeEventListener("keydown", onKey),
};
}
+42
View File
@@ -0,0 +1,42 @@
import { sanitizeSvg } from "../parse/svg.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
/** No network, no scripts, no plugins: only inline styles may apply. */
const POLICY = "default-src 'none'; style-src 'unsafe-inline'";
export const mountSvg: ViewerMount = async ({ parent, file, source, button, signal }) => {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
if (!view?.DOMParser || !view.XMLSerializer) throw new Error("No XML parser available");
const safe = sanitizeSvg(text, view);
if (!safe) throw new Error("Not a valid SVG document");
// Sanitised markup still renders inside a sandboxed frame: two layers, so a
// gap in either one is not on its own enough to run anything.
const frame = sandboxFrame(parent, {
title: source.name || "SVG preview",
policy: POLICY,
body: safe,
// Centred and contained: a small icon should not be blown up to the full
// height of the frame just because the frame is tall.
css:
"html,body{display:flex;align-items:center;justify-content:center;padding:1rem;box-sizing:border-box}" +
"svg{display:block;max-width:100%;max-height:100%}",
});
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Source", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Preview" : "Source");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : frame);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(frame);
};
+181
View File
@@ -0,0 +1,181 @@
import { elementFactory } from "./dom.ts";
/** Must match `.wrn-viewer-sheet td` in viewer.css for windowing to line up. */
const ROW_HEIGHT = 28;
const OVERSCAN = 20;
export interface TableData {
header: string[];
rows: string[][];
truncated?: boolean;
}
export interface TableView {
/** Scroll container content: the windowed grid. */
node: HTMLElement;
caption: string;
/** Re-measure and redraw, e.g. after the view is shown again. */
refresh: () => void;
dispose: () => void;
}
/** Spreadsheet column labels: A, B … Z, AA, AB … */
export function columnLabel(index: number): string {
let label = "";
for (let n = index; n >= 0; n = Math.floor(n / 26) - 1)
label = String.fromCharCode(65 + (n % 26)) + label;
return label;
}
/**
* A spreadsheet grid, not a data table: lettered columns across the top,
* numbered rows down the side, and the file's first row kept as row 1 rather
* than promoted into a header — because in a spreadsheet it *is* row 1.
*
* Only the visible rows plus overscan exist in the DOM; two spacer rows stand
* in for everything above and below.
*/
export function renderTable(scroller: HTMLElement, data: TableData): TableView {
const element = elementFactory(scroller.ownerDocument);
// Row 1 of a sheet is data, so the parsed header rejoins the rows here.
const rows = data.header.length ? [data.header, ...data.rows] : data.rows;
const columns = rows.reduce((max, row) => Math.max(max, row.length), 0);
const caption = `${rows.length}${data.truncated ? "+" : ""} rows × ${columns} columns`;
// A column of numbers reads right-aligned; one of words does not. Row 1 is
// skipped because a label above a numeric column is still a label.
const numeric = Array.from({ length: columns }, (_, column) => {
const values = rows
.slice(1, 51)
.map((row) => (row[column] ?? "").trim())
.filter(Boolean);
return values.length > 0 && values.every((value) => /^-?[\d.,]+%?$/.test(value));
});
const wrapper = element("div");
wrapper.className = "wrn-viewer-sheet";
const grid = element("table");
const head = element("thead");
const headRow = element("tr");
const corner = element("th");
corner.setAttribute("data-viewer-index", "");
corner.setAttribute("aria-label", "Row numbers");
headRow.append(corner);
for (let i = 0; i < columns; i++) {
const cell = element("th", columnLabel(i));
cell.scope = "col";
headRow.append(cell);
}
head.append(headRow);
const body = element("tbody");
grid.append(head, body);
wrapper.append(grid);
const spacer = (height: number) => {
const row = element("tr");
row.setAttribute("data-viewer-spacer", "");
const cell = element("td");
cell.colSpan = columns + 1;
cell.style.height = `${height}px`;
cell.style.padding = "0";
row.append(cell);
return row;
};
let windowStart = -1;
const draw = () => {
const visible = Math.ceil((scroller.clientHeight || 400) / ROW_HEIGHT) + OVERSCAN * 2;
const start = Math.max(0, Math.floor(scroller.scrollTop / ROW_HEIGHT) - OVERSCAN);
if (start === windowStart) return;
windowStart = start;
const slice = rows.slice(start, start + visible);
const nodes: HTMLElement[] = [spacer(start * ROW_HEIGHT)];
slice.forEach((row, index) => {
const line = element("tr");
const number = element("th", String(start + index + 1));
number.setAttribute("data-viewer-index", "");
number.scope = "row";
line.append(number);
for (let i = 0; i < columns; i++) {
const cell = element("td", row[i] ?? "");
if (numeric[i]) cell.setAttribute("data-viewer-numeric", "");
line.append(cell);
}
// Row 1 usually carries the column names, so it is given some weight.
if (start + index === 0 && data.header.length) line.setAttribute("data-viewer-labels", "");
nodes.push(line);
});
nodes.push(spacer(Math.max(0, rows.length - start - slice.length) * ROW_HEIGHT));
body.replaceChildren(...nodes);
};
draw();
scroller.addEventListener("scroll", draw, { passive: true });
return {
node: wrapper,
caption,
refresh: () => {
windowStart = -1;
draw();
},
dispose: () => scroller.removeEventListener("scroll", draw),
};
}
export function formatSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
}
export interface ListedEntry {
name: string;
size: number;
directory: boolean;
}
/** Archive contents table, shared by the tar/gzip and zip previews. */
export function renderEntries(doc: Document, entries: ListedEntry[], label: string): HTMLElement {
const element = elementFactory(doc);
const wrapper = element("div");
wrapper.className = "wrn-viewer-table";
wrapper.setAttribute("aria-label", label);
const table = element("table");
const head = element("thead");
const headRow = element("tr");
const sizeHead = element("th", "Size");
sizeHead.setAttribute("data-viewer-numeric", "");
headRow.append(element("th", "Name"), sizeHead);
head.append(headRow);
const body = element("tbody");
for (const entry of entries) {
const row = element("tr");
const name = element("td", entry.name);
name.setAttribute("data-viewer-grow", "");
const size = element("td", entry.directory ? "" : formatSize(entry.size));
size.setAttribute("data-viewer-numeric", "");
row.append(name, size);
body.append(row);
}
table.append(head, body);
wrapper.append(table);
return wrapper;
}
/** "3 files, 1.2 MB uncompressed" — the line above an archive listing. */
export function entriesSummary(entries: ListedEntry[], note = ""): string {
const files = entries.filter((entry) => !entry.directory);
const total = files.reduce((sum, entry) => sum + entry.size, 0);
return [
`${files.length} file${files.length === 1 ? "" : "s"}`,
`${formatSize(total)} uncompressed`,
note,
]
.filter(Boolean)
.join(", ");
}
+32
View File
@@ -0,0 +1,32 @@
import { parseDelimited } from "../parse/delimited.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { renderTable } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
export const mountTable: ViewerMount = async ({ parent, file, source, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const text = await requireFile(file).text();
if (signal.aborted) return;
const table = parseDelimited(text, {
delimiter: /\.tsv$/i.test(source.name) ? "\t" : undefined,
});
// A single column means no delimiter was found: show it as text instead.
if (table.header.length < 2) throw new Error("Not a delimited table");
const view = renderTable(parent, table);
const caption = element("p", view.caption);
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Table" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(caption, showingRaw ? raw : view.node);
if (!showingRaw) view.refresh();
});
toggle.setAttribute("aria-pressed", "false");
parent.append(caption, view.node);
return view.dispose;
};
+45
View File
@@ -0,0 +1,45 @@
import { elementFactory } from "./dom.ts";
/**
* Shared collapsible tree used by the JSON and XML previews. Children are built
* on first expand, so a deep document costs nothing until it is opened.
*/
export function branch(
doc: Document,
label: string,
summaryClass: string,
fill: (into: HTMLElement) => void,
open = false,
): HTMLElement {
const element = elementFactory(doc);
const details = element("details");
details.open = open;
const summary = element("summary", label);
summary.className = summaryClass;
const children = element("div");
children.className = "wrn-viewer-branch";
details.append(summary, children);
let filled = false;
const build = () => {
if (filled || !details.open) return;
filled = true;
fill(children);
};
details.addEventListener("toggle", build);
build();
return details;
}
export function leaf(doc: Document, label: string, value: string, valueClass: string): HTMLElement {
const element = elementFactory(doc);
const line = element("div");
line.className = "wrn-viewer-leaf";
if (label) {
line.append(element("span", label));
line.append(doc.createTextNode(": "));
}
const rendered = element("span", value);
rendered.className = valueClass;
line.append(rendered);
return line;
}
+108
View File
@@ -0,0 +1,108 @@
import { setButtonLabel } from "./dom.ts";
import { readSource } from "../source.ts";
import type { ViewerSource } from "../types.ts";
/** Peak envelope in bounded bins, including every channel. */
export function waveformPeaks(channels: Float32Array[], bins = 160): number[] {
const length = Math.max(0, ...channels.map((channel) => channel.length));
return Array.from({ length: bins }, (_, i) => {
let peak = 0;
for (const channel of channels)
for (
let n = Math.floor((i * length) / bins);
n < Math.min(channel.length, Math.ceil(((i + 1) * length) / bins));
n++
)
peak = Math.max(peak, Math.abs(channel[n] ?? 0));
return Math.min(1, peak);
});
}
/** User-triggered decoding preserves streaming playback until a waveform is requested. */
export function mountWaveform(
parent: HTMLElement,
audio: HTMLMediaElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const button = doc.createElement("button");
button.type = "button";
button.className = "wrn-viewer-action";
setButtonLabel(button, "Show waveform");
const note = doc.createElement("p");
note.setAttribute("role", "status");
const canvas = doc.createElement("canvas");
canvas.width = 640;
canvas.height = 96;
canvas.className = "wrn-viewer-waveform";
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", "Audio waveform");
const seek = doc.createElement("input");
seek.type = "range";
seek.min = "0";
seek.max = "1000";
seek.value = "0";
seek.setAttribute("aria-label", "Seek audio");
let context: AudioContext | undefined;
let disposed = false;
const update = () => {
if (Number.isFinite(audio.duration) && audio.duration > 0)
seek.value = String(Math.round((audio.currentTime / audio.duration) * 1000));
};
seek.oninput = () => {
if (Number.isFinite(audio.duration))
audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
};
audio.addEventListener("timeupdate", update);
button.onclick = async () => {
button.disabled = true;
note.textContent = "Building waveform…";
parent.append(note);
try {
if (!view?.AudioContext) throw new Error("No decoder");
// 8 MiB encoded and 10 minutes of audio bound this optional analysis.
if (!Number.isFinite(audio.duration) || audio.duration > 600)
throw new Error("Duration unavailable or too long");
const file = await readSource(source, 8 * 1024 * 1024, { signal });
if (disposed) return;
context = new view.AudioContext();
const decoded = await context.decodeAudioData(await file.arrayBuffer());
if (disposed) return;
const peaks = waveformPeaks(
Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)),
);
const draw = canvas.getContext("2d");
if (!draw) throw new Error("No canvas");
draw.fillStyle =
view.getComputedStyle(parent).getPropertyValue("--wrn-color-primary") ||
view.getComputedStyle(parent).color;
peaks.forEach((peak, i) => {
const h = Math.max(1, peak * 90);
draw.fillRect(i * 4, (96 - h) / 2, 3, h);
});
note.textContent = "";
button.remove();
parent.append(canvas, seek);
} catch {
if (!disposed) {
note.textContent = "Waveform unavailable for this file. Audio controls remain available.";
button.disabled = false;
}
} finally {
await context?.close().catch(() => {});
context = undefined;
}
};
parent.append(button);
return () => {
disposed = true;
audio.removeEventListener("timeupdate", update);
void context?.close().catch(() => {});
button.remove();
note.remove();
canvas.remove();
seek.remove();
};
}
+75
View File
@@ -0,0 +1,75 @@
import { elementFactory } from "./dom.ts";
import { renderTable, type TableData, type TableView } from "./table-view.ts";
export interface WorkbookSheet extends TableData {
name: string;
}
/**
* A workbook: the grid scrolls, and the sheet bar stays beneath it with the
* active tab joined to the sheet — where a spreadsheet keeps it.
*
* Shared by `.xlsx` and `.ods` so the same file looks the same whichever
* reader opened it.
*/
export function renderWorkbook(parent: HTMLElement, sheets: WorkbookSheet[]): () => void {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const shell = element("div");
shell.className = "wrn-viewer-workbook";
const surface = element("div");
surface.className = "wrn-viewer-workbook-surface";
const bar = element("div");
bar.className = "wrn-viewer-sheetbar";
bar.setAttribute("role", "tablist");
bar.setAttribute("aria-label", "Sheets");
const status = element("span");
status.className = "wrn-viewer-sheetbar-status";
let view: TableView | undefined;
let current = 0;
const show = (index: number) => {
current = Math.max(0, Math.min(sheets.length - 1, index));
const sheet = sheets[current]!;
view?.dispose();
view = renderTable(surface, sheet);
surface.replaceChildren(view.node);
status.textContent = view.caption;
tabs.forEach((tab, position) => {
const selected = position === current;
tab.setAttribute("aria-selected", String(selected));
tab.tabIndex = selected ? 0 : -1;
});
view.refresh();
};
const tabs = sheets.map((sheet, index) => {
const tab = element("button", sheet.name);
tab.type = "button";
tab.setAttribute("role", "tab");
tab.className = "wrn-viewer-sheettab";
tab.title = sheet.name;
tab.addEventListener("click", () => show(index));
return tab;
});
// Arrow keys walk the bar, as a tablist should.
bar.addEventListener("keydown", (event) => {
const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
event.preventDefault();
const next = (current + step + sheets.length) % sheets.length;
show(next);
tabs[next]?.focus();
});
bar.append(...tabs, status);
// One sheet needs no tabs, but its size is still worth stating.
if (sheets.length === 1) bar.classList.add("is-single");
shell.append(surface, bar);
parent.append(shell);
show(0);
return () => view?.dispose();
}
+62
View File
@@ -0,0 +1,62 @@
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { branch, leaf } from "./tree.ts";
import type { ViewerMount } from "../types.ts";
function label(node: Element): string {
const attributes = [...node.attributes]
.map((attribute) => `${attribute.name}="${attribute.value}"`)
.join(" ");
return `<${node.tagName}${attributes ? ` ${attributes}` : ""}>`;
}
function render(doc: Document, node: Element, depth: number): HTMLElement {
const children = [...node.childNodes].filter(
(child) => child.nodeType === 1 || (child.textContent ?? "").trim(),
);
const elements = children.filter((child) => child.nodeType === 1) as Element[];
if (!elements.length)
return leaf(doc, label(node), (node.textContent ?? "").trim(), "wrn-viewer-value");
return branch(
doc,
label(node),
"wrn-viewer-key",
(into) => {
for (const child of children)
into.append(
child.nodeType === 1
? render(doc, child as Element, depth + 1)
: leaf(doc, "", (child.textContent ?? "").trim(), "wrn-viewer-value"),
);
},
depth < 2,
);
}
export const mountXml: ViewerMount = async ({ parent, file, button, signal }) => {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
if (!view?.DOMParser) throw new Error("No XML parser available");
const parsed = new view.DOMParser().parseFromString(text, "application/xml");
const root = parsed.documentElement;
// Malformed XML throws, and the ladder falls through to the source view.
if (!root || parsed.getElementsByTagName("parsererror").length)
throw new Error("Malformed XML document");
const tree = element("div");
tree.className = "wrn-viewer-tree";
tree.append(render(doc, root, 0));
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Tree" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : tree);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(tree);
};
+22
View File
@@ -0,0 +1,22 @@
import { loadOptional } from "../optional/load.ts";
import type { listZip } from "../optional/zip.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { entriesSummary, renderEntries } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
export const mountZip: ViewerMount = async ({ parent, file, source, signal }) => {
const element = elementFactory(parent.ownerDocument);
const { listZip: list } = await loadOptional<{ listZip: typeof listZip }>(
"viewer-zip",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const entries = await list(bytes);
if (signal.aborted) return;
if (!entries.length) throw new Error("Archive holds no readable entries");
parent.append(
element("p", entriesSummary(entries)),
renderEntries(parent.ownerDocument, entries, `Contents of ${source.name}`),
);
};
+95
View File
@@ -0,0 +1,95 @@
import { extensionOf, kindOfExtension, kindOfMime } from "./kinds.ts";
import { defaultRegistry, downloadRenderer } from "./registry.ts";
import { sniff } from "./sniff.ts";
import { readBounded, safeSourceUrl } from "./source.ts";
import type { ResolveOptions, ViewerKind, ViewerPlan, ViewerSource } from "./types.ts";
export async function resolve(
source: ViewerSource,
options: ResolveOptions = {},
): Promise<ViewerPlan> {
const ext = extensionOf(source.name);
let kind = kindOfMime(source.mime ?? source.blob?.type ?? "") ?? kindOfExtension(ext);
const fallback = (reason: string): ViewerPlan => ({
kind,
renderer: downloadRenderer,
fallback: [],
needsConvert: false,
sandboxed: false,
reason,
});
if ([".exe", ".dll"].includes(ext)) return fallback("Executable files cannot be previewed");
const registry = options.registry ?? defaultRegistry;
const candidates = (k: ViewerKind, extension = ext) => registry.candidates(k, extension);
// No renderer for the kind is not an oversized file: leave the cap open so the
// ladder reports why nothing can display it rather than "exceeds preview limit".
const capFor = (k: ViewerKind) => {
const caps = candidates(k).map((r) => r.maxInlineBytes ?? 2 * 1024 * 1024);
return caps.length ? Math.max(...caps) : Infinity;
};
try {
let size = source.blob?.size ?? source.size;
let bytes: Uint8Array;
if (source.blob) {
bytes = new Uint8Array(await source.blob.slice(0, 4096).arrayBuffer());
} else {
const url = source.src && safeSourceUrl(source.src);
if (!url) return fallback("No readable file source");
const request = options.fetch ?? fetch;
const head = await request(url, { method: "HEAD", signal: options.signal });
if (head.ok) {
const length = head.headers.get("content-length");
if (length !== null && /^\d+$/.test(length)) size = Math.max(size ?? 0, Number(length));
kind = kindOfMime(head.headers.get("content-type") ?? "") ?? kind;
}
if (size !== undefined && size > capFor(kind)) return fallback("File exceeds preview limit");
const response = await request(url, {
headers: { Range: "bytes=0-4095" },
signal: options.signal,
});
bytes = await readBounded(response, 4096, true);
}
const detected = sniff(bytes);
if (detected.executable) return fallback("Executable files cannot be previewed");
const previous = kind;
// Plain UTF-8 retains code classification; active markup and signatures win.
// RTF is the one office format that is genuinely text, so `doc` survives a
// text signature too — otherwise a .rtf loses its extension and its reader.
// A generic container signature (OLE, ZIP, unrecognised binary) says only
// "not text", so a .docx or .psd keeps the kind its extension implies.
const generic = detected.kind === "binary" || detected.kind === "archive";
const keepsExtensionKind =
(detected.kind === "text" && ["text", "code", "markup", "sheet", "doc"].includes(kind)) ||
(generic && ["doc", "sheet", "archive", "binary"].includes(kind));
kind = keepsExtensionKind ? kind : detected.kind;
// An extension override must not bypass conflicting signatures.
const selectedExt =
kind === previous || (detected.kind === "text" && previous === "binary") ? ext : "";
const available = [];
let needsConvert = false;
for (const renderer of candidates(kind, selectedExt)) {
// A tier 3 renderer needs a converter, and `load` is how it proves one is
// reachable. Without that check it could never report itself unavailable.
if (renderer.tier === 3 && !renderer.load) continue;
if (size !== undefined && size > (renderer.maxInlineBytes ?? 2 * 1024 * 1024)) continue;
try {
await renderer.load?.(options);
if (renderer.tier === 3) needsConvert = true;
available.push(renderer);
} catch {
/* Optional dependency unavailable. */
}
}
const renderer = available.shift();
if (!renderer) return fallback("Preview unavailable for this file");
return {
kind,
renderer,
fallback: [...available, downloadRenderer],
needsConvert: needsConvert && renderer.tier === 3,
sandboxed: kind === "markup" || kind === "vector",
};
} catch {
return fallback("File could not be loaded");
}
}
@@ -0,0 +1,5 @@
import { handleCapabilities } from "../convert/service.ts";
export function GET(): Response {
return handleCapabilities();
}
+9
View File
@@ -0,0 +1,9 @@
import { handleFile } from "../uploads/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleFile(ctx.req);
}
export async function HEAD(ctx: { req: Request }): Promise<Response> {
return handleFile(ctx.req);
}
+5
View File
@@ -0,0 +1,5 @@
import { handleConvert } from "../convert/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleConvert(ctx.req, "preview");
}
+5
View File
@@ -0,0 +1,5 @@
import { handleSource } from "../uploads/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleSource(ctx.req);
}
+5
View File
@@ -0,0 +1,5 @@
import { handleConvert } from "../convert/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleConvert(ctx.req, "text");
}
+5
View File
@@ -0,0 +1,5 @@
import { handleConvert } from "../convert/service.ts";
export async function GET(ctx: { req: Request }): Promise<Response> {
return handleConvert(ctx.req, "thumb");
}
+468
View File
@@ -0,0 +1,468 @@
import { mountFilmstrip } from "./filmstrip.ts";
import { richPdfRenderer } from "./renderers/pdf-rich.ts";
import { mountExtractedText } from "./text.ts";
import { resolve } from "./resolve.ts";
import { defaultRegistry, downloadRenderer } from "./registry.ts";
import { elementFactory, setButtonLabel } from "./renderers/dom.ts";
import { viewerActions, type ViewerAction, type ViewerActionContext } from "./actions.ts";
import { canPrint, printViewer } from "./print.ts";
import { sniff } from "./sniff.ts";
import { directUrl, readSource, safeSourceUrl } from "./source.ts";
import { resolveKey } from "./uploads/client.ts";
import { startViewerTriggers } from "./overlay.ts";
import type { Renderer, ViewerKind, ViewerSource } from "./types.ts";
const DEFAULT_CAP = 2 * 1024 * 1024;
function formatBytes(bytes: number): string {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
}
/** Mount a preview, including local File/Blob sources. Dispose before reusing the host. */
export interface MountViewerOptions {
richPdf?: boolean;
/**
* Opens covering the page, with a Close control that returns it inline. This
* is a layered overlay rather than the browser's Fullscreen API, so it needs
* no user gesture and can be the state a viewer mounts in.
*/
fullScreen?: boolean;
/** `page` drops the card chrome and fills its container, for a preview route. */
layout?: "inline" | "page";
/** Actions for this viewer only, offered after the globally registered ones. */
actions?: ViewerAction[];
/** Drops the built-in Print action when an app has its own. */
print?: boolean;
}
export function mountViewer(
host: HTMLElement,
input: ViewerSource | ViewerSource[],
options: MountViewerOptions = {},
): () => void {
if (Array.isArray(input)) return mountFilmstrip(host, input, options);
const original = [...host.childNodes].map((node) => node.cloneNode(true));
const registry = defaultRegistry.fork();
if (options.richPdf) registry.register(richPdfRenderer());
const controller = new AbortController();
// Reassigned once a storage key has been exchanged for a URL.
let source = input;
const doc = host.ownerDocument;
const element = elementFactory(doc);
let disposed = false;
let cleanup: (() => void) | undefined;
const urls: string[] = [];
const objectUrl = (blob: Blob) => {
const url = URL.createObjectURL(blob);
urls.push(url);
return url;
};
host.classList.add("wrn-viewer");
if (options.layout === "page") host.classList.add("wrn-viewer-as-page");
host.setAttribute("aria-label", source.name || "File preview");
let expanded = options.fullScreen === true;
const setExpanded = (next: boolean) => {
expanded = next;
host.classList.toggle("wrn-viewer-expanded", expanded);
// The control swaps between Close and Expand, so re-offer the toolbar.
offerActions(lastRenderer, lastKind);
};
if (expanded) host.classList.add("wrn-viewer-expanded");
const toolbar = host.querySelector<HTMLElement>("[data-viewer-toolbar]") ?? element("div");
toolbar.classList.add("wrn-viewer-toolbar");
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", "File preview controls");
const title =
toolbar.querySelector<HTMLElement>("[data-viewer-title]") ??
element("span", source.name || "File");
title.textContent = source.name || "File";
if (!toolbar.contains(title)) toolbar.append(title);
const controls = toolbar.querySelector<HTMLElement>("[data-viewer-controls]") ?? element("span");
const actions = toolbar.querySelector<HTMLElement>("[data-viewer-actions]") ?? element("span");
if (!toolbar.contains(actions)) toolbar.append(actions);
if (!toolbar.contains(controls)) toolbar.append(controls);
const content = host.querySelector<HTMLElement>("[data-viewer-content]") ?? element("div");
content.classList.add("wrn-viewer-content");
const status = element("p", "Loading preview…");
status.setAttribute("role", "status");
content.replaceChildren(status);
if (!host.contains(toolbar)) host.append(toolbar);
if (!host.contains(content)) host.append(content);
const button = (label: string, action: () => void, iconName?: string) => {
const node = element("button");
node.type = "button";
node.className = "wrn-viewer-action";
setButtonLabel(node, label, iconName);
node.addEventListener("click", action);
controls.append(node);
return node;
};
const actionButton = (label: string, run: () => void, iconName?: string) => {
const node = element("button");
node.type = "button";
node.className = "wrn-viewer-action";
setButtonLabel(node, label, iconName);
node.addEventListener("click", run);
actions.append(node);
return node;
};
if (host.requestFullscreen)
actionButton("Fullscreen", () => {
const promise =
doc.fullscreenElement === host ? doc.exitFullscreen() : host.requestFullscreen();
promise.catch(() => {});
});
const downloadUrl = source.blob
? objectUrl(source.blob)
: source.src && safeSourceUrl(source.src);
const downloadLink = (url: string) => {
const link = element("a");
link.href = url;
link.download = source.name;
link.rel = "noopener noreferrer";
link.className = "wrn-viewer-action";
setButtonLabel(link, "Download");
actions.append(link);
};
if (downloadUrl) downloadLink(downloadUrl);
const showDownload = (reason = "Preview unavailable for this file") => {
cleanup?.();
cleanup = undefined;
controls.replaceChildren();
const template = host.querySelector<HTMLTemplateElement>(
'[data-viewer-template="DownloadCard"]',
);
content.replaceChildren(template ? template.content.cloneNode(true) : element("p", reason));
const reasonNode = content.querySelector("[data-viewer-reason]");
if (reasonNode) reasonNode.textContent = reason;
const bytes = source.blob?.size ?? source.size;
const meta = [source.name, bytes === undefined ? "" : formatBytes(bytes)]
.filter(Boolean)
.join(" · ");
const metaNode = content.querySelector("[data-viewer-size]");
if (metaNode) metaNode.textContent = meta;
else if (meta) content.append(element("p", meta));
content.setAttribute("data-viewer-state", "fallback");
// An app action may still apply to a file the viewer cannot render.
offerActions();
};
let lastRenderer: Renderer | undefined;
let lastKind: ViewerKind | undefined;
async function render(renderer: Renderer, blob: Blob | undefined, kind: ViewerKind) {
if (renderer === downloadRenderer) {
showDownload();
return;
}
if (!renderer.mount)
throw new Error(`Renderer "${renderer.component}" has no browser mount adapter`);
controls.replaceChildren();
const aliases: Record<string, string> = {
SvgViewer: "VectorViewer",
HtmlViewer: "DocViewer",
DocxViewer: "DocViewer",
ConvertedViewer: "DocViewer",
TableViewer: "SheetViewer",
JsonViewer: "CodeViewer",
XmlViewer: "CodeViewer",
ZipViewer: "ArchiveViewer",
RichPdfViewer: "PdfViewer",
};
const template = [...host.querySelectorAll<HTMLTemplateElement>("[data-viewer-template]")].find(
(node) => node.dataset.viewerTemplate === (aliases[renderer.component] ?? renderer.component),
);
content.replaceChildren(...(template ? [template.content.cloneNode(true)] : []));
const parent = content.querySelector<HTMLElement>("[data-viewer-mount]") ?? content;
content.setAttribute("data-viewer-state", "ready");
const dispose = await renderer.mount({
parent,
file: blob,
sourceUrl: async () => {
if (!blob) {
const url = directUrl(source);
if (!url) throw new Error("No readable file source");
return url;
}
// Serving the body under its sniffed type keeps a mislabelled response
// from choosing how the element interprets it.
const detected = sniff(new Uint8Array(await blob.slice(0, 4096).arrayBuffer()));
return objectUrl(new Blob([blob], { type: detected.mime ?? "application/octet-stream" }));
},
source,
kind,
button,
objectUrl,
degrade: showDownload,
signal: controller.signal,
});
if (disposed) {
dispose?.();
return;
}
cleanup = dispose || undefined;
lastRenderer = renderer;
lastKind = kind;
offerActions(renderer, kind);
}
/**
* Built-in and app-supplied actions, offered after every render so a `when`
* predicate can look at what actually drew — printing is meaningless on a
* download card, and an app action may only suit one kind.
*/
function offerActions(renderer?: Renderer, kind?: ViewerKind) {
// Re-offered after every render, so anything from the previous one goes
// first; the actions slot is not cleared by the render itself.
for (const node of host.querySelectorAll("[data-viewer-action]")) node.remove();
const context: ViewerActionContext = { host, content, source, renderer, kind };
const builtIn: ViewerAction[] = [
...(options.print === false
? []
: [
{
id: "print",
label: "Print",
icon: "print",
placement: "actions" as const,
when: canPrint,
run: printViewer,
},
]),
// Offered only once the viewer is covering the page, so an inline viewer
// is not cluttered with a control that would do nothing.
...(expanded
? [
{
id: "close",
label: "Close",
icon: "close",
placement: "actions" as const,
run: () => setExpanded(false),
},
]
: []),
];
for (const action of [...builtIn, ...viewerActions(), ...(options.actions ?? [])]) {
if (action.when && !action.when(context)) continue;
const node = element("button");
node.type = "button";
node.className = "wrn-viewer-action";
setButtonLabel(node, action.label, action.icon);
node.dataset.viewerAction = action.id;
node.addEventListener("click", () => void action.run(context));
(action.placement === "controls" ? controls : actions).append(node);
}
}
void (async () => {
if (source.key && !source.src && !source.blob) {
const resolved = await resolveKey(source, {
signal: controller.signal,
baseURI: doc.baseURI,
});
if (disposed) return;
if (!resolved) {
showDownload("This file is not available to preview");
return;
}
source = resolved;
title.textContent = source.name || "File";
host.setAttribute("aria-label", source.name || "File preview");
const url = safeSourceUrl(source.src ?? "");
// The download control could not be built before the link existed.
if (url) downloadLink(url);
}
if (source.src) source = { ...source, src: new URL(source.src, doc.baseURI).href };
const stopText = mountExtractedText(actions, source, controller.signal);
controller.signal.addEventListener("abort", stopText, { once: true });
const plan = await resolve(source, {
signal: controller.signal,
baseURI: doc.baseURI,
registry: options.richPdf ? registry : undefined,
});
if (disposed) return;
if (plan.renderer === downloadRenderer) {
showDownload(plan.reason);
return;
}
try {
// A streaming renderer is pointed at the original URL: the browser fetches
// and decodes it, so nothing is buffered here and playback can start early.
// Trying every leading streaming candidate before touching the body keeps
// the ladder intact — a failed conversion still falls through to the
// in-package reader rather than straight to the download card.
const streamable = directUrl(source) ? [plan.renderer, ...plan.fallback] : [];
for (const renderer of streamable) {
if (!renderer.streaming) break;
if (disposed) return;
try {
await render(renderer, undefined, plan.kind);
return;
} catch {
cleanup?.();
cleanup = undefined;
}
}
// The body only serves renderers that actually read it, so the cap comes
// from those. A streaming renderer's open-ended limit must never become
// the size of an unbounded download.
const readers = [plan.renderer, ...plan.fallback].filter(
(renderer) => renderer !== downloadRenderer && !(renderer.streaming && directUrl(source)),
);
const cap = readers.reduce(
(limit, renderer) => Math.max(limit, renderer.maxInlineBytes ?? DEFAULT_CAP),
0,
);
const blob = await readSource(source, Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_CAP, {
signal: controller.signal,
});
// Resolve the actual body again: a URL may change between HEAD/range/body.
const verified = await resolve(
{ ...source, blob },
{
signal: controller.signal,
baseURI: doc.baseURI,
registry: options.richPdf ? registry : undefined,
},
);
for (const renderer of [verified.renderer, ...verified.fallback]) {
if (disposed) return;
if (renderer !== downloadRenderer && blob.size > (renderer.maxInlineBytes ?? DEFAULT_CAP))
continue;
try {
await render(renderer, blob, verified.kind);
return;
} catch {
cleanup?.();
cleanup = undefined;
}
}
if (!disposed) showDownload();
} catch {
if (!disposed) showDownload("File could not be loaded within the preview limit");
}
})().catch(() => {
if (!disposed) showDownload("File could not be loaded");
});
return () => {
disposed = true;
controller.abort();
cleanup?.();
for (const url of urls) URL.revokeObjectURL(url);
host.replaceChildren(...original);
};
}
function parseFiles(value?: string): ViewerSource[] | undefined {
if (!value) return;
try {
const files: unknown = JSON.parse(value);
if (
Array.isArray(files) &&
files.length &&
files.length <= 500 &&
files.every(
(item) =>
item &&
typeof item === "object" &&
typeof item.name === "string" &&
(typeof item.src === "string" || typeof item.key === "string"),
)
)
return files;
} catch {
/* Invalid serialized file lists fall back to the single source. */
}
}
/** Observe framework navigation and changed source attributes. */
export function startViewerRuntime(root: Document = document): () => void {
const mounted = new Map<HTMLElement, { signature: string; dispose: () => void }>();
const scan = () => {
for (const [host, state] of mounted)
if (!root.contains(host)) {
state.dispose();
mounted.delete(host);
}
for (const host of root.querySelectorAll<HTMLElement>('[data-wrnexus-runtime="viewer"]')) {
const signature = [
host.dataset.files,
host.dataset.richPdf,
host.dataset.fullScreen,
host.dataset.layout,
host.dataset.src,
host.dataset.key,
host.dataset.store,
host.dataset.name,
host.dataset.mime,
host.dataset.size,
].join("\n");
if (mounted.get(host)?.signature === signature) continue;
mounted.get(host)?.dispose();
const size = Number(host.dataset.size);
mounted.set(host, {
signature,
dispose: mountViewer(
host,
parseFiles(host.dataset.files) ?? {
src: host.dataset.src,
key: host.dataset.key,
store: host.dataset.store,
name: host.dataset.name ?? "",
mime: host.dataset.mime,
size: host.dataset.size && Number.isFinite(size) && size >= 0 ? size : undefined,
},
{
richPdf: host.dataset.richPdf === "true",
fullScreen: host.dataset.fullScreen === "true",
layout: host.dataset.layout === "page" ? "page" : "inline",
},
),
});
}
};
const stopTriggers = startViewerTriggers(root);
const Observer = root.defaultView?.MutationObserver ?? MutationObserver;
const observer = new Observer(scan);
scan();
observer.observe(root.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
"data-full-screen",
"data-layout",
"data-files",
"data-rich-pdf",
"data-src",
"data-key",
"data-store",
"data-name",
"data-mime",
"data-size",
],
});
return () => {
stopTriggers();
observer.disconnect();
for (const item of mounted.values()) item.dispose();
mounted.clear();
};
}
if (typeof document !== "undefined") {
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", () => startViewerRuntime(), { once: true });
else startViewerRuntime();
}
+59
View File
@@ -0,0 +1,59 @@
import type { ViewerKind } from "./types.ts";
export interface SniffResult {
kind: ViewerKind;
mime?: string;
executable?: boolean;
}
/** Bounded signature inspection. Unknown bytes must never become active markup. */
export function sniff(bytes: Uint8Array): SniffResult {
const at = (value: string, offset = 0) =>
[...value].every((ch, i) => bytes[offset + i] === ch.charCodeAt(0));
const hex = (...values: number[]) => values.every((value, i) => bytes[i] === value);
if (at("MZ") || hex(0x7f, 0x45, 0x4c, 0x46)) return { kind: "binary", executable: true };
if (hex(137, 80, 78, 71, 13, 10, 26, 10)) return { kind: "image", mime: "image/png" };
if (hex(255, 216, 255)) return { kind: "image", mime: "image/jpeg" };
if (at("GIF87a") || at("GIF89a")) return { kind: "image", mime: "image/gif" };
if (at("RIFF") && at("WEBP", 8)) return { kind: "image", mime: "image/webp" };
if (at("BM")) return { kind: "image", mime: "image/bmp" };
if (hex(0, 0, 1, 0)) return { kind: "image", mime: "image/x-icon" };
if (at("%PDF-")) return { kind: "pdf", mime: "application/pdf" };
// Design and print formats: recognised so they reach the download card (and,
// later, a converter) instead of being mistaken for text.
if (at("8BPS")) return { kind: "binary", mime: "image/vnd.adobe.photoshop" };
if (hex(0x49, 0x49, 0x2a, 0x00) || hex(0x4d, 0x4d, 0x00, 0x2a))
return { kind: "binary", mime: "image/tiff" };
if (at("ftyp", 4)) {
if (at("avif", 8) || at("avis", 8)) return { kind: "image", mime: "image/avif" };
return { kind: "video", mime: at("qt ", 8) ? "video/quicktime" : "video/mp4" };
}
if (at("RIFF") && at("WAVE", 8)) return { kind: "audio", mime: "audio/wav" };
if (at("OggS")) return { kind: "audio", mime: "audio/ogg" };
if (at("fLaC")) return { kind: "audio", mime: "audio/flac" };
if (bytes[0] === 255 && ((bytes[1] ?? 0) & 0xf6) === 0xf0)
return { kind: "audio", mime: "audio/aac" };
if (at("ID3") || (bytes[0] === 255 && ((bytes[1] ?? 0) & 0xe0) === 0xe0))
return { kind: "audio", mime: "audio/mpeg" };
if (hex(0x1a, 0x45, 0xdf, 0xa3)) return { kind: "video", mime: "video/webm" };
if (at("PK") || hex(0x1f, 0x8b) || at("Rar!") || at("7z") || at("ustar", 257))
return { kind: "archive" };
try {
const text = new TextDecoder("utf-8", { fatal: true })
.decode(bytes, { stream: true })
.replace(/^\uFEFF/, "")
.trimStart();
if (
[...text].some((char) => {
const code = char.charCodeAt(0);
return code <= 8 || (code >= 14 && code <= 31);
})
)
return { kind: "binary" };
if (/<svg(?:\s|>)/i.test(text)) return { kind: "vector", mime: "image/svg+xml" };
if (/<(?:!doctype\s+html|html|head|body|script|iframe)(?:\s|>)/i.test(text))
return { kind: "markup", mime: "text/html" };
return { kind: "text", mime: "text/plain" };
} catch {
return { kind: "binary" };
}
}
+68
View File
@@ -0,0 +1,68 @@
import type { ResolveOptions, ViewerSource } from "./types.ts";
export function safeSourceUrl(src: string): string | undefined {
try {
const url = new URL(src, typeof location === "undefined" ? "http://localhost" : location.href);
return ["http:", "https:", "blob:", "data:"].includes(url.protocol) ? url.href : undefined;
} catch {
return undefined;
}
}
/** The source's own http(s) URL, when it has one that an element can load. */
export function directUrl(source: ViewerSource): string | undefined {
if (source.blob || !source.src) return undefined;
const url = safeSourceUrl(source.src);
return url && /^https?:/i.test(url) ? url : undefined;
}
export async function readBounded(
response: Response,
cap: number,
prefixOnly = false,
): Promise<Uint8Array> {
if (!response.ok || !response.body) throw new Error("File could not be read");
const reader = response.body.getReader();
const parts: Uint8Array[] = [];
let length = 0;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
if (!prefixOnly && length + next.value.length > cap)
throw new Error("File exceeds preview limit");
const part = next.value.subarray(0, cap - length);
parts.push(part);
length += part.length;
if (prefixOnly && length >= cap) break;
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
}
export async function readSource(
source: ViewerSource,
cap: number,
options: ResolveOptions = {},
): Promise<Blob> {
if (source.blob) {
if (source.blob.size > cap) throw new Error("File exceeds preview limit");
return source.blob;
}
const url = source.src && safeSourceUrl(source.src);
if (!url) throw new Error("No readable file source");
const response = await (options.fetch ?? fetch)(url, { signal: options.signal });
const bytes = await readBounded(response, cap);
return new Blob([bytes as Uint8Array<ArrayBuffer>], {
type: response.headers.get("content-type") ?? source.mime ?? "",
});
}
+66
View File
@@ -0,0 +1,66 @@
import { setButtonLabel } from "./renderers/dom.ts";
import { convertUrl, converterFormats } from "./convert/client.ts";
import { extensionOf } from "./kinds.ts";
import { readBounded } from "./source.ts";
import type { ViewerSource } from "./types.ts";
/** Bounded plain text for search/indexing consumers; the server rechecks source access. */
export async function extractText(
source: ViewerSource,
options: { baseURI?: string; signal?: AbortSignal; fetch?: typeof fetch } = {},
): Promise<string> {
const url = convertUrl(source, "text", options.baseURI);
if (!url) throw new Error("Text extraction needs a URL");
const response = await (options.fetch ?? fetch)(url, { signal: options.signal });
if (!response.headers.get("content-type")?.startsWith("text/plain")) {
await response.body?.cancel();
throw new Error("Text extraction unavailable");
}
return new TextDecoder().decode(await readBounded(response, 2 * 1024 * 1024));
}
export function mountExtractedText(
parent: HTMLElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const button = doc.createElement("button");
button.type = "button";
button.className = "wrn-viewer-action";
setButtonLabel(button, "Extract text");
const details = doc.createElement("details");
const summary = doc.createElement("summary");
summary.textContent = "Extracted text";
const text = doc.createElement("pre");
details.append(summary, text);
let disposed = false;
void converterFormats({ baseURI: doc.baseURI })
.then((formats) => {
if (!disposed && source.src && formats[extensionOf(source.name)]) parent.append(button);
})
.catch(() => {});
button.onclick = async () => {
button.disabled = true;
try {
const result = await extractText(source, { baseURI: doc.baseURI, signal });
if (disposed) return;
text.textContent = result || "No text was found.";
details.open = true;
parent.append(details);
} catch {
if (!disposed) {
text.textContent = "Text extraction is unavailable.";
details.open = true;
parent.append(details);
}
} finally {
if (!disposed) button.disabled = false;
}
};
return () => {
disposed = true;
button.remove();
details.remove();
};
}
+93
View File
@@ -0,0 +1,93 @@
export type ViewerKind =
| "image"
| "vector"
| "pdf"
| "code"
| "text"
| "markup"
| "sheet"
| "doc"
| "audio"
| "video"
| "archive"
| "binary";
export interface ViewerSource {
src?: string;
blob?: Blob | File;
/** `@wrnexus/uploader` object key, resolved by `/api/viewer/source`. */
key?: string;
/** Store holding the key. Omit for the app's default store. */
store?: string;
name: string;
mime?: string;
size?: number;
}
/**
* Everything a renderer may touch. Built-in and app-defined renderers receive
* the same object, so `component` stays a plain label rather than a value the
* runtime switches on.
*/
export interface ViewerMountContext {
/** Scroll container the renderer owns. Already emptied. */
parent: HTMLElement;
/**
* The bounded, re-classified body. Absent only for `streaming` renderers,
* which are handed a URL instead of the bytes.
*/
file?: Blob;
/**
* Element `src` for this file: the original http(s) URL for a streaming
* renderer, otherwise an object URL serving the body under its sniffed type.
*/
sourceUrl: () => Promise<string>;
source: ViewerSource;
kind: ViewerKind;
/** Append a toolbar control. Removed with the rest of the chrome on dispose. */
button: (label: string, action: () => void) => HTMLButtonElement;
/** Object URL revoked when the viewer is disposed. */
objectUrl: (blob: Blob) => string;
/** Abandon this renderer and show the download card instead. */
degrade: (reason?: string) => void;
signal: AbortSignal;
}
export type ViewerMount = (
context: ViewerMountContext,
) => void | (() => void) | Promise<void | (() => void)>;
export interface Renderer {
kind: ViewerKind;
exts: string[];
tier: 1 | 2 | 3;
/** Display name, and the `.wrn` component name where one exists. */
component: string;
load?: (options?: ResolveOptions) => Promise<unknown>;
maxInlineBytes?: number;
/**
* Renders from a URL rather than from bytes. For an http(s) source the
* element is pointed at the original URL, so the browser streams it and the
* app's own CSP (`media-src`/`frame-src` falling back to `default-src`)
* applies to a same-origin request instead of to a `blob:` URL.
*/
streaming?: boolean;
/** Browser adapter. A renderer without one can never be selected for display. */
mount?: ViewerMount;
}
export interface ViewerPlan {
kind: ViewerKind;
renderer: Renderer;
fallback: Renderer[];
needsConvert: boolean;
sandboxed: boolean;
reason?: string;
}
export interface ResolveOptions {
baseURI?: string;
fetch?: typeof fetch;
signal?: AbortSignal;
registry?: import("./registry.ts").RendererRegistry;
}
+43
View File
@@ -0,0 +1,43 @@
import type { ViewerSource } from "../types.ts";
const SOURCE_ROUTE = "/api/viewer/source";
interface SourceDescriptor {
src: string;
name: string;
mime?: string;
}
/**
* Trades a storage key for something loadable. The signing, and the app's own
* access check, happen on the server: the browser only ever sees the resulting
* short-lived URL.
*
* Returns undefined when uploader sources are not enabled or the caller may not
* see this object, which leaves the ladder to show the download card.
*/
export async function resolveKey(
source: ViewerSource,
init: { signal?: AbortSignal; baseURI?: string } = {},
): Promise<ViewerSource | undefined> {
const base = init.baseURI ?? (typeof document === "undefined" ? undefined : document.baseURI);
if (!source.key || !base) return undefined;
const url = new URL(SOURCE_ROUTE, base);
url.searchParams.set("key", source.key);
if (source.store) url.searchParams.set("store", source.store);
try {
const response = await fetch(url.href, { signal: init.signal, credentials: "same-origin" });
if (!response.ok) return undefined;
const descriptor = (await response.json()) as SourceDescriptor;
if (!descriptor?.src) return undefined;
return {
...source,
src: descriptor.src,
// An explicit name on the component wins over the key's basename.
name: source.name || descriptor.name,
mime: source.mime || descriptor.mime,
};
} catch {
return undefined;
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { ViewerUploadsOptions } from "./types.ts";
const KEY = "__wrnexusViewerUploads";
/** Arms uploader-backed sources. See `configureConversion` for why this is a global. */
export function configureUploads(options: ViewerUploadsOptions | undefined): void {
(globalThis as Record<string, unknown>)[KEY] = options;
}
export function uploadsConfig(): ViewerUploadsOptions | undefined {
return (globalThis as Record<string, unknown>)[KEY] as ViewerUploadsOptions | undefined;
}
+3
View File
@@ -0,0 +1,3 @@
export { configureUploads, uploadsConfig } from "./config.ts";
export { handleFile, handleSource, uploadedConversionSource, FILE_ROUTE } from "./service.ts";
export type { ViewerUploadsOptions } from "./types.ts";
+190
View File
@@ -0,0 +1,190 @@
import {
contentTypeOf,
createSignedFileToken,
getStore,
hasStorage,
storedUrl,
verifySignedFileToken,
} from "@wrnexus/uploader";
import { uploadsConfig } from "./config.ts";
import type { ResolvedSource } from "../convert/types.ts";
const DEFAULT_TTL_MS = 5 * 60 * 1000;
export const FILE_ROUTE = "/api/viewer/file";
const problem = (status: number, message: string) =>
new Response(message, {
status,
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
});
/** Keys are opaque object paths; anything that could escape one is refused. */
function safeKey(key: string): boolean {
return (
key.length > 0 &&
key.length <= 1024 &&
!key.startsWith("/") &&
!key.includes("..") &&
!key.includes("\\") &&
// No control characters: keys reach a filesystem or an S3 request line.
![...key].some((character) => character.charCodeAt(0) < 0x20)
);
}
function allowed(store: string): boolean {
const config = uploadsConfig();
if (!config) return false;
if (!hasStorage(store)) return false;
return !config.stores || config.stores.includes(store);
}
/**
* `GET /api/viewer/source` — turns a storage key into what the browser needs:
* a URL it can load plus whatever metadata is free to produce.
*
* Public objects answer with their own URL. Private ones answer with a
* short-lived signed link to the file route, and only after the app's own
* `authorize` hook agrees.
*/
export async function handleSource(request: Request): Promise<Response> {
const config = uploadsConfig();
if (!config) return problem(404, "Uploader sources are not enabled");
const url = new URL(request.url);
const key = url.searchParams.get("key") ?? "";
const storeName = url.searchParams.get("store") ?? "";
if (!key || !safeKey(key)) return problem(400, "A valid key is required");
if (storeName && !allowed(storeName)) return problem(403, "That store is not viewable");
let store;
try {
store = getStore(storeName || undefined);
} catch {
return problem(404, "No such store");
}
if (!allowed(store.name)) return problem(403, "That store is not viewable");
const name = key.split("/").pop() || key;
// The MIME comes from the uploader's own extension table rather than a second
// copy of it here; the viewer still sniffs the bytes before rendering.
const mime = contentTypeOf(name, "");
if (store.access === "public") {
const src = storedUrl(store, key);
if (!src) return problem(404, "Not available");
return Response.json({ src, name, mime }, { headers: { "cache-control": "no-store" } });
}
if (!config.secret) return problem(403, "Private stores need a signing secret");
if (!config.authorize) return problem(403, "Private stores need an authorize hook");
if (!(await config.authorize(request, { store: store.name, key })))
return problem(403, "Not allowed");
const token = await createSignedFileToken(
{
store: store.name,
key,
expiresAt: Date.now() + (config.ttlMs ?? DEFAULT_TTL_MS),
disposition: "inline",
},
config.secret,
);
const src = `${FILE_ROUTE}?token=${encodeURIComponent(token)}`;
return Response.json({ src, name, mime }, { headers: { "cache-control": "no-store" } });
}
async function openToken(token: string) {
const config = uploadsConfig();
if (!config?.secret) return undefined;
const claim = await verifySignedFileToken(token, config.secret);
if (!claim || !allowed(claim.store) || !safeKey(claim.key)) return undefined;
const store = getStore(claim.store);
const object = await store.driver.get(claim.key);
return object ? { claim, object } : undefined;
}
function fileHeaders(contentType: string, size?: number): Headers {
const headers = new Headers({
"content-type": contentType || "application/octet-stream",
"content-disposition": "inline",
"x-content-type-options": "nosniff",
// A signed link is per-viewer and short-lived; it must not enter a shared cache.
"cache-control": "private, no-store",
"accept-ranges": "bytes",
});
if (size !== undefined) headers.set("content-length", String(size));
return headers;
}
/** `GET /api/viewer/file` — streams a private object for a valid signed link. */
export async function handleFile(request: Request): Promise<Response> {
const url = new URL(request.url);
const token = url.searchParams.get("token") ?? "";
if (!token) return problem(400, "A token is required");
const opened = await openToken(token);
// An expired, forged or revoked link is indistinguishable from a missing one.
if (!opened) return problem(404, "Not Found");
const { object } = opened;
if (request.method === "HEAD")
return new Response(null, { headers: fileHeaders(object.contentType, object.size) });
const range = request.headers.get("range");
const buffered = object.body instanceof Uint8Array ? object.body : undefined;
const match = buffered && /^bytes=(\d*)-(\d*)$/.exec(range ?? "");
if (buffered && match) {
// Seeking in audio and video needs ranges; only buffered bodies can serve
// them without reading the whole object back through memory.
const total = buffered.byteLength;
const start = match[1] ? Number(match[1]) : Math.max(0, total - Number(match[2] || 0));
const end = match[1] && match[2] ? Math.min(Number(match[2]), total - 1) : total - 1;
if (!Number.isFinite(start) || start >= total || end < start)
return new Response(null, {
status: 416,
headers: { "content-range": `bytes */${total}` },
});
const slice = buffered.subarray(start, end + 1);
const headers = fileHeaders(object.contentType, slice.byteLength);
headers.set("content-range", `bytes ${start}-${end}/${total}`);
return new Response(slice as unknown as BodyInit, { status: 206, headers });
}
return new Response(object.body as unknown as BodyInit, {
headers: fileHeaders(object.contentType, object.size),
});
}
/**
* Conversion source policy for uploader-backed apps: a converter may only open
* what a valid signed link already grants, so converted output inherits exactly
* the access check of its input.
*/
export function uploadedConversionSource() {
return async (
request: Request,
params: { src: string; name: string },
): Promise<ResolvedSource | undefined> => {
let token: string | null;
try {
const url = new URL(params.src, request.url);
if (url.origin !== new URL(request.url).origin || url.pathname !== FILE_ROUTE)
return undefined;
token = url.searchParams.get("token");
} catch {
return undefined;
}
if (!token) return undefined;
const opened = await openToken(token);
if (!opened) return undefined;
const { object } = opened;
const body =
object.body instanceof Uint8Array
? new Response(object.body as unknown as BodyInit).body!
: object.body;
return {
body,
size: object.size,
identity: JSON.stringify([opened.claim.store, opened.claim.key]),
};
};
}
+23
View File
@@ -0,0 +1,23 @@
export interface ViewerUploadsOptions {
/**
* Stores the viewer may serve from. Omit to allow every configured store.
* Naming them is the cheapest way to keep an unrelated bucket out of reach.
*/
stores?: string[];
/**
* Secret used to sign short-lived links to private objects. Without it,
* private stores are refused outright — nothing is served unsigned.
*/
secret?: string;
/** Lifetime of a signed link. Default 5 minutes. */
ttlMs?: number;
/**
* The app's own access check, called before a private link is minted. There is
* no safe default for "may this viewer see this object", so a private store
* without this hook is refused.
*/
authorize?: (
request: Request,
params: { store: string; key: string },
) => boolean | Promise<boolean>;
}