From 836ee8116b77b2f20979ede0d47857c73ed91e21 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 06:37:39 +0900 Subject: [PATCH 01/20] docs(spec): add office & PDF file viewing design --- .../2026-06-25-office-pdf-viewing-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/specs/2026-06-25-office-pdf-viewing-design.md diff --git a/docs/specs/2026-06-25-office-pdf-viewing-design.md b/docs/specs/2026-06-25-office-pdf-viewing-design.md new file mode 100644 index 0000000..bf2f9d7 --- /dev/null +++ b/docs/specs/2026-06-25-office-pdf-viewing-design.md @@ -0,0 +1,159 @@ +# Office & PDF File Viewing — Design + +**Date:** 2026-06-25 +**Status:** Approved (brainstorming) — ready for implementation plan + +## Summary + +Extend reefdoc to browse and preview four binary document formats — +**PDF, DOCX, XLSX, and PPTX** — alongside the existing markdown / mermaid / +allium support. Rendering happens **client-side** in the browser, consistent +with reefdoc's "thin server, client renders everything" architecture. The Go +binary remains a file API; it gains only the ability to list these files in the +tree and serve their raw bytes with a correct Content-Type. + +## Goals + +- List `.pdf`, `.docx`, `.xlsx`, `.pptx` files in the file-tree navigator. +- Open such a file in a tab and render a **static preview** in the content pane. +- Keep the server thin: no server-side rendering or format conversion. +- Pay zero cost on the markdown-only path (renderer libraries load lazily). + +## Non-Goals (YAGNI) + +Explicitly out of scope for this work: + +- Table of contents for binary documents. +- Live-reload (re-render on disk change) for binary documents. +- Favorites (☆) for binary files. +- Scroll-position restore for binary documents. +- Server-side rendering, conversion, or thumbnailing. +- Download / "open externally" fallback UI. +- Offline / vendored renderer libraries (CDN is acceptable). +- Pixel-perfect PPTX fidelity. +- In-document link interception for binary content. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Rendering location | Client-side | Matches existing architecture; availability of mature client-side renderers (PDF.js, SheetJS, docx-preview, a PPTX lib). | +| PPTX scope | Included, lower fidelity accepted | "Browse to see the content," not pixel-perfect. | +| Byte delivery | One endpoint (`/api/file`), branch on extension | Minimal surface change; reuses existing path-safety logic. | +| Library delivery | CDN, lazy-loaded on first use | Consistent with current mermaid / highlight.js pattern; no cost to markdown path. | +| Feature integration | Minimal / static preview | Simplest, clearest scope. No TOC / live-reload / favorites / scroll-restore for binary files. | +| Error handling | Best-effort, no special error UI | reefdoc is a previewer, not a file manager. | +| Size cap | No cap for binary types | Browser / renderer handles whatever it gets. Existing 5MB cap stays on the text path only. | + +## Architecture + +### Server (Go) — thin, two small changes + +1. **Tree listing recognizes the new extensions.** `tree.go` currently gates + tree membership on `isMarkdown()`. Introduce a broader `isViewable()` that + includes the existing text extensions (`.md`, `.markdown`, `.allium`) plus + `.pdf`, `.docx`, `.xlsx`, `.pptx`. The `Node` JSON shape is unchanged + (name / path / isDir / modTime); the frontend decides how to render based on + extension. + +2. **`/api/file` serves binary correctly.** `handleFile` currently always sets + `Content-Type: text/plain; charset=utf-8`, which corrupts binary bytes. Set + the Content-Type by extension instead: + - `.md`, `.markdown`, `.allium` → `text/plain; charset=utf-8` (unchanged) + - `.pdf` → `application/pdf` + - `.docx` / `.xlsx` / `.pptx` → their official MIME types (or + `application/octet-stream`; the frontend reads `ArrayBuffer` regardless, so + the exact MIME mainly matters for PDF.js and browser sniffing) + + All existing path-safety logic (`SafeJoin`, symlink resolution) is untouched + and still applies. No new endpoint and no server-side size logic. + +### Frontend — renderer registry (`web/viewers.js`) + +A new module owns all binary rendering behind one interface. The registry maps +file extension → an async viewer function: + +``` +viewer(bytes /* ArrayBuffer */, container /* HTMLElement */) => Promise +``` + +Each viewer is responsible for lazy-loading its CDN library (cached after first +load), rendering into the container, and letting any failure surface naturally +(best-effort — no special error UI). + +**Four viewers**, each a small, focused, independently testable unit: + +- `pdf` → **PDF.js**, renders pages to canvas elements stacked in the container. +- `docx` → **docx-preview** (docx → styled HTML into the container). +- `xlsx` → **SheetJS**, parses the workbook and renders sheet(s) as HTML table(s). +- `pptx` → a **PPTX library** (lower fidelity accepted). + +**Public surface of the module:** + +- `getViewer(path)` → the viewer fn for that extension, or `null` if the path is + not a binary viewable type. +- `lazyLoad(url)` → injects a ` diff --git a/web/viewers.js b/web/viewers.js index 54d316d..eb499e7 100644 --- a/web/viewers.js +++ b/web/viewers.js @@ -45,7 +45,25 @@ export function isBinaryDoc(path) { // --- viewers (implemented in later tasks) --- async function viewPdf(bytes, container) { - throw new Error('pdf viewer not yet implemented'); + const pdfjs = await lazyImport('pdfjs-dist'); + pdfjs.GlobalWorkerOptions.workerSrc = + 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.mjs'; + // copy bytes — pdf.js may transfer/detach the buffer + const doc = await pdfjs.getDocument({ data: bytes.slice(0) }).promise; + container.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'pdf-doc'; + container.appendChild(wrap); + for (let n = 1; n <= doc.numPages; n++) { + const page = await doc.getPage(n); + const viewport = page.getViewport({ scale: 1.5 }); + const canvas = document.createElement('canvas'); + canvas.className = 'pdf-page'; + canvas.width = viewport.width; + canvas.height = viewport.height; + wrap.appendChild(canvas); + await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; + } } async function viewDocx(bytes, container) { throw new Error('docx viewer not yet implemented'); From c74a98c62fe5bcac08acc7fc4e493cb7bc68ab73 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:25:57 +0900 Subject: [PATCH 09/20] feat(viewers): render DOCX files with docx-preview --- web/index.html | 3 ++- web/viewers.js | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/index.html b/web/index.html index 35b0614..91c0968 100644 --- a/web/index.html +++ b/web/index.html @@ -14,7 +14,8 @@ "markdown-it-task-lists": "https://cdn.jsdelivr.net/npm/markdown-it-task-lists@2.1.1/+esm", "highlight.js": "https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/+esm", "mermaid": "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm", - "pdfjs-dist": "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/+esm" + "pdfjs-dist": "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/+esm", + "docx-preview": "https://cdn.jsdelivr.net/npm/docx-preview@0.3.3/+esm" } } diff --git a/web/viewers.js b/web/viewers.js index eb499e7..4c0ff67 100644 --- a/web/viewers.js +++ b/web/viewers.js @@ -66,7 +66,12 @@ async function viewPdf(bytes, container) { } } async function viewDocx(bytes, container) { - throw new Error('docx viewer not yet implemented'); + const docx = await lazyImport('docx-preview'); + container.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'docx-doc'; + container.appendChild(wrap); + await docx.renderAsync(bytes, wrap); } async function viewXlsx(bytes, container) { throw new Error('xlsx viewer not yet implemented'); From e73f1782d4c5295445e08cb12cc9fe26a36025fc Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:27:56 +0900 Subject: [PATCH 10/20] feat(viewers): render XLSX files with SheetJS --- web/index.html | 3 ++- web/viewers.js | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/web/index.html b/web/index.html index 91c0968..1396802 100644 --- a/web/index.html +++ b/web/index.html @@ -15,7 +15,8 @@ "highlight.js": "https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/+esm", "mermaid": "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm", "pdfjs-dist": "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/+esm", - "docx-preview": "https://cdn.jsdelivr.net/npm/docx-preview@0.3.3/+esm" + "docx-preview": "https://cdn.jsdelivr.net/npm/docx-preview@0.3.3/+esm", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs" } } diff --git a/web/viewers.js b/web/viewers.js index 4c0ff67..1951cf1 100644 --- a/web/viewers.js +++ b/web/viewers.js @@ -74,7 +74,21 @@ async function viewDocx(bytes, container) { await docx.renderAsync(bytes, wrap); } async function viewXlsx(bytes, container) { - throw new Error('xlsx viewer not yet implemented'); + const XLSX = await lazyImport('xlsx'); + const wb = XLSX.read(bytes, { type: 'array' }); + container.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'xlsx-doc'; + container.appendChild(wrap); + for (const name of wb.SheetNames) { + const heading = document.createElement('h2'); + heading.textContent = name; + wrap.appendChild(heading); + const table = document.createElement('div'); + table.className = 'xlsx-sheet'; + table.innerHTML = XLSX.utils.sheet_to_html(wb.Sheets[name]); + wrap.appendChild(table); + } } async function viewPptx(bytes, container) { throw new Error('pptx viewer not yet implemented'); From 25beca844029b04ca1a43671e6d5529189d1cf24 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:29:36 +0900 Subject: [PATCH 11/20] fix(viewers): use SheetJS type:buffer for ArrayBuffer input --- web/viewers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/viewers.js b/web/viewers.js index 1951cf1..3701ff3 100644 --- a/web/viewers.js +++ b/web/viewers.js @@ -75,7 +75,7 @@ async function viewDocx(bytes, container) { } async function viewXlsx(bytes, container) { const XLSX = await lazyImport('xlsx'); - const wb = XLSX.read(bytes, { type: 'array' }); + const wb = XLSX.read(bytes, { type: 'buffer' }); container.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'xlsx-doc'; From 52f20aeb9676f691f112c787a6651c1ffe3d891b Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:31:02 +0900 Subject: [PATCH 12/20] feat(viewers): render PPTX files (lower fidelity) --- web/index.html | 3 ++- web/viewers.js | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/web/index.html b/web/index.html index 1396802..2d0d422 100644 --- a/web/index.html +++ b/web/index.html @@ -16,7 +16,8 @@ "mermaid": "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm", "pdfjs-dist": "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/+esm", "docx-preview": "https://cdn.jsdelivr.net/npm/docx-preview@0.3.3/+esm", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs" + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs", + "pptx-preview": "https://cdn.jsdelivr.net/npm/pptx-preview@1.0.1/+esm" } } diff --git a/web/viewers.js b/web/viewers.js index 3701ff3..e3e3ac9 100644 --- a/web/viewers.js +++ b/web/viewers.js @@ -91,5 +91,12 @@ async function viewXlsx(bytes, container) { } } async function viewPptx(bytes, container) { - throw new Error('pptx viewer not yet implemented'); + const { init } = await lazyImport('pptx-preview'); + container.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'pptx-doc'; + container.appendChild(wrap); + const width = container.clientWidth || 960; + const previewer = init(wrap, { width, height: Math.round(width * 0.5625) }); + await previewer.preview(bytes); } From b4a04cf145e8739c7c6be8a7b89e7717a1ea0ae7 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:33:38 +0900 Subject: [PATCH 13/20] feat(app): route binary documents through the viewer registry --- web/app.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/web/app.js b/web/app.js index 2a8a45d..fb6fbb4 100644 --- a/web/app.js +++ b/web/app.js @@ -5,6 +5,7 @@ import { buildToc, slugify } from './toc.js'; import { createFavorites, isFavorite, toggleFavorite, listFavorites } from './favorites.js'; import { isRecent } from './recency.js'; import { renderAllium } from './allium.js'; +import { getViewer } from './viewers.js'; const render = createRenderer(); const store = createTabStore(); @@ -378,6 +379,19 @@ async function show(path) { return; } tab.missing = false; + + const viewer = getViewer(path); + if (viewer) { + // Binary document: read raw bytes (no size cap) and render a static + // preview. No TOC, no scroll-restore. Best-effort — let failures surface. + const bytes = await res.arrayBuffer(); + if (seq !== showSeq) return; + tocEl.innerHTML = ''; + contentEl.innerHTML = ''; + await viewer(bytes, contentEl); + return; + } + const text = await res.text(); if (seq !== showSeq) return; if (text.length > MAX_BYTES) { From 6ae6a5eba23f5010fda10dc25c50be7dc9059d36 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:35:32 +0900 Subject: [PATCH 14/20] feat(tree): omit favorite star on binary document rows --- web/app.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/app.js b/web/app.js index fb6fbb4..8ce2cda 100644 --- a/web/app.js +++ b/web/app.js @@ -5,7 +5,7 @@ import { buildToc, slugify } from './toc.js'; import { createFavorites, isFavorite, toggleFavorite, listFavorites } from './favorites.js'; import { isRecent } from './recency.js'; import { renderAllium } from './allium.js'; -import { getViewer } from './viewers.js'; +import { getViewer, isBinaryDoc } from './viewers.js'; const render = createRenderer(); const store = createTabStore(); @@ -260,7 +260,8 @@ function renderNode(node) { } else { item.dataset.path = node.path; if (isRecent(node.modTime)) item.appendChild(makeRecentDot()); - item.appendChild(makeStar(node.path, false)); + // Binary documents are static previews and cannot be favorited. + if (!isBinaryDoc(node.path)) item.appendChild(makeStar(node.path, false)); item.addEventListener('click', () => open(node.path, node.name)); } return wrap; From b1a1c907980824fb38035fcb3f18cfe4341c8007 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:37:03 +0900 Subject: [PATCH 15/20] feat(app): skip live-reload for static binary previews --- web/app.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/app.js b/web/app.js index 8ce2cda..4c1e250 100644 --- a/web/app.js +++ b/web/app.js @@ -510,6 +510,8 @@ function connectSSE() { reloadLevel(msg.path); } else if (msg.type === 'change') { markRecentInTree(msg.path); + // Binary documents are static previews — do not live-reload them. + if (isBinaryDoc(msg.path)) return; const tab = getTab(store, msg.path); if (!tab) return; if (msg.path === store.active) show(msg.path); From edaf4fec127a0eea73f09aa0bd590ae090592cb3 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:43:47 +0900 Subject: [PATCH 16/20] build: embed web/viewers.js in the binary --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 8b3e460..097ddd5 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,7 @@ import ( "github.com/exilis/reefdoc/internal/server" ) -//go:embed web/index.html web/app.css web/app.js web/allium.js web/render.js web/tabs.js web/toc.js web/favorites.js web/recency.js +//go:embed web/index.html web/app.css web/app.js web/allium.js web/render.js web/tabs.js web/toc.js web/favorites.js web/recency.js web/viewers.js var webFS embed.FS // version is overridden at release time via -ldflags "-X main.version=". From e2b73f1468e8cae452c66f2bb37f0b525c266f91 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:44:39 +0900 Subject: [PATCH 17/20] style: layout for binary document previews --- web/app.css | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/web/app.css b/web/app.css index be1b81f..a01f2df 100644 --- a/web/app.css +++ b/web/app.css @@ -96,3 +96,23 @@ body[data-theme="dark"] .allium-block--contract { border-left-color:#a97ee8; } .allium-kw { font-family:ui-monospace,monospace; font-size:11px; padding:1px 6px; border-radius:10px; background:var(--border); color:var(--muted); text-transform:uppercase; letter-spacing:0.04em; } .allium-name { font-family:ui-monospace,monospace; font-size:14px; font-weight:600; color:var(--fg); } .allium-body { margin:0; padding:12px; border-radius:0; font-size:13px; } + +/* Binary document previews */ +.pdf-doc, .docx-doc, .xlsx-doc, .pptx-doc { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} +.pdf-page { + max-width: 100%; + height: auto; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); +} +.xlsx-doc { align-items: stretch; } +.xlsx-sheet { overflow-x: auto; } +.xlsx-sheet table { border-collapse: collapse; } +.xlsx-sheet td, .xlsx-sheet th { + border: 1px solid var(--border); + padding: 2px 6px; +} From d652eebe7c34d733fe15564e5ffb493bcf17a591 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:46:09 +0900 Subject: [PATCH 18/20] docs: announce PDF and Office document previews --- CHANGELOG.md | 9 +++++++++ README.md | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4f312..471e127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to reefdoc are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - 2026-06-25 + +### Added +- Browse and preview binary document formats in the browser: **PDF** + (PDF.js), **DOCX** (docx-preview), **XLSX** (SheetJS), and **PPTX** + (lower fidelity). These files now appear in the file tree and open in a + tab as a static preview. Rendering is fully client-side; renderer + libraries lazy-load from CDN on first use. + ## [0.8.1] - 2026-06-09 ### Added diff --git a/README.md b/README.md index 89d6538..2b5097e 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ files change on disk. You edit markdown in your own editor; `reefdoc` is the preview. -> **Latest release: [v0.8.1](https://github.com/exilis/reefdoc/releases/tag/v0.8.1).** -> Recent highlights: **clicking a markdown link or an Allium `use` path opens the -> referenced file in a new tab**; the `.claude` directory is now visible in the tree. +> **Latest release: [v0.9.0](https://github.com/exilis/reefdoc/releases/tag/v0.9.0).** +> Recent highlights: **PDF, DOCX, XLSX, and PPTX files now preview in the +> browser**, rendered fully client-side. > Full history in the [changelog](CHANGELOG.md). ```bash @@ -39,6 +39,7 @@ go build -o reefdoc . && ./reefdoc ./docs - Tabs for multiple open documents - GitHub-flavored markdown, code syntax highlighting, and mermaid diagrams - [Allium](https://allium.dev) spec files (`.allium`) rendered as formatted cards +- Preview PDF, DOCX, XLSX, and PPTX files in the browser (rendered client-side) - Auto table of contents from document headings - Dark / light theme (mermaid follows the theme) - Live reload: edit a file in any editor and the open tab updates From 338beddc98cce664860d8b9e965d48941ae393e1 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 07:51:27 +0900 Subject: [PATCH 19/20] fix(viewers): load pptx-preview from esm.sh (jsdelivr +esm breaks lodash import) --- web/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/index.html b/web/index.html index 2d0d422..2c7cad5 100644 --- a/web/index.html +++ b/web/index.html @@ -17,7 +17,7 @@ "pdfjs-dist": "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/+esm", "docx-preview": "https://cdn.jsdelivr.net/npm/docx-preview@0.3.3/+esm", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs", - "pptx-preview": "https://cdn.jsdelivr.net/npm/pptx-preview@1.0.1/+esm" + "pptx-preview": "https://esm.sh/pptx-preview@1.0.7" } } From 45704127e88cadbfcf2435911dc251e6f6eaea1c Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Thu, 25 Jun 2026 08:50:27 +0900 Subject: [PATCH 20/20] fix(viewers): scale PPTX slides to fit the content pane (both dimensions) --- web/app.css | 3 +++ web/viewers.js | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/web/app.css b/web/app.css index a01f2df..1936006 100644 --- a/web/app.css +++ b/web/app.css @@ -109,6 +109,9 @@ body[data-theme="dark"] .allium-block--contract { border-left-color:#a97ee8; } height: auto; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); } +/* PPTX: render at a base size, scale to fit the pane (both dimensions). */ +.pptx-fit { position: relative; overflow: hidden; } +.pptx-stage { transform-origin: top left; } .xlsx-doc { align-items: stretch; } .xlsx-sheet { overflow-x: auto; } .xlsx-sheet table { border-collapse: collapse; } diff --git a/web/viewers.js b/web/viewers.js index e3e3ac9..71ce926 100644 --- a/web/viewers.js +++ b/web/viewers.js @@ -96,7 +96,49 @@ async function viewPptx(bytes, container) { const wrap = document.createElement('div'); wrap.className = 'pptx-doc'; container.appendChild(wrap); - const width = container.clientWidth || 960; - const previewer = init(wrap, { width, height: Math.round(width * 0.5625) }); + + // pptx-preview renders at a fixed pixel size, so render at a crisp base + // width and then scale the result down with a CSS transform so the whole + // slide fits inside the content pane (both width and height). `stage` holds + // the rendered slide at base size; `fit` reserves the scaled box in layout + // and re-fits on resize (a transform alone does not shrink the layout box). + const BASE_W = 1280; + const BASE_H = Math.round(BASE_W * 0.5625); // 16:9 + const fit = document.createElement('div'); + fit.className = 'pptx-fit'; + const stage = document.createElement('div'); + stage.className = 'pptx-stage'; + stage.style.width = BASE_W + 'px'; + stage.style.height = BASE_H + 'px'; + fit.appendChild(stage); + wrap.appendChild(fit); + + const previewer = init(stage, { width: BASE_W, height: BASE_H }); await previewer.preview(bytes); + + const refit = () => { + // Available area: content pane inner box minus its padding. + const cs = getComputedStyle(container); + const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight); + const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom); + const availW = Math.max(1, container.clientWidth - padX); + const availH = Math.max(1, container.clientHeight - padY); + const scale = Math.min(availW / BASE_W, availH / BASE_H, 1); + stage.style.transform = `scale(${scale})`; + // Reserve the scaled footprint so the flex column sizes correctly. + fit.style.width = BASE_W * scale + 'px'; + fit.style.height = BASE_H * scale + 'px'; + }; + refit(); + // Re-fit on any change to the content pane's size (window resize, sidebar + // drag, etc). The observer disconnects itself once this slide is removed + // from the pane (e.g. when another document is opened and clears it). + const ro = new ResizeObserver(() => { + if (!container.contains(wrap)) { + ro.disconnect(); + return; + } + refit(); + }); + ro.observe(container); }