#!/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. Second run: three German vocabulary lists (4,200 lines, 152 tables), 2026-09-30, which rendered without an error and with two silently wrong results -- an emphasis that wrapped and a table's empty header corner -- so "renders clean" is not "converted"; the fixes are in the spec's "What the second conversion taught". Usage: python3 md_to_sks.py [sibling.md ...] The siblings are Markdown files converted alongside this one: a link to one of them becomes a link to its html page (..//index.html, where ktext writes it); a link to any other .md file is a gap. """ import re, sys, os, datetime from collections import Counter SPECIALS = "^@#|*" # ^ first: the other quotings introduce ^ marks = Counter() SIBLINGS = set() # stems of the .md files converted together 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 if not line.strip(): out.append(""); i += 1 continue # A PARAGRAPH goes through inline() whole, its line breaks kept. # Converting line by line left a literal "*" on each line of an # emphasis that wrapped, and the leftovers then paired with EACH # OTHER, so the wrong words came out italic -- 33 paragraphs of the # vocabulary lists, visible only in a diff. para = [line]; i += 1 while i < n and src[i].strip() and not (HEADING.match(src[i]) or TABLEROW.match(src[i]) or FENCE.match(src[i]) or BULLET.match(src[i])): if ORDERED.match(src[i]): # A numbered line hard against the line above is a wrapped # line that happens to begin with a number ("1. and 3. # Sg."). CommonMark reads it as a list; the writer did not # mean one. Kept in the paragraph, and said. out.append(mark("judgment", "a line beginning with a number " "was kept in its paragraph, not read as a list")) para.append(src[i]); i += 1 out.append(inline("\n".join(para))) 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] SIBLINGS.update(os.path.splitext(os.path.basename(a))[0] for a in sys.argv[3:]) 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()