Files
klammertext/sks/tns/md_to_sks.py
T
ackandClaude Fable 5.1 f4dcdf3ffd @link :section links to a section by its heading, with an alone value (from dev 831455f36fcd)
A section link names the TEXT of a heading: :section alone means the current
file, a value names the file of the same @document that has it. Resolved by
@document at assembly in html and tex; errors for a missing file, a missing
title, a doubled title and a link outside a @document. The Markdown
converter reads a Markdown anchor as a section link to the heading its slug
names. Also from dev: the converter's four silent defects fixed and --chapter
(9ae2c4cd), and the engine's alone-value error text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 20:55:39 +02:00

604 lines
29 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; with --chapter also
section -- 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. The marker is the contract: a construct this program
does not handle must leave one, never pass through as prose. It does not
hold yet -- see "What the reference implementation does not do" in the
spec for the constructs that still pass through unmarked.
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". A review against a probe document (2026-10-11) found twelve more
ways the output was silently wrong and eight constructs passed through
with no marker; four were fixed first (a ragged nested list dropped items,
a loose list became one @ol per item, the "@@" separator ran over @c
literals, an escaped bar split a table cell), and the probe became the
fixture sks/tst/md_to_sks_test.sh: the conversion, in both modes, must
equal a golden file line for line and then render. The golden files pin
only what is right; the remaining defects are listed in the spec and join
the probe as they are fixed.
Usage: python3 md_to_sks.py [--chapter] <input.md> <output.kt> [sibling.md ...]
The siblings are Markdown files converted alongside this one. Without
--chapter each file is a document of its own, and a link to a sibling
becomes a link to its html page (../<stem>/index.html, where ktext writes
it); a link to any other .md file is a gap.
With --chapter (Andy, 2026-10-12) the file is one CHAPTER of a book whose
@document is written by hand and names the chapters through :files: no
@document wrapper is emitted, no :title is lifted, and every heading maps
one level deeper -- "#" is @s1, the chapter's own heading. A link to a
sibling is then a link to another chapter of the same book, written
"@link <its title> :section @" (:section finds a section by its heading;
implemented 2026-10-12), the title being the sibling's first "#" heading
read from the file. A Markdown link targets a FILE, so the title is a
guess, and a "section" marker records it. The same mechanism converts a
Markdown anchor, "#some-heading" or "other.md#some-heading": the slug is
matched against the headings of the file it points into, and the link
names that heading's text -- again marked, since the match is a guess.
The motivating case was ~/projects/kokkoja/docs/, five topic files
cross-linked as "[A host's Kokkoja](host.md)".
"""
import re, sys, os, datetime, argparse
from collections import Counter
SPECIALS = "^@#|*" # ^ first: the other quotings introduce ^
marks = Counter()
SIBLINGS = {} # stem -> path of the .md files converted together
CHAPTER = False # --chapter: a chapter of a hand-written book
STEM = "" # the file being converted
HEADINGS = {} # stem -> [(slug, level, title as Klammertext)], lazily
def slug(text):
"""GitHub's anchor for a heading: lower case, punctuation other than
hyphen and underscore removed, every whitespace a hyphen. A duplicate
heading gets -1, -2 ... appended, which lookups retry without."""
return re.sub(r'\s', '-', re.sub(r'[^\w\s-]', '', text.lower()).strip())
def headings_of(stem):
"""The headings of a file converted alongside (or this one): the anchor
each would have in Markdown, its level, and the title AS THE CONVERTER
EMITS IT -- manual number stripped, inline Markdown converted -- which
is the text a :section link must name. Headings inside fenced code
are not headings."""
if stem in HEADINGS:
return HEADINGS[stem]
found = []
try:
with open(SIBLINGS[stem], encoding="utf-8") as f:
fenced = False
for line in f:
line = line.rstrip("\n")
if FENCE.match(line):
fenced = not fenced
continue
m = HEADING.match(line)
if m and not fenced:
text = m.group(2)
title = MANUAL_NUMBER.sub("", text) if MANUAL_NUMBER.match(text) else text
found.append((slug(text), len(m.group(1)), title))
except (OSError, KeyError):
pass
HEADINGS[stem] = found
return found
def find_heading(stem, anchor):
"""The (level, title) a Markdown anchor names in STEM's file, or None.
An anchor made unique with -N is retried as its base."""
for s_, level, title in headings_of(stem):
if s_ == anchor:
return level, title
m = re.match(r'^(.*)-\d+$', anchor)
if m:
for s_, level, title in headings_of(stem):
if s_ == m.group(1):
return level, title
return None
def sibling_title(stem, fallback):
"""A sibling chapter's title: its first "#" heading."""
for _, level, title in headings_of(stem):
if level == 1:
return title
return fallback
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'(?<!\!)\[([^\]]*)\]\(([^)]+)\)')
# A bold run may hold italic runs ("**only for *sein* and *haben***"): the
# group admits a balanced *...* inside, so the closing ** is found past it.
BOLD = re.compile(r'\*\*((?:[^*]|\*[^*\s][^*]*\*)+)\*\*|__([^_]+)__')
# 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)
# A link that cannot be converted keeps its words and records the
# loss. The marker goes BEFORE the words: a link is usually
# followed by punctuation, and a marker between the words and the
# period leaves the space before the marker standing ("words .").
def section_link(title, stem, note):
"""A link to a section by its title (@link :section, resolved by
@document; 2026-10-12). :section alone means this file; a
heading in another chapter names that file as the value. The
label is kept as :text only where it differs from the title.
The title is a GUESS from a Markdown anchor or a file, so the
marker stays."""
where = "" if stem == STEM else " " + stem
text = "" if label == title else " :text " + emphasis(label)
return (keep(mark("section", note)) + " " +
opening("@link " + inline(title) + " :section" + where + text) + closing())
if target.startswith("#"):
# An anchor into this file: Markdown's slug of a heading. The
# heading it names becomes a :section link to that heading's
# text; an anchor matching no heading is the gap it always was.
found = find_heading(STEM, target[1:])
if found:
return section_link(found[1], STEM, f'link to {target}, read as the heading "{found[1]}"')
return keep(mark("gap", f"link to {target}, no such heading")) + " " + emphasis(label)
m_file = re.match(r'^([^/#]+)\.md(?:#(.+))?$', target)
if m_file:
# Another Markdown file, with or without an anchor into it.
# "@link X.md" would be read as an anchor (href="#X.md").
stem, anchor = m_file.group(1), m_file.group(2)
if stem not in SIBLINGS:
return keep(mark("gap", f"link to {target}, not converted")) + " " + emphasis(label)
if CHAPTER:
# Another chapter of the same book: a link to a section by
# its title -- the heading the anchor names, or the file's
# own title (its first "#") when there is no anchor.
if anchor:
found = find_heading(stem, anchor)
if not found:
return keep(mark("gap", f"link to {target}, no such heading")) + " " + emphasis(label)
return section_link(found[1], stem, f'link to {target}, read as the heading "{found[1]}"')
title = sibling_title(stem, label)
return section_link(title, stem, f'link to {target}, read as its title "{title}"')
if anchor:
# Converted as separate documents, the other page's ids are
# not known here.
return keep(mark("gap", f"link to {target}: a heading on another page")) + " " + emphasis(label)
target = f"../{stem}/index.html"
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)
# An arrow is the SKS klammer (Andy, 2026-10-01): U+2192, and the
# halfwidth U+FFEB his documents use.
t = re.sub(r'[\u2192\uffeb]', lambda m: keep(OPEN + "@rightarrow@" + CLOSE), 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)
# The code spans stay placeholders through the two fixes below, so that
# neither touches their content: a code span is LITERAL, and the "@@"
# rule once rewrote a Klammertext example inside one ("`@@name.k`"
# rendered as "@ @name.k"). Only the delimiter sentinels go on now; the
# content comes back last.
text = re.sub(r'(\x00\d+\x00)', OPEN + r'\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.
# EXCEPT where the katomizer splits the neighbour off itself (kdesc
# --rewrite, the "punctuation" and "klammer" categories): trailing
# punctuation after a closing "@" -- "It is @i italicized @." -- and
# an opening bracket before an opening "@name". Those are legal as
# written, and the "#-" was noise (Andy, 2026-10-12). Everything else
# that is not a space needs the fix: a word character, a quote, an
# underscore ("*name*_x"), a digit.
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. The code spans are still placeholders here,
# on purpose (see above): a "@@" inside one is the author's.
text = re.sub(r'@(?=@)', '@ ', 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@".
return re.sub(r'\x00(\d+)\x00',
lambda m: "@c " + spans[int(m.group(1))] + " c@", 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 list_marker(line):
"""The marker of an item line -- its bullet character, or the delimiter
after its number ("." or ")") -- and None for a line that is no item.
CommonMark starts a NEW list when the marker changes."""
m = BULLET.match(line)
if m:
return line[len(m.group(1))]
m = ORDERED.match(line)
if m:
return line[len(m.group(1)) + len(m.group(2))]
return None
def cells(line):
"""The cells of one table row.
A bar escaped as "\\|" is cell CONTENT -- GFM requires the escape even
inside a code span -- so the split is on the unescaped bars only, and
the backslash is then dropped: inline() quotes a prose bar to "^|", and
a bar inside a code span goes into the @c literal as it is. Splitting
on every bar shifted the header of any table that documents Klammertext
(whose own tables are full of "\\|") one column right, silently.
"""
inner = TABLEROW.match(line).group(1)
return [c.strip().replace("\\|", "|") for c in re.split(r'(?<!\\)\|', inner)]
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 CHAPTER:
level += 1 # "#" is the chapter's heading, @s1
elif level == 1 and title is None:
title = inline(text); i += 1; continue
elif level == 1:
# A second "#": not a title. Probably a part, or the file
# is two documents; neither is the converter's call, so it
# is a top-level section and the marker says so. (It was
# "@s0", which does not exist, and failed at render.)
out.append(mark("judgment", "a second top-level heading was "
"made @s1; it may be a @part, or a second document"))
level = 2
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)
# A Markdown table has no caption and no number.
opts = " :number false"
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 = []
while i < n:
if (BULLET.match(src[i]) or ORDERED.match(src[i]) or
(src[i].strip() and src[i].startswith((" ", "\t")))):
block.append(src[i]); i += 1
elif not src[i].strip():
# A blank line ends the list UNLESS the next non-blank
# line is another item of it: a loose list (items
# separated by blank lines) is one list, and ending it
# here made every item its own @ol, each numbered 1.
# "Of it" is CommonMark's rule: an indented item (a
# nested one) or one with the same marker; a different
# marker starts a new list. An indented non-item after
# the blank (a code block inside the item) still ends
# the list, as it always did.
j = i
while j < n and not src[j].strip():
j += 1
if j < n and list_marker(src[j]) and (
src[j].startswith((" ", "\t")) or
list_marker(src[j]) == list_marker(block[0])):
i = j
else:
break
else:
break
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):
"""The items at one level, each with its children.
An item's children are the item lines that begin at or past its
CONTENT column (where the text after the marker starts, as in
CommonMark); an item line that begins before that is the next item
at THIS level, however it is indented. The loop never stops on an
indent: the caller has already bounded the lines, so there is
nothing an indent could hand back to. The earlier rule took the
first child's indent as the sublist's and broke off at anything
shallower, so " - a1" followed by " - a2" DROPPED a2 -- and the
document rendered without a word about it.
"""
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: # a stray wrapped line: previous item's
if items:
text, children = items[-1]
items[-1] = (text + " " + lines[i].strip(), children)
i += 1
continue
text = m.groups()[-1]
content_col = m.start(m.lastindex)
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)) >= content_col:
child_lines.append(lines[i]); i += 1
elif m2:
break
elif child_lines: # a wrapped line of the last child
child_lines.append(lines[i]); i += 1
else: # a wrapped line of this item
text += " " + lines[i].strip(); i += 1
children = parse(child_lines) if child_lines else None
items.append((text, children))
return items, ordered
items, ordered = parse([l for l in block if l.strip()])
return emit(items, ordered, 0)
def main():
global CHAPTER
ap = argparse.ArgumentParser(
description="Convert a Markdown file to the Standard Klammer Set.",
epilog="Siblings are the .md files converted alongside; a link to one "
"is converted as a link to its page (or, with --chapter, to "
"its section), a link to any other .md file is a gap.")
ap.add_argument("--chapter", action="store_true",
help="the file is a chapter of a hand-written @document: "
"no wrapper, '#' maps to @s1, sibling links are :section links")
ap.add_argument("input", metavar="input.md")
ap.add_argument("output", metavar="output.kt")
ap.add_argument("siblings", metavar="sibling.md", nargs="*")
args = ap.parse_args()
global STEM
CHAPTER = args.chapter
src_path, dst_path = args.input, args.output
STEM = os.path.splitext(os.path.basename(src_path))[0]
SIBLINGS.update((os.path.splitext(os.path.basename(a))[0], a) for a in args.siblings)
SIBLINGS[STEM] = src_path # its own headings resolve "#anchor" links
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("")
if CHAPTER:
tail = [""]
else:
head += ["@document", ":structure plain"]
if title:
head.append(f":title {title}")
head += [":text", ""]
tail = ["", "@", ""]
text = "\n".join(head + body + tail)
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 ("lossy", "gap", "judgment", "section"):
if kind != "section" or CHAPTER:
print(f" {kind:9} {marks[kind]}")
main()