2026-08-23 20:48:26 +02:00
|
|
|
import re
|
2026-07-18 18:48:23 +02:00
|
|
|
|
2026-08-26 23:00:58 +02:00
|
|
|
import kutil
|
2026-09-12 18:11:48 +02:00
|
|
|
import klammer_base
|
2026-08-26 23:00:58 +02:00
|
|
|
|
2026-07-25 21:16:21 +02:00
|
|
|
# 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.
|
|
|
|
|
|
2026-08-23 20:48:26 +02:00
|
|
|
def txt_nl_marker():
|
|
|
|
|
return "__KTEXTNEWLINE__"
|
|
|
|
|
|
|
|
|
|
def txt_vspace_marker():
|
|
|
|
|
return "__KTEXTVSPACE__"
|
|
|
|
|
|
2026-09-07 22:37:05 +02:00
|
|
|
# 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__"
|
|
|
|
|
|
2026-09-16 16:59:49 +02:00
|
|
|
# A ZERO-WIDTH mark that says "this line is already laid out" (2026-09-16).
|
|
|
|
|
# The justification pass takes a paragraph beginning with a space or the
|
|
|
|
|
# nbsp marker as verbatim, and until now that was the only way a block
|
|
|
|
|
# could say so -- which broke the moment a block was flush left: a @table
|
|
|
|
|
# with :hpos left :offset 0 (what @columns emits) began with its first
|
|
|
|
|
# cell, so its rows were re-flowed into one line. A block that lays itself
|
|
|
|
|
# out puts this at the start of every line (txt_lay_out); it is nothing to
|
|
|
|
|
# txt_visible_length and is removed at the end of txt_justify_blocks.
|
|
|
|
|
def txt_verbatim_marker():
|
|
|
|
|
return "__KTEXTVERBATIM__"
|
|
|
|
|
|
|
|
|
|
def txt_lay_out(text):
|
|
|
|
|
"""text with every line marked as already laid out."""
|
|
|
|
|
mark = txt_verbatim_marker()
|
|
|
|
|
return "\n".join(mark + line for line in text.split("\n"))
|
|
|
|
|
|
|
|
|
|
def txt_is_laid_out(par):
|
|
|
|
|
"""True for a paragraph the justification pass must leave alone."""
|
|
|
|
|
return par.startswith((" ", txt_nbsp_marker(), txt_verbatim_marker()))
|
|
|
|
|
|
2026-09-12 18:11:48 +02:00
|
|
|
# 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)))
|
|
|
|
|
|
2026-09-30 16:13:23 +02:00
|
|
|
# A COLOUR marker carries its colour: __KTEXTFG<rrggbb>__ opens a foreground
|
|
|
|
|
# colour, __KTEXTFGOFF__ restores the default (SGR 39, never a blanket reset).
|
|
|
|
|
# Used by @image under txt/ansi (2026-09-27, Andy's terminal images): the
|
|
|
|
|
# character carries the luminance and the colour the hue, so stripping the
|
|
|
|
|
# escapes still leaves a monochrome picture -- the invariant kept.
|
|
|
|
|
def txt_color_marker(rgb=None, background=False):
|
|
|
|
|
"""A foreground colour marker, or with background=True a background one
|
|
|
|
|
(__KTEXTBG<rrggbb>__ / __KTEXTBGOFF__, SGR 48;2 and 49): the quadrant
|
|
|
|
|
and halfblock image styles set two colours per cell."""
|
|
|
|
|
ground = "BG" if background else "FG"
|
|
|
|
|
if rgb is None:
|
|
|
|
|
return f"__KTEXT{ground}OFF__"
|
|
|
|
|
r, g, b = rgb
|
|
|
|
|
return f"__KTEXT{ground}{int(r):02X}{int(g):02X}{int(b):02X}__"
|
|
|
|
|
|
|
|
|
|
_COLOR_MARKER_RGX = re.compile(r"__KTEXT(FG|BG)(?:([0-9A-F]{6})|OFF)__")
|
|
|
|
|
|
2026-09-12 18:11:48 +02:00
|
|
|
def txt_strip_styles(text, K):
|
2026-09-30 16:13:23 +02:00
|
|
|
return _COLOR_MARKER_RGX.sub("", _STYLE_MARKER_RGX.sub("", text))
|
2026-09-12 18:11:48 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-09-30 16:13:23 +02:00
|
|
|
def color(m):
|
|
|
|
|
ground, hex6 = m.group(1), m.group(2)
|
|
|
|
|
code = 38 if ground == "FG" else 48
|
|
|
|
|
if hex6 is None:
|
|
|
|
|
return f"\033[{code + 1}m" # 39 / 49: the default fore-/background
|
|
|
|
|
return f"\033[{code};2;{int(hex6[0:2], 16)};{int(hex6[2:4], 16)};{int(hex6[4:6], 16)}m"
|
|
|
|
|
return _COLOR_MARKER_RGX.sub(color, text)
|
2026-09-12 18:11:48 +02:00
|
|
|
|
|
|
|
|
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)."""
|
2026-09-30 16:13:23 +02:00
|
|
|
s = _COLOR_MARKER_RGX.sub("", _STYLE_MARKER_RGX.sub("", s)).replace(txt_verbatim_marker(), "")
|
2026-09-12 18:11:48 +02:00
|
|
|
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
|
|
|
|
|
|
2026-09-07 22:37:05 +02:00
|
|
|
# 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")
|
|
|
|
|
|
2026-09-21 22:37:13 +02:00
|
|
|
def txt_runs(par):
|
|
|
|
|
"""A paragraph as runs of lines: [(laid_out, text), ...].
|
|
|
|
|
|
|
|
|
|
"Laid out or prose" was decided ONCE per paragraph, by its first line,
|
|
|
|
|
so a laid-out block written directly after prose -- no blank line
|
|
|
|
|
between them -- was re-flowed with it, every one of its lines marked
|
|
|
|
|
or not: a generator whose prose ran straight into its @table put the
|
|
|
|
|
table's header on the last line of the sentence (2026-09-20). A blank
|
|
|
|
|
line should not be what protects a block that has already said, line
|
|
|
|
|
by line, that it is laid out.
|
|
|
|
|
|
|
|
|
|
So a line that BEGINS WITH THE VERBATIM MARKER is laid out wherever it
|
|
|
|
|
stands, and the paragraph divides into runs at each change. Only the
|
|
|
|
|
marker divides: it is explicit and per line (txt_lay_out). A leading
|
|
|
|
|
space or nbsp keeps the older, whole-paragraph meaning and only on the
|
|
|
|
|
FIRST line, since a later line of prose may legitimately begin with
|
|
|
|
|
one (a footnote's hanging indent).
|
|
|
|
|
"""
|
|
|
|
|
if par.startswith((" ", txt_nbsp_marker())):
|
|
|
|
|
return [(True, par)]
|
|
|
|
|
mark = txt_verbatim_marker()
|
|
|
|
|
runs = []
|
|
|
|
|
for line in par.split("\n"):
|
|
|
|
|
laid_out = line.startswith(mark)
|
|
|
|
|
if runs and runs[-1][0] == laid_out:
|
|
|
|
|
runs[-1][1].append(line)
|
|
|
|
|
else:
|
|
|
|
|
runs.append((laid_out, [line]))
|
|
|
|
|
return [(laid_out, "\n".join(lines)) for laid_out, lines in runs]
|
|
|
|
|
|
2026-08-23 20:48:26 +02:00
|
|
|
def txt_justify_blocks(text, K):
|
2026-07-18 18:48:23 +02:00
|
|
|
delim = '__DIVIDE__'
|
2026-07-25 21:16:21 +02:00
|
|
|
text = re.sub("\n\n+", delim, text)
|
2026-07-18 18:48:23 +02:00
|
|
|
result = ""
|
|
|
|
|
for par in text.split(delim):
|
2026-07-25 21:16:21 +02:00
|
|
|
stripped = par.strip()
|
2026-08-23 20:48:26 +02:00
|
|
|
n = stripped.count(txt_vspace_marker())
|
|
|
|
|
if n and stripped == txt_vspace_marker() * n:
|
2026-07-25 21:16:21 +02:00
|
|
|
# A paragraph of only @vspace markers: n blank lines in addition
|
|
|
|
|
# to the normal paragraph separation.
|
|
|
|
|
result += "\n" * n
|
|
|
|
|
continue
|
2026-07-18 18:48:23 +02:00
|
|
|
|
2026-08-26 23:00:58 +02:00
|
|
|
# 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.
|
|
|
|
|
#
|
2026-09-07 22:37:05 +02:00
|
|
|
# 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.)
|
2026-08-26 23:00:58 +02:00
|
|
|
#
|
|
|
|
|
# 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.
|
2026-09-21 22:37:13 +02:00
|
|
|
#
|
|
|
|
|
# The decision is per RUN of lines, not per paragraph (txt_runs):
|
|
|
|
|
# prose is filled, a laid-out run is copied through, and the runs
|
|
|
|
|
# stay on consecutive lines as they were written.
|
2026-09-07 22:37:05 +02:00
|
|
|
nbsp = txt_nbsp_marker()
|
2026-09-21 22:37:13 +02:00
|
|
|
filled = []
|
|
|
|
|
for laid_out, run in txt_runs(par):
|
|
|
|
|
if run and not laid_out:
|
|
|
|
|
label = re.match("(?:" + re.escape(nbsp) + r")*\[\d+\] ", run)
|
|
|
|
|
run = "\n".join(txt_wrap(
|
|
|
|
|
run, int(K.Target_txt_width),
|
|
|
|
|
subsequent_indent=" " * len(label.group(0)) if label else ""))
|
|
|
|
|
filled.append(run)
|
|
|
|
|
result += "\n".join(filled) + "\n\n"
|
2026-08-23 20:48:26 +02:00
|
|
|
result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result)
|
|
|
|
|
result = re.sub(r"\n? *" + txt_nl_marker() + r" *\n?", "\n", result)
|
2026-09-16 16:59:49 +02:00
|
|
|
result = result.replace(txt_verbatim_marker(), "")
|
2026-09-07 22:37:05 +02:00
|
|
|
result = result.replace(txt_nbsp_marker(), " ")
|
2026-08-23 20:48:26 +02:00
|
|
|
return result
|
|
|
|
|
|
2026-08-26 23:00:58 +02:00
|
|
|
def txt_insert_captions(text, K):
|
2026-09-21 22:37:13 +02:00
|
|
|
# The caption's INDENT is the block's, read off the run of nbsp markers
|
|
|
|
|
# written before the caption marker (txt_util.add_caption) -- not
|
|
|
|
|
# Target_txt_indent, which put the caption of a flush block two columns
|
|
|
|
|
# to the right of the block it captions. Every line produced here is
|
|
|
|
|
# marked laid-out: a flush caption begins with neither a space nor an
|
|
|
|
|
# nbsp, and an unmarked first line made the justification pass re-flow
|
|
|
|
|
# the caption, its rule and the whole block under it into one paragraph.
|
2026-08-26 23:00:58 +02:00
|
|
|
mark = kutil.caption_delimiter()
|
2026-09-21 22:37:13 +02:00
|
|
|
nbsp = txt_nbsp_marker()
|
|
|
|
|
caption_rgx = re.compile(
|
|
|
|
|
f"((?:{re.escape(nbsp)})*){mark}(.*?){mark}(.*?){mark}", re.S)
|
|
|
|
|
# One sequence PER LABEL, as in html and tex: a listing after a table is
|
|
|
|
|
# "Listing 1", not "Listing 2". It was one counter for every label.
|
|
|
|
|
caption_numbers = {}
|
2026-08-26 23:00:58 +02:00
|
|
|
width = int(K.Target_txt_width)
|
2026-09-22 22:53:13 +02:00
|
|
|
verbatim = txt_verbatim_marker()
|
|
|
|
|
def block_width(lines):
|
|
|
|
|
# The visible width of the laid-out block adjacent to a caption:
|
|
|
|
|
# its lines carry the verbatim marker, and the first line that does
|
|
|
|
|
# not (or a blank line) ends the block.
|
|
|
|
|
widest = 0
|
|
|
|
|
for line in lines:
|
|
|
|
|
if not line.startswith(verbatim) or not line.strip():
|
|
|
|
|
break
|
|
|
|
|
widest = max(widest, txt_visible_length(line))
|
|
|
|
|
return widest
|
2026-08-26 23:00:58 +02:00
|
|
|
def replace(m):
|
2026-09-21 22:37:13 +02:00
|
|
|
indent_run, name, text = m.groups()
|
2026-09-30 16:13:23 +02:00
|
|
|
# The name may carry flags after "+": "center" sets the caption
|
|
|
|
|
# centred on its block (a figure's, as in html and tex).
|
|
|
|
|
name, _, flags = name.partition("+")
|
|
|
|
|
centered = "center" in flags.split("+")
|
2026-09-21 22:37:13 +02:00
|
|
|
indent = len(indent_run) // len(nbsp)
|
|
|
|
|
indented_width = max(1, width - indent)
|
2026-09-22 22:53:13 +02:00
|
|
|
# The block the caption belongs to: below a top caption, above a
|
|
|
|
|
# bottom one. Its width is what the caption's rule is drawn to,
|
|
|
|
|
# so the rule under "Table 1 - Prices" is as long as the table's
|
|
|
|
|
# own rules, not as long as those words. (Before the match sits
|
|
|
|
|
# the caption line's own verbatim marker: drop that line.)
|
|
|
|
|
after = m.string[m.end():].split("\n")[1:]
|
|
|
|
|
before = m.string[:m.start()].split("\n")[:-1]
|
|
|
|
|
block = max(block_width(after), block_width(reversed(before)))
|
2026-08-26 23:00:58 +02:00
|
|
|
# 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:
|
2026-09-21 22:37:13 +02:00
|
|
|
caption_numbers[name] = caption_numbers.get(name, 0) + 1
|
|
|
|
|
caption = f"{name} {caption_numbers[name]} - {text}"
|
2026-08-26 23:00:58 +02:00
|
|
|
else:
|
|
|
|
|
caption = text
|
2026-09-12 18:11:48 +02:00
|
|
|
# Visible widths throughout: a caption may carry style markers.
|
2026-09-30 16:13:23 +02:00
|
|
|
# NO RULE under (or over) a caption: none of the targets draws one
|
|
|
|
|
# (Andy, 2026-09-28 -- the rule this pass drew since 2026-08 was a
|
|
|
|
|
# txt invention; a table's rules are its own :hline, a listing's its
|
|
|
|
|
# own frame). A centred caption is set on the block's width.
|
2026-09-21 22:37:13 +02:00
|
|
|
lines = [caption]
|
2026-09-12 18:11:48 +02:00
|
|
|
if txt_visible_length(caption) > indented_width:
|
2026-09-21 22:37:13 +02:00
|
|
|
lines = txt_wrap(caption, indented_width)
|
2026-09-30 16:13:23 +02:00
|
|
|
if centered:
|
|
|
|
|
measure = max(block - indent, max(txt_visible_length(e) for e in lines))
|
|
|
|
|
lines = [" " * max(0, (measure - txt_visible_length(e)) // 2) + e for e in lines]
|
2026-09-21 22:37:13 +02:00
|
|
|
lead = txt_verbatim_marker() + indent_run
|
|
|
|
|
# The first line's marker is already in the text, before the indent
|
|
|
|
|
# run this match begins with (add_caption); the others get theirs.
|
|
|
|
|
return indent_run + ("\n" + lead).join(lines)
|
2026-08-26 23:00:58 +02:00
|
|
|
result = caption_rgx.sub(replace, text)
|
|
|
|
|
return result
|
|
|
|
|
|
2026-08-23 20:48:26 +02:00
|
|
|
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}]"
|
2026-09-07 22:37:05 +02:00
|
|
|
result += (txt_nbsp_marker() * (width - len(label))
|
|
|
|
|
+ label + " " + footnote + "\n\n")
|
2026-08-23 20:48:26 +02:00
|
|
|
return result
|
|
|
|
|
|
2026-09-18 20:02:44 +02:00
|
|
|
|
|
|
|
|
# Section numbering under txt (2026-09-18). Numbering is a DOCUMENT
|
|
|
|
|
# service, as it is in html (a pass @document runs over the page) and tex
|
|
|
|
|
# (LaTeX's own counters inside the document): a heading rendered outside
|
|
|
|
|
# a @document -- kdesc's renders of the ref/ generators -- is not
|
|
|
|
|
# numbered. Document.txt() (sks/document/document_txt.py) opens its
|
|
|
|
|
# result with the document marker; Section.txt() emits a section marker
|
|
|
|
|
# carrying the level and the :n override; this phase, first in the txt
|
|
|
|
|
# and ansi lists, numbers the headings when the document marker is there
|
|
|
|
|
# and strips every marker either way. A heading at a level with no
|
|
|
|
|
# parent numbers as "0.1", as html and tex print it -- the txt target
|
|
|
|
|
# does not diverge from them. The single-source functions are the
|
|
|
|
|
# TODO #47 pattern.
|
|
|
|
|
|
|
|
|
|
def txt_document_marker():
|
|
|
|
|
return "__KTEXTDOCUMENT__"
|
|
|
|
|
|
|
|
|
|
def txt_section_marker(level, n=-1):
|
|
|
|
|
# LEVEL 0 is a part, 1-7 the sections; N > -1 sets the number.
|
|
|
|
|
return f"__KTEXTSECTION__{level}__{n}__"
|
|
|
|
|
|
|
|
|
|
def txt_insert_section_numbers(text, K):
|
|
|
|
|
# The marker is the first thing the document emits, but the result
|
|
|
|
|
# reaches the phase behind whatever newlines the apply left in front.
|
|
|
|
|
head = text.lstrip()
|
|
|
|
|
in_document = head.startswith(txt_document_marker())
|
|
|
|
|
if in_document:
|
|
|
|
|
text = head[len(txt_document_marker()):].lstrip("\n")
|
|
|
|
|
levels = [0] * 8 # 0: parts; 1-7: sections (parts do not reset them, as in LaTeX)
|
|
|
|
|
marker_rgx = re.compile(r"__KTEXTSECTION__(\d+)__(-?\d+)__ ?")
|
|
|
|
|
def number(match):
|
|
|
|
|
if not in_document:
|
|
|
|
|
return ""
|
|
|
|
|
level, n = int(match.group(1)), int(match.group(2))
|
|
|
|
|
levels[level] = n if n > -1 else levels[level] + 1
|
|
|
|
|
if level == 0:
|
|
|
|
|
return f"{levels[0]} " # a part resets nothing below it
|
|
|
|
|
for i in range(level + 1, len(levels)):
|
|
|
|
|
levels[i] = 0
|
|
|
|
|
return ".".join(str(v) for v in levels[1:level + 1]) + " "
|
|
|
|
|
return marker_rgx.sub(number, text)
|
|
|
|
|
|
|
|
|
|
# HTML
|
|
|
|
|
|
2026-08-23 20:48:26 +02:00
|
|
|
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
|
2026-10-12 20:55:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# @link :section outside a @document (2026-10-12). A section link is
|
|
|
|
|
# resolved when @document assembles its chapters, so one that is applied
|
|
|
|
|
# outside any document can never be resolved: its marker would reach the
|
|
|
|
|
# output. @document sets K_document_file on each chapter's Machine; a
|
|
|
|
|
# Machine without it that still holds the marker after its own apply is
|
|
|
|
|
# that case. (Inside a document the outer Machine's apply ends with the
|
|
|
|
|
# resolved text -- html writes files and returns nothing -- so the marker
|
|
|
|
|
# is gone by the time this runs there.)
|
|
|
|
|
def check_section_links(text, K):
|
|
|
|
|
if "__seclink__" in text and not getattr(K, "K_document_file", ""):
|
|
|
|
|
raise ValueError("A @link with :section names a section by its heading, "
|
|
|
|
|
"which only a @document can resolve, and this link is "
|
|
|
|
|
"outside any @document")
|
|
|
|
|
return text
|