Files
klammertext/sks/tns/md_to_sks.py
Andy Kopra 4a4c7404cc Markdown to PDF: the mdpdf command, and the fonts it needs
This snapshot carries sks/tns/, the translation directory, into the
distribution for the first time, together with the two default font
families its stylesheet names.

sks/tns/ holds two converters in opposite directions.  md_to_sks.py
converts Markdown to Klammertext, recording what it cannot convert exactly
as "#[MD ... ]#" markers so a draft carries its own worklist.  md_to_pdf.py
renders Markdown straight to PDF through a headless Chromium driven over the
DevTools Protocol, bypassing Klammertext entirely -- the route for a
document that is not ready to convert, and a permanent one for Markdown that
Klammertext cannot represent well.  Neither is loaded by the SKS; md_to_pdf
needs markdown-it-py, which it keeps in a virtual environment of its own and
creates with --setup.

The everyday form of the second is the mdpdf command, a shell function in
sks/tns/mdpdf.sh that env/runtime.env sources, so anyone with the
Klammertext environment has it:

    mdpdf notes.md          # writes notes.pdf beside it

It supplies the house fonts, the size matching, and the code wrapping,
completes on *.md at the TAB key, and takes its defaults from MDPDF_*
variables so one can be changed in a shell profile without copying the
function.  It is POSIX shell rather than zsh, since runtime.env is sourced
from bash profiles too.

Two things the stylesheet does that a print stylesheet usually cannot.  Code
lines are wrapped to a column count MEASURED from the rendered page rather
than written down -- the browser is asked how many characters a code box
holds, over every box in the document, so the wrapping stays right when the
fonts, sizes or margins change.  And the page number is a CSS Paged Media
margin box, which current Chromium implements, so it is set in the
document's own face instead of the browser's generic sans.

fnt/ gains EB Garamond and Source Sans 3, the serif and sans the stylesheet
asks for by default.

 (from dev 97d4f244c737)
2026-08-09 15:54:05 +02:00

328 lines
14 KiB
Python

#!/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()