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>
413 lines
18 KiB
Python
413 lines
18 KiB
Python
if __name__ == "__main__":
|
|
import sys
|
|
sys.path.append("../kutil")
|
|
sys.path.append("../target")
|
|
|
|
import sys
|
|
import re
|
|
import klammer_base
|
|
import kutil
|
|
import html_util
|
|
from html_util import E
|
|
import latex_util as L
|
|
import pprint
|
|
import phases
|
|
|
|
def escape_newlines(s):
|
|
# A newline becomes the marker the html paragraph pass turns back into a
|
|
# line break, so a verbatim source file keeps its lines. Used by Source.
|
|
return re.sub("\n", " ___NL___ ", s)
|
|
|
|
def undash(s):
|
|
# Verbatim text must show the hyphens the writer typed: the target's
|
|
# "--"/"---" transforms have already run, so put them back. Used by
|
|
# Code_fragment.
|
|
result = re.sub("__MDASH__", "---", s)
|
|
return re.sub("__NDASH__", "--", result)
|
|
|
|
def is_comment(s):
|
|
return s.strip().startswith("//")
|
|
|
|
def split_blocks(s):
|
|
blocks = []
|
|
in_code = True
|
|
block = ""
|
|
for line in s.rstrip().split("\n"):
|
|
if is_comment(line):
|
|
if in_code:
|
|
blocks.append(block)
|
|
block = line + "\n"
|
|
in_code = False
|
|
else:
|
|
block += line + "\n"
|
|
else:
|
|
if not in_code:
|
|
blocks.append(block)
|
|
block = line + "\n"
|
|
in_code = True
|
|
else:
|
|
block += line + "\n"
|
|
if block:
|
|
blocks.append(block)
|
|
return blocks
|
|
|
|
def get_lines(comment, code, n):
|
|
lines = code.split("\n")
|
|
if len(lines) < n:
|
|
raise Exception(
|
|
f"Error in @code block comment:\n{comment}\n{code}\nNeed {n} lines, but there are only {len(lines)}")
|
|
if len(lines) == n:
|
|
return (code, "")
|
|
else:
|
|
return ("\n".join(lines[:n]), "\n".join(lines[n:]))
|
|
|
|
def parse_blocks(blocks):
|
|
box_comment_rgx = re.compile("\\s*//(\\d+)\\s+.*", re.S)
|
|
i = 0
|
|
block_pairs = []
|
|
while i < len(blocks):
|
|
match = box_comment_rgx.match(blocks[i])
|
|
if match:
|
|
boxed_code, rest_code = get_lines(blocks[i], blocks[i+1], int(match.group(1)))
|
|
block_pairs.append([boxed_code, blocks[i]])
|
|
block_pairs.append([rest_code, ""])
|
|
i += 2
|
|
else:
|
|
block_pairs.append([blocks[i], ""])
|
|
i += 1
|
|
return block_pairs
|
|
|
|
|
|
# Characters that are special to LATEX but not to Klammertext. # and ^ are
|
|
# deliberately ABSENT: they are Klammertext specials and are handled by
|
|
# quote_specials() below, not here -- see tex_line().
|
|
latex_escapes = {
|
|
"\\": "\\textbackslash{}", "{": "\\{", "}": "\\}", "%": "\\%",
|
|
"$": "\\$", "&": "\\&", "_": "\\_", "~": "\\textasciitilde{}",
|
|
"<": "\\textless{}", ">": "\\textgreater{}",
|
|
}
|
|
latex_escape_rgx = re.compile("[" + re.escape("".join(latex_escapes)) + "]")
|
|
|
|
def escape_latex(s):
|
|
r"""Escape LaTeX special characters in code text.
|
|
|
|
ONE pass, not a sequence of str.replace() calls: a sequence corrupts
|
|
the replacements it has already made. Replacing \ first yields
|
|
\textbackslash{}, whose braces the later { and } replacements then
|
|
escape in turn -- so a C++ "\n" typesets as \{}n. A single regex pass
|
|
over the original string cannot revisit its own output.
|
|
"""
|
|
return latex_escape_rgx.sub(lambda m: latex_escapes[m.group()], s)
|
|
|
|
def quote_specials(s):
|
|
r"""Quote the Klammertext special characters in code text.
|
|
|
|
An @eval result is RE-READ as Klammertext, so a special character that
|
|
reaches the result raw is interpreted again: a # in "#include" starts a
|
|
text removal and silently eats the rest of the line. Quoting is the
|
|
fix, and it must be quoting rather than LaTeX escaping -- \# would put
|
|
the # back and be eaten in turn.
|
|
|
|
The quoted forms resolve at final processing: the tex target declares
|
|
^# -> \# and ^^ -> \textasciicircum{} in its :escape list, and any
|
|
other quoted special decodes back to its own character. So the target,
|
|
not this code, decides what a # becomes in LaTeX.
|
|
|
|
^ is quoted FIRST, since the other quotings introduce ^ characters.
|
|
"""
|
|
for ch in "^@#|":
|
|
s = s.replace(ch, "^" + ch)
|
|
return s
|
|
|
|
def tex_line(line):
|
|
r"""One code line, ready to be a \klline argument.
|
|
|
|
Three steps in this order: LaTeX-escape the characters only LaTeX cares
|
|
about; quote the ones Klammertext would re-interpret (the escapes above
|
|
introduce none of them, so the two passes cannot interfere); then make
|
|
every space a ~ -- a non-breaking space, exactly one character wide in
|
|
a monospace font, which LaTeX will not collapse -- so the code's
|
|
indentation and internal alignment survive.
|
|
"""
|
|
return quote_specials(escape_latex(line)).replace(" ", "~")
|
|
|
|
def widest_line(lines):
|
|
r"""The line to measure the block's width with.
|
|
|
|
Measured on the RAW lines: in a monospace font character count is
|
|
exact, whereas the escaped text is longer than it typesets
|
|
(\textbackslash{} is eleven characters and one glyph). The chosen
|
|
line is escaped afterwards, for \settowidth.
|
|
"""
|
|
return max(lines, key=len) if lines else ""
|
|
|
|
def tex_block(code, comment):
|
|
r"""One block: its lines, shaded if the block is annotated, beside its
|
|
comment. See the "Annotated code listings" section of sty/code.sty,
|
|
which owns the layout; this only supplies the three arguments."""
|
|
lines = code.strip("\n").split("\n")
|
|
if not any(line.strip() for line in lines):
|
|
return ""
|
|
# Every line is a plain \klline; whether the BLOCK is shaded is decided
|
|
# by \klcodebox from the comment, because the shading is one box around
|
|
# the block (that is what gives it vertical padding).
|
|
body = "\\\\\n".join(f"\\klline{{{tex_line(line)}}}" for line in lines)
|
|
return ("\\klblock{" + tex_line(widest_line(lines)) + "}{%\n"
|
|
+ body + "}{" + comment + "}\n")
|
|
|
|
def comment_text(comment):
|
|
"""The prose of a block comment: the // and the line count removed."""
|
|
return re.sub(r"^\s*//\d*\s*", "", comment.strip(), flags=re.S).strip()
|
|
|
|
html_escapes = {"&": "&", "<": "<", ">": ">"}
|
|
html_escape_rgx = re.compile("[" + re.escape("".join(html_escapes)) + "]")
|
|
|
|
def html_line(text):
|
|
"""Code text as html: the markup characters escaped, then the
|
|
Klammertext specials quoted for the @eval read-back (same reason as
|
|
tex_line -- a raw # in "#include" would start a text removal). The
|
|
html entities introduce no Klammertext special, so the two passes
|
|
cannot interfere. No target :escape entries apply here, so each
|
|
quoted special decodes back to its own character."""
|
|
return quote_specials(html_escape_rgx.sub(
|
|
lambda m: html_escapes[m.group()], text))
|
|
|
|
def html_block(code, comment):
|
|
r"""One block: its lines beside its comment.
|
|
|
|
The layout is CSS (sks/code/css/code.css): .code_block is a flex row
|
|
with align-items center -- the same model as the LaTeX \parbox[c]
|
|
pair -- and .code_text is an inline-block with white-space: pre, so
|
|
its shrink-to-fit width IS the block's longest line and the shading
|
|
is one solid rectangle with no per-line work. Nothing is measured
|
|
here: unlike LaTeX, the browser does the layout.
|
|
"""
|
|
lines = code.strip("\n").split("\n")
|
|
if not any(line.strip() for line in lines):
|
|
return ""
|
|
shade = "code_border" if comment else "code_no_border"
|
|
result = f'<div class="code_text {shade}">{html_line(code.strip(chr(10)))}</div>'
|
|
if comment:
|
|
result += f'<div class="code_comment">{comment}</div>'
|
|
return f'<div class="code_block">{result}</div>\n'
|
|
|
|
|
|
class Code(klammer_base.Klammer_base):
|
|
id = 0
|
|
def __init__(self, K):
|
|
super().__init__(K)
|
|
self.text = phases.expand_whitespace_markers(self.text)
|
|
|
|
def annotated(self, pairs):
|
|
"""Does any block of this listing carry a comment?"""
|
|
return any(comment.strip() for _, comment in pairs)
|
|
|
|
def tex(self):
|
|
# No table: comments sit a fixed distance from their own block and
|
|
# deliberately do not align with each other, and each block's box is
|
|
# as wide as that block's longest line — so there is no column to
|
|
# align and nothing for a table to do. The layout is in
|
|
# sty/code.sty; see its "Annotated code listings" section.
|
|
#
|
|
# This is a RENDERER (final LaTeX, no klammers in the result), so
|
|
# the Klammermachine leaves it alone. It must never emit @code:
|
|
# a klammer that generates itself re-enters its own body with no
|
|
# base case, which the depth guard catches at 200 levels.
|
|
pairs = parse_blocks(split_blocks(self.text))
|
|
blocks = "".join(tex_block(code, comment_text(comment))
|
|
for code, comment in pairs)
|
|
listing = "\\begin{klcode}\n" + blocks + "\\end{klcode}\n"
|
|
captioned = bool(self.number or self.caption)
|
|
annotated = self.annotated(pairs)
|
|
inset = L.offset_length(self.offset)
|
|
|
|
# A caption does NOT by itself require a box, and boxing a listing
|
|
# costs page breaking -- a minipage cannot break, and listings are
|
|
# often long. Only two things genuinely need the shared box that
|
|
# add_caption builds:
|
|
#
|
|
# * a caption BESIDE the listing (:caption_side left or right),
|
|
# which has to know the listing's width;
|
|
# * an unannotated listing that must be centered or right-aligned,
|
|
# which has to be measured before it can be moved.
|
|
#
|
|
# Everything else is placed unboxed, and keeps breaking: the offset
|
|
# becomes \klshift/\klindent inside the environment, and the caption
|
|
# becomes a paragraph above or below, indented and width-matched to
|
|
# the listing. An annotated listing never needs measuring -- it
|
|
# spans the text column by construction.
|
|
beside = captioned and self.caption_side in ("left", "right")
|
|
must_measure = not annotated and (self.hpos != "left" or inset != "0pt")
|
|
if beside or must_measure:
|
|
return L.block(self.tex_boxed(listing, pairs, annotated,
|
|
captioned, inset))
|
|
return L.block(self.tex_unboxed(listing, annotated, captioned, inset))
|
|
|
|
def tex_boxed(self, listing, pairs, annotated, captioned, inset):
|
|
r"""The listing as a box: placed and captioned like a table or an
|
|
image, at the cost of not breaking across pages."""
|
|
measure = ""
|
|
if annotated:
|
|
# It spans the text column, so an offset narrows it rather than
|
|
# moving it; a centered element has no margin to be inset from.
|
|
width = ("\\linewidth" if self.hpos == "center"
|
|
else f"\\dimexpr\\linewidth-{inset}\\relax")
|
|
else:
|
|
# The \settowidth must stay OUTSIDE the minipage it sizes.
|
|
lines = [line for code, _ in pairs
|
|
for line in code.strip("\n").split("\n")]
|
|
measure = ("\\settowidth{\\kllistingwidth}{\\ttfamily "
|
|
+ tex_line(widest_line(lines)) + "}\n")
|
|
width = "\\kllistingwidth"
|
|
boxed = L.minipage(listing, width, vertical="t", center=False)
|
|
if captioned:
|
|
# add_caption attaches the caption to the box and hands the pair
|
|
# to caption_wrapper, so :pos and :offset move both together.
|
|
return measure + L.add_caption(
|
|
boxed, "Listing", self.number, self.caption, width,
|
|
hpos=self.hpos, side=self.caption_side,
|
|
font_symbol=self.caption_font,
|
|
font_size=self.caption_font_size, offset=self.offset)
|
|
return measure + L.caption_wrapper(boxed, self.hpos,
|
|
offset=self.offset)
|
|
|
|
def tex_unboxed(self, listing, annotated, captioned, inset):
|
|
r"""The listing in the running vertical list, so it can break across
|
|
pages. The offset is \leftskip plus a matching reduction of the
|
|
width \klblock computes its comment column from; the caption is a
|
|
paragraph on the same indent and width."""
|
|
shift = inset if self.hpos == "left" else "0pt"
|
|
# \hpos center cannot move a full-width listing, so it takes no
|
|
# offset -- the same rule the boxed path and caption_wrapper use.
|
|
indent = "0pt" if (annotated and self.hpos == "center") else inset
|
|
setup = (f"\\setlength{{\\klshift}}{{{shift}}}"
|
|
f"\\setlength{{\\klindent}}{{{indent}}}\n")
|
|
result = setup + listing
|
|
if captioned:
|
|
caption = L.make_caption_text(
|
|
self.number, "Listing", self.caption,
|
|
self.caption_font, self.caption_font_size)
|
|
# \nobreak: a caption must not be separated from its listing by
|
|
# a page break, even though the listing itself may break.
|
|
block = (f"\\noindent\\hspace*{{{shift}}}"
|
|
f"\\parbox[t]{{\\dimexpr\\linewidth-{indent}\\relax}}"
|
|
f"{{{caption}}}\\par")
|
|
if self.caption_side == "top":
|
|
result = block + "\\nobreak\n" + result
|
|
else:
|
|
result = result + "\\nobreak\n" + block + "\n"
|
|
return result
|
|
|
|
def html(self):
|
|
pairs = parse_blocks(split_blocks(self.text))
|
|
result = "".join(html_block(code, comment_text(comment))
|
|
for code, comment in pairs)
|
|
# The same two cases as tex_place, with the browser doing the work.
|
|
# An annotated listing's rows must stay full width so the comment
|
|
# column's flex: 1 has a remainder to take; an unannotated one gets
|
|
# width: fit-content, which is what lets the position container
|
|
# center or right-align it.
|
|
listing = E("div").cls("code_listing").body(result)
|
|
if not self.annotated(pairs):
|
|
listing.cls("code_listing_box")
|
|
# As in tex: the caption goes through the shared helper, so it sits
|
|
# in the same position container as the listing and moves with it.
|
|
if self.number or self.caption:
|
|
return html_util.add_caption(
|
|
listing, "Listing", self.number, self.caption,
|
|
self.caption_font, self.hpos, self.caption_side, True,
|
|
self.caption_font_size, offset=self.offset)
|
|
return html_util.hpos_container(listing, self.hpos, self.offset).str()
|
|
|
|
|
|
class Code_fragment(klammer_base.Klammer_base):
|
|
def __init__(self, K):
|
|
super().__init__(K)
|
|
|
|
# code_text is a LITERAL parameter (sks/code/code.k), so its content
|
|
# reaches here exactly as written and NOTHING has escaped it -- the
|
|
# machine's target-character pass does not touch literal content. Both
|
|
# methods must therefore escape it themselves, with the same helpers the
|
|
# block form uses on its lines. Before 2026-08-16 the parameter was an
|
|
# ordinary string and the machine did the escaping; html() got away with
|
|
# handling "<" by hand and tex() with nothing at all.
|
|
def html(self):
|
|
return f'<span class="code">{html_line(undash(self.code_text.strip()))}</span>'
|
|
|
|
def tex(self):
|
|
return f"{{\\tt {tex_line(self.code_text.strip())}}}"
|
|
|
|
|
|
def extract_marked_region(src, marker, filename):
|
|
"""The region of `src` between two lines that are exactly "//<marker>".
|
|
|
|
The delimiter lines must consist SOLELY of "//" + marker and start in the
|
|
first column, so a marker cannot be matched inside indented code or in a
|
|
trailing comment. Both delimiters are the same text: the source brackets a
|
|
region rather than naming a start and a separate end.
|
|
|
|
A marker that is missing, or that appears only once, is an ERROR -- the
|
|
document asked for a region the file does not offer, and silently listing
|
|
the whole file (or nothing) would let the document drift from the code it
|
|
claims to quote, which is the one thing this option exists to prevent.
|
|
"""
|
|
delimiter = "//" + marker
|
|
lines = src.split("\n")
|
|
at = [i for i, line in enumerate(lines) if line == delimiter]
|
|
if len(at) < 2:
|
|
found = "once" if len(at) == 1 else "not at all"
|
|
raise Exception(
|
|
f'The marker "{marker}" appears {found} in "{filename}".\n'
|
|
f' A marked region is bracketed by TWO lines that are exactly\n'
|
|
f' "{delimiter}", each beginning in the first column.')
|
|
if len(at) > 2:
|
|
raise Exception(
|
|
f'The marker "{marker}" appears {len(at)} times in "{filename}"\n'
|
|
f' (lines {", ".join(str(i + 1) for i in at)}); a region needs exactly two.')
|
|
region = lines[at[0] + 1:at[1]]
|
|
while region and not region[0].strip():
|
|
region.pop(0)
|
|
while region and not region[-1].strip():
|
|
region.pop()
|
|
return "\n".join(region)
|
|
|
|
|
|
class Source(Code):
|
|
"""@source_listing -- a Code listing whose text comes from a FILE.
|
|
|
|
It IS a Code: @source_listing and @code differ only in where the text
|
|
comes from, so they must render identically, and subclassing is what
|
|
guarantees that rather than a second implementation that drifts.
|
|
|
|
It rendered separately until 2026-08-16, and was wrong in a way nothing
|
|
caught: html() quoted only "@" and tex() wrapped the raw text in a
|
|
verbatim environment. An @eval result is RE-READ as Klammertext, so an
|
|
unquoted "#" starts a text removal -- and since the html path had already
|
|
joined the source into one line, a file beginning "#include" produced an
|
|
EMPTY LISTING and exited 0. quote_specials() in this module documents
|
|
exactly that hazard, and this was the one place not using it. Inheriting
|
|
Code's rendering fixes both targets at once: tex_line()/html_line() quote
|
|
the Klammertext specials AND escape the target's own, so the author of the
|
|
source file needs to know about neither.
|
|
|
|
A verbatim environment could not have been made correct here, incidentally:
|
|
a quoted "^#" resolves to "\#" through the tex target's escape list, which
|
|
inside verbatim would print as "\#" rather than "#".
|
|
"""
|
|
def __init__(self, K):
|
|
# Klammer_base, NOT Code: Code's constructor expands whitespace markers
|
|
# in self.text, and there is no "text" parameter here -- the text does
|
|
# not exist until the file has been read. File content carries no
|
|
# whitespace markers anyway, since those come from the katomizer.
|
|
klammer_base.Klammer_base.__init__(self, K)
|
|
try:
|
|
with open(self.filename) as fp:
|
|
text = fp.read()
|
|
except OSError as e:
|
|
raise Exception(
|
|
f'Cannot read the source listing "{self.filename}": {e.strerror}.\n'
|
|
f' A relative name resolves against the DOCUMENT\'s directory.')
|
|
if self.marker:
|
|
text = extract_marked_region(text, self.marker, self.filename)
|
|
self.text = text
|