diff --git a/README.md b/README.md index 1224e29..eb23719 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ are regenerated on each release — patches cannot be merged directly. Report problems (or send patches) to the author; accepted changes are applied to the development tree and appear in a following snapshot. -This snapshot was assembled from development commit `931ea5490a5d`. +This snapshot was assembled from development commit `16f82cc887af`. ## License diff --git a/com/Makefile b/com/Makefile index 8e76200..ee29daa 100644 --- a/com/Makefile +++ b/com/Makefile @@ -59,7 +59,7 @@ DEPFLAGS = -MMD -MP -MF $(basename $(@F)).d # parallel (as mac/ and the sks/ components already do) rather than one after # another -- the serial links were about a quarter of a clean rebuild. all : | mac sks - $(MAKE) -j commands + $(MAKE) -j$(JOBS) commands # Separate target to build commands (called after dependencies are ready) .PHONY: commands @@ -80,12 +80,12 @@ $(BINDIR) : # Build mac/ objects mac : - $(MAKE) -C $(KM) -j + $(MAKE) -C $(KM) -j$(JOBS) # Build sks/ components (depends on mac) sks : | mac - $(MAKE) -C $(KS)/kutil -j - $(MAKE) -C $(KS)/target -j + $(MAKE) -C $(KS)/kutil -j$(JOBS) + $(MAKE) -C $(KS)/target -j$(JOBS) $(MAKE) -C $(KS)/document # Explicit rules for each command - link against shared library, output to bin/ diff --git a/doc/install/linux_source_install.md b/doc/install/linux_source_install.md index a6b9bc0..3e96b9f 100644 --- a/doc/install/linux_source_install.md +++ b/doc/install/linux_source_install.md @@ -106,11 +106,11 @@ first, then the commands. The build is optimized (`-O3`) by default — the buil you want to install and run: ```bash -make -C com -j # lib/libklammertext.so + sks/*.so + bin/{ktext,kdesc,kdiag} +make -C com # lib/libklammertext.so + sks/*.so + bin/{ktext,kdesc,kdiag} ``` (For a slower `-O0` debug build with AddressSanitizer, intended for development, -prefix `DEBUG=1`: `DEBUG=1 make -C com -j`.) +prefix `DEBUG=1`: `DEBUG=1 make -C com`.) Verify the build: @@ -320,7 +320,7 @@ To update an existing source installation to the latest version: ```bash cd $KLAMMERTEXT_HOME git pull -make -C com -j # rebuild library, SKS components, and commands (optimized) +make -C com # rebuild library, SKS components, and commands (optimized) ``` The TeX Live tree only needs rebuilding if the SKS's package requirements diff --git a/doc/install/macos_source_install.md b/doc/install/macos_source_install.md index 2b800be..8d1e3d4 100644 --- a/doc/install/macos_source_install.md +++ b/doc/install/macos_source_install.md @@ -86,12 +86,12 @@ then the commands: ```sh cd "$KLAMMERTEXT_HOME" -make -C com -j # optimized (-O3) by default; libklammertext.so + sks/*.so + bin/{ktext,kdesc,kdiag} +make -C com # optimized (-O3) by default; libklammertext.so + sks/*.so + bin/{ktext,kdesc,kdiag} ktext -s '@eval 1 + 1 @' -d # smoke test — prints 2 ``` (For a debug build with AddressSanitizer, intended for development, prefix -`DEBUG=1`: `DEBUG=1 make -C com -j`.) +`DEBUG=1`: `DEBUG=1 make -C com`.) For a document smoke test, create a small file and render it to HTML: @@ -208,7 +208,7 @@ To update an existing source installation to the latest version: ```sh cd "$KLAMMERTEXT_HOME" git pull -make -C com -j # rebuild library, SKS components, and commands (optimized) +make -C com # rebuild library, SKS components, and commands (optimized) ``` Rebuild the TeX Live tree only if the SKS's package requirements changed (rare); diff --git a/doc/install/windows_source_install.md b/doc/install/windows_source_install.md index 0526d2a..b7ad9dc 100644 --- a/doc/install/windows_source_install.md +++ b/doc/install/windows_source_install.md @@ -138,13 +138,13 @@ sets `PYTHON=python`. Nothing is edited by hand. In the UCRT64 shell, in the repository: ```bash -make -C com -j +make -C com ``` -`-j` compiles in parallel; on a machine with 8 GB of memory or less the -optimizing compiler may run out of memory and report `out of memory -allocating`. If so, run `make -C com` without `-j`: slower, and it -finishes. +The build compiles as many files at once as the machine has cores. On a +machine with 8 GB of memory or less the optimizing compiler may run out +of memory and report `out of memory allocating`; if so, `make -C com +JOBS=2`, or `JOBS=1`: slower, and it finishes. This builds the library (`klammertext.dll`), the Standard Klammer Set's components and the three commands (`ktext.exe`, `kdesc.exe`, `kdiag.exe`) @@ -311,7 +311,7 @@ In the UCRT64 shell: ```bash cd $KLAMMERTEXT_HOME git pull -make -C com -j +make -C com ``` @@ -379,7 +379,7 @@ installed one may not. Install it from https://aka.ms/vs/17/release/vc_redist.x64.exe and try the import again. **"klammertext.dll was not found" or "libstdc++-6.dll was not found".** -`bin\` is not on the `Path`, or the build did not finish: `make -C com -j` +`bin\` is not on the `Path`, or the build did not finish: `make -C com` places the library and the runtime DLLs in `bin\` beside the commands. **"The shell Klammertext runs sh commands with on Windows is missing".** diff --git a/env/makefile.env b/env/makefile.env index 4019985..82cb1bd 100644 --- a/env/makefile.env +++ b/env/makefile.env @@ -55,6 +55,16 @@ PYTHON_INC := $(shell python3-config --includes) PYTHON_LIB := $(shell python3-config --ldflags --embed) endif +# How many compiles run at once. Every sub-make below passes -j$(JOBS): a +# bare "-j" means UNLIMITED to GNU make, and from 2026-02 until 2026-09-30 +# that is what the sub-makes carried -- all ~47 optimising compiles at once, +# which no one noticed on the development machines' memory and which put an +# 8 GB Raspberry Pi 5 into an out-of-memory livelock three times in a day +# (and was the Windows Sandbox's "parallel OOM"). The outer command never +# governed it: "make -C com" without -j was not serial. Override for a +# small machine: make -C com JOBS=1. +JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2) + # Flags common to all platforms. CPPFLAGS = -I$(KLAMMERTEXT_HOME)/mac $(PYTHON_INC) CXXFLAGS = -Wall -Wextra -Weffc++ -Wshadow -std=$(CPP_VERSION) -fPIC $(OPTIMIZE) $(SANITIZE) diff --git a/mac/Makefile b/mac/Makefile index d0b0068..eb19402 100644 --- a/mac/Makefile +++ b/mac/Makefile @@ -76,7 +76,7 @@ ifneq ($(filter clang,$(MAKECMDGOALS)),) @: else $(MAKE) clean - $(MAKE) -j all + $(MAKE) -j$(JOBS) all endif # "make clang" = incremental build; "make clang redo" = full clean rebuild diff --git a/sks/Makefile b/sks/Makefile index 436aeea..ddd2de6 100644 --- a/sks/Makefile +++ b/sks/Makefile @@ -35,15 +35,15 @@ all : support $(SO_DIRS) support : $(SUPPORT_DIRS) kutil : - $(MAKE) -j -C $(KS)/kutil + $(MAKE) -j$(JOBS) -C $(KS)/kutil target : kutil - $(MAKE) -j -C $(KS)/target + $(MAKE) -j$(JOBS) -C $(KS)/target # Pattern rule for the DSO directories # Each depends on the library and support directories $(SO_DIRS) : support $(LIBRARY) - $(MAKE) -j -C $(KS)/$@ + $(MAKE) -j$(JOBS) -C $(KS)/$@ # Clean all subdirectories clean : diff --git a/sks/code/code_block.py b/sks/code/code_block.py index 7bf7b91..6da3feb 100644 --- a/sks/code/code_block.py +++ b/sks/code/code_block.py @@ -379,12 +379,10 @@ class Code(klammer_base.Klammer_base): width = max(len(line.rstrip()) for line in lines) rule = indent + klammer_base.hide_typographic("-" * width) body = [indent + prepared(line) for line in lines] - # A caption above the listing is ruled off by the caption pass, so - # the listing draws its own top rule only when nothing does. - captioned = self.number or self.caption - caption_above = captioned and self.caption_side in ("top", "left") - if not caption_above: - body.insert(0, rule) + # The listing's frame is its own: a rule above and below, whether + # or not a caption stands over it (the caption pass draws no rule, + # 2026-09-28 -- no target rules a caption off). + body.insert(0, rule) body.append(rule) result = phases.txt_lay_out("\n".join(body)) return txt_util.add_caption( diff --git a/sks/image/image.k b/sks/image/image.k index df77b50..bee9db7 100644 --- a/sks/image/image.k +++ b/sks/image/image.k @@ -9,6 +9,22 @@ @@@ @@@state Image_output_dir :desc Output directory for images :value img @@@ +@@@state Image_txt_style + :desc How an image is drawn in characters under txt and ansi: + halfblock (two pixels per cell in full colour under ansi -- the + nearest to the raster, and the default; under txt a solid + block, since its picture is only its colours), shade (block + shades, coloured tiles under ansi), ascii (a density ramp of + typewriter characters), quadrant (four sub-pixels per cell, two + colours per cell under ansi), braille (eight dots per cell). + :value halfblock +@@@ +@@@state Image_txt_dark_background + :desc Under txt and ansi, the density ramp assumes a dark terminal + background (true): bright pixels are the dense characters. + false reverses it for a light background. + :value true +@@@ @@@state Image_default_width :desc Default width for images :value .667w @@@ @@ -45,10 +61,18 @@ An image read from a file. The basename names the file, which is looked for in the image search path and converted to a format the target can use. ^:width is the image's width in the text column, ^:hpos and ^:offset place -it there, and the caption arguments caption it. +it there, and the caption arguments caption it. Under txt and ansi the +image is drawn in characters (the Image_txt_style state variable: +halfblock, the default, or shade, ascii, quadrant, braille), and ansi +colours them; the +measure is the terminal's width +less a small margin when ktext runs in one, else the text width, and +^:width applies to that (a fraction, a number of columns, or f for all of +it). The Image_txt_dark_background state variable (true) reverses the +ramp for a light terminal. @@ -@@image.html,tex :: @eval image.Image(K) eval@ @@ +@@image.html,tex,txt :: @eval image.Image(K) eval@ @@ @@image_grid.k image_specs.rest(2) diff --git a/sks/image/image.py b/sks/image/image.py index 243029b..7a609c0 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -9,6 +9,8 @@ import shutil import kutil import html_util import latex_util +import txt_util +import phases import klammer_base import image_cache import pprint @@ -29,16 +31,241 @@ class Image(klammer_base.Klammer_base): 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.cache.get(self.K_target, self.basename) + # Plain text renders the image as characters (txt() below) and reads + # its pixels from the raster the html target would use. + self.cache_target = "html" if self.K_target in ("txt", "ansi") else self.K_target + self.source, self.pwidth, self.pheight = self.cache.get(self.cache_target, self.basename) self.as_string = as_string if width: self.width = width self.rel_image = None self.rel_fraction = 1.0 if self.rel: - self.rel_image, self.rel_pwidth, self.rel_pheight = self.cache.get(self.K_target, self.rel) + self.rel_image, self.rel_pwidth, self.rel_pheight = self.cache.get(self.cache_target, self.rel) self.rel_fraction = self.pwidth / self.rel_pwidth + # ---- plain text and the terminal (txt, ansi) ----------------------- + # The picture is characters. Five styles (Image_txt_style, Andy + # 2026-09-28, halfblock the default; notes/ansi_images.md): in four of them each cell's + # character carries LUMINANCE -- a ramp (shade, ascii) or a shape of set + # sub-pixels (quadrant, braille) -- and under ansi colour markers carry + # the hue, so the txt render is a monochrome picture on its own and + # stripping the escapes from the ansi render gives it byte for byte (the + # ansi invariant, sks/tst/ansi_test.sh). The fifth, halfblock, is the + # nearest to the raster (two pixels per cell in full colour) at the price + # the invariant makes explicit: its txt twin is a solid block. + TXT_STYLES = ("halfblock", "shade", "ascii", "quadrant", "braille") + TXT_RAMPS = {"ascii": " .:-=+*#%@", # dark to bright, for a dark background + "shade": " ░▒▓█"} + # Quadrant characters by the bit mask of set quarters: 8 = top left, + # 4 = top right, 2 = bottom left, 1 = bottom right. + TXT_QUADRANTS = " ▗▖▄▝▐▞▟▘▚▌▙▀▜▛█" + TXT_MARGIN = 4 # columns kept free at the terminal's right edge + TXT_CELL_ASPECT = 0.5 # a terminal cell is about twice as tall as wide + + @staticmethod + def terminal_columns(): + """The width of the terminal ktext runs in, or None when it runs + under a pipe or a redirect on every descriptor (a file beside the + input is written from a terminal too, so stdout being a file is not + the end of it: stderr and stdin are tried as well).""" + for fd in (1, 2, 0): + try: + if os.isatty(fd): + columns = os.get_terminal_size(fd).columns + if columns > 0: + return columns + except OSError: + pass + return None + + def txt_indent(self): + # @code's rule: a left- or right-placed block is inset by :offset, + # "none" flows flush, and "center" keeps the block convention. + if self.hpos in ("left", "right"): + return int(kutil.parse_width("txt", self.offset)[0]) + if self.hpos == "none": + return 0 + return int(self.Target_txt_indent) + + def txt_columns(self, indent): + """How many character columns the picture gets: the terminal's + width less a margin when there is one (Andy: use the window), else + Target_txt_width, less the indent either way; and :width applies to + that measure as it does to the text width elsewhere -- a fraction + (.5w, the declared default), a bare number of columns, or f/none + for the whole measure.""" + columns = Image.terminal_columns() + measure = (columns - Image.TXT_MARGIN) if columns else int(self.Target_txt_width) + measure = max(8, measure - indent) + w = str(self.width).strip() + if w in ("f", "none", ""): + return measure + m = re.match(r"^([0-9.]+)([a-z]*)$", w) + if not m: + return measure + number, unit = float(m.group(1)), m.group(2) + if unit in ("w", "pw"): + return max(4, min(measure, int(round(number * measure)))) + if unit == "": + return max(4, min(measure, int(round(number)))) + # Any other unit (px, cm, in, pt, em) has no meaning in plain text: + # the length parser says so, with the remedy, rather than this + # silently taking the whole measure (which is what it did for a day). + kutil.parse_width("txt", w) + return measure + + def txt_samples(self, width, height): + """The image resampled to width x height: rows of (luminance, rgb) + with luminance 0..1 and rgb 8-bit. The pixels come from the SOURCE + when it is a raster: the cache's copy of a photograph is a JPEG at + Image_quality, whose shifted colours and edge ringing are invisible + on a page and plain to see in eighty characters. A PDF source has + to go through the cache's rasterising.""" + import OpenImageIO as oiio + source = self.cache.find_file(self.cache_target, self.basename) or self.source + if os.path.splitext(source)[1][1:].lower() in ("pdf", "eps"): + source = self.source + src = oiio.ImageBuf(source) + roi = oiio.ROI(0, width, 0, height, 0, 1, 0, src.nchannels) + small = oiio.ImageBufAlgo.resize(src, roi=roi) + rows = [] + for y in range(height): + row = [] + for x in range(width): + p = small.getpixel(x, y) + r, g, b = (p[0], p[1], p[2]) if len(p) >= 3 else (p[0], p[0], p[0]) + lum = min(1.0, max(0.0, 0.2126 * r + 0.7152 * g + 0.0722 * b)) + rgb = tuple(min(255, int(c * 255 + 0.5)) for c in (r, g, b)) + row.append((lum, rgb)) + rows.append(row) + return rows + + @staticmethod + def txt_dither(samples, dark_background): + """Floyd-Steinberg error diffusion of the luminance to one bit: True + where a dot or a quarter is SET, which on a dark background is the + bright side and on a light one the dark side.""" + h = len(samples); w = len(samples[0]) if h else 0 + lum = [[s[0] if dark_background else 1.0 - s[0] for s in row] for row in samples] + bits = [[False] * w for _ in range(h)] + for y in range(h): + for x in range(w): + old = lum[y][x] + new = 1.0 if old >= 0.5 else 0.0 + bits[y][x] = new == 1.0 + err = old - new + if x + 1 < w: + lum[y][x + 1] += err * 7 / 16 + if y + 1 < h: + if x > 0: + lum[y + 1][x - 1] += err * 3 / 16 + lum[y + 1][x] += err * 5 / 16 + if x + 1 < w: + lum[y + 1][x + 1] += err * 1 / 16 + return bits + + @staticmethod + def txt_toned(rgb, dark): + """The pixel's colour halfway toward the paper -- black on a dark + setting, white on a light one -- for the BACKGROUND of a ramp cell: + every cell sets both grounds explicitly (Andy, 2026-09-28: a black + pixel must be black on a white terminal too, not the terminal + showing through a space), and the character keeps its density on a + tile of the pixel's own hue.""" + paper = 0 if dark else 255 + return tuple(int((c + paper) / 2 + 0.5) for c in rgb) + + @staticmethod + def txt_mean(colors): + if not colors: + return None + n = len(colors) + return tuple(int(sum(c[i] for c in colors) / n + 0.5) for i in range(3)) + + def txt_cells(self, style, cols, rows, dark): + """Rows of cells (character, fg rgb or None, bg rgb or None).""" + cells = [] + if style in ("ascii", "shade"): + ramp = Image.TXT_RAMPS[style] + if not dark: + ramp = ramp[::-1] + for row in self.txt_samples(cols, rows): + cells.append([(ramp[min(len(ramp) - 1, int(lum * len(ramp)))], rgb, Image.txt_toned(rgb, dark)) for lum, rgb in row]) + elif style == "halfblock": + samples = self.txt_samples(cols, rows * 2) + for y in range(rows): + cells.append([("▀", samples[2 * y][x][1], samples[2 * y + 1][x][1]) for x in range(cols)]) + elif style == "quadrant": + samples = self.txt_samples(cols * 2, rows * 2) + bits = Image.txt_dither(samples, dark) + for y in range(rows): + row = [] + for x in range(cols): + quads = [(2 * y, 2 * x, 8), (2 * y, 2 * x + 1, 4), (2 * y + 1, 2 * x, 2), (2 * y + 1, 2 * x + 1, 1)] + mask = sum(b for yy, xx, b in quads if bits[yy][xx]) + on = [samples[yy][xx][1] for yy, xx, b in quads if bits[yy][xx]] + off = [samples[yy][xx][1] for yy, xx, b in quads if not bits[yy][xx]] + fg = Image.txt_mean(on) or Image.txt_mean(off) + bg = Image.txt_mean(off) or Image.txt_mean(on) + row.append((Image.TXT_QUADRANTS[mask], fg, bg)) + cells.append(row) + elif style == "braille": + samples = self.txt_samples(cols * 2, rows * 4) + bits = Image.txt_dither(samples, dark) + dots = ((0, 0, 1), (0, 1, 2), (0, 2, 4), (1, 0, 8), (1, 1, 16), (1, 2, 32), (0, 3, 64), (1, 3, 128)) + for y in range(rows): + row = [] + for x in range(cols): + mask = sum(b for dx, dy, b in dots if bits[4 * y + dy][2 * x + dx]) + on = [samples[4 * y + dy][2 * x + dx][1] for dx, dy, b in dots if bits[4 * y + dy][2 * x + dx]] + off = [samples[4 * y + dy][2 * x + dx][1] for dx, dy, b in dots if not bits[4 * y + dy][2 * x + dx]] + # Both grounds explicit: the set dots in their colour, the + # unset ones in theirs (as quadrant does with its quarters). + fg = Image.txt_mean(on) or Image.txt_mean(off) + bg = Image.txt_mean(off) or Image.txt_mean(on) + row.append((chr(0x2800 + mask), fg, bg)) + cells.append(row) + return cells + + def txt(self): + from code_block import quote_specials + style = str(getattr(self, "Image_txt_style", "halfblock")).strip().lower() + if style not in Image.TXT_STYLES: + raise Exception(f'Image_txt_style "{style}" is not one of {", ".join(Image.TXT_STYLES)}') + dark = str(getattr(self, "Image_txt_dark_background", "true")).strip().lower() in ("true", "1", "yes") + indent = self.txt_indent() + cols = self.txt_columns(indent) + rows = max(1, int(round(self.pheight / self.pwidth * cols * Image.TXT_CELL_ASPECT))) + nbsp = txt_util.NBSP + lead = nbsp * indent + lines = [] + for cell_row in self.txt_cells(style, cols, rows, dark): + # The characters are prepared as @code prepares a line -- the + # Klammertext specials quoted (this result is re-read), the + # typographic ones hidden ("--" stays two hyphens), spaces as the + # nbsp marker -- BEFORE the colour markers go in, which hold + # nothing either step would touch. A marker is written only where + # the colour changes, and both grounds are restored at the end. + out = [] + fg_now = bg_now = None + for ch, fg, bg in cell_row: + if fg != fg_now: + out.append(phases.txt_color_marker(fg)) + fg_now = fg + if bg != bg_now: + out.append(phases.txt_color_marker(bg, background=True)) + bg_now = bg + out.append(klammer_base.hide_typographic(quote_specials(ch)).replace(" ", nbsp)) + if fg_now is not None: + out.append(phases.txt_color_marker(None)) + if bg_now is not None: + out.append(phases.txt_color_marker(None, background=True)) + lines.append(lead + "".join(out)) + result = phases.txt_lay_out("\n".join(lines)) + return txt_util.add_caption(result, "Figure", self.number, self.caption, + self.caption_side or "bottom", indent, centered=True) + def html(self): img_dir = f"{self.K_output_dir}/{self.K_output_basename}/{self.Image_output_dir}" diff --git a/sks/target/phases.py b/sks/target/phases.py index d7d6fd8..3bb721b 100644 --- a/sks/target/phases.py +++ b/sks/target/phases.py @@ -75,20 +75,43 @@ _STYLE_MARKER_RGX = re.compile( "|".join(re.escape(txt_style_marker(s, on)) for s in TXT_STYLES for on in (True, False))) +# A COLOUR marker carries its colour: __KTEXTFG__ 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__ / __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)__") + def txt_strip_styles(text, K): - return _STYLE_MARKER_RGX.sub("", text) + return _COLOR_MARKER_RGX.sub("", _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 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) 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).replace(txt_verbatim_marker(), "") + s = _COLOR_MARKER_RGX.sub("", _STYLE_MARKER_RGX.sub("", s)).replace(txt_verbatim_marker(), "") return len(klammer_base.unescape_ktesc(s).replace(txt_nbsp_marker(), " ")) def txt_wrap(text, width, subsequent_indent=""): @@ -257,6 +280,10 @@ def txt_insert_captions(text, K): return widest def replace(m): indent_run, name, text = m.groups() + # 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("+") indent = len(indent_run) // len(nbsp) indented_width = max(1, width - indent) # The block the caption belongs to: below a top caption, above a @@ -278,12 +305,16 @@ def txt_insert_captions(text, K): else: caption = text # Visible widths throughout: a caption may carry style markers. + # 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. lines = [caption] if txt_visible_length(caption) > indented_width: lines = txt_wrap(caption, indented_width) - line_length = max(txt_visible_length(e) for e in lines) - line_length = max(line_length, block - indent) - lines.append("-" * line_length) + 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] 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. diff --git a/sks/target/txt_util.py b/sks/target/txt_util.py index 3f50f92..c919090 100644 --- a/sks/target/txt_util.py +++ b/sks/target/txt_util.py @@ -58,7 +58,7 @@ def block_width(text_width, indent): return max(1, int(text_width) - indent) def add_caption(element, caption_label, number, caption_text, - side="top", indent=0): + side="top", indent=0, centered=False): """Attach a caption to an already-laid-out block. `element` is the block's text, indented by `indent` columns of NBSP. @@ -95,10 +95,14 @@ def add_caption(element, caption_label, number, caption_text, # marker substitution, so a caption that arrived there as plain text # got none of it: ":number false" silently lost its rule. The label # is what says "number me", not the presence of the marker. + # `centered` rides in the marker's name as a "+center" flag, which the + # numbering pass reads off: a figure's caption is centred under it, as + # in html and tex; a table's or a listing's stays at the block's edge. + flag = "+center" if centered else "" if number: - caption = kutil.caption_marker(caption_label, caption_text, None) + caption = kutil.caption_marker(caption_label + flag, caption_text, None) elif caption_text: - caption = kutil.caption_marker("", caption_text, None) + caption = kutil.caption_marker(flag, caption_text, None) else: return element # Marked laid-out, like the block: with no indent the caption begins