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:
ackandClaude Fable 5.1 committed 2026-09-30 16:13:23 +02:00
1 parent d14da90bbf
commit ed7bedf5a7
13 files changed
+335 -41

No files matched your search

+26 -2
View File
@@ -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
View File
@@ -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}"