Initial commit: Klammertext source distribution

Curated source subset assembled by klammertext-dev's doc/make_dist.sh: the Klammermachine (mac), the Standard Klammer Set (sks), the commands (com), editor plugins and install guides (doc), a test subset (tst), and lib/bin placeholders. Builds with 'make -C com'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 18:48:23 +02:00
commit 2ba7ceee7a
272 changed files with 27634 additions and 0 deletions

35
sks/image/css/image.css Normal file
View File

@@ -0,0 +1,35 @@
img {
margin: 0;
padding: 0;
border: 0;
}
.image_grid_row {
display: flex;
flex-direction: row;
justify-content: space-between; /* center; */
align-content: center;
align-items: flex-start;
/*gap: 0px;*/
}
.image_grid {
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
align-items: flex-start;
flex-wrap: wrap;
gap: 8px;
}
.image_border {
border: solid gray 1px;
}
.image_margin {
margin-top: 2em;
margin-bottom: 2em;
}

1
sks/image/css/list.txt Normal file
View File

@@ -0,0 +1 @@
image.css

59
sks/image/image.k Normal file
View File

@@ -0,0 +1,59 @@
#[
@@@state image_search_path.list :create Series of directories for image search
:value . imgsrc ../imgsrc ../dbg
@@@
]#
@@@state Image_search_path :desc Series of directories for image search
:value . imgsrc ../imgsrc ../dbg
@@@
@@@state Image_output_dir :desc Output directory for images :value img @@@
@@@state Image_default_width :desc Default width for images :value .667w @@@
#
# @@@argtype pixels | a pixel count :pattern \d+px @@@
# @@image.k basename : Image read from a file @@
# @@image.html basename | width | height : @eval import image ; result = image.image(K) eval@ @@
@@image
basename
:id
:width.length .5w
@caption_arguments@
:vmargin.bool true
:center.bool true
:hpos.hpos center
:rel
:abswidth.number 0.0
:border.bool false
:
@eval image.Image(K) eval@
@@
@@image_grid
image_specs.rest
:caption
:number.bool true
:cell_number.bool false
:landscape.bool false
:scale.number 0.98
:caption_side.side bottom
:caption_side_center.bool true
#
:thumbnail.bool false
:allow_break.bool true
# :indent.length
# :xmargin.length
:id
:captionfont
:caption_width.float .9
:rel
:hsep.number 0.02
:
@eval image_grid.Image_grid(K) eval@
@@
@@fig spec.figure_id :
@reference *spec* | Figure @
@@

148
sks/image/image.py Normal file
View File

@@ -0,0 +1,148 @@
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 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)
# pprint.pprint(self.__dict__)
# self.show()
# /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)
self.basename = klammer_base.unescape_ktesc(self.basename)
self.source, self.pwidth, self.pheight, self.file_error = self.cache.get(self.K_target, self.basename)
if self.file_error:
self.file_error_message = f'\nERROR: File "{self.K_input_filenames}" not found'
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.file_error = self.cache.get(self.K_target, self.rel)
self.rel_fraction = self.pwidth / self.rel_pwidth
if self.file_error:
self.file_error_message = f'\nERROR: File "{self.K_input_filenames}" not found'
def html(self):
img_dir = f"{self.K_output_dir}/{self.K_output_basename}/{self.Image_output_dir}"
# output_dir = self.K_output_dir or "."
# input_basename = self.__dict__.get("K_input_name", "")
# input_basename = "/" + input_basename if input_basename else ""
# img_dir = f"{output_dir}/{self.image_output_dir}{input_basename}"
#print("img_dir:", img_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)
# width, scale_x, scale_y = kutil.parse_length(
# "html",
# self.width if self.width != "none" else self.image_default_width)
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))
#if self.width:
# kutil.msg("rel: " + str(self.rel_fraction))
length, value, ltype = kutil.parse_length("html", self.width, self.rel_fraction)
if ltype == "w":
percent = float(value) * 100
result.sty(f"width: {percent}%")
else:
result.attr("width", length)
if self.border:
result.cls("image_border")
# print(result)
#result.attr("style", f"width: {width}")
# if width and width != "none":
# result.attr("style", f"width: {width}")
# if width == "none":
# if scale_x:
# result.attr("style", "width: 0px;")
# result.attr("scale-x", scale_x)
# elif scale_y:
# result.attr("style", "height: 0px;")
# result.attr("scale-y", scale_y)
if self.file_error:
self.caption += self.file_error_message
result = html_util.add_caption(
result, "Figure", self.number, self.caption, self.caption_font,
self.hpos, self.caption_side, False, self.caption_font_size)
result = E("div").body(result).cls("image_margin")
if self.as_string:
result = str(result) + "\n"
return result
def tex(self):
# kutil.msg(f"number: {self.number}")
#selfsource, width, height = self.cache.get(self.K_target, self.basename)
source = os.path.abspath(self.source)
#if self.abswidth != 0:
# width = f"{self.abswidth}\\paperwidth"
#if self.caption or self.number:
# width = "\\textwidth"
#else:
self.number = self.number == "true"
width = kutil.parse_length("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}}}'
if self.file_error:
self.caption += self.file_error_message
result = latex_util.add_caption(
result, "Figure", self.number, self.caption, width,
self.hpos, self.caption_side)
#result, "Figure", self.number, self.caption, self.hpos, self.caption_side,
#self.width, self.caption_side_center, self.vmargin, self.caption_margin)
else:
result = f'\\includegraphics[width={width}]{{{source}}}'
result = latex_util.caption_wrapper(result, self.hpos)
name = f"Reference-Figure-{Image.id}"
result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}"
Image.id += 1
return result
def txt(self):
return f"[Image: {self.basename}\nCaption: {self.caption}]"

