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 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/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/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/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/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/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; +} 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 { 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 ---------------------------------------------------------- */ 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) diff --git a/tests/rendering/pdf-document.pdf b/tests/rendering/pdf-document.pdf new file mode 100644 index 00000000..d298bfb6 Binary files /dev/null and b/tests/rendering/pdf-document.pdf differ 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"