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:
1 parent
760fda5470
commit
9b0d0d7897
34 files changed
+895
-394
No files matched your search
+1
-1
@@ -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@ @@
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user