Typographic transforms (---, quote pairs, ~) no longer touch verbatim text: @c/@code/@source_listing content and ^'...'^ spans show exactly the characters written. "^" before any punctuation character quotes it in every target (the apostrophe excepted: ^' opens a literal span), with the new :resolve option on @@@target declaring per-target renderings. The ^UUUU^ code-point form accepts 4-6 hex digits, the full Unicode range. The html output and transform spellings are polyglot (XML-valid), in preparation for an EPUB target. New suites: transform_test, character_test (engine), typography_test (SKS). (from dev 07ce5ea86a0a)
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()
|