import re import kutil import klammer_base # Emitted by @vspace.txt (sks/block/block.k), one marker per line of space. # An @eval result consisting only of whitespace is trimmed away by the # Klammermachine, so vertical space must travel as markers and become # newlines here, after blank-line runs have been normalized. def txt_nl_marker(): return "__KTEXTNEWLINE__" def txt_vspace_marker(): return "__KTEXTVSPACE__" # A space that survives to the output. "~" used to serve, and could not: # it is also a character a writer may want, and the substitution at the end # of txt_justify_blocks turned EVERY tilde into a space -- so a literal # tilde could not be written in the txt target at all (a "^~" resolves # before the phases run, and by then is indistinguishable). A marker of # its own frees the character. SYNC: the txt target's "~ ^0020^" transform # in sks/target/target.k is the WRITER-facing convention (a tilde means a # non-breaking space, as in LaTeX) and is deliberately unchanged. def txt_nbsp_marker(): return "__KTEXTNBSP__" # The type-style markers, emitted by @b.txt, @i.txt, @u.txt and @ri.txt # (sks/font/font.k) around their text. Plain text has no bold, but a # terminal does: the txt target STRIPS these in its last phase # (txt_strip_styles) and the ansi target, which :includes txt, RESOLVES # them to SGR escape sequences in its last phase (ansi_resolve_styles). # So the two targets differ in nothing but that phase, and stripping the # escape sequences from an ansi render gives the txt render byte for byte # (sks/tst/ansi_test.sh pins it). Every phase before the last measures # them as ZERO width -- txt_visible_length below, which txt_wrap and the # table layout use -- or a bold word would cost thirteen columns of line # that the reader never sees. # # The OFF sequences are the specific resets (22 ends bold, 23 italic, 24 # underline), never "\033[0m": @b @i x @ y @ must leave y bold, and a # blanket reset would cancel the outer style with the inner one. TXT_STYLES = { "bold": ("\033[1m", "\033[22m"), "italic": ("\033[3m", "\033[23m"), "underline": ("\033[4m", "\033[24m"), } def txt_style_marker(style, on=True): if style not in TXT_STYLES: raise ValueError(f"unknown type style {style!r}; one of {sorted(TXT_STYLES)}") return f"__KTEXT{style.upper()}{'' if on else 'OFF'}__" _STYLE_MARKER_RGX = re.compile( "|".join(re.escape(txt_style_marker(s, on)) for s in TXT_STYLES for on in (True, False))) def txt_strip_styles(text, K): return _STYLE_MARKER_RGX.sub("", text) def ansi_resolve_styles(text, K): for style, (on, off) in TXT_STYLES.items(): text = text.replace(txt_style_marker(style), on) text = text.replace(txt_style_marker(style, False), off) return text def txt_visible_length(s): """How many columns s occupies once its markers resolve: a KTESC marker is one glyph, an nbsp marker one space, a style marker nothing. len() is the wrong measure for anything laid out (see txt_util).""" s = _STYLE_MARKER_RGX.sub("", s) return len(klammer_base.unescape_ktesc(s).replace(txt_nbsp_marker(), " ")) def txt_wrap(text, width, subsequent_indent=""): """textwrap.wrap(text, width, break_long_words=False) measured by VISIBLE width. textwrap counts characters, so a marker inside a word -- a bold word carries a thirteen-character marker at each end -- made it break lines a dozen columns early. Whitespace runs are kept as written (sentence spacing survives), a word longer than the measure stands on its own line, and no word is broken at a hyphen.""" chunks = re.split(r"(\s+)", text.strip()) lines, line, sep = [], "", "" for i in range(0, len(chunks), 2): word = chunks[i] if word: if line and (txt_visible_length(line) + len(sep) + txt_visible_length(word) > width): lines.append(line) line = subsequent_indent + word else: line += sep + word sep = " " * len(chunks[i + 1]) if i + 1 < len(chunks) else "" if line: lines.append(line) return lines # The tex line break, carried as a marker. # # @nl.tex used to emit "\\newline" directly, and that is destroyed the moment # it passes through a GENERATOR. A klammer whose @eval result still holds # klammers is generator output, so the machine escapes its text before # applying them -- which is what lets a writer put a "%" in generated data # without knowing LaTeX. A backslash meets the same rule and becomes # "\\textbackslash{}". So a row written # # @offer_list work done @nl@ (delivery date) | 10 || ... # # resolved @nl to "\\newline" while binding the rest argument, handed that to # Python as ordinary text, and had it neutralised on the way back: # "\\textbackslash{}newline" reached the .tex and printed as literal text. # # The marker has no backslash, so the escape pass has nothing to act on, and # tex_resolve_markers puts the real command back at the very end. # # LETTERS AND DIGITS ONLY -- deliberately unlike the txt markers above. The # tex target escapes "_" to "\\_", so a "__KTEXT...__" marker would arrive as # "\\_\\_KTEXT...\\_\\_" and never match. Nothing in this marker is in tex's # :escape list. def tex_nl_marker(): return "KTEXNEWLINEMARK" # Declared as an :after_apply phase on BOTH tex and pdf. ":includes" carries # escapes, transforms and resolves from tex into pdf but NOT phases # (target_registry.cpp), and pdf's own phase compiles the document, so this # has to run before it -- hence the explicit entry in each rather than # inheritance. def tex_resolve_markers(text, K): return text.replace(tex_nl_marker(), "\\newline") def txt_justify_blocks(text, K): delim = '__DIVIDE__' text = re.sub("\n\n+", delim, text) result = "" for par in text.split(delim): stripped = par.strip() n = stripped.count(txt_vspace_marker()) if n and stripped == txt_vspace_marker() * n: # A paragraph of only @vspace markers: n blank lines in addition # to the normal paragraph separation. result += "\n" * n continue # A paragraph BEGINNING WITH WHITESPACE is verbatim: it is filled to # no width and keeps the line breaks it was written with. The same # in-text rule the error messages use (justify() in mac/util.cpp), # and the one a klammer relies on to emit lines that must not # re-flow -- a @table's rows, a listing. # # The nbsp MARKER counts as that whitespace, and must: a klammer's # indent cannot be a real space, because Eval::eval TRIMS the result # katoms, so the leading spaces a klammer emits never reach this # pass at all. The marker is the only space that survives, and it # becomes one at the end of this function. (It was the character # "~" until 2026-08-27, which meant every tilde in the document # became a space and a literal tilde could not be written at all.) # # The emission below was INSIDE this "if" until 2026-08-26, so a # verbatim paragraph was not left alone, it was DELETED: the one # branch that had nothing to do also did not copy the text through. # An indented paragraph vanished from the output with no error. nbsp = txt_nbsp_marker() if par and par[0] != ' ' and not par.startswith(nbsp): label = re.match("(?:" + re.escape(nbsp) + r")*\[\d+\] ", par) par = "\n".join(txt_wrap( par, int(K.Target_txt_width), subsequent_indent=" " * len(label.group(0)) if label else "")) result += par + "\n\n" result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result) result = re.sub(r"\n? *" + txt_nl_marker() + r" *\n?", "\n", result) result = result.replace(txt_nbsp_marker(), " ") return result def txt_insert_captions(text, K): mark = kutil.caption_delimiter() caption_rgx = re.compile(f"{mark}(.*?){mark}(.*?){mark}", re.S) caption_number = 0 width = int(K.Target_txt_width) indent = int(K.Target_txt_indent) indented_width = width - indent def replace(m): nonlocal caption_number name, text = m.groups() # An empty label means an unnumbered caption (":number false"). It # must NOT take a number from the sequence: a reader counting the # captions they can see would then find the next one a number # ahead. The same is true of html and tex, where an unnumbered # caption emits no marker and so never reaches their counter. if name: caption_number += 1 caption = f"{name} {caption_number} - {text}" else: caption = text # Visible widths throughout: a caption may carry style markers. if txt_visible_length(caption) > indented_width: caption = "\n".join(txt_wrap( caption, indented_width, subsequent_indent=txt_nbsp_marker()*indent)) line_length = max([txt_visible_length(e) for e in caption.split("\n")]) line = txt_nbsp_marker() * indent + "-" * line_length return caption + "\n" + line result = caption_rgx.sub(replace, text) return result def txt_footnote_marker(): return "KTEXTFOOTNOTE" def txt_format_footnotes(text, K): footnote_number = 0 footnotes = [] def replace(m): nonlocal footnote_number, footnotes footnote_number += 1 footnotes.append(m.group(1)) return f"[{footnote_number}]" start_mark = "__" + txt_footnote_marker() end_mark = txt_footnote_marker() + "__" footnote_rgx = re.compile(rf"\s*{start_mark}\s+(.*?)\s+{end_mark}", re.S) result = footnote_rgx.sub(replace, text).rstrip() + "\n\n" if footnotes: rule_count = int(round(float(K.Target_txt_width) * 0.4)) result += "-" * rule_count + "\n\n" # Calculate maximum width for right-justified numbers: width = len(f"[{len(footnotes)}]") for n, footnote in enumerate(footnotes, 1): label = f"[{n}]" result += (txt_nbsp_marker() * (width - len(label)) + label + " " + footnote + "\n\n") return result def html_footnote_marker(): return "kt-footnote" def html_format_footnotes(text, K=None): fnum = 0 footnotes = [] def replace(m): nonlocal fnum, footnotes fnum += 1 footnotes.append(m.group(1)) href = f'href ="#_footnote_{fnum}"' id = f'id="_footnote_src_{fnum}"' return f'{fnum}' #return f'{fnum}' start_mark = f"<{html_footnote_marker()}>" end_mark = f"" footnote_rgx = re.compile(rf"\s*{start_mark}\s*(.*?)\s*{end_mark}", re.S) result = footnote_rgx.sub(replace, text).rstrip() + "\n\n" if footnotes: result += '
\n' for n, footnote in enumerate(footnotes, 1): result += f'{n} {footnote}
\n' # Temporarily add room for footnotes to move to the top of the window for the test: result += '
\n' return result