Files
klammertext/sks/target/phases.py
T
ack 9b0d0d7897 The ansi target, txt table styles, kdesc reference from ref/, typeface and table-width fixes (from dev fcec5e2e77ac)
Since the previous release: Machine::read_string() (the fs::path/std::string
overload pair split); kdesc -c and -u rendered from the ref/ reflection
klammerset; a fit column's tex width measured in TeX over every entry;
@typeface resolving to the family its css declares (a store directory name
now works in html) and hieroglyphs in @unicode_examples set in their
typeface; the ansi target -- plain text with the type styles a terminal can
show, identical to txt once the escape sequences are stripped -- with the
txt @table carrying :header_style, :style and :hline; @block anchoring,
@table spacing and the Image_quality state variable; and the rewritten
doc/klammertext_for_llm.md.
2026-09-12 18:11:48 +02:00

255 lines
11 KiB
Python

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'<a {id} {href}><sup class="footnote_in_text">{fnum}</sup></a>'
#return f'<a href="#_footnote_{fnum}" id="_footnote_src_{fnum}"><sup>{fnum}</sup></a>'
start_mark = f"<{html_footnote_marker()}>"
end_mark = f"</{html_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:
result += '<hr class="footnote_rule"/>\n'
for n, footnote in enumerate(footnotes, 1):
result += f'<a href="#_footnote_src_{n}" id="_footnote_{n}"><sup>{n} </sup></a>{footnote}<br/>\n'
# Temporarily add room for footnotes to move to the top of the window for the test:
result += '<div style="height: 100lh"></div>\n'
return result