Guide
Typography and Styling for Markdown PDFs: Fonts, Colour and Emoji
Published July 30, 2026
Markdown deliberately has no styling. That is its strength as a writing format and the entire problem when you export to PDF: every visual decision — typeface, size, colour, spacing — has to come from somewhere else, and the defaults you get are whatever your converter’s author happened to like.
This guide covers those decisions: which typefaces work in print, how font embedding actually works, why emoji turn into empty rectangles, and how to build a colour scheme that does not fall apart on a monochrome printer.
Choosing a typeface
The screen-versus-print distinction matters more than any individual font choice. Screens are low-resolution, backlit, and read at arm’s length. Paper is high-resolution, reflective, and read closer. Type that is comfortable in one context is often not in the other.
Serif or sans-serif
The old claim that serifs are more readable in print and sans-serifs on screen was never well supported by evidence, and modern high-DPI screens have eroded whatever difference existed. Treat it as a convention rather than a rule:
- Serif for continuous prose. Reports, essays, books, anything with long paragraphs. Serifs give each letter more distinguishing detail, which helps at small sizes on paper.
- Sans-serif for reference and structure. Documentation, tables, resumes, anything scanned rather than read linearly.
- Sans-serif for headings regardless. A sans heading over serif body is a reliable pairing and needs no thought.
Fonts that hold up in print
| Typeface | Class | Good for | Notes |
|---|---|---|---|
| Source Serif 4 | Serif | Reports, long prose | Free, designed for print and screen, wide weight range |
| EB Garamond | Serif | Books, academic | Free Garamond revival; sets small, use 11.5–12pt |
| Charter / Charis SIL | Serif | Technical prose | Free, designed for low-resolution output, very robust |
| Georgia | Serif | Anything, safe fallback | Preinstalled nearly everywhere; large x-height |
| Inter | Sans | Documentation, UI-adjacent docs | Free, excellent at small sizes, huge weight range |
| Source Sans 3 | Sans | Reports, resumes | Free, neutral, pairs with Source Serif |
| IBM Plex Sans | Sans | Technical documents | Free, slightly distinctive, good mono companion |
| Helvetica / Arial | Sans | Resumes, safe fallback | Preinstalled; unremarkable but never wrong |
For monospace — code blocks, which is most of what monospace does in a Markdown document:
| Typeface | Notes |
|---|---|
| JetBrains Mono | Free, tall x-height, very legible small; good default |
| IBM Plex Mono | Free, narrower advance so more chars per line |
| Source Code Pro | Free, conservative, pairs with Source Serif/Sans |
| SF Mono / Menlo / Consolas | Preinstalled on macOS / Windows respectively |
Monospace choice matters more in print than on screen, because you cannot scroll. A wide monospace font means fewer characters fit the measure before wrapping, and wrapped code is harder to read. IBM Plex Mono fits noticeably more per line than JetBrains Mono at the same point size.
Pairings that work
If you do not want to think about it:
- Source Serif 4 + Inter + JetBrains Mono — modern, free, wide range, sets well from 9pt up.
- EB Garamond + Source Sans 3 + Source Code Pro — traditional, good for academic work.
- Georgia + Helvetica + Consolas — zero embedding needed; available on essentially every machine.
- IBM Plex Serif + IBM Plex Sans + IBM Plex Mono — one family, three classes, guaranteed coherent.
Sizes
Print sizes are not screen sizes. A full table is in the print CSS guide; the short version is body text at 10.5–11.5pt, code two to three points smaller than body, and headings scaled from there.
The other lever is measure — line length. Optimal is 60–75 characters. On A4 with 20mm margins and 11pt body text, you land around 90–95 characters, which is too long; the eye loses its place returning to the next line. Either widen the margins to 30mm, or accept it, or go two-column. Widening the margins is usually right for prose and wrong for anything with tables or code.
Font embedding
A PDF that references a font without embedding it renders correctly only on machines that have that font installed. Everywhere else, the viewer substitutes — and substitution changes metrics, which reflows lines, which changes pagination. Your carefully-fitted one-page resume becomes two pages on the recruiter’s machine.
Embedding avoids this by writing the glyph outlines into the PDF itself.
How it works in a browser pipeline
Browsers embed automatically for any font they actually loaded. The requirement is simply that the font is loaded at print time, which means a real @font-face rule pointing at a reachable file:
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
Three points that cause real problems:
Declare every weight and style you use. If you only declare regular and your document uses bold, the browser synthesises bold by algorithmically thickening the outlines. Synthetic bold looks smeared in print and is a common cause of “why does my PDF look slightly wrong”. The same applies to italic — synthetic italic is a mechanical slant, not a true italic with its own letterforms.
Use font-display: block, not swap. With swap, the browser renders in a fallback font while loading and swaps in the real font when it arrives. If the print snapshot happens during that window, you get the fallback embedded. block makes the browser wait.
Wait for fonts before printing. In an automated pipeline, this is the single most common cause of wrong output:
await page.evaluateHandle("document.fonts.ready");
await page.pdf({ path: "out.pdf", format: "A4" });
Without the document.fonts.ready await, Puppeteer frequently snapshots mid-load.
Variable fonts
A variable font packs an entire weight axis into one file, which is usually smaller than three static weights and lets you use any intermediate value:
@font-face {
font-family: "Inter Variable";
src: url("/fonts/Inter-Variable.woff2") format("woff2-variations");
font-weight: 100 900;
}
h2 { font-weight: 620; } /* not available in static weights */
Embedding behaviour differs: some engines embed the full variable font, others instantiate and embed only the static instances you used. The first produces a larger PDF; the second is what you usually want. WeasyPrint and recent Chrome both instantiate. If PDF size matters and you are on an older engine, static weights are the safe choice.
Subsetting
A full Latin font is 200–400KB. A subset containing only the glyphs your document uses is often under 20KB. For a document with a lot of text this barely matters; for a one-page resume it is the difference between a 30KB and a 400KB attachment.
Most browser pipelines subset automatically when embedding. If you are building font files yourself, pyftsubset from fonttools is the standard tool:
pyftsubset SourceSerif4-Regular.ttf \
--unicodes="U+0000-00FF,U+2018-201F,U+2013-2014,U+2026" \
--flavor=woff2 \
--output-file=SourceSerif4-Regular.subset.woff2
That range covers Basic Latin, Latin-1 Supplement, curly quotes, en/em dashes and the ellipsis — enough for most English documents. Subset too aggressively and a stray character renders as a box, so include the punctuation you actually use.
Licensing
Worth stating plainly because it is easy to get wrong: embedding a font in a PDF is distribution of that font. Most open licences (SIL OFL, Apache) permit it without restriction. Many commercial desktop licences do not, or require a separate embedding licence. If you are producing documents commercially with a purchased typeface, check the EULA. All the fonts recommended above are SIL OFL or Apache licensed and are safe to embed.
Fixing emoji
Emoji rendering as empty rectangles — “tofu” — is the most reported styling failure in Markdown-to-PDF conversion, and it has exactly two causes.
Cause one: encoding. If the pipeline reads your file as ASCII or Latin-1, the emoji bytes are misinterpreted before any font is consulted. This is now rare in browser pipelines, which assume UTF-8, but it still happens with CLI tools reading files without an explicit encoding. Ensure <meta charset="utf-8"> in any HTML template, and pass explicit encoding flags in scripts.
Cause two, which is nearly always the real one: no emoji font in the stack. Georgia, Helvetica, Source Serif and every other text font contains no emoji glyphs. When the renderer hits U+1F680 and no font in the cascade has that codepoint, it draws the missing-glyph box.
The fix is appending emoji fonts to the family list:
body {
font-family: "Source Serif 4", Georgia, serif,
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
}
Order matters and the emoji fonts go last. The cascade is per-character: for a letter, the browser finds it in Source Serif and stops; for an emoji, it falls through to the first font that has it.
On a server
"Apple Color Emoji" exists on macOS, "Segoe UI Emoji" on Windows. A Linux container has neither. If your pipeline renders in Docker, the font must be installed in the image:
RUN apt-get update && \
apt-get install -y --no-install-recommends fonts-noto-color-emoji && \
rm -rf /var/lib/apt/lists/*
Then fc-cache -f if the image does not rebuild the cache on install. This is the fix for “emoji work locally but not in CI”, which is an extremely common report.
Colour emoji in PDF
A wrinkle worth knowing: colour emoji fonts use one of several colour-glyph formats (Apple’s sbix, Microsoft’s COLR/CPAL, Google’s CBDT/CBLC). PDF has no native concept of colour glyph tables, so engines have to rasterise them into embedded images. Consequences:
- Emoji in the PDF are bitmaps, not vectors. They pixelate if scaled up.
- Some engines fall back to a monochrome outline instead. You get a black-and-white emoji, which is correct but not what you designed.
- A document with many emoji grows noticeably, since each is an embedded image.
If you need guaranteed-consistent colour emoji, converting to inline SVG before export gives full control:
img.emoji {
height: 1.15em;
width: 1.15em;
vertical-align: -0.15em;
display: inline-block;
margin: 0 0.05em;
}
That is what Twemoji does. For most documents it is over-engineering; install the font and move on.
Colour schemes
Two constraints shape colour choice in print that do not apply on screen.
Browsers strip backgrounds by default. Discussed in the print CSS guide, but restating because it bites here: your carefully-chosen code block background silently disappears unless you set print-color-adjust: exact.
The document may end up greyscale. Not hypothetically — office printers default to monochrome, and PDFs get printed. If information is encoded only in hue, it is lost.
Designing for greyscale survival
The test is simple: convert to greyscale and check nothing became ambiguous. To pass it, ensure every colour distinction is doubled by a non-colour distinction.
/* Fails in greyscale: only hue distinguishes these. */
.status-ok { color: #16a34a; }
.status-fail { color: #dc2626; }
/* Passes: weight and a symbol carry the meaning too. */
.status-ok::before { content: "✓ "; }
.status-fail::before { content: "✗ "; font-weight: 700; }
.status-ok { color: #15803d; }
.status-fail { color: #b91c1c; font-weight: 600; }
Similarly, callout boxes distinguished only by background tint become identical grey blocks. Give each a border style or an icon:
.callout { border-left: 3pt solid; padding-left: 10pt; }
.callout-note { border-color: #2563eb; }
.callout-warn { border-color: #d97706; border-left-style: double; }
Contrast on white
Screen colour palettes are usually tuned against a dark-ish or mid-grey background. On paper — pure white, high contrast, reflective — the same colours often look washed out. Mid-tone greys in particular: #999 is a comfortable secondary text colour on screen and nearly illegible in print.
Practical floors for print:
| Use | Minimum | Notes |
|---|---|---|
| Body text | #1a1a1a | Pure black is fine; slightly-off black is softer |
| Secondary text | #555 | Do not go lighter than #666 |
| Rules and borders | #ccc | Below this may not render on some printers |
| Link colour | #1d4ed8 | Needs to stay distinguishable in greyscale — darker than screen blue |
Hairline borders are a real trap: 0.5pt renders crisply in a PDF viewer and can disappear entirely on a 300dpi laser printer, which cannot resolve it. Use 0.75pt minimum for anything that must survive printing.
Dark mode PDFs
People ask for these, and they are almost always the wrong artefact. Worth being direct about why.
A PDF is a document, not an interface. It has no way to know whether it is being viewed on a screen or committed to paper, and no way to adapt. A dark-background PDF sent to a printer produces a page of solid toner: slow, expensive, prone to smearing, and often refused outright by print drivers with a “high ink coverage” warning. Recipients who print it will not thank you.
There is also no prefers-color-scheme in PDF. The choice is baked in at export.
Where a dark PDF genuinely does make sense:
- Slide decks and presentation exports, projected rather than printed.
- Documents you know are screen-only and read in a dark environment — a reference sheet kept open on a second monitor.
- Design artefacts where the dark treatment is the content.
If you are producing one, the implementation is straightforward but you must force the backgrounds:
@media print {
html, body {
background: #12121a !important;
color: #e6e6ea;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
@page {
/* The page box background must be set too, or the margins stay white. */
background: #12121a;
}
h1, h2, h3 { color: #f4f4f6; }
a { color: #7dd3fc; }
pre, code { background: #1c1c26; color: #e6e6ea; }
th { background: #22222e; }
th, td { border-color: #33333f; }
hr { border-color: #33333f; }
}
The @page { background } line is the one people miss. Setting body background dark leaves the page margins white, producing a dark rectangle floating in a white border. Not all engines honour a background on @page — Paged.js and WeasyPrint do; Chrome’s native pipeline does not, and there the workaround is a full-bleed absolutely-positioned backdrop element.
For a document that must work both ways, export two files. Trying to make one PDF serve both is not possible.
A styling baseline
Combining everything, a complete starting point for a professional text document:
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-Regular.woff2") format("woff2");
font-weight: 400; font-style: normal; font-display: block;
}
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-Bold.woff2") format("woff2");
font-weight: 700; font-style: normal; font-display: block;
}
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-Italic.woff2") format("woff2");
font-weight: 400; font-style: italic; font-display: block;
}
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-Variable.woff2") format("woff2-variations");
font-weight: 100 900; font-display: block;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2");
font-weight: 400; font-display: block;
}
@media print {
body {
font-family: "Source Serif 4", Georgia, serif,
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
font-size: 11pt;
line-height: 1.55;
color: #1a1a1a;
background: #fff;
font-kerning: normal;
font-variant-ligatures: common-ligatures;
text-rendering: optimizeLegibility;
hyphens: auto;
}
h1, h2, h3, h4 {
font-family: "Inter", system-ui, sans-serif;
color: #0f0f0f;
letter-spacing: -0.011em;
line-height: 1.25;
}
h1 { font-size: 22pt; font-weight: 700; }
h2 { font-size: 16pt; font-weight: 650; }
h3 { font-size: 13pt; font-weight: 600; }
/* Tabular figures keep number columns aligned. */
table, .numeric {
font-variant-numeric: tabular-nums;
}
code, pre {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-variant-ligatures: none; /* no -> arrows in code */
}
pre {
font-size: 9pt;
line-height: 1.45;
background: #f7f7f9;
border: 0.75pt solid #e2e2e6;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
a { color: #1d4ed8; }
blockquote {
border-left: 2.5pt solid #d4d4d8;
color: #444;
font-style: italic;
padding-left: 12pt;
}
}
Three details in there worth calling out:
hyphens: auto matters more in print than screen. Justified or narrow-measure text without hyphenation produces ugly rivers of whitespace. It requires a lang attribute on <html> to pick the right hyphenation dictionary — without lang="en", it silently does nothing.
font-variant-numeric: tabular-nums forces all digits to the same advance width, so figures in a table column line up vertically. Most text fonts default to proportional figures, where 1 is narrower than 8. In a table of numbers this looks broken.
font-variant-ligatures: none on code is deliberate. Fonts like JetBrains Mono ship programming ligatures that render -> as a single arrow glyph. Attractive on screen; in a printed document that someone may transcribe from, it misrepresents the actual characters.
Try it yourself
The editor here ships several typographic themes — clean sans, GitHub-styled, and an academic serif — with fonts already embedded and emoji fallbacks configured, so tofu boxes and synthetic bold are not something you have to think about. The paginated preview uses the same font files as the export, which means the line breaks you see are the line breaks you get.
Related: print CSS and pagination for the layout layer, and tables, math, diagrams and images for styling rich content.
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