Markdown to PDF: the mdpdf command, and the fonts it needs
This snapshot carries sks/tns/, the translation directory, into the
distribution for the first time, together with the two default font
families its stylesheet names.
sks/tns/ holds two converters in opposite directions. md_to_sks.py
converts Markdown to Klammertext, recording what it cannot convert exactly
as "#[MD ... ]#" markers so a draft carries its own worklist. md_to_pdf.py
renders Markdown straight to PDF through a headless Chromium driven over the
DevTools Protocol, bypassing Klammertext entirely -- the route for a
document that is not ready to convert, and a permanent one for Markdown that
Klammertext cannot represent well. Neither is loaded by the SKS; md_to_pdf
needs markdown-it-py, which it keeps in a virtual environment of its own and
creates with --setup.
The everyday form of the second is the mdpdf command, a shell function in
sks/tns/mdpdf.sh that env/runtime.env sources, so anyone with the
Klammertext environment has it:
mdpdf notes.md # writes notes.pdf beside it
It supplies the house fonts, the size matching, and the code wrapping,
completes on *.md at the TAB key, and takes its defaults from MDPDF_*
variables so one can be changed in a shell profile without copying the
function. It is POSIX shell rather than zsh, since runtime.env is sourced
from bash profiles too.
Two things the stylesheet does that a print stylesheet usually cannot. Code
lines are wrapped to a column count MEASURED from the rendered page rather
than written down -- the browser is asked how many characters a code box
holds, over every box in the document, so the wrapping stays right when the
fonts, sizes or margins change. And the page number is a CSS Paged Media
margin box, which current Chromium implements, so it is set in the
document's own face instead of the browser's generic sans.
fnt/ gains EB Garamond and Source Sans 3, the serif and sans the stylesheet
asks for by default.
(from dev 97d4f244c737)
This commit is contained in:
327
sks/tns/md_to_sks.py
Normal file
327
sks/tns/md_to_sks.py
Normal file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Markdown -> Standard Klammer Set converter (first implementation).
|
||||
|
||||
SPECIFICATION: doc/markdown_to_klammertext.md. That document is authoritative
|
||||
and is written to be sufficient to rebuild this program; this program is its
|
||||
reference implementation, not the other way round. Its "The algorithm"
|
||||
section states the pass order, which is forced, and the traps that make it so.
|
||||
|
||||
The conversion is to the SKS, not to Klammertext the language: the
|
||||
correspondence between a Markdown construct and a klammer is a property of
|
||||
the klammer set. That is why this lives under sks/ rather than in mac/.
|
||||
|
||||
What cannot be converted exactly is recorded in the output as a
|
||||
#[MD <kind>: ... ]# marker -- lossy, gap, or judgment -- so a converted
|
||||
document carries its own worklist and "how far from done is this file" is a
|
||||
grep. A file with no markers is inside the convertible subset.
|
||||
|
||||
First run: the Rectify user guide (1,805 lines), 2026-08-07. It rendered to
|
||||
html and pdf without error or warning, with 99 markers -- 91 of them internal
|
||||
anchor links, the one gap that appears at volume.
|
||||
|
||||
Usage: python3 md_to_sks.py <input.md> <output.kt>
|
||||
"""
|
||||
import re, sys, os, datetime
|
||||
from collections import Counter
|
||||
|
||||
SPECIALS = "^@#|*" # ^ first: the other quotings introduce ^
|
||||
marks = Counter()
|
||||
|
||||
def mark(kind, text):
|
||||
"""A #[MD ... ]# marker.
|
||||
|
||||
Two things have to be neutralised in the text, both of which cost a
|
||||
session's debugging if they are not:
|
||||
|
||||
* "]#" would close the block early and "#[" would open one that must then
|
||||
be balanced (the nesting hazard);
|
||||
* a klammer name written with its "@" is READ before the block is
|
||||
removed -- mark_literal_klammer_content() runs first in
|
||||
process_katoms() -- so "@code has no :language" makes the engine demand
|
||||
a "code@" close from inside removed text. Quoting the @ makes it a
|
||||
special katom, which the literal scan does not see. (TODO #39.)
|
||||
"""
|
||||
marks[kind] += 1
|
||||
text = text.replace("]#", "] #").replace("#[", "# [")
|
||||
text = text.replace("@", "^@").replace("|", "^|")
|
||||
return f"#[MD {kind}: {text} ]#"
|
||||
|
||||
def quote(s):
|
||||
"""Quote every Klammertext special in writer text."""
|
||||
for ch in SPECIALS:
|
||||
s = s.replace(ch, "^" + ch)
|
||||
return s
|
||||
|
||||
# ---- inline ---------------------------------------------------------------
|
||||
|
||||
CODE = re.compile(r'`([^`\n]+)`')
|
||||
LINK = re.compile(r'(?<!\!)\[([^\]]*)\]\(([^)]+)\)')
|
||||
BOLD = re.compile(r'\*\*([^*]+)\*\*|__([^_]+)__')
|
||||
# Markdown's underscore emphasis does not open or close INSIDE a word, and
|
||||
# technical prose is full of identifiers like *name*_rectified_1.*ext* where
|
||||
# the underscores are part of the name. So the closing _ must not be
|
||||
# followed by a word character -- without that, the converter invents
|
||||
# emphasis in the middle of filenames.
|
||||
ITAL = re.compile(r'(?<![\*\w])\*([^*\s][^*]*)\*(?!\*)|(?<![_\w])_([^_\s][^_]*)_(?![\w_])')
|
||||
STRIKE = re.compile(r'~~([^~]+)~~')
|
||||
|
||||
def inline(text):
|
||||
"""Convert one run of inline Markdown.
|
||||
|
||||
Order matters: code spans are protected first (their content must not be
|
||||
read as emphasis), links and emphasis consume their markers next, and only
|
||||
then are the remaining specials quoted -- quoting first would hide the
|
||||
** and * that emphasis is recognised by.
|
||||
"""
|
||||
spans = []
|
||||
def stash(m):
|
||||
spans.append(m.group(1))
|
||||
return f"\x00{len(spans)-1}\x00"
|
||||
text = CODE.sub(stash, text)
|
||||
|
||||
# Markers are Klammertext, not writer text: they must survive the quoting
|
||||
# pass below untouched, or the "#[" that makes them a removal block is
|
||||
# itself quoted and the marker renders into the output instead of
|
||||
# vanishing from it. Stash them like code spans.
|
||||
verbatim = []
|
||||
def keep(s):
|
||||
verbatim.append(s)
|
||||
return f"\x01{len(verbatim)-1}\x01"
|
||||
|
||||
# OPEN and CLOSE wrap the delimiters this function emits, so the
|
||||
# whitespace fix below can find exactly its own delimiters. Matching
|
||||
# them by regex on the finished text does not work: " @" is both the
|
||||
# closing delimiter and the space before an opening "@i", and a rule
|
||||
# keyed on it rewrites "@i" into "@#- i".
|
||||
OPEN, CLOSE = "\x03", "\x02"
|
||||
def opening(s): return keep(OPEN + s)
|
||||
def closing(): return keep(" @" + CLOSE)
|
||||
|
||||
def link(m):
|
||||
label, target = m.group(1), m.group(2)
|
||||
if target.startswith("#"):
|
||||
# An anchor into this document: no klammer gives an arbitrary
|
||||
# heading an anchor, so keep the words and record the loss.
|
||||
return emphasis(label) + " " + keep(mark("gap", f"link to {target}"))
|
||||
return opening("@link " + quote(target) + " :text ") + emphasis(label) + closing()
|
||||
|
||||
def emphasis(t):
|
||||
"""The marker-consuming transforms, sharing THIS call's stashes.
|
||||
|
||||
A link label or a struck-through run is itself inline Markdown and
|
||||
must be converted -- but not by re-entering inline(), which would
|
||||
start empty stashes while the text already holds this call's
|
||||
placeholders. A code span inside a link label then restores against
|
||||
the wrong list: IndexError, found by converting a second document.
|
||||
"""
|
||||
t = LINK.sub(link, t)
|
||||
t = STRIKE.sub(lambda m: emphasis(m.group(1)) + " " +
|
||||
keep(mark("gap", "strikethrough")), t)
|
||||
t = BOLD.sub(lambda m: opening("@b ") + (m.group(1) or m.group(2)) + closing(), t)
|
||||
return ITAL.sub(lambda m: opening("@i ") + (m.group(1) or m.group(2)) + closing(), t)
|
||||
|
||||
text = emphasis(text)
|
||||
|
||||
# Everything that is still writer text gets quoted; the stashed fragments
|
||||
# are Klammertext already.
|
||||
parts = re.split(r'(\x00\d+\x00|\x01\d+\x01)', text)
|
||||
text = "".join(p if re.match(r'^[\x00\x01]', p) else quote(p) for p in parts)
|
||||
|
||||
text = re.sub(r'\x01(\d+)\x01', lambda m: verbatim[int(m.group(1))], text)
|
||||
text = re.sub(r'\x00(\d+)\x00',
|
||||
lambda m: OPEN + "@c " + quote(spans[int(m.group(1))]) + " @" + CLOSE, text)
|
||||
# Klammertext delimiters need whitespace around them, but Markdown
|
||||
# emphasis abuts its neighbours: "un**bold**ed" and "*name*_rectified"
|
||||
# both put a word character hard against a delimiter, which the
|
||||
# katomizer then reads as an unparsable word. A space makes it parse,
|
||||
# and "#-" removes that space again from the OUTPUT, so intra-word
|
||||
# emphasis converts exactly rather than approximately.
|
||||
# Any non-space neighbour, not just a word character: "*name*_x" and
|
||||
# "_1.*ext*" put an underscore or a period against the delimiter.
|
||||
text = re.sub(r'(?<=\S)' + OPEN, ' #-', text).replace(OPEN, "")
|
||||
text = re.sub(CLOSE + r'(?=[^\s])', '#- ', text).replace(CLOSE, "")
|
||||
# A closing "@" abutting the next klammer's opening "@" spells "@@",
|
||||
# which is a DEFINITION delimiter -- the engine then reports a span that
|
||||
# ends without a beginning, pointing at text the writer never wrote.
|
||||
# Adjacent klammers are ordinary in converted prose (*a*_b_ produces
|
||||
# two), so separate them.
|
||||
return re.sub(r'@(?=@)', '@ ', text)
|
||||
|
||||
# ---- blocks ---------------------------------------------------------------
|
||||
|
||||
HEADING = re.compile(r'^(#{1,6})\s+(.*?)\s*$')
|
||||
MANUAL_NUMBER = re.compile(r'^\d+(\.\d+)*\.?\s+')
|
||||
FENCE = re.compile(r'^\s*```(\w*)\s*$')
|
||||
BULLET = re.compile(r'^(\s*)[-*+]\s+(.*)$')
|
||||
ORDERED = re.compile(r'^(\s*)(\d+)[.)]\s+(.*)$')
|
||||
TABLEROW = re.compile(r'^\s*\|(.*)\|\s*$')
|
||||
ALIGNROW = re.compile(r'^\s*\|[\s:|-]+\|\s*$')
|
||||
|
||||
def cells(line):
|
||||
return [c.strip() for c in TABLEROW.match(line).group(1).split("|")]
|
||||
|
||||
def convert(path):
|
||||
src = open(path, encoding="utf-8").read().split("\n")
|
||||
out, i, n = [], 0, len(src)
|
||||
title = None
|
||||
stripped_numbers = 0
|
||||
dropped_toc = False
|
||||
|
||||
while i < n:
|
||||
line = src[i]
|
||||
|
||||
m = FENCE.match(line)
|
||||
if m: # fenced code
|
||||
lang, body, i = m.group(1), [], i + 1
|
||||
while i < n and not FENCE.match(src[i]):
|
||||
body.append(src[i]); i += 1
|
||||
i += 1
|
||||
if lang:
|
||||
out.append(mark("lossy", f'fenced language "{lang}" dropped '
|
||||
"-- @code has no :language"))
|
||||
out.append("@code |")
|
||||
out.extend(body) # literal: nothing to quote
|
||||
out.append("code@")
|
||||
out.append("")
|
||||
continue
|
||||
|
||||
m = HEADING.match(line)
|
||||
if m:
|
||||
level, text = len(m.group(1)), m.group(2)
|
||||
if level == 1 and title is None:
|
||||
title = inline(text); i += 1; continue
|
||||
if MANUAL_NUMBER.match(text):
|
||||
text = MANUAL_NUMBER.sub("", text); stripped_numbers += 1
|
||||
if re.match(r'^contents$', text, re.I):
|
||||
# Klammertext generates a table of contents; a hand-written
|
||||
# one would duplicate it. Skip to the next heading.
|
||||
j = i + 1
|
||||
while j < n and not HEADING.match(src[j]):
|
||||
j += 1
|
||||
out.append(mark("judgment",
|
||||
"a hand-written Contents section was dropped; "
|
||||
":structure article generates one"))
|
||||
out.append("")
|
||||
dropped_toc = True
|
||||
i = j
|
||||
continue
|
||||
out.append(f"@s{level-1} {inline(text)} @")
|
||||
out.append("")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if TABLEROW.match(line): # table
|
||||
rows, align = [], None
|
||||
while i < n and TABLEROW.match(src[i]):
|
||||
if ALIGNROW.match(src[i]):
|
||||
align = [("r" if c.endswith(":") and c.startswith(":") is False
|
||||
else "c" if c.startswith(":") and c.endswith(":")
|
||||
else "l") for c in cells(src[i])]
|
||||
else:
|
||||
rows.append([inline(c) for c in cells(src[i])])
|
||||
i += 1
|
||||
body = " ||\n".join(" | ".join(r) for r in rows)
|
||||
opts = f" :cell_hpos {' '.join(align)}" if align and set(align) != {"l"} else ""
|
||||
# A Markdown table says nothing about column widths, and
|
||||
# Klammertext's default "fit" never wraps -- a prose column then
|
||||
# runs off the page (the pdf target warns). Give the widest
|
||||
# column "fill", which takes the remaining width but no more than
|
||||
# its own widest line.
|
||||
widths = [max((len(r[c]) for r in rows if c < len(r)), default=0)
|
||||
for c in range(max(len(r) for r in rows))]
|
||||
if max(widths) > 40:
|
||||
widest = widths.index(max(widths))
|
||||
spec = " ".join("fill" if c == widest else "fit"
|
||||
for c in range(len(widths)))
|
||||
opts += f" :column_width {spec}"
|
||||
out.append(mark("judgment",
|
||||
"column widths are not in the Markdown; the "
|
||||
f"widest column ({max(widths)} characters) was "
|
||||
"given fill so the table wraps instead of "
|
||||
"overflowing"))
|
||||
out.append(f"@table{opts} |")
|
||||
out.append(body)
|
||||
out.append("table@")
|
||||
out.append("")
|
||||
continue
|
||||
|
||||
if BULLET.match(line) or ORDERED.match(line):
|
||||
block, base = [], None
|
||||
while i < n and (BULLET.match(src[i]) or ORDERED.match(src[i]) or
|
||||
(src[i].strip() and src[i].startswith((" ", "\t")))):
|
||||
block.append(src[i]); i += 1
|
||||
out.append(convert_list(block))
|
||||
out.append("")
|
||||
continue
|
||||
|
||||
out.append(inline(line) if line.strip() else "")
|
||||
i += 1
|
||||
|
||||
return out, title, stripped_numbers, dropped_toc
|
||||
|
||||
def convert_list(block):
|
||||
"""One list, possibly nested, as @ul/@ol with bar-separated items."""
|
||||
def emit(items, ordered, depth):
|
||||
klammer = "@ol" if ordered else "@ul"
|
||||
parts = []
|
||||
for text, children in items:
|
||||
body = inline(text)
|
||||
if children:
|
||||
body += " " + emit(children[0], children[1], depth + 1)
|
||||
parts.append(body)
|
||||
return klammer + " " + " | ".join(parts) + " @"
|
||||
|
||||
def parse(lines, indent):
|
||||
items, i = [], 0
|
||||
ordered = bool(ORDERED.match(lines[0])) if lines else False
|
||||
while i < len(lines):
|
||||
m = BULLET.match(lines[i]) or ORDERED.match(lines[i])
|
||||
if not m or len(m.group(1)) < indent:
|
||||
break
|
||||
text = m.groups()[-1]
|
||||
i += 1
|
||||
child_lines = []
|
||||
while i < len(lines):
|
||||
m2 = BULLET.match(lines[i]) or ORDERED.match(lines[i])
|
||||
if m2 and len(m2.group(1)) > indent:
|
||||
child_lines.append(lines[i]); i += 1
|
||||
elif not m2 and lines[i].strip():
|
||||
text += " " + lines[i].strip(); i += 1
|
||||
else:
|
||||
break
|
||||
children = None
|
||||
if child_lines:
|
||||
sub, sub_ordered = parse(child_lines, len(BULLET.match(child_lines[0]).group(1))
|
||||
if BULLET.match(child_lines[0])
|
||||
else len(ORDERED.match(child_lines[0]).group(1)))
|
||||
children = (sub, sub_ordered)
|
||||
items.append((text, children))
|
||||
return items, ordered
|
||||
|
||||
items, ordered = parse([l for l in block if l.strip()], 0)
|
||||
return emit(items, ordered, 0)
|
||||
|
||||
def main():
|
||||
src_path, dst_path = sys.argv[1], sys.argv[2]
|
||||
body, title, stripped, dropped_toc = convert(src_path)
|
||||
today = datetime.date.today().isoformat()
|
||||
head = [f"#[MD source: {os.path.abspath(src_path)}",
|
||||
f" converted {today} ]#", ""]
|
||||
if stripped:
|
||||
head.append(mark("judgment",
|
||||
f"{stripped} headings carried a manual number "
|
||||
'("2.1 ..."); the numbers were removed because @s1/@s2 '
|
||||
"number the headings themselves"))
|
||||
head.append("")
|
||||
head += ["@document", ":structure plain"]
|
||||
if title:
|
||||
head.append(f":title {title}")
|
||||
head += [":text", ""]
|
||||
text = "\n".join(head + body + ["", "@", ""])
|
||||
text = re.sub(r'\n{3,}', "\n\n", text)
|
||||
open(dst_path, "w", encoding="utf-8").write(text)
|
||||
print(f"{dst_path}: {len(text.splitlines())} lines")
|
||||
for kind in ("source", "lossy", "gap", "judgment"):
|
||||
print(f" {kind:9} {marks[kind]}")
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user