Export to PDF via the browser's print pipeline
Sharing a link covers most readers, but a document sometimes has to become a
file. An Export PDF button (and Ctrl/Cmd+P) prints the rendered pane through
the browser's own "Save as PDF" destination, so math stays selectable text and
diagrams stay vector SVG, with no rasterizing library and no compile server.
print.css turns the two-pane layout into a single flowing document: the toolbar
and editor are hidden, the fixed heights that make the screen version scroll
are lifted (otherwise the print is clipped to one page), and page breaks are
kept out of equations, diagrams and table rows. A table continued on the next
page repeats its header row, while long code blocks and long tables are allowed
to break, since forbidding it mostly leaves blank paper.
The document's first heading becomes the tab title and the filename the browser
suggests for the PDF.
4 changed files+198−1
README.mdmodified+8−0View file
@@ -13,6 +13,14 @@ A markdown editor whose document lives entirely in the URL. Type markdown on the
1313
1414 Raw HTML in the document is deliberately rendered as text rather than passed through. Since any document can arrive via a shared link, HTML passthrough would let a link author run script in the viewer's browser.
1515
16+## Export to PDF
17+
18+Sharing a link is enough for most readers, but a document sometimes has to become a file: an attachment, a printed handout, something to archive. The **Export PDF** button (or the browser's own Ctrl/Cmd+P) prints the rendered pane through the browser's "Save as PDF" destination, so no rasterizing library and no compile server are involved. Math stays selectable text, diagrams stay vector SVG, and the file is produced entirely on the reader's machine.
19+
20+The print stylesheet in `src/print.css` turns the two-pane layout into a single flowing document: the toolbar and the editor are hidden, the fixed heights that make the screen version scroll are lifted, and page breaks are kept out of the middle of equations, diagrams and table rows. A table continued on the next page repeats its header row. Long code blocks and long tables are allowed to break across pages, since forbidding it mostly leaves blank paper. The document's first heading becomes the filename the browser suggests.
21+
22+Note that page size, margins beyond the stylesheet's defaults, and whether the page carries the date-and-URL header are controlled by the browser's print dialog rather than by this page. We emphasize that the result is a print of the same HTML shown on screen, so it is not typeset the way LaTeX would typeset it: there is no float placement, no hyphenation and justification pass, and no numbered sections or cross-references.
23+
1624 ## How the URL encoding works
1725
1826 The document is stored in the hash fragment as
src/App.tsxmodified+29−1View file
@@ -1,9 +1,11 @@
1-import { useCallback, useEffect, useRef, useState } from 'react'
1+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22 import CodeMirrorEditor from './CodeMirrorEditor'
33 import Preview from './Preview'
44 import { encodeDocument, decodeDocument } from './url'
5+import { documentTitle } from './title'
56 import sampleDoc from './sample.md?raw'
67 import './App.css'
8+import './print.css'
79
810 function readHash(): string | null {
911 const h = window.location.hash.replace(/^#/, '')
@@ -62,6 +64,29 @@ export default function App() {
6264 setTimeout(() => setCopied(false), 1500)
6365 }, [text])
6466
67+ // The first heading names the document, both in the tab and (below) in the
68+ // filename the browser suggests for the PDF.
69+ const docTitle = useMemo(() => documentTitle(text), [text])
70+ useEffect(() => {
71+ document.title =
72+ docTitle && docTitle !== 'mdshare' ? `${docTitle} \u00b7 mdshare` : 'mdshare'
73+ }, [docTitle])
74+
75+ // Export is the browser's own print-to-PDF over the rendered pane (see
76+ // print.css), so math stays selectable text and diagrams stay vector. The
77+ // tab title is what browsers offer as the PDF filename, so drop the
78+ // ' \u00b7 mdshare' suffix for the duration of the print.
79+ const exportPdf = useCallback(() => {
80+ const restore = document.title
81+ document.title = docTitle || 'document'
82+ const onAfterPrint = () => {
83+ document.title = restore
84+ window.removeEventListener('afterprint', onAfterPrint)
85+ }
86+ window.addEventListener('afterprint', onAfterPrint)
87+ window.print()
88+ }, [docTitle])
89+
6590 const newDocument = useCallback(() => {
6691 if (text.trim() && text !== sampleDoc) {
6792 if (!window.confirm('Discard the current document? (The old link keeps working.)')) return
@@ -99,6 +124,9 @@ export default function App() {
99124 URL: {urlLength.toLocaleString()} chars
100125 </span>
101126 <button onClick={newDocument}>New</button>
127+ <button onClick={exportPdf} title="Print or save as PDF (Ctrl/Cmd+P)">
128+ Export PDF
129+ </button>
102130 <button className="primary" onClick={copyLink}>
103131 {copied ? 'Copied!' : 'Copy link'}
104132 </button>
src/print.cssadded+122−0View file
@@ -0,0 +1,122 @@
1+/* Print / export-to-PDF styles.
2+ *
3+ * Exporting a PDF means printing the rendered document with the browser's own
4+ * "Save as PDF" destination, which keeps everything vector: KaTeX math stays
5+ * selectable text, mermaid diagrams stay SVG, and no rasterizing library is
6+ * needed. The rules below turn the two-pane app into a single flowing
7+ * document: the chrome and the editor are hidden, the height and overflow
8+ * constraints that make the screen layout scroll are lifted (otherwise the
9+ * print would be clipped to one page), and page breaks are steered away from
10+ * the middle of code blocks, tables, equations and diagrams.
11+ */
12+
13+@page {
14+ margin: 18mm 16mm;
15+}
16+
17+@media print {
18+ html,
19+ body,
20+ #root {
21+ height: auto;
22+ }
23+
24+ .topbar,
25+ .splitter,
26+ .pane:not(.pane-preview),
27+ .code-block .copy-button {
28+ display: none !important;
29+ }
30+
31+ .app,
32+ .panes {
33+ display: block;
34+ height: auto;
35+ min-height: 0;
36+ }
37+
38+ /* The screen layout scrolls the preview inside a fixed-height pane; for
39+ print the pane has to be a plain block of arbitrary height. */
40+ .pane-preview {
41+ display: block;
42+ overflow: visible;
43+ flex: none;
44+ }
45+
46+ .preview {
47+ max-width: none;
48+ padding: 0;
49+ font-size: 11pt;
50+ line-height: 1.5;
51+ }
52+
53+ .preview p,
54+ .preview li {
55+ orphans: 3;
56+ widows: 3;
57+ }
58+
59+ .preview h1,
60+ .preview h2,
61+ .preview h3,
62+ .preview h4 {
63+ break-after: avoid;
64+ }
65+
66+ /* Long code blocks and tables are allowed to run across a page boundary,
67+ the way LaTeX breaks a verbatim block or a longtable; forbidding it just
68+ leaves most of a page blank. Individual rows and equations, which no
69+ reader wants split, are kept whole. */
70+ .preview tr,
71+ .preview thead,
72+ .preview .katex-display,
73+ .preview img,
74+ .mermaid-diagram {
75+ break-inside: avoid;
76+ }
77+
78+ /* A table continued on the next page repeats its header row. */
79+ .preview thead {
80+ display: table-header-group;
81+ }
82+
83+ /* On screen these scroll sideways; on paper there is nowhere to scroll, so
84+ let the content wrap or shrink to the page width instead of being cut. */
85+ .preview pre {
86+ white-space: pre-wrap;
87+ word-break: break-word;
88+ overflow-x: visible;
89+ }
90+
91+ .preview .katex-display,
92+ .mermaid-diagram {
93+ overflow: visible;
94+ }
95+
96+ .preview table {
97+ display: table;
98+ width: 100%;
99+ overflow-x: visible;
100+ }
101+
102+ .preview img,
103+ .mermaid-diagram svg {
104+ max-width: 100%;
105+ height: auto;
106+ }
107+
108+ /* Code and table shading is part of how the document reads, so ask for it
109+ even when the print dialog has background graphics switched off. */
110+ .preview pre,
111+ .preview :not(pre) > code,
112+ .preview th,
113+ .preview tr:nth-child(2n) {
114+ print-color-adjust: exact;
115+ -webkit-print-color-adjust: exact;
116+ }
117+
118+ .preview a {
119+ color: inherit;
120+ text-decoration: underline;
121+ }
122+}
src/title.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+// The document's first heading doubles as its name: it becomes the browser tab
2+// title and, more usefully, the filename the browser offers by default when
3+// the document is printed to PDF.
4+
5+const MAX_LENGTH = 80
6+
7+// Strip the inline markdown that would otherwise end up in a filename, along
8+// with the characters that operating systems disallow there.
9+function clean(s: string): string {
10+ return s
11+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
12+ .replace(/[`*_~]/g, '')
13+ .replace(/\s+#+\s*$/, '')
14+ .replace(/[\\/:*?"<>|]/g, '-')
15+ .replace(/\s+/g, ' ')
16+ .trim()
17+ .slice(0, MAX_LENGTH)
18+ .trim()
19+}
20+
21+// The first ATX heading outside of a fenced code block, falling back to the
22+// first non-empty line. Fences are tracked because a comment line such as
23+// `# load the data` inside a python block is not a heading.
24+export function documentTitle(markdown: string): string {
25+ let inFence = false
26+ let firstLine = ''
27+ for (const raw of markdown.split('\n')) {
28+ const line = raw.trim()
29+ if (/^(```|~~~)/.test(line)) {
30+ inFence = !inFence
31+ continue
32+ }
33+ if (inFence || !line) continue
34+ const heading = /^#{1,6}\s+(.+)$/.exec(line)
35+ if (heading) return clean(heading[1])
36+ if (!firstLine) firstLine = line
37+ }
38+ return clean(firstLine)
39+}