From d79f02505e46aec3ad5515c672329122b4a31d3b Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Tue, 22 Sep 2026 13:59:57 +0200 Subject: [PATCH 1/7] feat(#308): view pdf files --- src/parser/parser.ts | 16 ++++++++++++++++ static/style.css | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/parser/parser.ts b/src/parser/parser.ts index dc4365e7..4076bd1f 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -58,6 +58,22 @@ export function renderBody( ); } + if (mime === 'application/pdf') { + const basename = pbasename(path); + // we use an object tag because that correctly re-calls the route + // without accepting HTML; an iframe on the other hand would call + // the same route with HTML accepted in the header again so we + // would embed the page itself again with infinite recursion. + // the object is appended as a direct child of `.content-pdf` (rather + // than fed through renderMarkdown) so it isn't wrapped in a

and + // can participate in the flex column that sizes it to the viewport. + return wrap( + 'pdf', + `${renderMarkdown(pathHeading(path))}`, + pdirname(path), + ); + } + // render text files if (mime.startsWith('text/') || mime === 'application/json') { const text = content ?? readFileSync(path).toString(); diff --git a/static/style.css b/static/style.css index 5d98ad9b..f09ba25d 100644 --- a/static/style.css +++ b/static/style.css @@ -12,6 +12,10 @@ html { body { padding: 2rem 4rem; } +#body-content { + width: 100%; + height: 100%; +} /* default to 900px for all content types */ *[class^="content-"] { @@ -19,6 +23,7 @@ body { margin: auto; } .content-txt { max-width: 1200px; } +.content-pdf { max-width: 1200px; } /* buttons at the top of the page */ #top-nav { @@ -43,6 +48,25 @@ body { } } +/* pdf viewer */ +/* pin the pdf page to the viewport height so the viewer can fill the + * remaining vertical space instead of the whole page scrolling */ +body:has(.content-pdf) { + height: 100dvh; + overflow: hidden; +} +.content-pdf { + height: 100%; + display: flex; + flex-direction: column; +} +.pdf-viewer { + width: 100%; + /* grow to fill; min-height:0 lets it shrink below intrinsic size */ + flex: 1 1 auto; + min-height: 0; +} + /* -------------------------------------------------------------------------- * DIRECTORY LISTS ---------------------------------------------------------- */ From ee876d4a0fb69c779aa121f321049a9d13f5e934 Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Tue, 29 Sep 2026 11:19:10 +0200 Subject: [PATCH 2/7] tests(#308): add pdf document --- tests/rendering/pdf-document.pdf | Bin 0 -> 7221 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/rendering/pdf-document.pdf diff --git a/tests/rendering/pdf-document.pdf b/tests/rendering/pdf-document.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d298bfb6b02d7dd740d4311dada9ed3f8a6a611b GIT binary patch literal 7221 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

vL>L?X?oE2847x8 zC_Z_z5?G;!!QxiE4}-?8(nG5fR_ozbV}-#(O!BiWC@@c5CBq_C;f%uJSIvPUKt#0S zeX7`1xS-MaRrsNCs;kCg!L70KeON4%oc=rq7D-sOMp!&%wH|)8Z&(C&l`S~*^07S*Z C7o~y# literal 0 HcmV?d00001 From b0ff78d00b4ffcad022cbaa3c6ba0b7cd15b9478 Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Tue, 29 Sep 2026 13:03:33 +0200 Subject: [PATCH 3/7] tests(#308): add pdf image --- tests/rendering/markdown-additional.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/rendering/markdown-additional.md b/tests/rendering/markdown-additional.md index 55a15237..c1276cd1 100644 --- a/tests/rendering/markdown-additional.md +++ b/tests/rendering/markdown-additional.md @@ -174,3 +174,10 @@ implementation: >>> 1. One >>> 2. > [!Two] >>> > Yeah + +## PDF images + +PDF documents can also be referenced as images and their first page will be +displayed as a vector graphics, similar to how it is done in LaTeX. + +![](pdf-document.pdf) From 1c0cc785e745f00728a294ebce9990094c4705a4 Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Tue, 29 Sep 2026 13:05:09 +0200 Subject: [PATCH 4/7] docs(#308): document features --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a2fb30ea..64bb2806 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ features! [Jupyviv](https://github.com/jannis-baum/Jupyviv) if this interests you) - view other plain text files with code syntax highlighting, or [optionally](docs/customization.md) render HTML files -- view image files +- view image files and PDFs - view & navigate directories using "back to parent directory" button at the top-left of the file viewer - automatic reloads when files change @@ -54,6 +54,8 @@ issue](https://github.com/jannis-baum/vivify/issues/new/choose) or Markdown](https://github.com/arve0/markdown-it-attrs?tab=readme-ov-file#examples) - table of contents with `[[toc]]` - `` tags, e.g. to style keyboard shortcuts +- PDFs can be referenced as images, resulting in their first page being + displayed as a vector graphic like in LaTeX You can find examples for all supported features in the files in the [`tests/rendering`](tests/rendering) directory. In case you are looking at these From 3c8f9178a4d15f4240bcf71288b914ca6e90cc9b Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Tue, 29 Sep 2026 15:03:24 +0200 Subject: [PATCH 5/7] chore(#308): install mupdf --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index 9b13cc17..ce0e5db1 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "markdown-it-task-lists": "^2.1.1", "markdown-it-texmath": "^1.0.0", "mermaid": "^12.0.0", + "mupdf": "^1.28.1", "node-stream-zip": "^1.16.0", "open": "^11.0.4", "tmp": "^0.2.7", diff --git a/yarn.lock b/yarn.lock index 0843c182..5503192e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2834,6 +2834,11 @@ ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +mupdf@^1.28.1: + version "1.28.1" + resolved "https://registry.yarnpkg.com/mupdf/-/mupdf-1.28.1.tgz#c66d2714bafa1f5c14e076504ed43d5e07bbb3e7" + integrity sha512-Gi11Ow2G1SlrXKJNZBL1eAIGFVih5+4ZKqjptamTVaj/5hnlrcVrVbyb7lHE2lfFKdxZPyv9ZtfOOq7XgjzEig== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" From 85820c5b36e8f2084f83335d82b28cdeaefa9821 Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Tue, 29 Sep 2026 15:04:33 +0200 Subject: [PATCH 6/7] feat(#308): integrate mupdf wasm & render first pdf page to svg --- sea-config.json | 3 ++- src/parser/pdf-svg.ts | 55 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/parser/pdf-svg.ts diff --git a/sea-config.json b/sea-config.json index 9e3c7e68..8e234507 100644 --- a/sea-config.json +++ b/sea-config.json @@ -2,6 +2,7 @@ "main": "build/bundle.js", "output": "build/sea-prep.blob", "assets": { - "static.zip": "build/static.zip" + "static.zip": "build/static.zip", + "mupdf-wasm.wasm": "node_modules/mupdf/dist/mupdf-wasm.wasm" } } diff --git a/src/parser/pdf-svg.ts b/src/parser/pdf-svg.ts new file mode 100644 index 00000000..fe098e45 --- /dev/null +++ b/src/parser/pdf-svg.ts @@ -0,0 +1,55 @@ +import { readFileSync, statSync } from 'fs'; +import { getAsset, isSea } from 'node:sea'; + +// The packaged single-executable build has no wasm file on disk, so we embed it +// as a SEA asset (see sea-config.json) and hand the bytes to mupdf's Emscripten +// module via this global, which it reads when first loaded. In dev mupdf finds +// the file in node_modules itself, so we only step in for the SEA build. +if (isSea()) { + (globalThis as { $libmupdf_wasm_Module?: unknown }).$libmupdf_wasm_Module = { + wasmBinary: new Uint8Array(getAsset('mupdf-wasm.wasm')), + // even with the binary provided, mupdf still resolves a wasm path via + // `new URL(..., import.meta.url)`, which is invalid in the bundle; this + // hook short-circuits that so it just uses the bytes above. + locateFile: (name: string) => name, + }; +} +// mupdf initializes its wasm with a top-level await, which would make the whole +// startup import graph async and stop the SEA build from ever reaching +// server.listen. So we load it lazily on the first render instead. +let mupdfModule: typeof import('mupdf') | undefined; +async function loadMupdf() { + if (!mupdfModule) mupdfModule = await import(/* webpackMode: "eager" */ 'mupdf'); + return mupdfModule; +} + +const cache = new Map(); + +export async function renderPdfPageToSvg(path: string): Promise { + const key = `${path}:${statSync(path).mtimeMs}`; + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const mupdf = await loadMupdf(); + const doc = mupdf.Document.openDocument(readFileSync(path), 'application/pdf'); + const page = doc.loadPage(0); + const out = new mupdf.Buffer(); + const writer = new mupdf.DocumentWriter(out, 'svg', ''); + + const device = writer.beginPage(page.getBounds()); + page.run(device, mupdf.Matrix.identity); + device.close(); + writer.endPage(); + writer.close(); + + const svg = out.asString(); + + device.destroy(); + writer.destroy(); + page.destroy(); + doc.destroy(); + out.destroy(); + + cache.set(key, svg); + return svg; +} From 0ee8e076f294dccd484d8ee8f4225437994981e3 Mon Sep 17 00:00:00 2001 From: Jannis Baum Date: Tue, 29 Sep 2026 15:04:55 +0200 Subject: [PATCH 7/7] feat(#308): parse pdf images --- src/parser/markdown.ts | 2 ++ src/parser/pdf-image.ts | 14 ++++++++++++++ src/routes/viewer.ts | 7 +++++++ static/markdown.css | 1 + 4 files changed, 24 insertions(+) create mode 100644 src/parser/pdf-image.ts diff --git a/src/parser/markdown.ts b/src/parser/markdown.ts index 45381bfa..83cbc3ea 100644 --- a/src/parser/markdown.ts +++ b/src/parser/markdown.ts @@ -6,6 +6,7 @@ import parseFrontMatter from './front-matter.js'; import highlight from './highlight.js'; import graphviz from './dot.js'; import mermaid from './mermaid.js'; +import pdfImage from './pdf-image.js'; import wikiLinks from './wiki-links.js'; import alerts from './alerts.js'; import config from '../config.js'; @@ -75,6 +76,7 @@ mdit.use(copycode); mdit.use(graphviz); mdit.use(alerts); mdit.use(mermaid); +mdit.use(pdfImage); mdit.use(wikiLinks); const renderMarkdown: Renderer = (content: string) => { diff --git a/src/parser/pdf-image.ts b/src/parser/pdf-image.ts new file mode 100644 index 00000000..631e5eb6 --- /dev/null +++ b/src/parser/pdf-image.ts @@ -0,0 +1,14 @@ +import type { MarkdownIt } from 'markdown-it'; + +export default function pdfImage(md: MarkdownIt) { + const defaultRender = md.renderer.rules.image!; + md.renderer.rules.image = (tokens, idx, options, env, self) => { + const token = tokens[idx]; + const src = token.attrGet('src'); + if (typeof src === 'string' && /\.pdf$/i.test(src.replace(/[?#].*$/, ''))) { + token.attrSet('src', `${src}?viv=svg`); + token.attrJoin('class', 'pdf-image'); + } + return defaultRender(tokens, idx, options, env, self); + }; +} diff --git a/src/routes/viewer.ts b/src/routes/viewer.ts index 94889a53..a5065c47 100644 --- a/src/routes/viewer.ts +++ b/src/routes/viewer.ts @@ -8,6 +8,7 @@ import { clientsAt, messageClients } from '../app.js'; import config from '../config.js'; import { pcomponents, pmime, preferredPath, urlToPath } from '../utils/path.js'; import { renderDirectory, renderErrorPage, renderBody, canRenderBody } from '../parser/parser.js'; +import { renderPdfPageToSvg } from '../parser/pdf-svg.js'; export const router = Router(); @@ -98,6 +99,12 @@ router.get(/.*/, async (req: Request, res: Response) => { })(); if (mime === undefined) throw 'Unable to determine MIME type'; + if (mime === 'application/pdf' && req.query.viv === 'svg') { + res.setHeader('Content-Type', 'image/svg+xml').send( + await renderPdfPageToSvg(path), + ); + return; + } if (shouldRenderBody) body = renderBody(path, mime); // shouldn't render body or failed to render -> send file data instead if (!body || !shouldRenderBody) { diff --git a/static/markdown.css b/static/markdown.css index bc6900d3..22b715a6 100644 --- a/static/markdown.css +++ b/static/markdown.css @@ -222,6 +222,7 @@ h1, h2 { /* images */ img, svg { max-width: 100%; } +img.pdf-image { background: white; } /* keyboard */ kbd {