2026-07-18 18:48:23 +02:00
|
|
|
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
|
2026-09-30 16:13:23 +02:00
|
|
|
import txt_util
|
|
|
|
|
import phases
|
2026-07-18 18:48:23 +02:00
|
|
|
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)
|
2026-09-18 20:02:44 +02:00
|
|
|
|
2026-07-18 18:48:23 +02:00
|
|
|
# /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()
|
2026-09-12 18:11:48 +02:00
|
|
|
self.cache = image_cache.Image_cache(cache_dir, self.Image_search_path, verbose=False,
|
|
|
|
|
quality=self.Image_quality)
|
2026-07-18 18:48:23 +02:00
|
|
|
self.basename = klammer_base.unescape_ktesc(self.basename)
|
2026-09-30 16:13:23 +02:00
|
|
|
# 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)
|
2026-07-18 18:48:23 +02:00
|
|
|
self.as_string = as_string
|
|
|
|
|
if width:
|
|
|
|
|
self.width = width
|
|
|
|
|
self.rel_image = None
|
|
|
|
|
self.rel_fraction = 1.0
|
|
|
|
|
if self.rel:
|
2026-09-30 16:13:23 +02:00
|
|
|
self.rel_image, self.rel_pwidth, self.rel_pheight = self.cache.get(self.cache_target, self.rel)
|
2026-07-18 18:48:23 +02:00
|
|
|
self.rel_fraction = self.pwidth / self.rel_pwidth
|
|
|
|
|
|
2026-09-30 16:13:23 +02:00
|
|
|
# ---- 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."""
|
2026-10-01 18:02:38 +02:00
|
|
|
samples = self.txt_samples_raw(width, height)
|
|
|
|
|
colors = str(getattr(self, "Image_ansi_colors", "true")).strip().lower()
|
|
|
|
|
if colors not in ("true", "256"):
|
|
|
|
|
raise Exception(f'Image_ansi_colors "{colors}" is not one of true, 256')
|
|
|
|
|
if colors == "256":
|
|
|
|
|
samples = Image.txt_palette_dither(samples)
|
|
|
|
|
return samples
|
|
|
|
|
|
|
|
|
|
def txt_samples_raw(self, width, height):
|
2026-09-30 16:13:23 +02:00
|
|
|
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
|
|
|
|
|
|
2026-10-01 18:02:38 +02:00
|
|
|
# The xterm 256-colour palette (the 6x6x6 cube and the 24 greys; the 16
|
|
|
|
|
# system colours are left out, a terminal redefines them), for a terminal
|
|
|
|
|
# that shows no 24-bit colour and maps each colour to its nearest entry
|
|
|
|
|
# (Apple's Terminal, 2026-10-01): dithering the picture to the palette
|
|
|
|
|
# FIRST spreads each cell's error over its neighbours instead of letting
|
|
|
|
|
# the terminal round whole runs of a sky to one entry.
|
|
|
|
|
TXT_LEVELS = (0, 95, 135, 175, 215, 255) # the cube's axis; the greys are 8, 18, ... 238
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def txt_nearest_palette(rgb):
|
|
|
|
|
r, g, b = rgb
|
|
|
|
|
cube = tuple(min(Image.TXT_LEVELS, key=lambda l: abs(l - v)) for v in (r, g, b))
|
|
|
|
|
grey = max(8, min(238, 8 + 10 * round(((r + g + b) / 3 - 8) / 10)))
|
|
|
|
|
# Weighted as the eye weighs the channels, so a grey-blue sky does
|
|
|
|
|
# not fall to a green or a yellow entry when its error moves it.
|
|
|
|
|
d = lambda c: 2 * (c[0] - r) ** 2 + 4 * (c[1] - g) ** 2 + 3 * (c[2] - b) ** 2
|
|
|
|
|
return cube if d(cube) <= d((grey, grey, grey)) else (grey, grey, grey)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def txt_palette_dither(samples):
|
|
|
|
|
"""samples (rows of (luminance, rgb)) with every rgb replaced by a
|
|
|
|
|
palette colour, the error diffused Floyd-Steinberg fashion in RGB."""
|
|
|
|
|
h, w = len(samples), len(samples[0]) if samples else 0
|
|
|
|
|
acc = [[list(map(float, rgb)) for lum, rgb in row] for row in samples]
|
|
|
|
|
out = []
|
|
|
|
|
for y in range(h):
|
|
|
|
|
row = []
|
|
|
|
|
for x in range(w):
|
|
|
|
|
want = tuple(min(255.0, max(0.0, v)) for v in acc[y][x])
|
|
|
|
|
got = Image.txt_nearest_palette(tuple(int(round(v)) for v in want))
|
|
|
|
|
# Three quarters of the error travel on: full diffusion
|
|
|
|
|
# over a 256-entry palette speckles a smooth sky with
|
|
|
|
|
# far-off entries, none of it looks posterised.
|
|
|
|
|
err = [0.75 * (a - b) for a, b in zip(want, got)]
|
|
|
|
|
for dx, dy, wgt in ((1, 0, 7 / 16), (-1, 1, 3 / 16), (0, 1, 5 / 16), (1, 1, 1 / 16)):
|
|
|
|
|
xx, yy = x + dx, y + dy
|
|
|
|
|
if 0 <= xx < w and yy < h:
|
|
|
|
|
acc[yy][xx] = [a + e * wgt for a, e in zip(acc[yy][xx], err)]
|
|
|
|
|
row.append((samples[y][x][0], got))
|
|
|
|
|
out.append(row)
|
|
|
|
|
return out
|
|
|
|
|
|
2026-09-30 16:13:23 +02:00
|
|
|
@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":
|
2026-10-01 18:02:38 +02:00
|
|
|
# Every cell is "▀" with the pair's two EXACT colours as foreground
|
|
|
|
|
# and background. Choosing the character by the pair's dithered
|
|
|
|
|
# brightness (tried 2026-10-01, to give txt a monochrome picture)
|
|
|
|
|
# merges every pair of one brightness class into one colour, and
|
|
|
|
|
# Andy saw the merged pairs at once on Linux; the txt twin stays a
|
|
|
|
|
# solid field, as Image_txt_style's description says.
|
2026-09-30 16:13:23 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-07-18 18:48:23 +02:00
|
|
|
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))
|
|
|
|
|
|
2026-09-16 16:59:49 +02:00
|
|
|
length, value, ltype = kutil.parse_width("html", self.width, self.rel_fraction)
|
2026-07-18 18:48:23 +02:00
|
|
|
if ltype == "w":
|
|
|
|
|
percent = float(value) * 100
|
|
|
|
|
result.sty(f"width: {percent}%")
|
2026-09-16 16:59:49 +02:00
|
|
|
elif ltype == "px":
|
|
|
|
|
# The html width ATTRIBUTE takes pixels only; every other unit
|
|
|
|
|
# is a style (an attribute of "2em" was silently ignored).
|
2026-07-18 18:48:23 +02:00
|
|
|
result.attr("width", length)
|
2026-09-16 16:59:49 +02:00
|
|
|
else:
|
|
|
|
|
result.sty(f"width: {length}")
|
2026-07-18 18:48:23 +02:00
|
|
|
|
|
|
|
|
if self.border:
|
|
|
|
|
result.cls("image_border")
|
|
|
|
|
|
|
|
|
|
result = html_util.add_caption(
|
2026-08-25 23:30:55 +02:00
|
|
|
result, "Figure", self.number, self.caption, self.caption_style,
|
|
|
|
|
self.hpos, self.caption_side, False, self.caption_size,
|
2026-08-06 13:11:37 +02:00
|
|
|
offset=self.offset)
|
2026-07-18 18:48:23 +02:00
|
|
|
|
|
|
|
|
result = E("div").body(result).cls("image_margin")
|
|
|
|
|
|
|
|
|
|
if self.as_string:
|
|
|
|
|
result = str(result) + "\n"
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tex(self):
|
2026-09-25 00:32:55 +02:00
|
|
|
# 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, "/")
|
2026-09-16 16:59:49 +02:00
|
|
|
width = kutil.parse_width("tex", self.width, self.rel_fraction)[0]
|
2026-07-18 18:48:23 +02:00
|
|
|
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,
|
2026-08-06 13:11:37 +02:00
|
|
|
self.hpos, self.caption_side, offset=self.offset)
|
2026-07-18 18:48:23 +02:00
|
|
|
else:
|
|
|
|
|
result = f'\\includegraphics[width={width}]{{{source}}}'
|
2026-08-06 13:11:37 +02:00
|
|
|
result = latex_util.caption_wrapper(result, self.hpos, offset=self.offset)
|
2026-07-18 18:48:23 +02:00
|
|
|
name = f"Reference-Figure-{Image.id}"
|
|
|
|
|
result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}"
|
|
|
|
|
Image.id += 1
|
2026-08-06 13:11:37 +02:00
|
|
|
# \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)
|