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.
This commit is contained in:
ack committed 2026-09-12 18:11:48 +02:00
1 parent 760fda5470
commit 9b0d0d7897
34 files changed
+895 -394

No files matched your search

+1 -1
View File
@@ -161,7 +161,7 @@ only makes some vertical space. @@
@@@argtype coords | x and y coordinates :pattern 'float'\s+'float' @@@
@@block.k : to.coords | content :width.float .5 :point.coords 0.0 0.0
@@block.k to.coords | content :width.float .5 :point.coords 0.0 0.0
: Absolute positioning of text block @@
@@block.tex :: @eval block.Block(K) eval@ @@
+8
View File
@@ -70,12 +70,20 @@ class Block(klammer_base.Klammer_base):
# NOT latex_util.block(): a textblock is absolutely positioned and
# does not participate in the normal flow, so breaking the paragraph
# around it would move the surrounding text.
#
# The trailing \par\kern0pt gives the block's vbox zero depth.
# textpos anchors :point against the box HEIGHT alone, so content
# whose last line has depth -- an @image, whose [c] minipage sits
# half below the baseline -- was anchored a quarter of its height
# too low (2in for a page-high picture) until 2026-09-08.
to_x, to_y = [float(e) for e in self.to.split()]
pt_x, pt_y = [float(e) for e in self.point.split()]
result = f"""
\\begin{{textblock}}{{{self.width}}}[{pt_x},{pt_y}]({to_x},{to_y})
\\setlength{{\\textwidth}}{{\\linewidth}}
\\vspace*{{-1\\parskip}}
{self.content.strip()}
\\par\\kern0pt
\\end{{textblock}}
"""
return result
+9 -3
View File
@@ -5,11 +5,17 @@
%\usepackage[absolute,showboxes]{textpos}
\usepackage[absolute]{textpos}
\setlength{\TPHorizModule}{\paperheight}
\setlength{\TPVertModule}{\paperwidth}
\setlength{\TPHorizModule}{\paperwidth}
\setlength{\TPVertModule}{\paperheight}
%\setlength{\TPVertModule}{1\paperheight}
\textblockorigin{0in}{0in}
% The paper's top-left corner. textpos's absolute mode subtracts TeX's
% 1in origin itself (\textblockorigin stores x-1in, y-1in), and
% document.sty already sets \hoffset and \voffset to -1in for the same
% reason, so {0in}{0in} here shifted every block 1in left and 1in up
% (until 2026-09-08). Cancelling the offsets is right under either
% convention.
\textblockorigin{-\hoffset}{-\voffset}
\newlength{\fwboxwidth}
\newcommand{\fwboxn}[1]{\setlength{\fwboxwidth}{#1}}
+14 -2
View File
@@ -276,9 +276,12 @@ void Document_class::collect_typefaces(const std::string& text)
// located error naming the available typefaces if the store
// does not have it, which is the whole error path for a
// misspelled or uninstalled typeface.
// Compared by directory name: a @typeface may write either
// the family name or the store's directory name, and both
// resolve to the same directory.
bool seen = false;
for (const auto& rf : m_resolved_extra)
if (rf.family_name == name) seen = true;
if (rf.dir_name == name_to_dirname(name)) seen = true;
if (!seen) {
// What a @typeface klammer asked for, and what the store
// resolved it to: a decision the user cannot read off
@@ -293,13 +296,22 @@ void Document_class::collect_typefaces(const std::string& text)
}
}
std::string Document_class::strip_typeface_markers(const std::string& text)
const Resolved_font& Document_class::resolved_typeface(const std::string& name) const
{
for (const auto& rf : m_resolved_extra)
if (rf.dir_name == name_to_dirname(name)) return rf;
// collect_typefaces() resolves every marker before this is asked.
throw Internal_error("Typeface \"" + name + "\" was not resolved");
}
std::string Document_class::resolve_typeface_families(const std::string& text)
{
std::string result {};
size_t pos = 0, start = 0, end = 0;
std::string name {}, scale {};
while (next_typeface_marker(text, pos, start, end, name, scale)) {
result += text.substr(pos, start - pos);
result += resolved_typeface(name).family_name;
pos = end;
}
result += text.substr(pos);
+6 -1
View File
@@ -144,7 +144,12 @@ public:
void collect_typefaces(const std::string& text);
// html: the marker carries no inline switch (the span holds the family
// name), so each is replaced by nothing.
std::string strip_typeface_markers(const std::string& text);
// html: each marker is replaced by the family name the store resolved
// its typeface to (the name as written may be the store's directory
// name, which no @font-face declares).
std::string resolve_typeface_families(const std::string& text);
// The resolved font for a typeface name as a @typeface wrote it.
const Resolved_font& resolved_typeface(const std::string& name) const;
// tex: each distinct (typeface, scale) gets a \newfontfamily command in
// the preamble; the marker is replaced by that command.
std::string resolve_typeface_markers(const std::string& text);
+7 -4
View File
@@ -109,12 +109,15 @@ std::string Document_class::font_definitions()
// var() with such a value computes to inherit, silently losing
// the font.
ss << ":root {\n";
// The family the store RESOLVED, which is what the localized css
// declares -- the name as written may be the store's directory
// name ("crimson-pro"), which matches no @font-face.
if (!m_serif_typeface.empty())
ss << " --serif: \"" << m_serif_typeface << "\", serif;\n";
ss << " --serif: \"" << m_resolved_serif.family_name << "\", serif;\n";
if (!m_sans_typeface.empty())
ss << " --sans-serif: \"" << m_sans_typeface << "\", sans-serif;\n";
ss << " --sans-serif: \"" << m_resolved_sans.family_name << "\", sans-serif;\n";
if (!m_mono_typeface.empty())
ss << " --monospace: \"" << m_mono_typeface << "\", monospace;\n";
ss << " --monospace: \"" << m_resolved_mono.family_name << "\", monospace;\n";
ss << "}\n";
}
// Font-size normalization across families is no longer emitted from
@@ -761,7 +764,7 @@ std::string Document_class::html()
install_resolved_font(rf, output_directory);
}
for (auto& [basename, text] : m_file_components) {
text = strip_typeface_markers(text);
text = resolve_typeface_families(text);
}
auto headings = insert_section_numbers();
insert_section_numbers("___NUM2___", false);
+8 -3
View File
@@ -49,12 +49,16 @@ unchanged.
@@b.k text : Use a bold weight of the current font for *text* @@
@@b.html :: <b>*text*</b> @@
@@b.tex :: \textbf{*text*} @@
@@b.txt :: *text* @@
# The txt bodies of @b, @i, @u and @ri emit STYLE MARKERS around the text
# (phases.txt_style_marker), which the txt target strips and the ansi
# target turns into escape sequences -- see the ansi target in
# sks/target/target.k. "#-" glues the marker to the text.
@@b.txt :: @eval phases.txt_style_marker("bold") @ #- *text* #- @eval phases.txt_style_marker("bold", False) @ @@
@@i.k text : Use an italic style of the current font for *text* @@
@@i.html :: <i>*text*</i> @@
@@i.tex :: \emph{*text*} @@ # \begin{emph}{*text*}\end{emph} @@
@@i.txt :: *text* @@
@@i.txt :: @eval phases.txt_style_marker("italic") @ #- *text* #- @eval phases.txt_style_marker("italic", False) @ @@
@@t.k text : Use the currently defined monospace font for *text* @@
@@t.html :: <span class="tt">*text*</span> @@
@@ -68,11 +72,12 @@ unchanged.
@@u.k text : Underline *text* in the current font @@
@@u.tex :: \underline{\botstrut{2pt}*text*} @@
@@u.txt :: @eval phases.txt_style_marker("underline") @ #- *text* #- @eval phases.txt_style_marker("underline", False) @ @@
@@ri.k text : Use a roman italic font for *text* (to override monospace environment) @@
@@ri.html :: <span class="ritalic">*text*</span> @@
@@ri.tex :: \textrm{\textit{*text*}} @@
@@ri.txt :: *text* @@
@@ri.txt :: @eval phases.txt_style_marker("italic") @ #- *text* #- @eval phases.txt_style_marker("italic", False) @ @@
@@ts.k scale.float | text : Resize *text* by *scale* @@
# @@ts.tex :: {\scalefont{*scale*}\em *text*} @@
+19 -10
View File
@@ -31,8 +31,12 @@ class Subsuper(klammer_base.Klammer_base):
# this marker where the typeface is needed, and @document collects the
# markers, resolves and localizes each typeface, declares it, and replaces
# the marker with whatever inline switch the target needs (a font-switch
# command for tex, nothing for html, which carries the family name in the
# span's own style). Same shape as the __CAPTION__ markers.
# command for tex; for html the marker sits IN the span's font-family value
# and becomes the family name the store resolved the typeface to, which
# is what the localized css declares -- the name as WRITTEN may be the
# store's directory name, "noto-sans-egyptian-hieroglyphs", and a span
# carrying that matched no @font-face until 2026-09-12). Same shape as
# the __CAPTION__ markers.
#
# SYNC: typeface_marker_rgx in sks/document/document_class.h -- a C++/Python
# pair, so the constant cannot be single-sourced the way a .k/.py pair can
@@ -53,18 +57,23 @@ class Typeface(klammer_base.Klammer_base):
# hieroglyphs, cuneiform -- and would resize them unpredictably.
# The family name is quoted -- an unquoted digit-initial word
# ("Source Sans 3") is invalid CSS -- with SINGLE quotes, because
# this sits inside a double-quoted html attribute.
style = (f"font-family: '{self.name}', serif; "
f"font-size-adjust: none")
# this sits inside a double-quoted html attribute. What is quoted
# here is the MARKER: @document replaces it with the resolved
# family name (see the marker note above).
style = "', serif; font-size-adjust: none"
if float(self.scale) != 1.0:
style += f"; font-size: {float(self.scale)}em"
# The STYLE is markup, so its typographically active characters are
# hidden: the html target's transforms turn a single quote into a
# curly one, which broke the css font-family value. The TEXT is
# writer content and is deliberately left alone -- its quotes and
# dashes should be transformed.
return (typeface_marker(self.name, self.scale)
+ f'<span style="{klammer_base.hide_typographic(style)}">'
# curly one, which broke the css font-family value. The MARKER is
# not hidden: a hyphen in the typeface name would become a KTESC
# sequence, which on the :files path is still there when @document
# looks for the marker. The TEXT is writer content and is
# deliberately left alone -- its quotes and dashes should be
# transformed.
hide = klammer_base.hide_typographic
return (f'<span style="{hide("font-family: " + chr(39))}'
+ typeface_marker(self.name, self.scale) + hide(style) + '">'
+ f'{self.text}</span>')
def tex(self):
+9
View File
@@ -12,6 +12,15 @@
@@@state Image_default_width :desc Default width for images :value .667w @@@
@@@state Image_quality
:desc JPEG quality, 1 to 100, at which a photograph -- a raster with
no transparency, whether it arrives as JPEG, PNG or TIFF -- is
written to the image cache for the html and pdf targets. The
default keeps print quality at a fraction of the size; 100 copies
or converts the source unchanged, as before 2026-09-09.
:value 85
@@@
#
# @@@argtype pixels | a pixel count :pattern \d+px @@@
# @@image.k basename : Image read from a file @@
+2 -1
View File
@@ -27,7 +27,8 @@ class Image(klammer_base.Klammer_base):
if not os.path.isdir(cache_dir):
import tempfile
cache_dir = tempfile.gettempdir()
self.cache = image_cache.Image_cache(cache_dir, self.Image_search_path, verbose=False)
self.cache = image_cache.Image_cache(cache_dir, self.Image_search_path, verbose=False,
quality=self.Image_quality)
self.basename = klammer_base.unescape_ktesc(self.basename)
self.source, self.pwidth, self.pheight, self.file_error = self.cache.get(self.K_target, self.basename)
if self.file_error:
+69 -2
View File
@@ -40,14 +40,49 @@ def image_dimensions(filename):
return width, height, resize
# Raster formats a photograph arrives in. Vector sources (pdf, eps) are
# never re-encoded, nor is a gif or webp (they may be animated, and only
# html takes them).
photograph_formats = {"jpg", "jpeg", "png", "tif", "tiff"}
def is_photograph(filename):
"""True if the file is a raster with no alpha channel: a photograph
(or a grayscale one) that JPEG stores well. A png with transparency,
or a palette image, is not one -- JPEG would flatten or posterize it."""
ext = os.path.splitext(filename)[1][1:].lower()
if ext not in photograph_formats:
return False
inp = oiio.ImageInput.open(filename)
if not inp:
return False
spec = inp.spec()
inp.close()
return spec.alpha_channel < 0 and spec.nchannels in (1, 3)
class Image_cache:
max_width = 1600 # Should be a state variable
cache_basedir = "_klammertext_imgsrc" # Should be a state variable
error_marker_filename = "_error_marker.png"
verbose_label = " [klammertext]" # Should parameterize verbosity
def __init__(self, cache_dir, search_path, verbose=False):
def __init__(self, cache_dir, search_path, verbose=False, quality=100):
#self.cache_dir = kutil.cache_directory(
self.dir = kutil.cache_directory(cache_dir, "_image_cache")
# JPEG quality (1-100) at which a photograph is written to the cache.
# 100 means the source is copied or converted as it is (the
# behaviour before 2026-09-09); below it, every photograph -- a
# JPEG source included -- is re-encoded at that quality, which is
# where a document's size mostly lives: a source JPEG is often
# near-lossless (quality 95+), and a png photograph is stored
# losslessly, at three to six times the size of a quality-85 JPEG
# with no visible difference at print resolution. The Image_quality
# state variable (sks/image/image.k) sets it.
try:
self.quality = int(quality)
except (TypeError, ValueError):
raise Exception(f'Image_quality must be an integer from 1 to 100, not "{quality}"')
if not 1 <= self.quality <= 100:
raise Exception(f"Image_quality must be from 1 to 100, not {self.quality}")
self.search = search_path.split(" ")
self.verbose = verbose or os.environ.get("KLAMMERTEXT_IMAGE_CACHE_DISPLAY")
self.required_format = { "html" : ["jpg", "jpeg", "JPEG", "png", "gif", "webp"],
@@ -75,6 +110,18 @@ class Image_cache:
return f"{self.dir}/{filename}.{cache_ext}"
def photograph_cache_filename(self, basename):
# The quality is part of the name, so changing it re-encodes and
# two documents at different qualities do not fight over one file.
return f"{self.dir}/{basename}.q{self.quality}.jpg"
def reencode(self, target, source_filename):
"""Whether this source is written to the cache as a quality-N JPEG."""
return self.quality < 100 \
and target in self.required_format \
and source_filename != self.error_image_filename \
and is_photograph(source_filename)
def find_file(self, target, basename):
result = None
candidates = []
@@ -131,6 +178,8 @@ class Image_cache:
if src[:2] == "..":
src = source_filename
dest = os.path.splitext(cached_filename)[1]
if cached_filename.endswith(f".q{self.quality}.jpg"):
dest = f"{dest} (quality {self.quality})"
dim = "" if width == new_width else f" [{new_width}x{new_height}]"
desc = "(copied)" if copied else f"-> {dest}{dim}"
#print(f"{Image_cache.verbose_label} To cache: {src} [{width}x{height}] {desc}")
@@ -148,7 +197,20 @@ class Image_cache:
cached_filename = f"{os.path.dirname(cached_filename)}/{os.path.basename(source_filename)}"
copied = False
if resize and width > Image_cache.max_width:
if self.reencode(target, source_filename):
cached_filename = self.photograph_cache_filename(basename)
buf = oiio.ImageBuf(source_filename)
if buf.has_error:
raise Exception(f'Cannot read image "{source_filename}": {buf.geterror()}')
if width > Image_cache.max_width:
new_width = Image_cache.max_width
new_height = int(round(new_width * float(height) / float(width)))
roi = oiio.ROI(0, new_width, 0, new_height, 0, 1, 0, buf.nchannels)
buf = oiio.ImageBufAlgo.resize(buf, roi=roi)
buf.specmod().attribute("CompressionQuality", self.quality)
if not buf.write(cached_filename):
raise Exception(f'Cannot write "{cached_filename}": {buf.geterror()}')
elif resize and width > Image_cache.max_width:
new_width = Image_cache.max_width
new_height = int(round(new_width * float(height) / float(width)))
if source_filename.endswith(".pdf"):
@@ -190,6 +252,11 @@ class Image_cache:
def get(self, target, basename):
cached_filename = self.find_cached_file(target, basename)
source_filename = self.find_file(target, basename)
if self.reencode(target, source_filename):
# A copy cached at another quality (or untouched, by an earlier
# run at 100) does not serve: the wanted file is the quality-N
# one, and cache_update_required() makes it if it is missing.
cached_filename = self.photograph_cache_filename(basename)
if cache_update_required(source_filename, cached_filename):
cached_filename, width, height = self.update_cache(target, basename, source_filename)
else:
+2 -1
View File
@@ -65,7 +65,8 @@ class Image_grid(klammer_base.Klammer_base):
self.cache = image_cache.Image_cache(
self.K_input_dir,
self.Image_search_path,
verbose=False)
verbose=False,
quality=self.Image_quality)
self.margin = 0.01
+14 -5
View File
@@ -67,16 +67,25 @@ class Klammer_base:
print(label)
pprint.pprint(self.__dict__)
# A target that :includes another renders through the included one's
# method: pdf is tex compiled, ansi is txt with escape sequences. The
# Python side cannot see :includes, so the relation is restated here.
# SYNC: the @@@target declarations in sks/target/target.k.
TARGET_METHOD = {"html": "html",
"tex": "tex", "pdf": "tex",
"txt": "txt", "ansi": "txt"}
def __str__(self):
result = None
if self.K_target == 'html':
method = Klammer_base.TARGET_METHOD.get(self.K_target)
if method == 'html':
result = self.html()
elif self.K_target in {'tex', 'pdf'}:
elif method == 'tex':
result = kutil.escape(self.tex())
elif self.K_target == 'txt':
elif method == 'txt':
result = self.txt()
if result is None:
#print(self.__dict__)
raise Exception(
f'Target "{self.K_target}" is undefined for klammer "@{self.K_klammer}"')
f'Target "{self.K_target}" is undefined for klammer class '
f'"{self.__class__.__name__}"')
return result
+1 -1
View File
@@ -70,7 +70,7 @@ class Link(klammer_base.Klammer_base):
def txt(self):
text = f'{self.text} ' if self.text else ''
return f'{text}[{self.url}]'
return f'{text}[{self.target}]'
class Target(klammer_base.Klammer_base):
def __init__(self, K):
+5
View File
@@ -36,3 +36,8 @@
\newlength{\klfillb}
\newlength{\klfillc}
\newlength{\klfilld}
% Scratch register in which a 'fit' or 'fill' column's width is measured
% (\settowidth, then \maxof over each further entry) before being frozen
% into the per-column macro \klfit<X> the column spec reads.
\newlength{\klfitwidth}
+129 -41
View File
@@ -440,28 +440,33 @@ class Table(klammer_base.Klammer_base):
self.rowspan_covered.add((row_i, col_i))
self.covered = self.colspan_covered | self.rowspan_covered
def column_width_text(self):
# For each column, the text used to measure a 'fit' width in the
# tex target. The longest cell's font is applied, so a bold or
# italic cell is measured in the font it will be set in.
self.column_widths = []
def column_width_candidates(self):
# For each column, the texts whose widths a 'fit' or 'fill' column's
# tex width is the maximum of: every cell of the column, each line
# of a multi-line cell separately, set in its own cell's font. The
# maximum is taken in TeX (tex_column_widths), not here: until
# 2026-09-12 one "longest" entry was chosen by len() of the tex
# MARKUP, so \texttt{\textasciicircum{}e'} (26 characters, 3
# visible) outranked a header "Written" and the header overflowed
# into the next column (TODO #59).
self.column_candidates = []
for col_i in range(len(self.cells[0])):
longest = ""
longest_style = "r"
candidates = []
for row_i in range(len(self.cells)):
cell = self.cells[row_i][col_i]
# A colspan anchor's text spans several columns and must
# not set the width of its own column.
if cell is not None and cell.colspan <= 1:
lines = [e.strip() for e in cell.text.split("\\newline")]
lines = sorted(lines, key=len)
longest_in_line = lines[-1]
if len(longest_in_line) > len(longest):
longest = longest_in_line
longest_style = cell.font
if longest_style != "r":
longest = font.tex_fontify(longest, longest_style, 1.0)
self.column_widths.append(longest)
if cell is None or cell.colspan > 1:
continue
for line in cell.text.split("\\newline"):
line = line.strip()
if not line:
continue
if cell.font != "r":
line = font.tex_fontify(line, cell.font, 1.0)
if line not in candidates:
candidates.append(line)
self.column_candidates.append(candidates)
# ---- :hpos -------------------------------------------------------------
#
@@ -530,7 +535,7 @@ class Table(klammer_base.Klammer_base):
cell_i + max(cspan, 1) == self.row_size)))
cells.append(row_cells)
self.cells = cells
self.column_width_text()
self.column_width_candidates()
# HTML
@@ -626,6 +631,41 @@ class Table(klammer_base.Klammer_base):
# in table.sty; one per fill column, in column order.
fill_registers = ["\\klfilla", "\\klfillb", "\\klfillc", "\\klfilld"]
@staticmethod
def width_macro(col_i):
# The macro holding a column's measured width: \klfitA ... \klfitZ,
# \klfitAA, ... (a control-sequence name holds letters only).
letters = ""
i = col_i
while i >= 0:
letters = chr(ord("A") + i % 26) + letters
i = i // 26 - 1
return "\\klfit" + letters
def tex_column_widths(self):
# Set each 'fit' or 'fill' column's width macro to the width of its
# widest entry, measured in TeX: \settowidth of the first candidate,
# then calc's \maxof against \widthof of each further one, in the
# scratch register \klfitwidth (table.sty); the result is frozen
# into \klfit<X> by \edef, so the column spec -- which array's p{}
# evaluates once per CELL -- reads a number instead of re-measuring
# every candidate. Flat, one line per candidate: a nested \maxof
# would recurse as deep as the column is long.
result = ""
for i, w in enumerate(extend(self.column_width, self.row_size)):
if w not in ("fit", "f", "fill"):
continue
candidates = self.column_candidates[i]
if not candidates:
result += "\\setlength{\\klfitwidth}{0pt}\n"
else:
result += f"\\settowidth{{\\klfitwidth}}{{{candidates[0]}}}\n"
for c in candidates[1:]:
result += (f"\\setlength{{\\klfitwidth}}"
f"{{\\maxof{{\\klfitwidth}}{{\\widthof{{{c}}}}}}}\n")
result += f"\\edef{Table.width_macro(i)}{{\\the\\klfitwidth}}\n"
return result
def tex_fill_widths(self):
# Set each 'fill' column's register to min(its share of the
# remaining width, its widest entry) -- the same rule the html auto
@@ -637,11 +677,10 @@ class Table(klammer_base.Klammer_base):
if not self.fill_columns:
return ""
widths = extend(self.column_width, self.row_size)
fit = [f"\\widthof{{{self.column_widths[i]}}}"
fit = [Table.width_macro(i)
for i, w in enumerate(widths) if w in ("fit", "f")]
remaining = "\\tablewidth" + "".join(f" - {e}" for e in fit)
widest = {i: f"\\widthof{{{self.column_widths[i]}}}"
for i in self.fill_columns}
widest = {i: Table.width_macro(i) for i in self.fill_columns}
total = " + ".join(widest[i] for i in self.fill_columns)
result = ""
for k, i in enumerate(self.fill_columns):
@@ -658,7 +697,7 @@ class Table(klammer_base.Klammer_base):
def tex_hpos(self):
# One column specification per column: the width comes from
# :column_width ('fit', 'fill' via its precomputed register, a
# :column_width ('fit' and 'fill' via their precomputed macros, a
# fraction of \tablewidth, or '*' for the remaining width), the
# justification from :cell_hpos.
def par_format(s, justification):
@@ -671,7 +710,7 @@ class Table(klammer_base.Klammer_base):
fill_ordinal = 0
for i, w in enumerate(extend(self.column_width, self.row_size)):
if w in ("fit", "f"):
widths.append(f"\\widthof{{{self.column_widths[i]}}}")
widths.append(Table.width_macro(i))
elif w == "fill":
widths.append(Table.fill_registers[fill_ordinal])
fill_ordinal += 1
@@ -804,9 +843,9 @@ class Table(klammer_base.Klammer_base):
# marker and prints the console warning with the :column_width
# primer -- the widths are only known at LaTeX run time, and prose
# kept out of TeX avoids the log's 79-column line wrapping.
return ("\\ifdim\\tableboxwidth>\\dimexpr\\textwidth+2pt\\relax\n"
return ("\\ifdim\\tableboxwidth>\\dimexpr\\linewidth+2pt\\relax\n"
f"\\message{{^^JKT-WIDE-TABLE {name} overfull by "
"\\the\\dimexpr\\tableboxwidth-\\textwidth\\relax^^J}\n"
"\\the\\dimexpr\\tableboxwidth-\\linewidth\\relax^^J}\n"
"\\fi\n")
def tex(self):
@@ -816,15 +855,25 @@ class Table(klammer_base.Klammer_base):
# minipage below: it computes the width the minipage consumes.
measure = self.get_width() + self.tex_width_check(name)
result = f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n"
if self.allow_break:
result += "\\vspace*{12pt}\n"
result += "\\begin{longtable}{"
# A page-breaking table is a longtable. A boxed one is a plain
# tabular: inside its minipage a longtable could not break anyway,
# and longtable refuses to run in a multicols or two-column page,
# which is where a boxed table is wanted (until 2026-09-08 both
# paths emitted longtable, so @table could not be used in
# @multicolumn at all).
env = "longtable" if self.allow_break else "tabular"
result += f"\\begin{{{env}}}{{"
result += self.tex_column_spec()
result += "}\n"
if self.allow_break:
if self.allow_break and (self.number or self.caption):
# The caption rides in longtable's last foot so it tracks the
# table across page breaks. Without a caption there is no foot:
# an empty one is still a full row (\vspace*{8pt} plus an empty
# \small line), which put ~20pt of blank under every uncaptioned
# breaking table until 2026-09-08.
result += self.make_caption()
result += self.tex_rows()
result += "\\end{longtable}\n"
result += f"\\end{{{env}}}\n"
if not self.allow_break:
# Box the table at its measured width so the caption tracks it
@@ -845,18 +894,27 @@ class Table(klammer_base.Klammer_base):
result = measure + result
result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}"
# The wrapper (add_caption/caption_wrapper) owns all vertical space
# around the table; longtable's own glue is zeroed.
# A boxed table's wrapper (add_caption/caption_wrapper) owns all
# vertical space around it, so longtable's own glue is zeroed. A
# page-breaking table has no wrapper and is set by longtable itself,
# so its glue is \parskip on both sides: it then sits in the text
# with the spacing of a paragraph (it was a fixed \vspace*{12pt}
# above and nothing below until 2026-09-08).
lt_glue = "\\parskip" if self.allow_break else "0pt"
result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n"
"\\setlength{\\LTpre}{0pt}\n"
"\\setlength{\\LTpost}{0pt}\n"
f"\\setlength{{\\LTpre}}{{{lt_glue}}}\n"
f"\\setlength{{\\LTpost}}{{{lt_glue}}}\n"
+ self.tex_position() +
# 2 \tabcolsep per column, minus the ones @{} removes at
# flush (lineless) edges.
f"\\setlength{{\\tablewidth}}{{\\textwidth - "
# flush (lineless) edges. \linewidth, not \textwidth: in
# a multicols column or a minipage the table must fit the
# column it is set in (the two are equal in plain text).
f"\\setlength{{\\tablewidth}}{{\\linewidth - "
f"{2 * self.row_size - self.flush_l - self.flush_r}\\tabcolsep}}\n"
# Fill widths need \tablewidth and must precede the
# measuring \savebox, whose column spec reads them.
# Column widths precede the fill widths, which read
# them; both precede the measuring \savebox, whose
# column spec reads them.
+ self.tex_column_widths()
+ self.tex_fill_widths()
+ result)
result = re.sub(r"\newline", r"\\\\", result)
@@ -907,16 +965,46 @@ class Table(klammer_base.Klammer_base):
left = space // 2
return txt_util.NBSP * left + cell + txt_util.NBSP * (space - left)
return cell + txt_util.NBSP * space
# A cell's type style, as make_cells() assigns it for html and tex:
# the column's :style code, overridden by :header_style in the
# header row. Plain text has one face, so only the styles a
# terminal can show are carried -- italic and bold, as the markers
# the ansi target resolves and the txt target strips (phases.py);
# the other codes (roman, sans, typewriter, code) style nothing.
STYLE = {"i": "italic", "b": "bold"}
def styled(cell, row_i, cell_i):
code = self.header_style if row_i == 0 and self.header else self.style[cell_i]
if cell and code in STYLE:
return (phases.txt_style_marker(STYLE[code]) + cell
+ phases.txt_style_marker(STYLE[code], False))
return cell
widths = self.txt_column_widths()
pos = [hpos(p) for p in self.cell_hpos]
result = ""
indent = txt_util.NBSP * int(self.Target_txt_indent)
colsep = " "
for row in self.rows:
# Horizontal lines are the same rule as the other targets (:hline,
# defaulting to under the header row and at the bottom when the
# table has a header): a rule of hyphens the width of the table at
# each boundary that has a line. A line over part of the columns
# is drawn full width here -- a partial rule in a plain-text table
# reads as an accident. Vertical lines have no plain-text form.
# The hyphens are HIDDEN (KTESC markers): a klammer's result is
# re-read as Klammertext and the txt target's dash transforms turn
# "--" into "-", so a bare run of hyphens came out a fraction of
# its length. (The caption rule escapes this by being drawn in a
# phase, after the transforms.)
rule = (indent + klammer_base.hide_typographic(
"-" * (sum(widths) + len(colsep) * (len(widths) - 1))) + "\n")
for row_i, row in enumerate(self.rows):
if row_i in self.s_hline.by_index:
result += rule
result += indent + colsep.join(
[pad(elt, width, align) for elt, width, align in
list(zip(row, widths, pos))])
[pad(styled(elt, row_i, cell_i), width, align)
for cell_i, (elt, width, align) in enumerate(zip(row, widths, pos))])
result += "\n"
if len(self.rows) in self.s_hline.by_index:
result += rule
result = txt_util.add_caption(
result, "Table", self.number, self.caption, self.caption_side, \
int(self.Target_txt_indent))
+77 -11
View File
@@ -1,7 +1,7 @@
import re
import textwrap
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
@@ -25,6 +25,75 @@ def txt_vspace_marker():
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
@@ -92,8 +161,8 @@ def txt_justify_blocks(text, K):
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,
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)
@@ -121,15 +190,12 @@ def txt_insert_captions(text, K):
caption = f"{name} {caption_number} - {text}"
else:
caption = text
caption_width = len(caption) + indent
if len(caption) > indented_width:
caption = textwrap.wrap(
# Visible widths throughout: a caption may carry style markers.
if txt_visible_length(caption) > indented_width:
caption = "\n".join(txt_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")])
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)
+22 -1
View File
@@ -65,7 +65,28 @@ of this: neither the transforms nor the quoting reach it.
:after_apply
phases.txt_format_footnotes ;
phases.txt_insert_captions ;
phases.txt_justify_blocks
phases.txt_justify_blocks ;
phases.txt_strip_styles
@@@
# Plain text for a TERMINAL: the txt target's output with the type styles
# a terminal can show (@b, @i, @u) as SGR escape sequences. Named for its
# FORMAT, not its destination -- a file of it is what "less -R" shows, and
# "ktext -t txt -d | less" is a stream that must stay plain -- so the
# writer picks it with -t, as they pick pdf over tex; kdesc picks it for
# its own output when stdout is a terminal. It includes txt and defines no
# klammers: @b.txt and its siblings emit style markers, which the txt
# target's last phase strips and this target's last phase resolves. So
# the two renders are equal once the escape sequences are removed
# (sks/tst/ansi_test.sh), and only the last phase differs. Phases are
# not inherited through :includes (see pdf), hence the restated list.
@@@target ansi | Plain text with ANSI escape sequences for type styles
:includes txt
:after_apply
phases.txt_format_footnotes ;
phases.txt_insert_captions ;
phases.txt_justify_blocks ;
phases.ansi_resolve_styles
@@@
@@@target html | HTML page
+1 -1
View File
@@ -44,7 +44,7 @@ def visible_length(s):
escape sequences as visible width, and the reason escape resolution
runs BEFORE the txt phases.)
"""
return len(klammer_base.unescape_ktesc(s).replace(NBSP, " "))
return phases.txt_visible_length(s)
def indent_text(text, indent):
"""Every line of text prefixed with `indent` columns of NBSP."""