232
sks/image/image_cache.py Normal file
View File

@@ -0,0 +1,232 @@
"""
Does every target has a preferred format?
HTML - JPEG (or SVG if available?)
LaTeX - PDF or EPS?
Plaintext - A URL?
EPUB - JPEG?
"""
import sys, os, glob, shutil, subprocess, re, codecs
import filecmp
import pdf_image
import kutil
import OpenImageIO as oiio
def find_ext(filenames, ext):
for f in filenames:
if f.endswith(ext):
return f
return None
def cache_update_required(source, cached):
return cached is None \
or not(os.path.exists(cached)) \
or (os.path.getmtime(cached) < os.path.getmtime(source))
def image_dimensions(filename):
resize = True
if filename.endswith(".pdf"):
width, height = pdf_image.dimensions(filename)
resize = False
else:
inp = oiio.ImageInput.open(filename)
if not inp:
raise Exception(f'Cannot read image "{filename}": {oiio.geterror()}')
spec = inp.spec()
width, height = spec.width, spec.height
inp.close()
return width, height, resize
class Image_cache:
max_width = 1600 # Should be a state variable
cache_basedir = "_klammertext_imgsrc" # Should be a state variable
error_marker_filename = "_error_marker.png"
verbose_label = " [klammertext]" # Should parameterize verbosity
def __init__(self, cache_dir, search_path, verbose=False):
#self.cache_dir = kutil.cache_directory(
self.dir = kutil.cache_directory(cache_dir, "_image_cache")
self.search = search_path.split(" ")
self.verbose = verbose or os.environ.get("KLAMMERTEXT_IMAGE_CACHE_DISPLAY")
self.required_format = { "html" : ["jpg", "jpeg", "JPEG", "png", "gif", "webp"],
"pdf" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"],
"tex" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"],
"latex" : ["png", "jpg", "jpeg", "JPEG", "pdf", "eps"] }
self.error_image_filename = f"{self.dir}/{Image_cache.error_marker_filename}"
if not os.path.exists(self.dir):
os.makedirs(self.dir)
self.search = self.search + [f"{os.path.dirname(cache_dir)}/imgsrc"]
def make_error_marker(self):
if not os.path.exists(self.error_image_filename):
err_buf = oiio.ImageBuf(oiio.ImageSpec(300, 100, 3, oiio.UINT8))
oiio.ImageBufAlgo.fill(err_buf, (1.0, 0.0, 0.0))
err_buf.write(self.error_image_filename)
def construct_cache_filename(self, target, filename):
basename, ext = os.path.splitext(filename)
required = self.required_format[target]
if ext in self.required_format[target]:
cache_ext = ext
else:
cache_ext = required[0]
return f"{self.dir}/{filename}.{cache_ext}"
def find_file(self, target, basename):
result = None
candidates = []
matches = []
for path in self.search:
candidates += glob.glob(f"{path}/{basename}.*")
candidates = sorted(candidates, key=lambda p: 0 if p.endswith(".png") else 1)
if False:
print("Candidates:")
for c in candidates:
print(f" {c}")
if candidates:
matches = []
for ext in self.required_format[target]:
candidate = find_ext(candidates, ext)
if candidate:
matches.append(candidate)
if not matches:
result = candidates[0]
else:
kutil.msg(kutil.red_text(f'No image with basename "{basename}" found in search path:'))
kutil.msg(kutil.red_text(" | " + " | ".join(self.search) + " | "))
self.make_error_marker()
matches = [self.error_image_filename]
result = matches[0] if matches else candidates[0]
"""
if len(matches) > 1:
target = f"for .{target} format"
print(f'{Image_cache.verbose_label} Warning: Multiple matches for "{basename}" {target}:')
sp = " "*len(Image_cache.verbose_label)
for f in matches:
print(f"{sp} {f}")
"""
return result
def find_cached_file(self, target, basename):
exts = self.required_format.get(target)
if exts is None:
raise Exception(
f'No preferred image format defined for target "{target}"')
result = None
for ext in exts:
cached_filename = f"{self.dir}/{basename}.{ext}"
if os.path.exists(cached_filename):
return cached_filename
return None
def describe_caching(self, source_filename, cached_filename, copied,
width, height, new_width, new_height):
src = os.path.relpath(source_filename)
if src[:2] == "..":
src = source_filename
dest = os.path.splitext(cached_filename)[1]
dim = "" if width == new_width else f" [{new_width}x{new_height}]"
desc = "(copied)" if copied else f"-> {dest}{dim}"
#print(f"{Image_cache.verbose_label} To cache: {src} [{width}x{height}] {desc}")
kutil.msg("To cache: {src} [{width}x{height}] {desc}")
def update_cache(self, target, basename, source_filename):
cached_filename = self.construct_cache_filename(target, basename)
width, height, resize = image_dimensions(source_filename)
new_width = width
new_height = height
source_format = os.path.splitext(source_filename)[-1][1:]
#print("Source:", source_format, basename)
if source_format in self.required_format[target]:
cached_filename = f"{os.path.dirname(cached_filename)}/{os.path.basename(source_filename)}"
copied = False
if resize and width > Image_cache.max_width:
new_width = Image_cache.max_width
new_height = int(round(new_width * float(height) / float(width)))
if source_filename.endswith(".pdf"):
pdf_image.convert(source_filename, cached_filename)
buf = oiio.ImageBuf(cached_filename)
else:
buf = oiio.ImageBuf(source_filename)
roi = oiio.ROI(0, new_width, 0, new_height, 0, 1, 0, buf.nchannels)
resized = oiio.ImageBufAlgo.resize(buf, roi=roi)
resized.write(cached_filename)
elif source_format in self.required_format[target]:
#print("COPY", source_filename, cached_filename)
if not os.path.exists(cached_filename) or \
not filecmp.cmp(source_filename, cached_filename, shallow=False):
shutil.copy2(source_filename, cached_filename)
copied = True
else:
try:
if source_filename.endswith(".pdf"):
pdf_image.convert(source_filename, cached_filename)
else:
buf = oiio.ImageBuf(source_filename)
if not buf.has_error:
buf.write(cached_filename)
else:
raise Exception(buf.geterror())
except Exception as err:
print(f'Cannot convert "{source_filename}" to '
f'"{os.path.splitext(cached_filename)[-1][1:]}":')
print(" ", err)
sys.exit(1)
if self.verbose:
self.describe_caching(source_filename, cached_filename, copied,
width, height, new_width, new_height)
return cached_filename, new_width, new_height
def get(self, target, basename):
cached_filename = self.find_cached_file(target, basename)
source_filename = self.find_file(target, basename)
if cache_update_required(source_filename, cached_filename):
cached_filename, width, height = self.update_cache(target, basename, source_filename)
else:
width, height, _ = image_dimensions(cached_filename)
if self.verbose:
base = os.path.basename(cached_filename)
#print(f"{Image_cache.verbose_label} From cache: {base} [{width}x{height}]")
kutil.msg("From cache: {base} [{width}x{height}]")
# print("image_cache:", cached_filename, Image_cache.error_marker_filename)
return cached_filename, width, height, cached_filename == Image_cache.error_marker_filename
def clear(self):
shutil.rmtree(self.dir)
os.makedirs(self.dir)
if __name__ == '__main__':
def getter(format, basename):
C.get(format, basename)
C.get(format, basename)
def label(format=""):
print("-"*80)
print(format)
home = os.environ.get("HOME")
C = Image_cache(f"{home}",
f"{home}/projects/klammertext/K/doc/handbook/imgsrc".split())
a = "heringsdorf_stairwell"
b = "usedom_clouds"
C.clear()
label("HTML")
getter("html", a)
getter("html", b)
label("LATEX")
getter("latex", a)
getter("latex", b)
label()

