Guide
Tables, Math, Diagrams and Images in Markdown PDFs
Published July 30, 2026
Plain prose converts to PDF reliably. Everything else — tables that outgrow the page, math that needs a typesetting engine, diagrams generated at runtime, images loaded from a dozen sources — is where conversion actually fails. This guide covers the four categories of rich content and the specific ways each one breaks.
Tables
Markdown tables are the most-used rich element and the most likely to paginate badly, because a table has a natural width that has nothing to do with your page width.
The header repetition rule
Restating from the print CSS guide because it is the single highest-value line of CSS for tables:
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
Without this, a table spanning pages has a header on the first page and unlabelled rows thereafter. GFM tables emit a real <thead>, so no extra markup is needed.
Controlling column widths
Markdown gives you no width control. The renderer uses automatic table layout, which measures content and distributes width — usually reasonably, sometimes not. A column of long URLs will hog space while a column of short numbers is squeezed.
Fixed layout plus explicit widths gives you control:
table {
table-layout: fixed;
width: 100%;
}
/* Widths by column position. */
table th:nth-child(1) { width: 22%; }
table th:nth-child(2) { width: 48%; }
table th:nth-child(3) { width: 15%; }
table th:nth-child(4) { width: 15%; }
With table-layout: fixed, the browser stops measuring content and uses your declared widths — much faster and entirely predictable, at the cost of having to declare them. Content that does not fit wraps rather than expanding the column.
If a specific table needs different widths, give it a class in your Markdown:
<div class="wide-first-col">
| Parameter | Description | Type | Default |
| --- | --- | --- | --- |
| `retries` | Number of attempts before failing | integer | `3` |
</div>
Remember the blank lines inside the div — without them the Markdown table renders as literal pipes.
Long content in cells
Two failure modes. Long unbroken strings — URLs, hashes, package names — overflow the cell and often the page:
td {
overflow-wrap: break-word;
word-break: break-word;
}
/* For cells that are specifically identifiers. */
td.identifier {
word-break: break-all;
font-family: ui-monospace, monospace;
font-size: 8.5pt;
}
overflow-wrap: break-word breaks only when a word cannot fit on its own line, which is what you want for prose. word-break: break-all breaks anywhere, which is right for hashes and wrong for sentences.
The second mode is simply too many columns. A table with nine columns does not fit A4 portrait at a readable size. Options, in order of preference:
- Rotate to landscape for that table — see the named-page technique in the print CSS guide.
- Split it into two tables grouped by meaning. Usually the columns cluster naturally.
- Transpose it. If you have four items with nine attributes, put attributes down the side.
- Shrink the font to 8pt. Legible, but a sign the table is doing too much.
Zebra striping and print
Alternating row backgrounds aid horizontal tracking in wide tables. They need the colour-adjust override:
tbody tr:nth-child(even) {
background: #f7f7f9;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
There is a subtle pagination interaction: nth-child counts rows in the source, not per page. A table breaking after an odd row means the next page starts with an even-striped row at the top, so the pattern appears to shift. It is cosmetic and unfixable in CSS — the renderer does not expose per-fragment row indices. If it bothers you, use horizontal rules instead of striping.
Numeric alignment
Markdown’s ---: alignment syntax works, and should be used for number columns:
| Item | Qty | Unit price | Total |
| --- | ---: | ---: | ---: |
| Widget | 12 | 4.50 | 54.00 |
| Gadget | 3 | 129.00 | 387.00 |
Pair it with tabular figures so digit columns actually line up:
table { font-variant-numeric: tabular-nums; }
Without this, most text fonts use proportional figures where 1 is narrower than 0, and right-aligned columns still look ragged.
Math
Markdown has no math syntax. Support comes from an extension that recognises delimiters — usually $...$ for inline and $$...$$ for display — and hands the contents to a typesetting engine.
KaTeX versus MathJax
Two engines dominate, and the choice affects PDF output materially.
| KaTeX | MathJax v3 | |
|---|---|---|
| Speed | Very fast, synchronous | Slower, asynchronous |
| Coverage | Most of LaTeX math | Essentially all of it, plus AMS packages |
| Output | HTML + CSS, or MathML | HTML/CSS, SVG, or MathML |
| PDF suitability | Excellent (with SVG or good font embedding) | Excellent (SVG output is ideal) |
| Bundle size | ~280KB with fonts | ~1MB+ |
For a Markdown-to-PDF pipeline, KaTeX is usually the better choice: it renders synchronously, which removes an entire class of race condition where the PDF snapshot happens before the math finishes typesetting. That race is the most common cause of math appearing as raw $x^2$ in output.
If you need obscure AMS environments or physics-package macros, MathJax’s coverage wins and you accept the async handling.
The async trap
With MathJax, you must wait for typesetting to complete:
await page.evaluate(() => window.MathJax.startup.promise);
await page.evaluateHandle("document.fonts.ready");
await page.pdf({ path: "out.pdf", format: "A4" });
Both awaits are needed — MathJax finishing does not mean its fonts have loaded.
KaTeX font embedding
KaTeX’s HTML output positions glyphs from its own KaTeX_Main, KaTeX_Math and KaTeX_Size fonts. All of them must be loaded and embedded, or the math renders with wrong metrics — visibly misaligned superscripts, radicals that do not reach over their contents.
If math looks subtly wrong rather than absent, missing KaTeX fonts is nearly always why. Ensure the full font directory is reachable, and prefer serving it locally over a CDN in an automated pipeline, where network flakiness produces intermittently broken output.
An alternative that sidesteps fonts entirely is MathML output with a MathML-capable engine, or MathJax’s SVG output, where each expression becomes a self-contained vector graphic. SVG output produces larger files but is completely robust.
Display versus inline
The Lorentz factor $\gamma = 1/\sqrt{1 - v^2/c^2}$ appears inline.
Displayed on its own line:
$$
\gamma = \frac{1}{\sqrt{1 - \dfrac{v^2}{c^2}}}
$$
Inline math should not change line height. It will if you use \frac inline — the fraction is taller than the line box and pushes lines apart unevenly. Use / for inline division, or \tfrac which sets a text-sized fraction:
/* Belt and braces: stop tall inline math disturbing leading. */
.katex { line-height: normal; }
p .katex-display { margin: 0; }
Display math needs break protection like any other block:
.katex-display {
break-inside: avoid;
page-break-inside: avoid;
margin: 12pt 0;
}
Equation numbering
Numbered, referenceable equations need CSS counters, since neither Markdown nor KaTeX provides them in this context:
body { counter-reset: equation; }
.katex-display.numbered {
counter-increment: equation;
position: relative;
}
.katex-display.numbered::after {
content: "(" counter(equation) ")";
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
font-size: 10pt;
}
Cross-references to those numbers require target-counter(), which needs Paged.js or WeasyPrint. For anything heavily cross-referenced, LaTeX remains the better tool — see the academic writing guide for where that line falls.
Mermaid diagrams
Mermaid turns text descriptions into flowcharts, sequence diagrams, Gantt charts and more. It is genuinely useful in documentation because the diagram lives in version control as text.
```mermaid
graph TD
A[Markdown source] --> B{Has diagrams?}
B -->|Yes| C[Mermaid renders SVG]
B -->|No| D[Skip]
C --> E[Paginate]
D --> E
E --> F[PDF]
```
The rendering timing problem
Mermaid renders asynchronously in the browser, after page load, by scanning for .mermaid elements and replacing them with SVG. In a PDF pipeline this creates the same race as MathJax, and it is worse because Mermaid’s rendering is slower.
If your diagrams come out as raw text or empty boxes, the snapshot happened first. Wait explicitly:
await page.evaluate(async () => {
await window.mermaid.run(); // v10+ API
});
await page.waitForSelector(".mermaid svg", { timeout: 15000 });
await page.pdf({ path: "out.pdf" });
Waiting for the svg child is more reliable than a fixed delay, because diagram complexity varies enormously.
Sizing for print
Mermaid sizes diagrams to the container width, which on a page is narrower than a browser window. Wide flowcharts get compressed to illegibility. Constrain and scale:
.mermaid {
break-inside: avoid;
page-break-inside: avoid;
text-align: center;
margin: 14pt 0;
}
.mermaid svg {
max-width: 100%;
height: auto;
}
For a genuinely wide diagram, the honest options are landscape orientation for that page, or restructuring the diagram. Mermaid’s graph LR (left-to-right) produces wide output; graph TD (top-down) produces tall output that paginates better. Switching direction is often the whole fix.
You can also cap the theme’s font size to fit more in:
mermaid.initialize({
startOnLoad: false,
theme: "neutral",
themeVariables: {
fontSize: "12px",
fontFamily: "Inter, sans-serif",
},
flowchart: { useMaxWidth: true, htmlLabels: true },
});
theme: "neutral" is the right choice for print — the default theme uses colour fills that mostly vanish in greyscale. useMaxWidth: true makes the SVG scale to its container rather than asserting a fixed pixel width.
htmlLabels and PDF
Mermaid can render node labels either as SVG <text> or as embedded HTML in a foreignObject. HTML labels support richer formatting; SVG text is more portable. Some PDF engines handle foreignObject poorly, dropping the labels and leaving empty shapes. If your diagrams render as unlabelled boxes, try htmlLabels: false.
SVG and vector graphics
An SVG in the PDF stays vector — infinitely scalable, crisp at any zoom, small — provided it survives the pipeline as SVG. Three ways to include one, with different outcomes:
Inline SVG. Paste the markup directly into your Markdown. Always stays vector, and CSS can style it. Verbose in source.
<svg viewBox="0 0 100 40" width="100" height="40" role="img" aria-label="Sparkline">
<polyline points="0,30 20,18 40,24 60,8 80,14 100,4"
fill="none" stroke="#2563eb" stroke-width="2"/>
</svg>
<img> pointing at an .svg file. Usually stays vector; some engines rasterise. Cleaner source.

CSS background-image. Reliably vector, but background images are stripped in print unless you set print-color-adjust: exact, and they cannot have alt text. Avoid for content.
Why an SVG might rasterise
- External references. An SVG that
<image>-references a bitmap, or@imports a font, may be flattened when those cannot be resolved at export time. - Filters.
feGaussianBlurand friends often force rasterisation, since PDF has no equivalent primitive. foreignObject. As with Mermaid labels, embedded HTML inside SVG is poorly supported and commonly rasterised or dropped.- CSS inside
<style>in the SVG may not apply when loaded via<img>, because that creates an isolated document. Use presentation attributes (fill="...") or inlinestyle="..."for anything that must survive.
Text inside an SVG needs the font available at export, exactly like body text. If the font is missing, the text either substitutes or disappears. For diagram labels that must be pixel-exact, converting text to paths removes the dependency at the cost of selectability.
Diagnosing broken images
Images that work in preview and vanish in the PDF are common. Causes, roughly in order of frequency:
Relative paths resolving against the wrong base. Your Markdown references ./images/diagram.png, relative to the Markdown file. The converter loads the HTML from a different directory, or from a data:/blob: URL with no meaningful base, and the relative path resolves nowhere. Fix by using absolute paths, or setting a <base href="...">, or embedding as data URIs.
Remote images not finished loading. External URLs are fetched asynchronously. Snapshot too early and you get nothing:
await page.goto(url, { waitUntil: "networkidle0" });
networkidle0 waits until there are no network connections for 500ms. It is the right choice for documents with remote images, and slower than load.
Hotlink protection and authentication. Many hosts reject requests without a matching Referer, or require cookies. GitHub’s private-repo asset URLs, Notion’s signed S3 URLs, and most CDNs with referer rules all fail here. Notion’s are especially notorious — exported Markdown contains time-limited signed URLs that expire within an hour. Download assets locally before converting.
Lazy loading. loading="lazy" tells the browser to defer until the image approaches the viewport. In a print snapshot, images far down the document may never enter it:
@media print {
img { loading: eager; } /* not a real CSS property — see below */
}
That does not work; loading is an HTML attribute, not a CSS property. You must strip it in the DOM before printing:
await page.evaluate(() => {
document.querySelectorAll("img[loading]").forEach((el) => {
el.loading = "eager";
});
});
await page.evaluate(() => Promise.all(
Array.from(document.images)
.filter((img) => !img.complete)
.map((img) => new Promise((res) => { img.onload = img.onerror = res; }))
));
Overflow clipping. An image wider than its container inside overflow: hidden is cropped rather than scaled. Always set img { max-width: 100% }.
CORS on canvas-derived images. If any part of your pipeline draws images to a canvas, cross-origin images without CORS headers taint it and reads fail silently.
Resolution
An image that looks fine on screen may be visibly soft in print, because print resolution is far higher. A 600px-wide screenshot displayed across a 170mm measure resolves to about 90dpi — noticeably blurry on paper.
Rule of thumb: for a printed document, supply raster images at roughly 2× their display size in CSS pixels, targeting 200–300dpi at final print size. For a 170mm-wide figure that means about 1400–2000px. Beyond 300dpi you are only inflating file size.
Screenshots specifically: capture at 2× device pixel ratio rather than upscaling afterwards. Upscaling adds no information.
Figures, captions and numbering
Markdown’s image syntax gives you alt text but no caption. The alt text is for assistive technology; a caption is visible content. They are different things and should usually differ.
<figure>
<img src="./diagrams/pipeline.svg" alt="Flowchart of the six-stage build pipeline">
<figcaption>The build pipeline after the March consolidation.</figcaption>
</figure>
figure {
break-inside: avoid;
page-break-inside: avoid;
margin: 16pt 0;
text-align: center;
}
figure img { max-width: 100%; }
figcaption {
font-size: 9pt;
color: #555;
margin-top: 5pt;
text-align: center;
line-height: 1.4;
}
/* Automatic figure numbering. */
body { counter-reset: figure; }
figure { counter-increment: figure; }
figcaption::before {
content: "Figure " counter(figure) ". ";
font-weight: 600;
color: #333;
}
break-inside: avoid on figure keeps image and caption together. Without it, the caption can strand on the following page, which looks like an error.
Note there is no float-with-text-wrap here. CSS floats in paged media are one of the least reliably implemented parts of the spec; a floated figure near a page boundary produces engine-dependent results ranging from correct to catastrophic. Centred, full-measure figures that interrupt the text flow are the reliable choice. If you genuinely need text wrapping around figures, that is one of the things LaTeX still does much better.
Try it yourself
The editor here renders GFM tables, KaTeX math and Mermaid diagrams in the paginated preview, with the rendering completed before export — so the timing races described above are not something you have to handle. Table header repetition and break-avoidance on figures and code blocks are applied by default in the built-in themes.
Related: print CSS and pagination, typography and styling, and academic writing for citations and cross-references.
About this guide
Written and maintained by the developer of MarkdownToFile — the browser-based converter this site runs on. Techniques described here are tested against the engines named in the text (Chromium's print pipeline, Paged.js, WeasyPrint), and engine limitations are stated rather than glossed over. Corrections are welcome via the contact page; more about the project on the about page.
Try it yourself — free, no signup
Convert your Markdown to a polished PDF right in your browser.
Open the editor