Files
klammertext/sks/tns/md_to_sks.py
Andy Kopra bd39d9a369 Literal @c, @source_listing with :marker, and a large-directory speedup
Three changes.

@c now takes its content literally, like @code -- it is the inline form
and @code the block form of the same thing.  The named close "c@" is
required, and characters that are special in a target no longer break
the file: @c a_b c@ renders correctly everywhere.  The Markdown
converter stops quoting inline code, since nothing needs protecting.

@source_file is renamed @source_listing.  Code read from a file is its
own klammer; @code is only for a block written inline (its never-
implemented :filename and :pattern options are removed).  The new
:marker P option lists the region between two lines that are exactly
//P, so the source file declares its own extractable regions.  A marker
missing or not appearing exactly twice is an error, never a fallback.

Rendering a document that sits in a large directory was paying a
recursive walk of that directory's whole tree on every @eval -- 27
seconds for a document that renders in a third of one.  The walk is now
a non-recursive look decided once per directory.

Assembled from dev commit 071b1b183de4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:58:50 +02:00

337 lines
15 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)
# Inline code goes in RAW, and is closed with the named delimiter. "@c"
# took an ordinary string parameter until 2026-08-16, so every Klammertext
# special inside a code span had to be quoted -- and about 40% of the
# inline code in the measured corpus contains one (see
# doc/markdown_to_klammertext.md). It is now a LITERAL parameter, like
# "@code": nothing inside is interpreted, so nothing needs quoting, and the
# source reads as the author wrote it. A literal klammer cannot be closed
# with a bare "@" -- the machine cannot find the end by counting -- so the
# close is "c@".
text = re.sub(r'\x00(\d+)\x00',
lambda m: OPEN + "@c " + spans[int(m.group(1))] + " c@" + 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()