Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]`
- `<kbd>` 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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion sea-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions src/parser/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down
16 changes: 16 additions & 0 deletions src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p> and
// can participate in the flex column that sizes it to the viewport.
return wrap(
'pdf',
`${renderMarkdown(pathHeading(path))}<object class="pdf-viewer" data="${basename}"></object>`,
pdirname(path),
);
}

// render text files
if (mime.startsWith('text/') || mime === 'application/json') {
const text = content ?? readFileSync(path).toString();
Expand Down
14 changes: 14 additions & 0 deletions src/parser/pdf-image.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
55 changes: 55 additions & 0 deletions src/parser/pdf-svg.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

export async function renderPdfPageToSvg(path: string): Promise<string> {
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;
}
7 changes: 7 additions & 0 deletions src/routes/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions static/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ h1, h2 {

/* images */
img, svg { max-width: 100%; }
img.pdf-image { background: white; }

/* keyboard */
kbd {
Expand Down
24 changes: 24 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ html {
body {
padding: 2rem 4rem;
}
#body-content {
width: 100%;
height: 100%;
}

/* default to 900px for all content types */
*[class^="content-"] {
max-width: 900px;
margin: auto;
}
.content-txt { max-width: 1200px; }
.content-pdf { max-width: 1200px; }

/* buttons at the top of the page */
#top-nav {
Expand All @@ -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 ---------------------------------------------------------- */

Expand Down
7 changes: 7 additions & 0 deletions tests/rendering/markdown-additional.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Binary file added tests/rendering/pdf-document.pdf
Binary file not shown.
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading