#!/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 : ... ]# 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 """ 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'(? 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()