# if __name__ == "__main__": # import sys # sys.path.append("../kutil") # sys.path.append("../target") import re import klammer_base import html_util from html_util import E import latex_util as L 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 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. Last, the typographically active characters are hidden as KTESC markers (klammer_base.hide_typographic): the html target's transforms run over the final result, and without this a "--check" in a listing became an en-dash. The markers decode after the transform pass. tex_line needs no such step -- the tex target declares no typographic transforms (LaTeX applies its input conventions itself), and its ~ is this code's own markup for a preserved space.""" return klammer_base.hide_typographic(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'
{html_line(code.strip(chr(10)))}
' if comment: result += f'
{comment}
' return f'
{result}
\n' def expand_whitespace_markers(text, K=None): def count(count_match): count = int(count_match) if count_match else 1 return count def replace_spaces(match): return " " * count(match.group(1)) def replace_newlines(match): return "\n" * count(match.group(1)) result = text result = re.compile(r" *#- *", re.S).sub("", result) result = re.compile(r" *#\+(\d*) *", re.S).sub(replace_spaces, result) result = re.compile(r"\s*\#\/(\d*)\s*", re.S).sub(replace_newlines, result) return result class Code(klammer_base.Klammer_base): id = 0 def __init__(self, K): super().__init__(K) self.text = 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'{html_line(self.code_text.strip())}' 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 "//". 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): r"""@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