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)
1188 lines
53 KiB
Python
1188 lines
53 KiB
Python
#!/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 <input.md> [output.pdf] [--css FILE]... [--browser PATH]
|
|
[--keep-html] [--paper A4|letter] [--margin INCHES]
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import http.server
|
|
import re
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
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.
|
|
#
|
|
# Flatpak-installed browsers come LAST, after every native one. Not because
|
|
# they work less well -- the protocol is identical -- but because a flatpak
|
|
# runs in a sandbox whose filesystem grants vary per application, and a native
|
|
# browser has no such variable. Preferring native keeps the simplest case the
|
|
# common one; the flatpaks are here so that a machine which has ONLY those
|
|
# still works, which is the ordinary state of a Pop!_OS box (the Pop!_Shop
|
|
# installs flatpaks) and so of a System76 user.
|
|
FLATPAK_BROWSERS = ["com.brave.Browser", "com.google.Chrome",
|
|
"org.chromium.Chromium"]
|
|
|
|
|
|
def flatpak_paths():
|
|
"""Where a flatpak's runnable wrapper lives, user installs before system.
|
|
|
|
A flatpak puts NOTHING on PATH and nothing in /opt, so the names above
|
|
find it only through these export directories. What is exported is an
|
|
ordinary executable that passes its arguments through to `flatpak run`,
|
|
so it needs no special handling anywhere else in this program.
|
|
|
|
Note the name: the wrapper is called `com.brave.Browser`, not
|
|
`brave-browser`. Putting the export directory on PATH therefore does NOT
|
|
make the earlier entries in BROWSERS resolve -- the application id has to
|
|
be looked for by name, which is what this does.
|
|
"""
|
|
roots = [Path.home() / ".local/share/flatpak/exports/bin",
|
|
Path("/var/lib/flatpak/exports/bin")]
|
|
return [str(root / app) for root in roots for app in FLATPAK_BROWSERS]
|
|
|
|
|
|
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"] + flatpak_paths()
|
|
|
|
|
|
# --- 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 <h2> 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"(<h[1-6]\b[^>]*>)\s*(\d+(?:\.\d+)*\.?)\s+")
|
|
TOC_NUMBER = re.compile(r'(<li>\s*<a href="#[^"]*"[^>]*>)\s*(\d+(?:\.\d+)*\.?)\s+')
|
|
TOC_ITEM = re.compile(r'(<li>)(\s*(?:<p>\s*)?<a href="#)')
|
|
|
|
|
|
def mark_section_numbers(html):
|
|
"""Wrap a leading section number in <span class="secnum">.
|
|
|
|
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)}<span class="secnum">{m.group(2)}</span>'
|
|
return TOC_NUMBER.sub(wrap, HEADING_NUMBER.sub(wrap, html))
|
|
|
|
|
|
def mark_contents_items(html):
|
|
"""Give a table-of-contents entry a class the stylesheet can reach.
|
|
|
|
Same recognition rule as TOC_NUMBER above -- a list item whose first
|
|
content is a link to a fragment -- but WITHOUT requiring a section
|
|
number, since an unnumbered contents is still a contents and its entries
|
|
should look like its neighbours'. An ordinary bulleted list, and a
|
|
fragment link in running text, are both left alone.
|
|
|
|
Marked here rather than matched in CSS because the rule is "the item's
|
|
FIRST content is a fragment link", which a selector cannot quite say:
|
|
:has(> a[href^="#"]) would also catch a paragraph that merely ends in a
|
|
cross-reference. This mirrors how .secnum is done, for the same reason.
|
|
|
|
The optional <p> is markdown-it's loose-list rendering: a contents with
|
|
blank lines between its entries wraps each in a paragraph.
|
|
"""
|
|
return TOC_ITEM.sub(r'<li class="toc">\2', 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 <klammertext>/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, publish):
|
|
"""The @font-face rules for one font in the store, with absolute URLs.
|
|
|
|
A font in the store is a <name>/ directory of .ttf files beside a
|
|
<name>.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 <base> that points elsewhere, so a relative URL would resolve
|
|
somewhere else entirely and the font would silently fall back.
|
|
|
|
*publish* turns a font file into the URL the page should ask for. It is a
|
|
parameter rather than a fixed `as_uri()` because the two consumers need
|
|
different answers: the page being printed is SERVED (so a sandboxed
|
|
browser can fetch it without any filesystem grant), while a --keep-html
|
|
file is meant to be opened later by hand, when no server is running, and
|
|
needs a file:// URL to be worth keeping.
|
|
|
|
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. That guard covers an unknown font NAME; an unreadable font
|
|
FILE is caught after loading instead, by check_fonts().
|
|
"""
|
|
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('{publish((d / m.group(1)).resolve())}')", 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", publish=None):
|
|
"""@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.
|
|
|
|
Returns (css, families): the family names are what check_fonts() later
|
|
asserts the browser actually loaded.
|
|
"""
|
|
if publish is None:
|
|
publish = lambda p: p.as_uri()
|
|
faces, variables, families = [], [], []
|
|
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, publish)
|
|
faces.append(css)
|
|
families.append(family)
|
|
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",
|
|
families)
|
|
|
|
|
|
# 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, report=True):
|
|
"""Wrap the fenced blocks and say what happened, or did not.
|
|
|
|
*report* is off when the same wrap is applied a second time to build the
|
|
--keep-html copy: the wrapping is identical, so saying so twice would only
|
|
suggest it had happened twice.
|
|
"""
|
|
text, wrapped, skipped = wrap_fenced_code(text, wrap)
|
|
if report and (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, base=None, report=True):
|
|
"""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 <base> element makes the document's relative image paths resolve. It is
|
|
a parameter because there are two answers: while printing, the base is the
|
|
local server's root, which serves the Markdown file's directory; for a
|
|
--keep-html file it is that directory's own file:// URL, so the kept file
|
|
still works when nothing is serving it.
|
|
"""
|
|
text = md_path.read_text(encoding="utf-8")
|
|
if wrap:
|
|
text = wrap_and_report(text, wrap, report)
|
|
body = mark_contents_items(mark_section_numbers(render_markdown(text)))
|
|
css = fonts_css + "\n".join(Path(p).read_text(encoding="utf-8") for p in css_paths)
|
|
if base is None:
|
|
base = md_path.resolve().parent.as_uri() + "/"
|
|
return (f'<!doctype html>\n<html><head><meta charset="utf-8">\n'
|
|
f'<base href="{base}">\n'
|
|
f'<title>{md_path.stem}</title>\n'
|
|
f'<style>\n{css}\n</style>\n</head>\n<body>\n{body}\n</body></html>\n')
|
|
|
|
|
|
def measure_code_columns(port, url, paper, margin):
|
|
"""How many monospace characters fit on one line inside a <pre>.
|
|
|
|
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
|
|
# <code> inside the <pre>, never the <pre> itself. Those are not the
|
|
# same size: the stylesheet's "code, pre" scale rule applies to both, so
|
|
# a <code> nested in a <pre> 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}")
|
|
|
|
|
|
# --- serving the document --------------------------------------------------
|
|
|
|
# The document is SERVED to the browser over the loopback interface rather
|
|
# than handed to it as a file:// path. The reason is sandboxing, and it is
|
|
# not hypothetical: measured on Kukka (Pop!_OS, browsers from the Pop!_Shop,
|
|
# 2026-08-09) the Brave flatpak grants itself /tmp while the Chrome flatpak
|
|
# does not, so the same file:// scheme works with one browser and fails with
|
|
# the other ON ONE MACHINE. Neither could read the font store under
|
|
# ~/projects at all, and a browser does not report a font it cannot fetch --
|
|
# it silently substitutes, so the PDF came out in Liberation Serif and the
|
|
# command reported success.
|
|
#
|
|
# Every sandbox shares the network namespace, which is already relied on: the
|
|
# DevTools port is reached at 127.0.0.1. So HTTP needs no grant from anyone,
|
|
# from any sandbox technology, present or future -- and it delivers the
|
|
# stylesheet, the fonts AND the document's own images by the same route. It
|
|
# also retires the <base>-points-at-the-source-directory arrangement, since
|
|
# the server root IS that directory.
|
|
#
|
|
# Bound to 127.0.0.1 only, on an ephemeral port, for the seconds the render
|
|
# takes.
|
|
|
|
DOCUMENT_URL = "/__md_to_pdf__.html"
|
|
|
|
# Stated here rather than trusted to the platform's table: what mimetypes
|
|
# knows about fonts differs by Python version and by /etc/mime.types, and a
|
|
# font served as application/octet-stream is at the mercy of the browser's
|
|
# sniffing.
|
|
for _suffix, _type in ((".ttf", "font/ttf"), (".otf", "font/otf"),
|
|
(".woff", "font/woff"), (".woff2", "font/woff2")):
|
|
mimetypes.add_type(_type, _suffix)
|
|
|
|
|
|
class Assets:
|
|
"""Files the page may fetch, published under stable, opaque URL paths.
|
|
|
|
A font lives outside the served directory (the font store is wherever
|
|
KLAMMERTEXT_FONTS or the distribution puts it), so it cannot be reached by
|
|
a relative URL. Rather than serve those directories wholesale, each file
|
|
is published individually and nothing else is reachable.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.by_url = {}
|
|
|
|
def publish(self, path):
|
|
path = Path(path).resolve()
|
|
for url, known in self.by_url.items():
|
|
if known == path:
|
|
return url
|
|
url = f"/__asset__/{len(self.by_url)}/{path.name}"
|
|
self.by_url[url] = path
|
|
return url
|
|
|
|
|
|
def start_server(doc_root, assets, html):
|
|
"""Serve *doc_root*, the published assets, and the document itself.
|
|
|
|
*html* is a one-element list, not a string: with --wrap-code auto the
|
|
document is rebuilt after being measured, and the server must then hand
|
|
out the new text. Returns (server, port); the caller shuts it down.
|
|
"""
|
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, directory=str(doc_root), **kw)
|
|
|
|
def log_message(self, *a):
|
|
pass # a render is not a web server log
|
|
|
|
def _send(self, body, content_type):
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
if self.command != "HEAD":
|
|
self.wfile.write(body)
|
|
|
|
def _route(self):
|
|
path = self.path.split("?", 1)[0]
|
|
if path == DOCUMENT_URL:
|
|
self._send(html[0].encode("utf-8"), "text/html; charset=utf-8")
|
|
return True
|
|
asset = assets.by_url.get(path)
|
|
if asset:
|
|
# A published file that cannot be read is a 404, not a
|
|
# traceback: the font store can name a file that is not there,
|
|
# and the useful report is check_fonts's ("the font X failed
|
|
# to load"), not this thread's stack.
|
|
try:
|
|
body = asset.read_bytes()
|
|
except OSError:
|
|
self.send_error(404)
|
|
return True
|
|
kind = mimetypes.guess_type(asset.name)[0] or "application/octet-stream"
|
|
self._send(body, kind)
|
|
return True
|
|
return False
|
|
|
|
def do_GET(self):
|
|
if not self._route():
|
|
super().do_GET()
|
|
|
|
def do_HEAD(self):
|
|
if not self._route():
|
|
super().do_HEAD()
|
|
|
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
server.daemon_threads = True
|
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
return server, server.server_address[1]
|
|
|
|
|
|
# --- 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 = ('<div style="font-family:sans-serif; font-size:9px; color:#555; '
|
|
'width:100%; text-align:center; margin:0 0.5in;">'
|
|
'<span class="pageNumber"></span> of <span class="totalPages"></span>'
|
|
'</div>')
|
|
# displayHeaderFooter turns BOTH on, and an unset header template falls back
|
|
# to Chromium's default (title and date). An empty div suppresses it.
|
|
EMPTY = '<div></div>'
|
|
|
|
|
|
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 check_fonts(ws, families):
|
|
"""Fail if a font the document asked for did not actually load.
|
|
|
|
This is the guard the rest of the program could not provide. An unknown
|
|
font NAME is caught in font_face_css, when the store is searched. But a
|
|
font whose file the browser cannot fetch -- wrong path, no permission, a
|
|
sandbox that cannot see the store -- is not an error the browser reports:
|
|
the CSS is valid, the family is simply unavailable, and it renders in
|
|
something else. The output looks finished and is wrong, which is the
|
|
worst failure a document tool can have. It cost a full diagnosis to
|
|
notice on Kukka, and only because pdffonts was run on the result.
|
|
|
|
Asked of the page rather than inferred: document.fonts holds one FontFace
|
|
per @font-face rule, each with a status. A face the browser tried and
|
|
failed to fetch reports "error". A face it never needed reports
|
|
"unloaded" -- an italic in a document with no italics -- and that is not a
|
|
failure, so only "error" counts. document.fonts.ready settles the
|
|
in-flight loads first, or the answer would be whatever had arrived.
|
|
"""
|
|
if not families:
|
|
return
|
|
status = ws.call("Runtime.evaluate", returnByValue=True, awaitPromise=True,
|
|
expression="""
|
|
(async () => {
|
|
try { await document.fonts.ready; } catch (e) {}
|
|
const worst = {};
|
|
for (const face of document.fonts) {
|
|
const name = face.family.replace(/^['"]|['"]$/g, '');
|
|
// "error" is sticky: one failed variant condemns the family.
|
|
if (worst[name] !== 'error') worst[name] = face.status;
|
|
}
|
|
return worst;
|
|
})()""")["result"]["value"]
|
|
bad = [f for f in families if status.get(f) == "error"]
|
|
missing = [f for f in families if f not in status]
|
|
if bad or missing:
|
|
for family in bad:
|
|
print(f"error: the font {family!r} failed to load; the document "
|
|
f"would have been rendered in a substitute.", file=sys.stderr)
|
|
for family in missing:
|
|
print(f"error: no @font-face for {family!r} reached the page.",
|
|
file=sys.stderr)
|
|
sys.exit("refusing to write a PDF in the wrong fonts.")
|
|
|
|
|
|
def print_to_pdf(port, url, pdf_path, paper, margin, page_numbers=False,
|
|
families=()):
|
|
"""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")
|
|
check_fonts(ws, list(families))
|
|
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}")
|
|
|
|
assets = Assets()
|
|
fonts_css, families = font_css(args.serif, args.sans, args.mono,
|
|
args.match, assets.publish)
|
|
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)
|
|
|
|
# The handler reads this list, so replacing element 0 republishes the
|
|
# document without restarting anything.
|
|
document = [""]
|
|
doc_root = md_path.resolve().parent
|
|
server, http_port = start_server(doc_root, assets, document)
|
|
base = f"http://127.0.0.1:{http_port}/"
|
|
url = base.rstrip("/") + DOCUMENT_URL
|
|
columns = 0
|
|
work = Path(tempfile.mkdtemp(prefix="md_to_pdf."))
|
|
try:
|
|
html = build_html(md_path, css_paths, fonts_css, wrap, base)
|
|
document[0] = html
|
|
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, url, args.paper, args.margin)
|
|
if columns:
|
|
html = build_html(md_path, css_paths, fonts_css, columns, base)
|
|
document[0] = html
|
|
print_to_pdf(port, url, pdf_path, args.paper,
|
|
args.margin, args.page_numbers, families)
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
if args.keep_html:
|
|
# Rebuilt for standing on its own: a kept file outlives the server,
|
|
# so its base and its fonts must be file:// URLs, not dead links to
|
|
# a port that closed when this program exited.
|
|
standalone_css, _ = font_css(args.serif, args.sans, args.mono,
|
|
args.match)
|
|
kept = pdf_path.with_suffix(".html")
|
|
kept.write_text(build_html(md_path, css_paths, standalone_css,
|
|
columns or wrap, report=False),
|
|
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:
|
|
server.shutdown()
|
|
server.server_close()
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ensure_renderer()
|
|
main()
|