feat(viewer): preview word documents (.docx/.doc/.odt/.rtf) as PDF
Nightly Build / build (push) Canceled after 10m54s

The file viewer converts word-processor documents to PDF server-side via
LibreOffice (skald_core::docx::DocxConverter), mirroring the LaTeX pipeline
but content-hash cached: the format is self-contained, so there is no
dependency graph and the file watcher needs no expansion. Container-only
documents are shuttled out and converted on the host. With no LibreOffice
installed the viewer says so and falls back to download-only. Downloads
still save the original document, not the preview PDF.
This commit is contained in:
Daniele
2026-09-08 16:30:13 +01:00
parent 4ea932ef54
commit 027d815b66
17 changed files with 657 additions and 17 deletions
+69 -3
View File
@@ -10,8 +10,9 @@ import './pdf-view.js'; // registers <pdf-view>; pdf.js itself is imported laz
/**
* Shared file-viewer engine. Holds all of the fetch / kind-detection /
* markdown-asset-rewriting / LaTeX-compile / live-watch logic plus `_renderBody`,
* driven purely by two methods: `_show(path)` and `_hide()`. It carries no
* markdown-asset-rewriting / LaTeX-compile / word-doc-convert / live-watch
* logic plus `_renderBody`, driven purely by two methods: `_show(path)` and
* `_hide()`. It carries no
* navigation or page chrome of its own — subclasses (desktop `<file-viewer-page>`
* and mobile `<mobile-file-viewer-page>`) wire visibility/path to those methods
* and provide their own `render()` header.
@@ -19,6 +20,10 @@ import './pdf-view.js'; // registers <pdf-view>; pdf.js itself is imported laz
const IMG_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif'];
const LATEX_EXTS = ['tex', 'latex'];
// Word-processor documents, converted to PDF server-side via LibreOffice
// (`?compile-docx=true`). Unlike LaTeX there is no readable source to fall
// back to: a failed or unavailable conversion leaves the binary state.
const WORD_EXTS = ['docx', 'doc', 'odt', 'rtf'];
const TEXT_EXTS = [
'txt', 'md', 'markdown', 'rs', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
'py', 'json', 'yml', 'yaml', 'toml', 'sh', 'bash', 'zsh', 'fish',
@@ -54,6 +59,7 @@ export function kindFor(path) {
// (srcdoc + sandbox="allow-scripts", no allow-same-origin) — see _renderBody.
if (ext === 'html' || ext === 'htm') return 'html';
if (LATEX_EXTS.includes(ext)) return 'latex';
if (WORD_EXTS.includes(ext)) return 'docx';
if (TEXT_EXTS.includes(ext)) return 'text';
return 'binary';
}
@@ -137,6 +143,7 @@ const VIEW_KINDS = {
svg: 'an SVG image',
html: 'a rendered HTML page',
latex: 'a compiled LaTeX document',
docx: 'an office document converted to PDF',
binary: 'a binary file, whose content is not displayed',
};
@@ -391,7 +398,10 @@ export class FileViewerBase extends LightElement {
/**
* Download the current file. LaTeX sources always download the compiled PDF
* (`compile-latex=true`); every kind is served with `force_download=true` so
* (`compile-latex=true`); word documents instead download the **original**
* file — unlike a `.tex` source, a `.docx` is itself the editable document
* people want to keep or send, while the PDF is only the preview mechanism.
* Every kind is served with `force_download=true` so
* the server sets `Content-Disposition: attachment` and the browser saves it
* (with the server-supplied name) instead of rendering inline.
*/
@@ -488,6 +498,8 @@ export class FileViewerBase extends LightElement {
if (oldUrl) URL.revokeObjectURL(oldUrl);
} else if (this._kind === 'latex') {
await this._loadLatex(path);
} else if (this._kind === 'docx') {
await this._loadDocx(path);
} else if (this._kind === 'text' || this._kind === 'html') {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -551,6 +563,39 @@ export class FileViewerBase extends LightElement {
this._content = await res.text();
}
/**
* Load a word-processor document (`.docx` / `.doc` / `.odt` / `.rtf`).
* Asks the server to convert it to PDF via LibreOffice; on any non-OK
* response (501 no LibreOffice, 504 timeout, 422 conversion error) there is
* no readable source to fall back to — the body is a zip — so the reason is
* kept in `_compileError` and `_renderBody` shows the binary state with the
* error block on top.
*/
async _loadDocx(path) {
const convertUrl = this._fileUrl(path, { 'compile-docx': 'true' });
try {
const res = await fetch(convertUrl);
if (res.ok) {
const blob = await res.blob();
// Swap URLs only after the new blob is ready so the preview never flickers.
const oldUrl = this._blobUrl;
this._blobUrl = URL.createObjectURL(blob);
if (oldUrl) URL.revokeObjectURL(oldUrl);
this._compileError = null;
return;
}
// The error body is a short plain-text reason (converter missing,
// timeout, or the captured soffice output) — shown verbatim, unlike the
// latex log which needs distilling.
let detail = '';
try { detail = (await res.text()).trim(); } catch { /* ignore */ }
this._compileError = detail || `HTTP ${res.status}`;
} catch (e) {
this._compileError = e.message || String(e);
}
this._revokeBlobUrl();
}
// ── History mode (git-versioned files) ─────────────────────────────────────
/**
@@ -901,6 +946,10 @@ export class FileViewerBase extends LightElement {
// a native .pdf is rendered (see the note above).
return html`<pdf-view class="fv-pdf" .src=${this._blobUrl}></pdf-view>`;
}
if (this._kind === 'docx' && this._blobUrl) {
// Successfully converted server-side (LibreOffice) — same render path.
return html`<pdf-view class="fv-pdf" .src=${this._blobUrl}></pdf-view>`;
}
if (this._kind === 'svg' && this._blobUrl) {
// `allow-same-origin` (and nothing else) is required so the iframe can load
// the blob: URL — those are only readable from their creating origin. With
@@ -910,6 +959,23 @@ export class FileViewerBase extends LightElement {
${keyed(this._blobUrl, html`<iframe class="fv-svg" sandbox="allow-same-origin" src=${this._blobUrl} title=${this._path}></iframe>`)}
</div>`;
}
if (this._kind === 'docx') {
// Conversion failed or LibreOffice is not installed — unlike LaTeX
// there is no readable source to show (the file is a zip), so the
// reason sits in a foldable block over the download-only state.
return html`
${this._compileError
? html`<details class="fv-compile-error">
<summary><i class="bi bi-exclamation-triangle text-warning"></i>&nbsp;${t('fv.docx_failed')}</summary>
<pre>${this._compileError}</pre>
</details>`
: nothing}
<div class="fv-state text-muted">
<i class="bi bi-file-earmark-word fs-3 d-block mb-2"></i>
${t('fv.binary_unavailable')}
</div>
`;
}
if (this._kind === 'binary') {
return html`<div class="fv-state text-muted">
<i class="bi bi-file-earmark-binary fs-3 d-block mb-2"></i>