diff --git a/README.md b/README.md index 1ca62b6..c22019c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ are regenerated on each release — patches cannot be merged directly. Report problems (or send patches) to the author; accepted changes are applied to the development tree and appear in a following snapshot. -This snapshot was assembled from development commit `34e536cb0329`. +This snapshot was assembled from development commit `97d4f244c737`. ## License diff --git a/env/runtime.env b/env/runtime.env index b688b54..c2eb57e 100644 --- a/env/runtime.env +++ b/env/runtime.env @@ -63,6 +63,15 @@ else fi unset _kt_uname +# --- The mdpdf command (Markdown -> PDF; sks/tns/mdpdf.sh) -------------------- +# Sourced here rather than left to each user's profile, so the command exists +# wherever the rest of the Klammertext environment does. The file defines one +# shell function and registers completion for it: POSIX shell, no output, and +# harmless in a non-interactive shell. +if [ -f "$KLAMMERTEXT_HOME/sks/tns/mdpdf.sh" ]; then + . "$KLAMMERTEXT_HOME/sks/tns/mdpdf.sh" +fi + # --- Optional per-machine escape hatch (gitignored, absent by default) --------- # Use an if-block (not `[ -f ] && .`) so that when the local file is absent this # file's final exit status is 0 -- otherwise `source runtime.env` returns diff --git a/fnt/eb-garamond.css b/fnt/eb-garamond.css new file mode 100644 index 0000000..955f3cb --- /dev/null +++ b/fnt/eb-garamond.css @@ -0,0 +1,28 @@ + +@font-face { + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 400; + src: url('eb-garamond/Regular.ttf') format('truetype'); +} + +@font-face { + font-family: 'EB Garamond'; + font-style: normal; + font-weight: 700; + src: url('eb-garamond/Bold.ttf') format('truetype'); +} + +@font-face { + font-family: 'EB Garamond'; + font-style: italic; + font-weight: 400; + src: url('eb-garamond/Italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'EB Garamond'; + font-style: italic; + font-weight: 700; + src: url('eb-garamond/BoldItalic.ttf') format('truetype'); +} diff --git a/fnt/eb-garamond/Bold.ttf b/fnt/eb-garamond/Bold.ttf new file mode 100644 index 0000000..d2a510d Binary files /dev/null and b/fnt/eb-garamond/Bold.ttf differ diff --git a/fnt/eb-garamond/BoldItalic.ttf b/fnt/eb-garamond/BoldItalic.ttf new file mode 100644 index 0000000..045d9d3 Binary files /dev/null and b/fnt/eb-garamond/BoldItalic.ttf differ diff --git a/fnt/eb-garamond/Italic.ttf b/fnt/eb-garamond/Italic.ttf new file mode 100644 index 0000000..6fbcf4c Binary files /dev/null and b/fnt/eb-garamond/Italic.ttf differ diff --git a/fnt/eb-garamond/Regular.ttf b/fnt/eb-garamond/Regular.ttf new file mode 100644 index 0000000..33f7156 Binary files /dev/null and b/fnt/eb-garamond/Regular.ttf differ diff --git a/fnt/source-sans-3.css b/fnt/source-sans-3.css new file mode 100644 index 0000000..4b67b14 --- /dev/null +++ b/fnt/source-sans-3.css @@ -0,0 +1,28 @@ + +@font-face { + font-family: 'Source Sans 3'; + font-style: normal; + font-weight: 400; + src: url('source-sans-3/Regular.ttf') format('truetype'); +} + +@font-face { + font-family: 'Source Sans 3'; + font-style: normal; + font-weight: 700; + src: url('source-sans-3/Bold.ttf') format('truetype'); +} + +@font-face { + font-family: 'Source Sans 3'; + font-style: italic; + font-weight: 400; + src: url('source-sans-3/Italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'Source Sans 3'; + font-style: italic; + font-weight: 700; + src: url('source-sans-3/BoldItalic.ttf') format('truetype'); +} diff --git a/fnt/source-sans-3/Bold.ttf b/fnt/source-sans-3/Bold.ttf new file mode 100644 index 0000000..bcbea46 Binary files /dev/null and b/fnt/source-sans-3/Bold.ttf differ diff --git a/fnt/source-sans-3/BoldItalic.ttf b/fnt/source-sans-3/BoldItalic.ttf new file mode 100644 index 0000000..649f36e Binary files /dev/null and b/fnt/source-sans-3/BoldItalic.ttf differ diff --git a/fnt/source-sans-3/Italic.ttf b/fnt/source-sans-3/Italic.ttf new file mode 100644 index 0000000..2c078bf Binary files /dev/null and b/fnt/source-sans-3/Italic.ttf differ diff --git a/fnt/source-sans-3/Regular.ttf b/fnt/source-sans-3/Regular.ttf new file mode 100644 index 0000000..8841373 Binary files /dev/null and b/fnt/source-sans-3/Regular.ttf differ diff --git a/sks/tns/markdown.css b/sks/tns/markdown.css new file mode 100644 index 0000000..861ae81 --- /dev/null +++ b/sks/tns/markdown.css @@ -0,0 +1,233 @@ +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
, 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);
+}
+
+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 */
+ }
+ }
+}
+
diff --git a/sks/tns/md_to_pdf.py b/sks/tns/md_to_pdf.py
new file mode 100644
index 0000000..98bb74a
--- /dev/null
+++ b/sks/tns/md_to_pdf.py
@@ -0,0 +1,934 @@
+#!/usr/bin/env python3
+"""Markdown -> PDF, via a headless Chromium browser driven over the DevTools
+Protocol.
+
+This reproduces, without a browser window or an extension, what Andy has been
+doing by hand: open a Markdown file in Firefox with the Markdown Viewer
+extension, apply a print stylesheet, and print to PDF. The stylesheet is the
+valuable part and is supplied by --css; the default is the one saved beside
+this script.
+
+WHY THE DEVTOOLS PROTOCOL AND NOT --print-to-pdf. The command-line flag is
+being withdrawn from Chromium. Measured 2026-08-08 on this machine: Chrome
+136 still honours it, Brave (Chromium 151) does not -- it starts, loads the
+page, and then simply idles until killed, for file:// and http:// alike.
+Chromium's own guidance is to drive printing through the protocol, so that is
+what this does. It will keep working as browsers advance; a script built on
+the flag has a shelf life.
+
+The protocol client here is hand-rolled on the standard library -- a WebSocket
+handshake and frame codec in about eighty lines -- in keeping with the way
+this project does the same for its language server and its .vsix builder. The
+one dependency is the Markdown renderer, markdown-it-py, which is the library
+family the Firefox extension itself uses. PEP 668 forbids installing it into
+the system Python, so it lives in a virtual environment:
+
+ python3 -m venv ~/.venvs/klammertext-tns
+ ~/.venvs/klammertext-tns/bin/pip install markdown-it-py mdit-py-plugins
+
+and this script re-executes itself with that interpreter when it needs to.
+
+Usage:
+ md_to_pdf.py [output.pdf] [--css FILE]... [--browser PATH]
+ [--keep-html] [--paper A4|letter] [--margin INCHES]
+"""
+import argparse
+import base64
+import re
+import json
+import os
+import shutil
+import socket
+import struct
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.request
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+DEFAULT_CSS = HERE / "markdown.css"
+VENV = Path.home() / ".venvs" / "klammertext-tns"
+
+# Browsers that can serve as the renderer, most preferred first. Any
+# Chromium will do: the protocol is the same. The .app paths are for macOS,
+# where nothing lands on PATH -- without them this finds no browser on a Mac
+# that has one installed.
+BROWSERS = ["/opt/brave.com/brave/brave", "brave-browser", "google-chrome",
+ "chromium", "chromium-browser",
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"]
+
+
+# --- the renderer ----------------------------------------------------------
+
+def setup_venv():
+ """Create the virtual environment this script needs.
+
+ Offered as --setup so that a new machine takes one command rather than
+ two remembered ones. PEP 668 marks the system Python as
+ externally-managed, so a venv is not a preference here; pip refuses to
+ install into the system otherwise.
+ """
+ print(f"creating {VENV}")
+ subprocess.run([sys.executable, "-m", "venv", str(VENV)], check=True)
+ pip = VENV / ("Scripts" if os.name == "nt" else "bin") / "pip"
+ subprocess.run([str(pip), "install", "-q", "markdown-it-py", "mdit-py-plugins"],
+ check=True)
+ print(f"installed markdown-it-py and mdit-py-plugins into {VENV}")
+
+
+def ensure_renderer():
+ """Re-exec under the venv interpreter unless EVERY requirement is present.
+
+ Done by re-exec rather than by manipulating sys.path so that the script
+ stays runnable as itself: `python3 md_to_pdf.py ...` works whether or not
+ the caller knows about the virtual environment.
+
+ Both packages are checked, not just the first. This machine's system
+ Python carries a distro markdown_it but no mdit_py_plugins, so a check of
+ markdown_it alone was satisfied, the re-exec never happened, and the
+ plugins were then silently skipped -- producing a PDF whose 138 internal
+ links pointed at heading anchors that had never been generated. The
+ links were present and did nothing, which is the worst way to fail.
+ """
+ if "--setup" in sys.argv:
+ setup_venv()
+ sys.exit(0)
+ try:
+ import markdown_it # noqa: F401
+ import mdit_py_plugins.anchors # noqa: F401
+ return
+ except ImportError:
+ pass
+ venv_python = VENV / "bin" / "python"
+ if venv_python.exists() and Path(sys.executable) != venv_python:
+ os.execv(str(venv_python), [str(venv_python), *sys.argv])
+ sys.exit(f"the Markdown renderer is not installed. Run:\n"
+ f" python3 {Path(__file__).name} --setup")
+
+
+def github_slug(title):
+ """A heading's anchor, by GitHub's rule.
+
+ Markdown documents write their internal links against this convention --
+ "## 2.9 Keystone correction" is linked as "(#29-keystone-correction)" --
+ so the slug has to match it exactly or every cross-reference in the
+ document lands nowhere. Lowercase, drop everything that is not
+ alphanumeric, space or hyphen, then spaces to hyphens.
+ """
+ import re
+ slug = re.sub(r"[^\w\s-]", "", title.strip().lower(), flags=re.UNICODE)
+ return re.sub(r"[\s]+", "-", slug)
+
+
+def render_markdown(text):
+ """Markdown -> HTML, configured like the Firefox extension's renderer:
+ CommonMark plus the GitHub-flavoured additions people actually write.
+
+ Heading anchors are not optional. markdown-it's core emits with no
+ id, so a document full of "[see](#some-section)" produces link
+ annotations in the PDF that point at destinations which do not exist --
+ the links are there, and clicking them does nothing. Measured on the
+ Rectify guide before this was added: 148 link annotations, 138 of them
+ dead.
+ """
+ # No try/except around these: the plugins are requirements, not
+ # improvements, and a missing one must stop the program rather than
+ # quietly produce a document that is wrong in a way nobody can see.
+ # ensure_renderer() has already checked they are importable.
+ from markdown_it import MarkdownIt
+ from mdit_py_plugins.footnote import footnote_plugin
+ from mdit_py_plugins.deflist import deflist_plugin
+ from mdit_py_plugins.anchors import anchors_plugin
+ md = (MarkdownIt("commonmark", {"html": True, "linkify": True,
+ "typographer": True})
+ .enable(["table", "strikethrough"])
+ .use(footnote_plugin).use(deflist_plugin)
+ .use(anchors_plugin, max_level=6, slug_func=github_slug))
+ return md.render(text)
+
+
+# A section number -- "2", "2.9", "3.1.4", with or without a trailing period
+# -- in the two places a document writes one: at the start of a heading, and
+# at the start of a table-of-contents entry. A contents entry is recognised
+# as a list item whose FIRST content is a link to a fragment: that is what a
+# Markdown table of contents is ("- [2.9 Keystone correction](#29-...)"), and
+# requiring the link to open the item leaves a numbered reference in running
+# text alone, where a wide gap would read as a mistake. Measured on the
+# 47-entry contents of the Rectify guide: every numbered fragment link in the
+# document opens a list item, and none appears mid-sentence.
+#
+# The whitespace after the number is part of each match and is CONSUMED --
+# see mark_section_numbers().
+HEADING_NUMBER = re.compile(r"(]*>)\s*(\d+(?:\.\d+)*\.?)\s+")
+TOC_NUMBER = re.compile(r'(\s*]*>)\s*(\d+(?:\.\d+)*\.?)\s+')
+
+
+def mark_section_numbers(html):
+ """Wrap a leading section number in .
+
+ The number is ordinary text in the Markdown ("## 2.9 Keystone
+ correction"), so the gap between it and the title is one space character
+ and nothing in a stylesheet can reach it. Marking the number gives the
+ stylesheet something to hold: markdown.css sets the distance with
+ --secnum-gap, and heading and contents entry take it from the same
+ property, so the two cannot drift apart.
+
+ The space after the number is consumed rather than left in place, so the
+ whole gap is the one CSS value -- otherwise it would be that value plus a
+ space whose width varies with the font.
+
+ This runs after rendering, not before, so the anchor slugs are computed
+ from the heading text as written and internal links still resolve. A
+ heading that opens with a number which is not a section number ("2026 in
+ review") gets the gap too; that is the price of not asking the document
+ to mark its own numbers.
+ """
+ wrap = lambda m: f'{m.group(1)}{m.group(2)}'
+ return TOC_NUMBER.sub(wrap, HEADING_NUMBER.sub(wrap, html))
+
+
+def font_store_dirs():
+ """The Klammertext font store's search order: the KLAMMERTEXT_FONTS
+ directories, then the distribution's own fnt/. Same order the engine
+ uses, so an installed font shadows a distributed one of the same name."""
+ dirs = [Path(d) for d in
+ os.environ.get("KLAMMERTEXT_FONTS",
+ str(Path.home() / ".klammertext" / "fonts")).split(":") if d]
+ home = os.environ.get("KLAMMERTEXT_HOME")
+ if home:
+ dirs.append(Path(home) / "fnt")
+ # This script lives in /sks/tns/, so it can find the
+ # distributed fonts without being told where they are. It has to:
+ # KLAMMERTEXT_HOME is set by a shell profile, and a Makefile rule or a
+ # non-interactive ssh session has no profile -- the guide's build failed
+ # on the Mac with "no font 'eb-garamond' ... Available: (none)" for
+ # exactly that reason, on a machine where the font was present all along.
+ own = HERE.parent.parent / "fnt"
+ if own not in dirs:
+ dirs.append(own)
+ return dirs
+
+
+def font_face_css(name):
+ """The @font-face rules for one font in the store, with absolute URLs.
+
+ A font in the store is a / directory of .ttf files beside a
+ .css declaring its variants -- already the browser's own format,
+ which is why this needs no conversion, only a path fix: the store writes
+ url('eb-garamond/Regular.ttf') relative to itself, and the generated HTML
+ carries a pointing at the Markdown file's directory, so a relative
+ URL would resolve somewhere else entirely and the font would silently
+ fall back.
+
+ Returns (css, family). A font the browser cannot find is not an error it
+ reports -- it just uses something else -- so an unknown name must fail
+ here instead.
+ """
+ import re
+ for d in font_store_dirs():
+ css_path = d / f"{name}.css"
+ if not css_path.exists():
+ continue
+ css = css_path.read_text(encoding="utf-8")
+ css = re.sub(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)",
+ lambda m: f"url('{(d / m.group(1)).resolve().as_uri()}')", css)
+ family = re.search(r"font-family:\s*['\"]([^'\"]+)['\"]", css)
+ return css, (family.group(1) if family else name)
+ available = sorted({p.stem for d in font_store_dirs() if d.is_dir()
+ for p in d.glob("*.css")})
+ sys.exit(f"no font {name!r} in the font store. Available: "
+ + (", ".join(available) or "(none)"))
+
+
+def font_metrics(ttf_path):
+ """(x-height, cap-height), each as a fraction of the em, from the OS/2
+ table -- the same source mac/font_store.cpp reads.
+
+ A sfnt file is a table directory: numTables at offset 4, then 16-byte
+ entries of tag/checksum/offset/length. unitsPerEm lives at offset 18 of
+ 'head'; sxHeight and sCapHeight at 86 and 88 of 'OS/2', and only from
+ version 2 of that table -- an older font reports neither, which is why
+ this can return zeros and the caller must cope.
+ """
+ data = ttf_path.read_bytes()
+ num_tables = struct.unpack(">H", data[4:6])[0]
+ tables = {}
+ for i in range(num_tables):
+ off = 12 + i * 16
+ tag, _, start, length = struct.unpack(">4sIII", data[off:off + 16])
+ tables[tag.decode("latin-1").strip()] = (start, length)
+ if "head" not in tables or "OS/2" not in tables:
+ return 0.0, 0.0
+ head = tables["head"][0]
+ units = struct.unpack(">H", data[head + 18:head + 20])[0] or 1000
+ os2 = tables["OS/2"][0]
+ version = struct.unpack(">H", data[os2:os2 + 2])[0]
+ if version < 2:
+ return 0.0, 0.0
+ x_height = struct.unpack(">h", data[os2 + 86:os2 + 88])[0]
+ cap_height = struct.unpack(">h", data[os2 + 88:os2 + 90])[0]
+ return x_height / units, cap_height / units
+
+
+def regular_face(store_dir, name):
+ """The Regular face of a font in the store, which the metrics come from."""
+ d = store_dir / name
+ for candidate in ("Regular.ttf", "Regular.otf"):
+ if (d / candidate).exists():
+ return d / candidate
+ faces = sorted(d.glob("*.ttf")) + sorted(d.glob("*.otf"))
+ return faces[0] if faces else None
+
+
+def scale_factors(names, match="average"):
+ """How much to scale each role so it looks the size of the serif font.
+
+ Three measures, the same three sks/document/document_html.cpp offers:
+
+ xheight serif_xh / other_xh equalises lowercase -- the letters
+ "e" and "x" come out the same height. fontspec calls this
+ MatchLowercase, and it is the usual answer when an old-style
+ serif meets a monospace.
+ capheight serif_ch / other_ch equalises capitals.
+ average the ratio of the MEANS the compromise, and the SKS default,
+ so the default here agrees with the other pipeline.
+
+ Which to use is not a fact about the fonts. It depends on what the eye
+ lands on: prose interleaved with lowercase identifiers wants xheight,
+ text full of CONSTANTS wants capheight. With a face whose x-height and
+ cap-height are far apart -- EB Garamond is 0.400 against 0.650 -- the
+ average visibly satisfies neither.
+
+ Returns {role: factor}; a role whose font reports no metrics is omitted
+ rather than guessed at.
+ """
+ pick = {"xheight": lambda xh, ch: xh,
+ "capheight": lambda xh, ch: ch,
+ "average": lambda xh, ch: (xh + ch) / 2.0}[match]
+ metrics = {}
+ for role, name in names.items():
+ if not name:
+ continue
+ for d in font_store_dirs():
+ if (d / f"{name}.css").exists():
+ face = regular_face(d, name)
+ if face:
+ xh, ch = font_metrics(face)
+ if xh > 0 and ch > 0:
+ metrics[role] = pick(xh, ch)
+ break
+ if "serif" not in metrics:
+ return {}
+ serif = metrics["serif"]
+ return {role: serif / m for role, m in metrics.items() if m > 0}
+
+
+def font_css(serif, sans, mono, match="average"):
+ """@font-face blocks plus the SKS's own custom-property names.
+
+ --serif, --sans and --mono are what sks/font/css/font.css calls them, so a
+ stylesheet written for one pipeline reads the same in the other. Family
+ names are QUOTED: an unquoted digit-initial name ("Source Sans 3") is
+ invalid CSS, and a font-family using it via var() computes to inherit --
+ the font is lost with no error anywhere.
+ """
+ faces, variables = [], []
+ for role, name, fallback in (("serif", serif, "serif"),
+ ("sans", sans, "sans-serif"),
+ ("mono", mono, "monospace")):
+ if not name:
+ continue
+ css, family = font_face_css(name)
+ faces.append(css)
+ variables.append(f' --{role}: "{family}", {fallback};')
+ if not variables:
+ return ""
+ # Scale factors, computed rather than guessed. Both spellings are
+ # emitted: the short ones this script has always used, and the ones
+ # sks/font/css/font.css defines, so a stylesheet written for either
+ # pipeline works with the other.
+ scales = scale_factors({"serif": serif, "sans": sans, "mono": mono}, match)
+ alias = {"sans": "sans-serif", "mono": "monospace"}
+ for role, factor in scales.items():
+ variables.append(f" --{role}-scale: {factor:.4f};")
+ if role in alias:
+ variables.append(f" --{alias[role]}-scale: {factor:.4f};")
+ return ("\n".join(faces) + "\n:root {\n" + "\n".join(variables) + "\n}\n")
+
+
+# A language's line-continuation character, where continuing a line is
+# legal at all. Absent from this table means DO NOT WRAP: PowerShell
+# continues with a backtick and a backslash would corrupt it, an .ini or
+# .desktop file has no continuation whatever, and an unlabelled block is as
+# likely to be a directory tree as it is to be code. Wrapping those would
+# turn a document that merely looks too wide into one that is wrong -- and
+# wrong silently, since a broken .desktop file reports nothing.
+CONTINUATION = {"bash": "\\", "sh": "\\", "shell": "\\", "zsh": "\\",
+ "console": "\\", "powershell": "`", "ps1": "`"}
+
+
+def unquoted_hash(line):
+ """The index of a comment's #, or -1. The quote tracking is crude, but
+ it only has to find a # that is not inside a string."""
+ quote = None
+ for i, c in enumerate(line):
+ if quote:
+ if c == quote:
+ quote = None
+ elif c in "\"'":
+ quote = c
+ elif c == "#":
+ return i
+ return -1
+
+
+def wrap_code_line(line, width, cont, indent=" "):
+ """A long line as a continued sequence, or None if it cannot be done.
+
+ Two content rules, both needed for bash alone, so neither is avoided by
+ assuming a language:
+
+ * break only at spaces OUTSIDE quotes, or a split lands inside a string
+ literal and changes what the command does;
+ * never break inside a comment. A backslash within a shell comment
+ does NOT continue it -- the comment ends at the newline regardless --
+ so the remainder would be read as a command. Breaking BEFORE the #
+ is safe, because the continuation puts the comment back into the same
+ logical line.
+ """
+ if len(line) <= width or line.rstrip().endswith(cont):
+ return None
+ stripped = line.lstrip()
+ lead = line[:len(line) - len(stripped)]
+ hash_at = unquoted_hash(line)
+
+ def break_points(s, floor):
+ quote, points = None, []
+ for i, c in enumerate(s):
+ if quote:
+ if c == quote:
+ quote = None
+ elif c in "\"'":
+ quote = c
+ elif c == " " and i > floor and (hash_at < 0 or i < hash_at):
+ points.append(i)
+ return points
+
+ pieces, rest, prefix = [], line, lead
+ while len(rest) > width:
+ room = width - len(cont) - 1
+ points = [b for b in break_points(rest, len(prefix)) if b <= room]
+ if not points:
+ break # nothing splittable in range
+ b = points[-1]
+ pieces.append(rest[:b] + " " + cont)
+ prefix = lead + indent
+ rest = prefix + rest[b + 1:]
+ hash_at = unquoted_hash(rest)
+ if not pieces:
+ return None
+ pieces.append(rest)
+ return pieces
+
+
+def wrap_fenced_code(text, width):
+ """Wrap over-long lines in fenced blocks whose language permits it.
+
+ Returns (text, wrapped, skipped): what it did, and what it did not. A
+ line left long still overflows the page, and an overflowing page makes
+ the browser shrink the WHOLE document -- so the caller reports the
+ remainder rather than letting it pass unnoticed.
+ """
+ out, inside, lang, wrapped, skipped = [], False, "", 0, 0
+ for line in text.split("\n"):
+ fence = re.match(r"\s*(?:```|~~~)(\w*)", line)
+ if fence:
+ if not inside:
+ lang = (fence.group(1) or "").lower()
+ inside = not inside
+ out.append(line)
+ continue
+ if inside and len(line) > width:
+ cont = CONTINUATION.get(lang)
+ pieces = wrap_code_line(line, width, cont) if cont else None
+ if pieces:
+ out.extend(pieces)
+ wrapped += 1
+ continue
+ skipped += 1
+ out.append(line)
+ return "\n".join(out), wrapped, skipped
+
+
+def wrap_and_report(text, wrap):
+ """Wrap the fenced blocks and say what happened, or did not."""
+ text, wrapped, skipped = wrap_fenced_code(text, wrap)
+ if wrapped or skipped:
+ note = f"wrapped {wrapped} code lines at {wrap} columns"
+ if skipped:
+ note += (f"; {skipped} left long -- no continuation character "
+ "exists for that block's language")
+ print(note)
+ return text
+
+
+def build_html(md_path, css_paths, fonts_css="", wrap=0):
+ """One self-contained HTML document.
+
+ The stylesheets are INLINED rather than linked: a headless browser fetches
+ a linked stylesheet asynchronously, and printing can begin before it
+ arrives -- an unstyled PDF that looks like a CSS bug. Inline text cannot
+ lose that race.
+
+ A element points at the Markdown file's own directory so that
+ relative image paths resolve, which lets the generated HTML live in a
+ temporary directory instead of beside the source.
+ """
+ text = md_path.read_text(encoding="utf-8")
+ if wrap:
+ text = wrap_and_report(text, wrap)
+ body = mark_section_numbers(render_markdown(text))
+ css = fonts_css + "\n".join(Path(p).read_text(encoding="utf-8") for p in css_paths)
+ base = md_path.resolve().parent.as_uri() + "/"
+ return (f'\n\n'
+ f' \n'
+ f'{md_path.stem} \n'
+ f'\n\n\n{body}\n\n')
+
+
+def measure_code_columns(port, url, paper, margin):
+ """How many monospace characters fit on one line inside a .
+
+ Asked of the browser rather than computed, because the answer depends on
+ things only it knows: the mono font's advance width at the size the
+ stylesheet computes for it (itself a metric-derived scale factor), the
+ pre's padding and border, and the printable width of the paper. Any of
+ those can change in the stylesheet, and a wrap column written down by
+ hand then quietly becomes wrong in one of two directions -- too large and
+ a line overflows, shrinking every page; too small and the code is broken
+ into continuations with the right half of the box left empty. The second
+ is what happened here: a hand-set 71 against a real capacity of 94, so
+ 12 of the Rectify guide's 66 code lines were being continued for no
+ reason (2026-08-09).
+
+ Measured under PRINT conditions, like the overflow check in
+ print_to_pdf: on screen the viewport width and the @media print padding
+ are both wrong.
+ """
+ width, height = (8.27, 11.69) if paper == "A4" else (8.5, 11.0)
+ available = (width - 2 * margin) * 96.0
+ ws = WebSocket(page_socket(port))
+ ws.call("Page.enable")
+ ws.call("Page.navigate", url=url)
+ ws.wait_for("Page.loadEventFired")
+ ws.call("Emulation.setEmulatedMedia", media="print")
+ ws.call("Emulation.setDeviceMetricsOverride", width=int(available), height=1200,
+ deviceScaleFactor=1, mobile=False)
+ # Every pre is measured, not just the first, and the NARROWEST answer
+ # wins: a block inside a list item is indented, and wrapping the document
+ # to what a full-width block holds would leave the indented ones to soft
+ # wrap -- the ragged break with no continuation character that the
+ # wrapping exists to replace. Measured on the Rectify guide: 624px at
+ # the margin, 584px inside a list.
+ #
+ # The font is taken from the element that actually carries the text, the
+ # inside the , never the itself. Those are not the
+ # same size: the stylesheet's "code, pre" scale rule applies to both, so
+ # a nested in a is scaled twice (14.00px -> 12.26px here).
+ # Measuring the pre's font therefore understates capacity by that factor
+ # -- 88 columns against the 104 the page really holds.
+ #
+ # A document with no code block at all returns 0, and the caller then
+ # leaves the text alone.
+ measure = ws.call("Runtime.evaluate", returnByValue=True, expression="""
+ (() => {
+ const pres = [...document.querySelectorAll('pre')];
+ if (!pres.length) return {columns: 0};
+ const probe = document.createElement('span');
+ probe.style.position = 'absolute';
+ probe.style.whiteSpace = 'pre';
+ probe.textContent = 'x'.repeat(100);
+ document.body.appendChild(probe);
+ let columns = Infinity;
+ for (const pre of pres) {
+ const cs = getComputedStyle(pre);
+ const content = pre.clientWidth
+ - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
+ probe.style.font = getComputedStyle(pre.querySelector('code') || pre).font;
+ const charWidth = probe.getBoundingClientRect().width / 100;
+ if (content > 0 && charWidth > 0)
+ columns = Math.min(columns, content / charWidth);
+ }
+ probe.remove();
+ return {columns: columns === Infinity ? 0 : columns};
+ })()""")["result"]["value"]
+ ws.call("Emulation.clearDeviceMetricsOverride")
+ ws.call("Emulation.setEmulatedMedia", media="")
+ if not measure["columns"]:
+ return 0
+ # One column in hand: the browser's soft wrap and this arithmetic agree to
+ # within a rounding error, and a line that lands exactly on the edge would
+ # be broken by the browser anyway -- undoing the point of wrapping it.
+ return max(20, int(measure["columns"]) - 1)
+
+
+# --- a WebSocket client, standard library only -----------------------------
+
+class WebSocket:
+ """The minimum client needed to talk CDP: RFC 6455 text frames, masked
+ outbound, reassembled inbound, with pings answered.
+
+ Inbound frames must handle the 64-bit length case: a printToPDF reply
+ carries the whole document as base64 and is routinely megabytes, far past
+ the 125-byte and 65535-byte forms.
+ """
+
+ def __init__(self, url):
+ _, _, rest = url.partition("://")
+ hostport, _, path = rest.partition("/")
+ host, _, port = hostport.partition(":")
+ self.sock = socket.create_connection((host, int(port or 80)))
+ key = base64.b64encode(os.urandom(16)).decode()
+ self.sock.sendall(
+ f"GET /{path} HTTP/1.1\r\nHost: {hostport}\r\n"
+ f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
+ f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
+ .encode())
+ # Read the handshake response one byte at a time: reading in blocks
+ # would consume the start of the first frame along with it.
+ head = b""
+ while not head.endswith(b"\r\n\r\n"):
+ b = self.sock.recv(1)
+ if not b:
+ raise RuntimeError("the browser closed the connection during the handshake")
+ head += b
+ if b"101" not in head.split(b"\r\n")[0]:
+ raise RuntimeError(f"websocket upgrade refused: {head.splitlines()[0]!r}")
+ self.next_id = 0
+
+ def _read(self, n):
+ buf = b""
+ while len(buf) < n:
+ chunk = self.sock.recv(n - len(buf))
+ if not chunk:
+ raise RuntimeError("the browser closed the connection")
+ buf += chunk
+ return buf
+
+ def send(self, method, **params):
+ self.next_id += 1
+ payload = json.dumps({"id": self.next_id, "method": method,
+ "params": params}).encode()
+ header = bytearray([0x81]) # FIN + text
+ n = len(payload)
+ if n < 126:
+ header.append(0x80 | n)
+ elif n < 65536:
+ header.append(0x80 | 126); header += struct.pack(">H", n)
+ else:
+ header.append(0x80 | 127); header += struct.pack(">Q", n)
+ mask = os.urandom(4)
+ header += mask
+ self.sock.sendall(bytes(header) +
+ bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
+ return self.next_id
+
+ def recv(self):
+ """One complete message, reassembling continuation frames."""
+ message = b""
+ while True:
+ b0, b1 = self._read(2)
+ opcode, length = b0 & 0x0F, b1 & 0x7F
+ if length == 126:
+ length = struct.unpack(">H", self._read(2))[0]
+ elif length == 127:
+ length = struct.unpack(">Q", self._read(8))[0]
+ data = self._read(length) # server frames are never masked
+ if opcode == 0x9: # ping -> pong, then keep reading
+ self.sock.sendall(b"\x8a\x80" + os.urandom(4))
+ continue
+ if opcode == 0x8:
+ raise RuntimeError("the browser closed the websocket")
+ message += data
+ if b0 & 0x80: # FIN
+ return json.loads(message)
+
+ def call(self, method, **params):
+ """Send a command and return its result, skipping the event stream."""
+ want = self.send(method, **params)
+ while True:
+ msg = self.recv()
+ if msg.get("id") == want:
+ if "error" in msg:
+ raise RuntimeError(f"{method}: {msg['error']}")
+ return msg.get("result", {})
+
+ def wait_for(self, event, timeout=60):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if self.recv().get("method") == event:
+ return True
+ raise RuntimeError(f"timed out waiting for {event}")
+
+
+# --- the browser -----------------------------------------------------------
+
+def find_browser(explicit):
+ for candidate in ([explicit] if explicit else BROWSERS):
+ path = shutil.which(candidate) or (candidate if Path(candidate).exists() else None)
+ if path:
+ return path
+ sys.exit("no Chromium-based browser found; pass --browser PATH")
+
+
+def free_port():
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def start_browser(browser, profile, port):
+ """Headless, with its own profile so a running browser is untouched.
+
+ --user-data-dir matters for more than tidiness: without it the launch
+ would attach to the user's existing session, and killing it afterwards
+ would close their windows.
+ """
+ proc = subprocess.Popen(
+ [browser, "--headless=new", "--disable-gpu", "--no-sandbox",
+ "--no-first-run", "--no-default-browser-check",
+ "--disable-component-update", "--disable-background-networking",
+ f"--user-data-dir={profile}", f"--remote-debugging-port={port}",
+ "about:blank"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ deadline = time.time() + 60
+ while time.time() < deadline:
+ if proc.poll() is not None:
+ sys.exit(f"{browser} exited before the protocol port opened")
+ try:
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version",
+ timeout=1) as r:
+ json.load(r)
+ return proc
+ except Exception:
+ time.sleep(0.2)
+ proc.kill()
+ sys.exit("the browser never opened its DevTools port")
+
+
+# Chromium's own page header and footer, requested by --page-numbers.
+# printToPDF takes HTML templates, in which a handful of magic classes are
+# substituted -- pageNumber, totalPages, title, url, date. The templates are
+# rendered in their own context with a default font size of a few pixels, so
+# they must carry their own inline style or they come out unreadably small,
+# and that context is NOT the document's: the template cannot use the
+# stylesheet's fonts or custom properties, which is why this one names a
+# generic sans.
+#
+# The CSS alternative now exists, and this is kept anyway. Paged Media
+# margin boxes (@page { @bottom-center { content: counter(page) } }) were
+# implemented by no browser when this was written; measured 2026-08-09 on
+# Brave 151 (Chromium 151), they work, count pages, and honour a
+# font-family taken from a custom property. A stylesheet that wants the
+# number in the document's own face should use them. Two cautions, both
+# measured on the same day: a webfont the document body never uses is not
+# loaded for a margin box -- var(--mono) in a box of a prose-only document
+# fell back to the platform monospace, and in one variant the box rendered
+# NOTHING at all -- and the feature is recent enough that an older Chromium
+# silently prints no number. --page-numbers has neither hazard, so it stays
+# the default answer and the templates stay here.
+FOOTER = (''
+ ' of '
+ '')
+# displayHeaderFooter turns BOTH on, and an unset header template falls back
+# to Chromium's default (title and date). An empty div suppresses it.
+EMPTY = ''
+
+
+def page_socket(port):
+ """The DevTools socket of the browser's existing page.
+
+ Attach to the about:blank page the browser was launched with, rather than
+ asking for a new target: /json/new has required PUT since a recent
+ Chromium (a GET or POST answers 405), and there is already a page.
+ """
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=10) as r:
+ targets = json.load(r)
+ pages = [t for t in targets if t.get("type") == "page" and t.get("webSocketDebuggerUrl")]
+ if not pages:
+ raise RuntimeError("the browser exposed no page to print")
+ return pages[0]["webSocketDebuggerUrl"]
+
+
+def print_to_pdf(port, url, pdf_path, paper, margin, page_numbers=False):
+ """Drive one page through load and print."""
+ ws = WebSocket(page_socket(port))
+ ws.call("Page.enable")
+ ws.call("Page.navigate", url=url)
+ ws.wait_for("Page.loadEventFired")
+ width, height = (8.27, 11.69) if paper == "A4" else (8.5, 11.0)
+ # Warn if the document is wider than the page. This is the failure that
+ # cost the most to find: Chromium does not clip or paginate overflow when
+ # printing, it SHRINKS THE WHOLE DOCUMENT until the widest element fits.
+ # So one over-long code line silently rescales every page, by a factor
+ # that changes whenever that line does -- and every font size then looks
+ # wrong for a reason nothing in the CSS explains. Measured here rather
+ # than assumed, and reported rather than left to be discovered.
+ #
+ # The measurement must be made under PRINT conditions. Measuring the
+ # page as it stands reports the browser window's width, which has nothing
+ # to do with the paper, and the @media print rules -- which change the
+ # padding and sizes that decide whether anything overflows -- are not
+ # even in effect. So emulate print media, force the viewport to the
+ # printable width, measure, and put both back before printing.
+ available = (width - 2 * margin) * 96.0 # CSS pixels of printable width
+ ws.call("Emulation.setEmulatedMedia", media="print")
+ ws.call("Emulation.setDeviceMetricsOverride", width=int(available), height=1200,
+ deviceScaleFactor=1, mobile=False)
+ measure = ws.call("Runtime.evaluate", returnByValue=True, expression="""
+ (() => {
+ const pres = [...document.querySelectorAll('pre')];
+ let charWidth = 0;
+ if (pres.length) {
+ const probe = document.createElement('span');
+ probe.style.font = getComputedStyle(pres[0]).font;
+ probe.style.position = 'absolute';
+ probe.style.whiteSpace = 'pre';
+ probe.textContent = 'x'.repeat(100);
+ document.body.appendChild(probe);
+ charWidth = probe.getBoundingClientRect().width / 100;
+ probe.remove();
+ }
+ return {width: document.body.scrollWidth,
+ over: pres.filter(e => e.scrollWidth > e.clientWidth + 1).length,
+ charWidth: charWidth};
+ })()""")["result"]["value"]
+ ws.call("Emulation.clearDeviceMetricsOverride")
+ ws.call("Emulation.setEmulatedMedia", media="")
+ if measure["width"] > available + 1:
+ shrink = available / measure["width"]
+ fits = int(available / measure["charWidth"]) if measure["charWidth"] else 0
+ print(f"warning: the content is {measure['width']:.0f}px wide but the page "
+ f"holds {available:.0f}px, so the browser will shrink every page to "
+ f"{shrink * 100:.0f}%.")
+ if measure["over"]:
+ print(f" {measure['over']} code blocks overflow; about {fits} "
+ f"columns fit. Try --wrap-code {fits}, or add "
+ "'pre {{ white-space: pre-wrap }}' to the stylesheet."
+ .replace("{{", "{").replace("}}", "}"))
+
+ # A footer needs room to sit in: with too small a bottom margin Chromium
+ # renders it under the text or not at all.
+ bottom = max(margin, 0.6) if page_numbers else margin
+ result = ws.call(
+ "Page.printToPDF",
+ printBackground=True,
+ displayHeaderFooter=page_numbers,
+ headerTemplate=EMPTY,
+ footerTemplate=FOOTER if page_numbers else EMPTY,
+ # The stylesheet is the point of this program, so let an @page rule in
+ # it win over these defaults when it says anything.
+ preferCSSPageSize=True,
+ paperWidth=width, paperHeight=height,
+ marginTop=margin, marginBottom=bottom,
+ marginLeft=margin, marginRight=margin)
+ Path(pdf_path).write_bytes(base64.b64decode(result["data"]))
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Markdown -> PDF via headless Chromium (CDP)")
+ ap.add_argument("input", nargs="?")
+ ap.add_argument("output", nargs="?")
+ ap.add_argument("--css", action="append", default=None,
+ help=f"stylesheet to apply (repeatable; default {DEFAULT_CSS.name})")
+ ap.add_argument("--browser", help="path to a Chromium-based browser")
+ ap.add_argument("--paper", choices=["A4", "letter"], default="A4")
+ ap.add_argument("--margin", type=float, default=0.5, help="inches")
+ ap.add_argument("--setup", action="store_true",
+ help="create the virtual environment this script needs, and exit")
+ ap.add_argument("--page-numbers", action="store_true",
+ help='number the pages ("3 of 21") in the footer')
+ ap.add_argument("--keep-html", action="store_true",
+ help="keep the intermediate HTML beside the PDF")
+ ap.add_argument("--serif", help="serif font from the Klammertext font store")
+ ap.add_argument("--sans", help="sans font from the Klammertext font store")
+ ap.add_argument("--mono", help="monospace font from the Klammertext font store")
+ ap.add_argument("--wrap-code", default=0, metavar="COLUMNS",
+ help="break code lines longer than COLUMNS at a word "
+ "boundary, using the language's continuation "
+ "character and an indent; a block whose language has "
+ "no continuation is left alone and reported. "
+ "\"auto\" asks the browser how many characters fit "
+ "in a code box and uses that, which is right by "
+ "construction when the fonts, sizes or margins change")
+ ap.add_argument("--match", choices=["average", "xheight", "capheight"],
+ default="average",
+ help="which measure the sans and mono fonts are scaled to "
+ "match: xheight equalises lowercase (the letter \"e\"), "
+ "capheight equalises capitals, average is the "
+ "compromise and the SKS default")
+ args = ap.parse_args()
+
+ if not args.input:
+ ap.error("an input file is required")
+ md_path = Path(args.input)
+ if not md_path.exists():
+ sys.exit(f"no such file: {md_path}")
+ pdf_path = Path(args.output) if args.output else md_path.with_suffix(".pdf")
+ css_paths = args.css or ([str(DEFAULT_CSS)] if DEFAULT_CSS.exists() else [])
+ for c in css_paths:
+ if not Path(c).exists():
+ sys.exit(f"no such stylesheet: {c}")
+
+ fonts_css = font_css(args.serif, args.sans, args.mono, args.match)
+ auto_wrap = str(args.wrap_code).lower() == "auto"
+ if not auto_wrap and not str(args.wrap_code).isdigit():
+ ap.error(f"--wrap-code takes a column count or \"auto\", "
+ f"not {args.wrap_code!r}")
+ wrap = 0 if auto_wrap else int(args.wrap_code)
+ html = build_html(md_path, css_paths, fonts_css, wrap)
+ work = Path(tempfile.mkdtemp(prefix="md_to_pdf."))
+ try:
+ html_path = work / (md_path.stem + ".html")
+ html_path.write_text(html, encoding="utf-8")
+ browser = find_browser(args.browser)
+ port = free_port()
+ proc = start_browser(browser, work / "profile", port)
+ try:
+ # --wrap-code auto: the unwrapped document is already loaded, so
+ # ask it how wide a code line may be, then build the real one.
+ # Two loads of the same page cost about a second and remove the
+ # only number in this pipeline that had to be guessed.
+ if auto_wrap:
+ columns = measure_code_columns(port, html_path.as_uri(),
+ args.paper, args.margin)
+ if columns:
+ html = build_html(md_path, css_paths, fonts_css, columns)
+ html_path.write_text(html, encoding="utf-8")
+ print_to_pdf(port, html_path.as_uri(), pdf_path, args.paper,
+ args.margin, args.page_numbers)
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ if args.keep_html:
+ kept = pdf_path.with_suffix(".html")
+ kept.write_text(html, encoding="utf-8")
+ print(f"{kept}")
+ print(f"{pdf_path} ({pdf_path.stat().st_size} bytes, "
+ f"{Path(browser).name}, {len(css_paths)} stylesheet"
+ f"{'s' if len(css_paths) != 1 else ''})")
+ finally:
+ shutil.rmtree(work, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ ensure_renderer()
+ main()
diff --git a/sks/tns/md_to_sks.py b/sks/tns/md_to_sks.py
new file mode 100644
index 0000000..1e3eac6
--- /dev/null
+++ b/sks/tns/md_to_sks.py
@@ -0,0 +1,327 @@
+#!/usr/bin/env python3
+"""Markdown -> Standard Klammer Set converter (first implementation).
+
+SPECIFICATION: doc/markdown_to_klammertext.md. That document is authoritative
+and is written to be sufficient to rebuild this program; this program is its
+reference implementation, not the other way round. Its "The algorithm"
+section states the pass order, which is forced, and the traps that make it so.
+
+The conversion is to the SKS, not to Klammertext the language: the
+correspondence between a Markdown construct and a klammer is a property of
+the klammer set. That is why this lives under sks/ rather than in mac/.
+
+What cannot be converted exactly is recorded in the output as a
+#[MD : ... ]# marker -- lossy, gap, or judgment -- so a converted
+document carries its own worklist and "how far from done is this file" is a
+grep. A file with no markers is inside the convertible subset.
+
+First run: the Rectify user guide (1,805 lines), 2026-08-07. It rendered to
+html and pdf without error or warning, with 99 markers -- 91 of them internal
+anchor links, the one gap that appears at volume.
+
+Usage: python3 md_to_sks.py
+"""
+import re, sys, os, datetime
+from collections import Counter
+
+SPECIALS = "^@#|*" # ^ first: the other quotings introduce ^
+marks = Counter()
+
+def mark(kind, text):
+ """A #[MD ... ]# marker.
+
+ Two things have to be neutralised in the text, both of which cost a
+ session's debugging if they are not:
+
+ * "]#" would close the block early and "#[" would open one that must then
+ be balanced (the nesting hazard);
+ * a klammer name written with its "@" is READ before the block is
+ removed -- mark_literal_klammer_content() runs first in
+ process_katoms() -- so "@code has no :language" makes the engine demand
+ a "code@" close from inside removed text. Quoting the @ makes it a
+ special katom, which the literal scan does not see. (TODO #39.)
+ """
+ marks[kind] += 1
+ text = text.replace("]#", "] #").replace("#[", "# [")
+ text = text.replace("@", "^@").replace("|", "^|")
+ return f"#[MD {kind}: {text} ]#"
+
+def quote(s):
+ """Quote every Klammertext special in writer text."""
+ for ch in SPECIALS:
+ s = s.replace(ch, "^" + ch)
+ return s
+
+# ---- inline ---------------------------------------------------------------
+
+CODE = re.compile(r'`([^`\n]+)`')
+LINK = re.compile(r'(? 40:
+ widest = widths.index(max(widths))
+ spec = " ".join("fill" if c == widest else "fit"
+ for c in range(len(widths)))
+ opts += f" :column_width {spec}"
+ out.append(mark("judgment",
+ "column widths are not in the Markdown; the "
+ f"widest column ({max(widths)} characters) was "
+ "given fill so the table wraps instead of "
+ "overflowing"))
+ out.append(f"@table{opts} |")
+ out.append(body)
+ out.append("table@")
+ out.append("")
+ continue
+
+ if BULLET.match(line) or ORDERED.match(line):
+ block, base = [], None
+ while i < n and (BULLET.match(src[i]) or ORDERED.match(src[i]) or
+ (src[i].strip() and src[i].startswith((" ", "\t")))):
+ block.append(src[i]); i += 1
+ out.append(convert_list(block))
+ out.append("")
+ continue
+
+ out.append(inline(line) if line.strip() else "")
+ i += 1
+
+ return out, title, stripped_numbers, dropped_toc
+
+def convert_list(block):
+ """One list, possibly nested, as @ul/@ol with bar-separated items."""
+ def emit(items, ordered, depth):
+ klammer = "@ol" if ordered else "@ul"
+ parts = []
+ for text, children in items:
+ body = inline(text)
+ if children:
+ body += " " + emit(children[0], children[1], depth + 1)
+ parts.append(body)
+ return klammer + " " + " | ".join(parts) + " @"
+
+ def parse(lines, indent):
+ items, i = [], 0
+ ordered = bool(ORDERED.match(lines[0])) if lines else False
+ while i < len(lines):
+ m = BULLET.match(lines[i]) or ORDERED.match(lines[i])
+ if not m or len(m.group(1)) < indent:
+ break
+ text = m.groups()[-1]
+ i += 1
+ child_lines = []
+ while i < len(lines):
+ m2 = BULLET.match(lines[i]) or ORDERED.match(lines[i])
+ if m2 and len(m2.group(1)) > indent:
+ child_lines.append(lines[i]); i += 1
+ elif not m2 and lines[i].strip():
+ text += " " + lines[i].strip(); i += 1
+ else:
+ break
+ children = None
+ if child_lines:
+ sub, sub_ordered = parse(child_lines, len(BULLET.match(child_lines[0]).group(1))
+ if BULLET.match(child_lines[0])
+ else len(ORDERED.match(child_lines[0]).group(1)))
+ children = (sub, sub_ordered)
+ items.append((text, children))
+ return items, ordered
+
+ items, ordered = parse([l for l in block if l.strip()], 0)
+ return emit(items, ordered, 0)
+
+def main():
+ src_path, dst_path = sys.argv[1], sys.argv[2]
+ body, title, stripped, dropped_toc = convert(src_path)
+ today = datetime.date.today().isoformat()
+ head = [f"#[MD source: {os.path.abspath(src_path)}",
+ f" converted {today} ]#", ""]
+ if stripped:
+ head.append(mark("judgment",
+ f"{stripped} headings carried a manual number "
+ '("2.1 ..."); the numbers were removed because @s1/@s2 '
+ "number the headings themselves"))
+ head.append("")
+ head += ["@document", ":structure plain"]
+ if title:
+ head.append(f":title {title}")
+ head += [":text", ""]
+ text = "\n".join(head + body + ["", "@", ""])
+ text = re.sub(r'\n{3,}', "\n\n", text)
+ open(dst_path, "w", encoding="utf-8").write(text)
+ print(f"{dst_path}: {len(text.splitlines())} lines")
+ for kind in ("source", "lossy", "gap", "judgment"):
+ print(f" {kind:9} {marks[kind]}")
+
+main()
diff --git a/sks/tns/mdpdf.sh b/sks/tns/mdpdf.sh
new file mode 100644
index 0000000..f27afb7
--- /dev/null
+++ b/sks/tns/mdpdf.sh
@@ -0,0 +1,94 @@
+# mdpdf -- Markdown to PDF in one word. A shell front end to md_to_pdf.py.
+#
+# mdpdf notes.md # writes notes.pdf beside it
+# mdpdf notes # the .md may be left off
+# mdpdf notes.md out.pdf --page-numbers
+#
+# This file is SOURCED, not run: `mdpdf` has to be a shell function so that
+# TAB completion can be attached to it (compdef and complete both work on
+# functions and commands, never on aliases). env/runtime.env sources it, so
+# a user who has the Klammertext environment at all has the command; sourcing
+# it by hand is only for a shell that does not load runtime.env:
+#
+# source "$KLAMMERTEXT_HOME/sks/tns/mdpdf.sh"
+#
+# It is plain POSIX shell rather than zsh, and works under bash, zsh and dash
+# alike. Nothing here needs zsh -- and runtime.env is sourced from bash
+# profiles too, so a zsh-only file would break for those users at the source,
+# with a syntax error rather than a message.
+#
+# WHAT THE FUNCTION ADDS over calling md_to_pdf.py directly: the house font
+# and wrapping defaults, tolerance of a bare basename, and completion. Each
+# default is a variable, so a user overrides one in their profile without
+# copying the function:
+#
+# MDPDF_SERIF, MDPDF_SANS, MDPDF_MONO font-store names
+# MDPDF_MATCH average | xheight | capheight
+# MDPDF_WRAP columns for --wrap-code;
+# auto = measure, 0 = off
+# MDPDF_BROWSER path to a Chromium-based browser
+#
+# They are read at CALL time, not here, so setting one after this file is
+# sourced still takes effect. Any further md_to_pdf.py option may be given on
+# the command line and wins over the default, since argparse takes the last
+# occurrence of an option.
+#
+# No particular browser is required. md_to_pdf.py finds one from its own
+# list -- Brave, then Chrome, then Chromium, on Linux and macOS -- because
+# they all speak the same DevTools protocol; MDPDF_BROWSER is for a browser
+# installed somewhere unusual, not for choosing a brand.
+
+mdpdf() {
+ if [ -z "$KLAMMERTEXT_HOME" ]; then
+ printf 'mdpdf: KLAMMERTEXT_HOME is not set -- source env/runtime.env\n' >&2
+ return 1
+ fi
+ if [ "$#" -lt 1 ]; then
+ printf 'usage: mdpdf [output.pdf] [md_to_pdf.py option ...]\n' >&2
+ return 1
+ fi
+
+ _mdpdf_in="$1"
+ shift
+ # A bare basename is accepted, but only as a fallback: a file that exists
+ # under the name given is never reinterpreted.
+ if [ ! -f "$_mdpdf_in" ] && [ -f "$_mdpdf_in.md" ]; then
+ _mdpdf_in="$_mdpdf_in.md"
+ fi
+ if [ ! -f "$_mdpdf_in" ]; then
+ printf 'mdpdf: no such file: %s\n' "$_mdpdf_in" >&2
+ unset _mdpdf_in
+ return 1
+ fi
+
+ # Prepended, so an explicit --browser on the command line still wins.
+ if [ -n "$MDPDF_BROWSER" ]; then
+ set -- --browser "$MDPDF_BROWSER" "$@"
+ fi
+
+ python3 "$KLAMMERTEXT_HOME/sks/tns/md_to_pdf.py" "$_mdpdf_in" \
+ --serif "${MDPDF_SERIF:-eb-garamond}" \
+ --sans "${MDPDF_SANS:-source-sans-3}" \
+ --mono "${MDPDF_MONO:-inconsolata}" \
+ --match "${MDPDF_MATCH:-xheight}" \
+ --wrap-code "${MDPDF_WRAP:-auto}" \
+ "$@"
+ _mdpdf_status=$?
+ unset _mdpdf_in
+ return $_mdpdf_status
+}
+
+# Completion, where the shell has it. In zsh compdef exists only after
+# compinit has run, so its absence is not an error; in bash the form is the
+# one doc/argument_completion.md gives for the other commands, plus -d so
+# directories can still be descended into.
+if [ -n "$ZSH_VERSION" ]; then
+ whence compdef >/dev/null 2>&1 && compdef '_files -g "*.md"' mdpdf
+elif [ -n "$BASH_VERSION" ]; then
+ complete -f -d -X '!*.md' mdpdf
+fi
+
+# Sourced from runtime.env, whose own exit status must stay 0: without this,
+# a shell where the completion test failed would report failure for
+# `source runtime.env`.
+true