import sys import os _klammertext_home = os.environ.get('KLAMMERTEXT_HOME', os.path.expanduser('~/projects/klammertext/K')) sys.path.append(os.path.join(_klammertext_home, 'sks/kutil')) sys.path.append(os.path.join(_klammertext_home, 'sks/target')) import re import os import shutil import kutil import html_util import latex_util import txt_util import phases import klammer_base import image_cache import pprint from html_util import E import sys class Image(klammer_base.Klammer_base): id = 0 def __init__(self, K, as_string=True, width=None): super().__init__(K) # /dev/shm is a fast RAM-backed tmpfs on Linux; on macOS it does not # exist, so fall back to the platform temp directory. cache_dir = "/dev/shm" 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, quality=self.Image_quality) self.basename = klammer_base.unescape_ktesc(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.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}" if not os.path.exists(img_dir): os.makedirs(img_dir) if not os.path.exists(f"{img_dir}/{os.path.basename(self.source)}"): shutil.copy2(self.source, img_dir) scale_x, scale_y = 1, 1 # Old result = E("img") if self.id: result.attr("id", self.id) filename = f"{self.Image_output_dir}/{os.path.basename(self.source)}" result.attr("src", filename).attr("alt", os.path.basename(filename)) length, value, ltype = kutil.parse_width("html", self.width, self.rel_fraction) if ltype == "w": percent = float(value) * 100 result.sty(f"width: {percent}%") elif ltype == "px": # The html width ATTRIBUTE takes pixels only; every other unit # is a style (an attribute of "2em" was silently ignored). result.attr("width", length) else: result.sty(f"width: {length}") if self.border: result.cls("image_border") result = html_util.add_caption( result, "Figure", self.number, self.caption, self.caption_style, self.hpos, self.caption_side, False, self.caption_size, offset=self.offset) result = E("div").body(result).cls("image_margin") if self.as_string: result = str(result) + "\n" return result def tex(self): # Forward slashes: this path is document text (\includegraphics, an # html src), where a Windows backslash is a TeX command or an escape. source = os.path.abspath(self.source).replace(os.sep, "/") width = kutil.parse_width("tex", self.width, self.rel_fraction)[0] width = re.sub("px", "pt", width) #result = f'\\includegraphics[width={width}]{{{source}}}' if self.number or self.caption: result = f'\\includegraphics[width=\\textwidth]{{{source}}}' result = latex_util.add_caption( result, "Figure", self.number, self.caption, width, self.hpos, self.caption_side, offset=self.offset) else: result = f'\\includegraphics[width={width}]{{{source}}}' result = latex_util.caption_wrapper(result, self.hpos, offset=self.offset) name = f"Reference-Figure-{Image.id}" result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}" Image.id += 1 # \includegraphics (bare or inside a caption wrapper's minipage) is # box material: it must sit in vertical mode or it is typeset beside # any text it follows. return latex_util.block(result)