114
sks/image/image_grid.py Normal file
View File

@@ -0,0 +1,114 @@
import os
import re
import kutil
import klammer_base
import image
import latex_util
import html
import html_util
from html_util import E
import image_cache
#import kutil
class Kargs:
def __init__(self, target, K, **kwargs):
self.__dict__ = kwargs
self.center = False
self.caption_font = "i"
self.caption_side = "bottom"
self.caption_side_center = False
self.Image_search_path = K.Image_search_path
self.number = K.cell_number
self.hpos = "none"
self.K_target = target
self.K_input_filenames = K.K_input_filenames
self.K_output_dir = K.K_output_dir
self.K_output_basename = K.K_output_basename
self.image_output_dir = K.Image_output_dir
self.K_toc_only = False
self.id = ""
self.as_string = False
self.rel = None
def img(K, target, basename, caption, width):
result = image.Image(
Kargs(target, K, basename=basename, caption=caption), width=width)
return result
class Image_grid(klammer_base.Klammer_base):
def __init__(self, K):
super().__init__(K)
self.K = K
image_pat = re.compile("([^\s]+)\s*(.*?)", re.S)
self.images = []
self.basenames = []
self.captions = []
for row in kutil.rest_args(self.image_specs, dimensions=2):
self.images.append(
[e.groups() for e in [image_pat.fullmatch(s.strip()) for s in row]])
self.basenames.append([e[0] for e in self.images[-1]])
self.captions.append([e[1] for e in self.images[-1]])
self.cache = image_cache.Image_cache(
os.path.dirname(os.path.abspath(self.K_input_filenames)),
self.Image_search_path,
verbose=False)
self.margin = 0.01
self.grid = []
for basenames, captions in zip(self.basenames, self.captions):
cell_widths = self.widths(self.K_target, basenames)
row = []
for basename, caption, cell_width in zip(basenames, captions, cell_widths):
row.append([basename, caption, cell_width])
self.grid.append(row)
def widths(self, target, basenames):
dims = []
for basename in basenames:
dims.append(self.cache.get("html", basename))
heights = [e[2] for e in dims]
total_height = sum(heights)
height_scales = [h/total_height for h in heights]
widths = [e[1] for e in dims]
widths = [e[0] / e[1] for e in zip(widths, height_scales)]
total_width = sum(widths)
margin = self.margin * total_width
total_width += margin * (len(widths) - 1)
result = [w/total_width for w in widths]
result = [f"{w:0.6f}w" for w in result]
return result
def html(self):
result = ""
for row in self.grid:
row_cells = ""
for basename, caption, cell_width in row:
cell_image = img(self.K, "html", basename, caption, cell_width).html()
row_cells += str(cell_image).strip() + "\n"
result += str(E("div").body(row_cells).cls("image_grid_row").attr("data-row-size", len(row)))
result = E("div").body(result).cls("image_grid")
if self.number or self.caption:
result = html_util.add_caption(result, "Figure", self.number, self.caption)
return str(result)
def tex(self):
result = ""
for row in self.grid:
row_cells = ""
for basename, caption, cell_width in row:
cell_image = img(self.K, "tex", basename, caption, cell_width).tex()
row_cells += str(cell_image).strip() + f"\\hspace*{{{self.margin}\\textwidth}}%\n"
#result += "\\begin{minipage}[t]{\\textwidth}" \
# + f"{{{row_cells}}}\n\\end{{minipage}}
result += latex_util.minipage(row_cells, vertical="t") + f"\\\\[{self.margin}\\textwidth]\n"
if self.number or self.caption:
result = latex_util.add_caption(
result.strip(), "Figure", self.number, self.caption, "\\textwidth")
else:
#result = f"\\begin{{minipage}}{{\\textwidth}}\n\\centering {result}\n\\end{{minipage}}\n"
result = latex_util.minipage("\\centering " + result, vertical="t") + "\n"
return result

