Markdown to PDF: the mdpdf command, and the fonts it needs
This snapshot carries sks/tns/, the translation directory, into the
distribution for the first time, together with the two default font
families its stylesheet names.
sks/tns/ holds two converters in opposite directions. md_to_sks.py
converts Markdown to Klammertext, recording what it cannot convert exactly
as "#[MD ... ]#" markers so a draft carries its own worklist. md_to_pdf.py
renders Markdown straight to PDF through a headless Chromium driven over the
DevTools Protocol, bypassing Klammertext entirely -- the route for a
document that is not ready to convert, and a permanent one for Markdown that
Klammertext cannot represent well. Neither is loaded by the SKS; md_to_pdf
needs markdown-it-py, which it keeps in a virtual environment of its own and
creates with --setup.
The everyday form of the second is the mdpdf command, a shell function in
sks/tns/mdpdf.sh that env/runtime.env sources, so anyone with the
Klammertext environment has it:
mdpdf notes.md # writes notes.pdf beside it
It supplies the house fonts, the size matching, and the code wrapping,
completes on *.md at the TAB key, and takes its defaults from MDPDF_*
variables so one can be changed in a shell profile without copying the
function. It is POSIX shell rather than zsh, since runtime.env is sourced
from bash profiles too.
Two things the stylesheet does that a print stylesheet usually cannot. Code
lines are wrapped to a column count MEASURED from the rendered page rather
than written down -- the browser is asked how many characters a code box
holds, over every box in the document, so the wrapping stays right when the
fonts, sizes or margins change. And the page number is a CSS Paged Media
margin box, which current Chromium implements, so it is set in the
document's own face instead of the browser's generic sans.
fnt/ gains EB Garamond and Source Sans 3, the serif and sans the stylesheet
asks for by default.
(from dev 97d4f244c737)
2026-08-09 15:54:05 +02:00
|
|
|
body {
|
|
|
|
|
font-family: var(--serif);
|
|
|
|
|
font-size: 100%;
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* The monospace font is scaled so its letters are the height of the serif
|
|
|
|
|
font's, rather than by eye: md_to_pdf.py computes --mono-scale from the
|
|
|
|
|
two fonts' x-height and cap-height (the same rule the SKS applies), so
|
|
|
|
|
this stays right if either font is changed. */
|
|
|
|
|
code, pre {
|
|
|
|
|
font-family: var(--mono);
|
|
|
|
|
font-size: calc(1em * var(--mono-scale, 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Once, not twice. A fenced block is <pre><code>, so both elements match
|
|
|
|
|
the rule above and the scale is applied to the scaled size: measured
|
|
|
|
|
before this rule, inline code came out at 14.00px and the same code in a
|
|
|
|
|
box at 12.26px -- a metric-matched scale silently defeating itself. The
|
|
|
|
|
code in a box inherits the pre's size instead. */
|
|
|
|
|
pre code {
|
|
|
|
|
font-size: 1em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h1, h2, h3, h4, h5, h6 {
|
|
|
|
|
font-family: var(--sans);
|
|
|
|
|
font-weight: normal;
|
|
|
|
|
padding-top: .5rem;
|
|
|
|
|
padding-bottom: 0rem;
|
|
|
|
|
margin-top: .0rem;
|
|
|
|
|
margin-bottom: 0rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h1 {
|
|
|
|
|
font-size: 1.2rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h2 {
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h3, h4, h5, h6 {
|
|
|
|
|
font-size: 1.0rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* The gap between a section number and its title, in a heading and in a
|
|
|
|
|
table-of-contents entry alike. The number is plain text in the Markdown
|
|
|
|
|
("## 2.9 Keystone correction", "- [2.9 Keystone correction](#29-...)");
|
|
|
|
|
md_to_pdf.py wraps it in this span and eats the space that followed it, so
|
|
|
|
|
the whole distance is set here. Change --secnum-gap to widen or narrow
|
|
|
|
|
it, or give the contents its own gap with a "li .secnum" rule. To rule a
|
|
|
|
|
contents into columns rather than merely space it, add
|
|
|
|
|
"display: inline-block" and a "min-width" there: the titles then align
|
|
|
|
|
instead of each starting one gap after a number of its own width. */
|
|
|
|
|
:root {
|
|
|
|
|
--secnum-gap: 0.5em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.secnum {
|
|
|
|
|
margin-right: var(--secnum-gap);
|
|
|
|
|
}
|
|
|
|
|
|
Render correctly under a sandboxed browser; unbulleted contents
mdpdf drives a headless Chromium, and a browser installed as a flatpak --
which is what the Pop!_Shop installs, and so the ordinary case on a Pop!_OS
or System76 machine -- was unusable in two ways, the second of them silent.
It was not found at all. A flatpak puts nothing on PATH and nothing in
/opt, and its wrapper is named com.brave.Browser rather than brave-browser,
so adding the export directory to PATH would not have helped either. The
application ids are now looked for in the flatpak export directories, after
every native browser, so a native one still wins where there is one.
Found, it then rendered in the wrong fonts and reported success. The
@font-face URLs pointed into the font store, which the sandbox cannot read,
and a browser does not report a font it cannot fetch -- it substitutes. The
PDF came out in a default serif and nothing said so. Granting the path
would not have travelled either: sandbox filesystem permissions differ from
one application to the next, so a scheme resting on a path works with one
browser and fails with another on the same machine.
So the document, its fonts and its images are now served to the browser
over the loopback interface instead of being passed as file:// paths. Every
sandbox shares the network namespace -- the DevTools connection already
depends on it -- so this needs no filesystem permission from any sandbox,
present or future. A --keep-html copy is still written with file:// URLs,
so it works when nothing is serving it.
A font that fails to load is now an error rather than a substitution: the
page is asked whether each requested family arrived, and no PDF is written
if one did not. A finished-looking document in the wrong typeface is the
worst failure this program can have.
Separately, a table-of-contents entry no longer carries a bullet. An entry
is a section title, and a marker in front of it reads as a list of things
rather than as a contents; ordinary bulleted lists are unaffected.
(from dev 12929fdff53b)
2026-08-09 20:24:20 +02:00
|
|
|
/* A table-of-contents entry carries no bullet. Each entry is a section
|
|
|
|
|
title, and a marker in front of it reads as a list of things rather than
|
|
|
|
|
as a contents. md_to_pdf.py puts this class on a list item whose first
|
|
|
|
|
content is a link to a fragment, which is what a Markdown contents is, so
|
|
|
|
|
an ordinary bulleted list keeps its bullets. The entries stay on the
|
|
|
|
|
list's indent; add "padding-left: 0" to the surrounding ul to bring a
|
|
|
|
|
contents out to the margin instead. */
|
|
|
|
|
li.toc {
|
|
|
|
|
list-style: none;
|
|
|
|
|
}
|
|
|
|
|
|
Markdown to PDF: the mdpdf command, and the fonts it needs
This snapshot carries sks/tns/, the translation directory, into the
distribution for the first time, together with the two default font
families its stylesheet names.
sks/tns/ holds two converters in opposite directions. md_to_sks.py
converts Markdown to Klammertext, recording what it cannot convert exactly
as "#[MD ... ]#" markers so a draft carries its own worklist. md_to_pdf.py
renders Markdown straight to PDF through a headless Chromium driven over the
DevTools Protocol, bypassing Klammertext entirely -- the route for a
document that is not ready to convert, and a permanent one for Markdown that
Klammertext cannot represent well. Neither is loaded by the SKS; md_to_pdf
needs markdown-it-py, which it keeps in a virtual environment of its own and
creates with --setup.
The everyday form of the second is the mdpdf command, a shell function in
sks/tns/mdpdf.sh that env/runtime.env sources, so anyone with the
Klammertext environment has it:
mdpdf notes.md # writes notes.pdf beside it
It supplies the house fonts, the size matching, and the code wrapping,
completes on *.md at the TAB key, and takes its defaults from MDPDF_*
variables so one can be changed in a shell profile without copying the
function. It is POSIX shell rather than zsh, since runtime.env is sourced
from bash profiles too.
Two things the stylesheet does that a print stylesheet usually cannot. Code
lines are wrapped to a column count MEASURED from the rendered page rather
than written down -- the browser is asked how many characters a code box
holds, over every box in the document, so the wrapping stays right when the
fonts, sizes or margins change. And the page number is a CSS Paged Media
margin box, which current Chromium implements, so it is set in the
document's own face instead of the browser's generic sans.
fnt/ gains EB Garamond and Source Sans 3, the serif and sans the stylesheet
asks for by default.
(from dev 97d4f244c737)
2026-08-09 15:54:05 +02:00
|
|
|
p, ol, ul {
|
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pre {
|
|
|
|
|
/* A code line wider than the page does not overflow or clip when
|
|
|
|
|
printing: Chromium shrinks the WHOLE document until it fits, silently,
|
|
|
|
|
by a factor that changes with the longest line. Wrapping guarantees
|
|
|
|
|
that never happens. md_to_pdf.py --wrap-code breaks shell lines at a
|
|
|
|
|
word boundary with a "\" first, which is copy-pasteable; this catches
|
|
|
|
|
what it cannot wrap (PowerShell, .ini, ASCII diagrams). */
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
padding-left: 0rem;
|
|
|
|
|
border: 1px solid #888;
|
|
|
|
|
background-color: #EFE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
th {
|
|
|
|
|
border-bottom: 1px solid #000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
table, th, td {
|
|
|
|
|
border: 1px solid gray;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@media print {
|
|
|
|
|
/* A section title never sits alone at the foot of a page: the break is
|
|
|
|
|
forbidden between a heading and whatever follows it, so the heading
|
|
|
|
|
moves to the next page with its text. break-inside keeps a heading
|
|
|
|
|
that wraps from being split across the fold.
|
|
|
|
|
|
|
|
|
|
Chromium honours these when it paginates for printing (measured on a
|
|
|
|
|
16-page test document: three headings stranded at a page bottom
|
|
|
|
|
before, none after, and the page count unchanged). A heading it
|
|
|
|
|
cannot honour them for -- one taller than the page -- is laid out
|
|
|
|
|
as if they were absent rather than looped over, which is the
|
|
|
|
|
specified behaviour for an unsatisfiable avoid.
|
|
|
|
|
|
|
|
|
|
This does NOT govern how much of the following paragraph comes with
|
|
|
|
|
the heading: that is `orphans`, below. */
|
|
|
|
|
h1, h2, h3, h4, h5, h6 {
|
|
|
|
|
break-after: avoid;
|
|
|
|
|
break-inside: avoid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* A paragraph split by a page break leaves at least two lines on each
|
|
|
|
|
side of it: never one line stranded at the foot of a page (orphans),
|
|
|
|
|
never one line arriving alone at the top of the next (widows) -- and
|
|
|
|
|
with break-after above, that top-of-page line would be the one
|
|
|
|
|
directly under a heading the break has just moved. Both properties
|
|
|
|
|
are inherited, so declaring them on body covers list items and table
|
|
|
|
|
cells too.
|
|
|
|
|
|
|
|
|
|
Measured on a 16-page test document: Chromium honours both -- with
|
|
|
|
|
orphans: 1 it packs the text into 15 pages, and with widows: 1 it
|
|
|
|
|
moves a line back across a page boundary (same page count, different
|
|
|
|
|
pagination) -- and 2 is ALREADY its default for both, so rendering
|
|
|
|
|
with these declarations is byte-identical to rendering without them.
|
|
|
|
|
They are here to pin the values, not to change today's output: the CSS
|
|
|
|
|
initial value is 2 but a UA is free to differ, and a stylesheet added
|
|
|
|
|
after this one could relax it. */
|
|
|
|
|
body {
|
|
|
|
|
orphans: 2;
|
|
|
|
|
widows: 2;
|
|
|
|
|
}
|
|
|
|
|
pre {
|
|
|
|
|
padding: .75rem 1.25rem .75rem 1.25rem;
|
|
|
|
|
break-inside: avoid;
|
|
|
|
|
}
|
|
|
|
|
a[href]:after {
|
|
|
|
|
content: none;
|
|
|
|
|
}
|
|
|
|
|
th, td {
|
|
|
|
|
padding: .25rem .75rem;
|
|
|
|
|
}
|
|
|
|
|
table {
|
|
|
|
|
margin-left: 1rem;
|
|
|
|
|
margin-bottom: .5rem;
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
}
|
|
|
|
|
th {
|
|
|
|
|
border-bottom: 1px solid #000;
|
|
|
|
|
text-align: left;
|
|
|
|
|
padding-right: 2rem;
|
|
|
|
|
padding-bottom: 0.2em;
|
|
|
|
|
}
|
|
|
|
|
tbody tr:first-child td {
|
|
|
|
|
padding-top: 0.0em;
|
|
|
|
|
}
|
|
|
|
|
blockquote {
|
|
|
|
|
margin-left: 1rem;
|
|
|
|
|
}
|
|
|
|
|
table, th, td {
|
|
|
|
|
border: 1px solid #BBB;
|
|
|
|
|
}
|
|
|
|
|
:root {
|
|
|
|
|
--link-blue: #042B8C; /* #03216C; */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a {
|
|
|
|
|
color: var(--link-blue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a, a:link, a:visited {
|
|
|
|
|
outline: 0;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a:hover {
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
text-underline-offset: 0.2em;
|
|
|
|
|
text-decoration-thickness: 1px;
|
|
|
|
|
outline: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* The page number, in the document's own face.
|
|
|
|
|
|
|
|
|
|
This is a CSS Paged Media margin box, and it is the reason to prefer
|
|
|
|
|
it over md_to_pdf.py's --page-numbers: Chromium's footer template is
|
|
|
|
|
rendered in a context of its own that cannot see this stylesheet, so
|
|
|
|
|
the number comes out in a generic sans, while var(--serif) here is the
|
|
|
|
|
EB Garamond the text is set in. Measured 2026-08-09 on Brave 151:
|
|
|
|
|
margin boxes work and count pages. They are recent, though -- an
|
|
|
|
|
older Chromium silently prints no number at all.
|
|
|
|
|
|
|
|
|
|
DO NOT ALSO PASS --page-numbers. The two mechanisms are independent
|
|
|
|
|
and both would draw, one over the other, and the flag additionally
|
|
|
|
|
widens the bottom margin.
|
|
|
|
|
|
|
|
|
|
HEIGHT. A margin box is drawn in the margin and cannot be moved out
|
|
|
|
|
of it, so where the number sits and how much text a page holds are
|
|
|
|
|
one question, not two. Chromium centres the box in the margin band
|
|
|
|
|
by default; these declarations pin it to the top of a slightly larger
|
|
|
|
|
band instead, which puts the number 27pt above the paper edge rather
|
|
|
|
|
than 12 -- a body line higher -- while the padding, all 1.5pt of it,
|
|
|
|
|
leaves the text block ending just above it. The text therefore keeps
|
|
|
|
|
the line it would otherwise have lost to the raised number: measured,
|
|
|
|
|
a document that grew from 3 pages to 4 when the number was raised
|
|
|
|
|
alone is back to 3.
|
|
|
|
|
|
|
|
|
|
THE TIGHT SPOT, and the number to change if it shows. Text and
|
|
|
|
|
number now share a narrow band, so on a page whose last line reaches
|
|
|
|
|
the bottom of the block they nearly touch: measured worst pages over
|
|
|
|
|
three documents, 7.6 / 3.1 / 2.4pt between the last line's box and
|
|
|
|
|
the number's. Nothing overlapped, but 2.4pt is close. Giving the
|
|
|
|
|
text back only HALF a line -- margin-bottom 0.60in, padding-top
|
|
|
|
|
4.4pt, the number within a point of where it is now -- measured
|
|
|
|
|
8.4 / 9.1 / 11.4pt instead, and cost no document a page.
|
|
|
|
|
|
|
|
|
|
var(--serif) is safe here because the body is set in it, so the
|
|
|
|
|
webfont is loaded. A face the document does not otherwise use is NOT
|
|
|
|
|
loaded for a margin box: var(--mono) in a prose-only document fell
|
|
|
|
|
back to the platform monospace, and in one variant the box rendered
|
|
|
|
|
nothing whatever -- silently, as ever with print CSS. */
|
|
|
|
|
@page {
|
|
|
|
|
margin-bottom: 0.56in; /* the number, and little else */
|
|
|
|
|
@bottom-center {
|
|
|
|
|
content: counter(page) " of " counter(pages);
|
|
|
|
|
font-family: var(--serif);
|
|
|
|
|
font-size: 9pt;
|
|
|
|
|
color: black;
|
|
|
|
|
vertical-align: top; /* not centred in the margin band */
|
|
|
|
|
padding-top: 1.5pt; /* its distance from the text */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|