feat(viewer): preview word documents (.docx/.doc/.odt/.rtf) as PDF
Nightly Build / build (push) Canceled after 10m54s
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:
@@ -464,6 +464,7 @@ function attachmentIcon(att) {
|
||||
const n = (att.name || '').toLowerCase();
|
||||
if (m.startsWith('image/')) return 'bi-file-earmark-image';
|
||||
if (m === 'application/pdf' || n.endsWith('.pdf')) return 'bi-file-earmark-pdf';
|
||||
if (/\.(docx?|odt|rtf)$/.test(n)) return 'bi-file-earmark-word';
|
||||
if (m.startsWith('audio/')) return 'bi-file-earmark-music';
|
||||
if (m.startsWith('video/')) return 'bi-file-earmark-play';
|
||||
if (m.startsWith('text/') || /\.(md|txt|csv|json|ya?ml|rs|js|ts|py)$/.test(n)) return 'bi-file-earmark-text';
|
||||
|
||||
@@ -329,7 +329,7 @@ export class FileExplorer extends LightElement {
|
||||
zip: 'bi-file-zip', gz: 'bi-file-zip', tar: 'bi-file-zip',
|
||||
mp3: 'bi-file-music', wav: 'bi-file-music', ogg: 'bi-file-music',
|
||||
mp4: 'bi-file-play', mov: 'bi-file-play', webm: 'bi-file-play',
|
||||
doc: 'bi-file-word', docx: 'bi-file-word',
|
||||
doc: 'bi-file-word', docx: 'bi-file-word', odt: 'bi-file-word', rtf: 'bi-file-word',
|
||||
xls: 'bi-file-excel', xlsx: 'bi-file-excel', csv: 'bi-file-excel',
|
||||
};
|
||||
return map[ext] ?? 'bi-file-earmark';
|
||||
|
||||
@@ -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> ${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>
|
||||
|
||||
@@ -1049,6 +1049,7 @@ export default {
|
||||
'fv.mode_source': 'Show source',
|
||||
'fv.binary_unavailable': 'Preview not available for this file type.',
|
||||
'fv.latex_failed': 'LaTeX compilation failed — showing source instead',
|
||||
'fv.docx_failed': 'Document conversion failed',
|
||||
'fv.tab_view': 'View',
|
||||
'fv.tab_edit': 'Edit',
|
||||
'fv.dirty_badge': 'Unsaved changes',
|
||||
|
||||
@@ -1036,6 +1036,7 @@ export default {
|
||||
'fv.mode_source': 'Afficher la source',
|
||||
'fv.binary_unavailable': 'Aperçu non disponible pour ce type de fichier.',
|
||||
'fv.latex_failed': 'Échec de la compilation LaTeX — affichage de la source à la place',
|
||||
'fv.docx_failed': 'Échec de la conversion du document',
|
||||
'fv.tab_view': 'Afficher',
|
||||
'fv.tab_edit': 'Modifier',
|
||||
'fv.dirty_badge': 'Modifications non enregistrées',
|
||||
|
||||
@@ -1036,6 +1036,7 @@ export default {
|
||||
'fv.mode_source': 'Mostra sorgente',
|
||||
'fv.binary_unavailable': 'Anteprima non disponibile per questo tipo di file.',
|
||||
'fv.latex_failed': 'Compilazione LaTeX fallita — mostra il sorgente',
|
||||
'fv.docx_failed': 'Conversione del documento non riuscita',
|
||||
'fv.tab_view': 'Visualizza',
|
||||
'fv.tab_edit': 'Modifica',
|
||||
'fv.dirty_badge': 'Modifiche non salvate',
|
||||
|
||||
Reference in New Issue
Block a user