51
sks/image/js/image.js Normal file
View File

@@ -0,0 +1,51 @@
function resize_images() {
//console.log("resize_images");
K.getv("#text img[data-scale-x]").forEach(
function (img) {
let text_area = K.get("#text"); //img.parentNode.parentNode;
let scale_x = parseFloat(K.attr(img, "data-scale-x"));
let style = window.getComputedStyle(text_area); //text_area.currentStyle;
let parent_width = text_area.clientWidth
- parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
let container = img.parentNode.parentNode;
if (container.classList.contains("image_grid_row")) {
if (!container.style) {
container.style = {};
}
container.style.width = parent_width + "px";
} else if (container.parentNode.classList.contains("image_grid_row")) {
container = container.parentNode;
if (!container.style) {
container.style = {};
}
container.style.width = parent_width + "px";
}
let image_width = scale_x * parent_width;
if (!img.style) {
img.style = {};
}
img.style.width = image_width + "px";
let caption = img.parentNode.getElementsByClassName("caption")[0];
if (typeof caption !== 'undefined') {
if (typeof caption.style == 'undefined') {
caption.style = {};
}
caption.style.width = image_width + "px";
}
});
K.getv(".caption_right, .caption_left").forEach(
function (img) {
K.width(img, img.parentNode.clientWidth);
});
}
window.addEventListener("load", function (event) {
window.addEventListener(
"resize",
function (event) {
resize_images();
});
resize_images();
});

