import re import textwrap import kutil # 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 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(textwrap.wrap( par, width=int(K.Target_txt_width), break_long_words=False, 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 caption_width = len(caption) + indent if len(caption) > indented_width: caption = textwrap.wrap( caption, indented_width, #initial_indent ="~"*indent, subsequent_indent=txt_nbsp_marker()*indent) caption = "\n".join(caption) caption_width = indented_width line_length = max([len(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