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
+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}"
|
||||
|
||||
|
||||
Reference in new issue
Block a user