81
sks/image/pdf_image.py Normal file
View File

@@ -0,0 +1,81 @@
import re, subprocess, os
def dimensions_from_prolog(filename):
mediabox_pat = re.compile(r"/MediaBox\s+\[(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\]", re.S)
width_pat = re.compile(r"/Width\s+(\d+)", re.S)
height_pat = re.compile(r"/Height\s+(\d+)", re.S)
width = None
height = None
with open(filename, mode="rb") as fp:
line = fp.readline().decode("ascii")
while line:
match = mediabox_pat.match(line)
if match:
x1, y1, x2, y2 = [int(e) for e in match.groups()]
width = x2 - x1
height = y2 - y1
else:
match = width_pat.match(line)
if match:
width = int(match.group(1))
else:
match = height_pat.match(line)
if match:
height = int(match.group(1))
if width and height:
break
line = fp.readline().decode("ascii")
return width, height
def dimensions_from_pdfinfo(filename):
sp = subprocess.Popen(
["/usr/bin/pdfinfo", filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = sp.communicate()
out = out.decode("ascii")
width, height = None, None
if out:
pat = re.compile(r"Page size:\s+([\d.]+) x ([\d.]+) pts", re.S)
match = pat.search(out)
if match:
width, height = [int(round(float(e))) for e in match.groups()]
return width, height
# Doesn't agree with pdfinfo; why?
# from pdf2image import convert_from_path
# def dimensions_from_pdf2image(filename):
# width, height = None, None
# if pdf2image_imported:
# img = convert_from_path(filename)[0]
# width, height = img.size
# return width, height
def dimensions(filename):
width, height = None, None
try:
width, height = dimensions_from_prolog(filename)
except:
if width is None:
width, height = dimensions_from_pdfinfo(filename)
if width is None:
raise Exception(f'Could not determine the width and height of "{filename}"')
return width, height
def convert(pdf_filename, image_filename):
if image_filename.endswith(".jpg"):
arg1 = "-jpeg"
elif image_filename.endswith(".png"):
arg1 = "-png"
else:
raise Exception(f'PDF file "{pdf_filename}" can only be converted to PNG or JPEG')
arg2 = "-singlefile"
command = "/usr/bin/pdftoppm"
basename = os.path.splitext(image_filename)[0]
sp = subprocess.Popen(
[command, arg1, arg2, pdf_filename, basename],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = sp.communicate()
if err:
raise Exception(f'Error creating image file from "{pdf_filename}": {err}')

2
sks/image/sty/image.sty Normal file
View File

@@ -0,0 +1,2 @@
\usepackage[xetex]{graphicx}
\usepackage[export]{adjustbox}