Images in plain text and the terminal; bounded build parallelism (from dev 16f82cc887af)
- @image under txt and ansi is a picture in characters, in five styles (Image_txt_style: halfblock the default, shade, ascii, quadrant, braille), sized to the terminal's width less a margin when ktext runs in one, every cell in its own colours under ansi; a figure's caption is centred under it and no txt caption is ruled off any more. - The build compiles as many files at once as the machine has cores (JOBS; make -C com JOBS=1 for a small machine): the sub-makes' bare -j meant unlimited parallelism and exhausted 8 GB machines. The three install pages say "make -C com". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d14da90bbf
commit
ed7bedf5a7
13 files changed
+335
-41
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+4
-4
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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".**
|
||||
|
||||
Vendored
+10
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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 :
|
||||
|
||||
@@ -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(
|
||||
|
||||
+26
-2
@@ -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)
|
||||
|
||||
+229
-2
@@ -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}"
|
||||
|
||||
|
||||
+37
-6
@@ -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<rrggbb>__ opens a foreground
|
||||
# colour, __KTEXTFGOFF__ restores the default (SGR 39, never a blanket reset).
|
||||
# Used by @image under txt/ansi (2026-09-27, Andy's terminal images): the
|
||||
# character carries the luminance and the colour the hue, so stripping the
|
||||
# escapes still leaves a monochrome picture -- the invariant kept.
|
||||
def txt_color_marker(rgb=None, background=False):
|
||||
"""A foreground colour marker, or with background=True a background one
|
||||
(__KTEXTBG<rrggbb>__ / __KTEXTBGOFF__, SGR 48;2 and 49): the quadrant
|
||||
and halfblock image styles set two colours per cell."""
|
||||
ground = "BG" if background else "FG"
|
||||
if rgb is None:
|
||||
return f"__KTEXT{ground}OFF__"
|
||||
r, g, b = rgb
|
||||
return f"__KTEXT{ground}{int(r):02X}{int(g):02X}{int(b):02X}__"
|
||||
|
||||
_COLOR_MARKER_RGX = re.compile(r"__KTEXT(FG|BG)(?:([0-9A-F]{6})|OFF)__")
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user