From 071896cfb3a8198b3d7aa312f27aea16f581e0af Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Tue, 13 Oct 2026 07:21:19 +0200 Subject: [PATCH 01/10] freetype: Update version from 2.10.4 to 2.15.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream archive was downloaded from https://download.savannah.gnu.org/releases/freetype/freetype-2.15.0.tar.xz Its SHA256 checksum matches the one in the release announcement: 93cd47eb719c325107f2d3cf087d06299e944c3b48a40b55537302304dea1dd5 and the signature `freetype-2.15.0.tar.xz.sig` is a good one from Werner Lemberg (key E30674707856409FF1948010BE6C3AAC63AD8E3F). From the announcement: > CHANGES BETWEEN 2.14.3 and 2.15.0 (2026-Oct-12) > > I. IMPORTANT CHANGES > > - Support for the ISO Open Font Format, Edition 5 (released in July > 2026) has been contributed by Behdad Esfahbod, […] > > . The new 'VARC' table allows a file size reduction of CJK > Variation fonts by up to 90%. > > . OpenType fonts with more than 65536 glyphs can now be handled. > […] > > . The new 'GLYF' table also supports outlines with mixed cubic and > conic segments. > > - The anti-aliased rasterizer now uses dynamic heap memory for large > glyphs, which significantly improves its performance at sizes > above 40 ppem. > > - The TrueType version 40 interpreter got several ClearType > rendering improvements. […] > > II. IMPORTANT BUG FIXES > > - Continued efforts to scrutinize FreeType with AI tools resulted in > more bugs being fixed, some with potential security implications > (e.g., CVE-2026-95512). The SONAME stays `libfreetype.so.6` (now `libfreetype.so.6.20.7`), and the library links against the same libraries as 2.10.4 (bzip2, libpng, zlib, HarfBuzz, Brotli). The validators gxvalid and otvalid are built in, and `FT_CONFIG_OPTION_SUBPIXEL_RENDERING` is defined, as before. Built with fakeroot beesh on hotelcalifornia from a local copy of the tarball, and again from beehive after mirroring. A test program renders an LCD glyph with the new library, and `fc-scan` works with it. Assisted-by: claude-opus-5.5 (Claude Code) --- freetype.be0 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freetype.be0 b/freetype.be0 index 1fe78c0d3..41e5c53f3 100755 --- a/freetype.be0 +++ b/freetype.be0 @@ -1,11 +1,11 @@ #!/bin/env beesh -# BEE_VERSION freetype-2.10.4-1 +# BEE_VERSION freetype-2.15.0-0 BEE_BUILDTYPE=autotools # SRCURL[0]="https://download.savannah.gnu.org/releases/freetype/freetype-${PKGVERSION}.tar.xz" -SRCURL[0]="https://beehive.molgen.mpg.de/0e6c0e9b218be3ba3e26e1d23b1c80dd/freetype-2.10.4.tar.xz" +SRCURL[0]="https://beehive.molgen.mpg.de/65d0c613b22d21bb909425c908627689/freetype-2.15.0.tar.xz" # PATCHURL+=() From 155da4a80a58cef38127e8c41702d3b6859226d0 Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Tue, 13 Oct 2026 07:35:41 +0200 Subject: [PATCH 02/10] freetype: Drop dead subpixel hinting sed The sed in `mee_patch` also tries to uncomment #define TT_CONFIG_OPTION_SUBPIXEL_HINTING 2 with a single space, but ftoption.h never had such a line: in 2.10.4 the line is already active and has two spaces, #define TT_CONFIG_OPTION_SUBPIXEL_HINTING 2 and in 2.15.0, where the v38 interpreter is gone, it has no value at all: #define TT_CONFIG_OPTION_SUBPIXEL_HINTING So that alternative matches nothing; it came in with the update to 2.7 in 2016. Only keep the one for `FT_CONFIG_OPTION_SUBPIXEL_RENDERING`. The installed ftoption.h of freetype-2.15.0-0 is byte-identical with and without the change, and the package has the same file list; built with fakeroot beesh on hotelcalifornia, this time from beehive. Assisted-by: claude-opus-5.5 (Claude Code) --- freetype.be0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freetype.be0 b/freetype.be0 index 41e5c53f3..f90779309 100755 --- a/freetype.be0 +++ b/freetype.be0 @@ -21,7 +21,7 @@ mee_patch() { bee_patch sed -ri "s:.*(AUX_MODULES.*valid):\1:" modules.cfg - sed -r "s:.*(#.*SUBPIXEL_(RENDERING|HINTING 2)) .*:\1:g" \ + sed -r "s:.*(#.*SUBPIXEL_RENDERING) .*:\1:g" \ -i include/freetype/config/ftoption.h } From 122fd6b07776dee65249fb452674731310b3270c Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Tue, 13 Oct 2026 07:35:41 +0200 Subject: [PATCH 03/10] smoke-tests: Add FreeType smoke test FreeType's test suite needs font files the release tarball does not ship, so add a script that checks an installed freetype package, or with `--prefix` the image directory of an uninstalled build: - SONAME `libfreetype.so.6`, no RPATH/RUNPATH, and NEEDED entries for bzip2, libpng, zlib, HarfBuzz, and Brotli -- configure drops each of them silently, when it does not find them. - `FT_CONFIG_OPTION_SUBPIXEL_RENDERING` defined in ftoption.h, and `FT_Library_SetLcdFilter()` returning 0 at run time. - All driver modules, including gxvalid and otvalid, which only `mee_patch` enables. - A test program compiled through freetype2.pc, whose run-time library version has to match the headers, renders a glyph with ink for an LCD from a TrueType, a CFF, and a Type 1 font of the host, and from gzip and bzip2 copies of the TrueType font opened through FreeType's stream wrappers. Missing font formats are reported as WARN. - `fc-scan` parses a font with the library. The checks pass for freetype-2.15.0-0 (with `--prefix`) and for the installed freetype 2.10.4. A stock 2.15.0 build without `mee_patch` fails, as it should: FAIL #define FT_CONFIG_OPTION_SUBPIXEL_RENDERING MISSING from ftoption.h FAIL module gxvalid MISSING FAIL module otvalid MISSING FAIL FT_Library_SetLcdFilter returns 7 -- built without SUBPIXEL_RENDERING FAIL /etc/hostname: open error 2 (the last one from `--font /etc/hostname`). Assisted-by: claude-opus-5.5 (Claude Code) --- scripts/smoke-tests/freetype-smoke-test | 424 ++++++++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100755 scripts/smoke-tests/freetype-smoke-test diff --git a/scripts/smoke-tests/freetype-smoke-test b/scripts/smoke-tests/freetype-smoke-test new file mode 100755 index 000000000..84e1892fe --- /dev/null +++ b/scripts/smoke-tests/freetype-smoke-test @@ -0,0 +1,424 @@ +#! /usr/bin/env python3 + +# Smoke-test an installed FreeType package. +# +# smoke-tests/freetype-smoke-test +# smoke-tests/freetype-smoke-test --prefix .../freetype-2.15.0-0/image +# smoke-tests/freetype-smoke-test --font /usr/share/fonts/foo.woff2 +# +# FreeType's own test suite needs font files that the release tarball does not +# ship, so freetype.be0 is verified here instead: this script checks the +# library for what the recipe expects of it, compiles a small program against +# it through freetype2.pc and runs it on one font of every format the host has. +# +# --prefix takes the image directory of a build instead, so a recipe can also be +# checked before installing it, which is the only way without root. +# +# configure enables zlib, bzip2, libpng, HarfBuzz and Brotli silently when it +# finds them, so a missing dependency drops a feature instead of failing the +# build; the NEEDED entries of the library are checked for that reason. zlib +# and bzip2 are also exercised for real, on compressed copies of a font. +# Fonts that the host does not have are reported as WARN. + +import argparse +import bz2 +import gzip +import os +import shutil +import subprocess +import sys +import tempfile + +# configure links these when it finds them, and 2.10.4 linked all of them. +NEEDED = ["libbz2.so.1.0", "libpng16.so.16", "libz.so.1", "libharfbuzz.so.0", + "libbrotlidec.so."] + +# Modules the package must have. gxvalid and otvalid are only built because +# mee_patch enables them in modules.cfg. +MODULES = ["truetype", "type1", "cff", "t1cid", "type42", "pfr", "winfonts", + "pcf", "bdf", "sfnt", "autofitter", "pshinter", "psaux", "psnames", + "smooth", "raster1", "gxvalid", "otvalid"] + +# Font formats to look for on the host, by file name extension. +FORMATS = {"TrueType": (".ttf",), "CFF": (".otf",), "Type 1": (".pfb", ".pfa")} + +PROGRAM = r""" +#include +#include FT_FREETYPE_H +#include FT_MODULE_H +#include FT_LCD_FILTER_H +#include FT_FONT_FORMATS_H +#include FT_GZIP_H +#include FT_BZIP2_H + +#include +#include +#include + +/* Render the character code for an LCD; true if that left any ink. */ +static int inked(FT_Face face, FT_ULong code, FT_Error *error) +{ + FT_UInt index = FT_Get_Char_Index(face, code); + + if (!index) + return 0; + *error = FT_Load_Glyph(face, index, FT_LOAD_RENDER | FT_LOAD_TARGET_LCD); + return !*error && face->glyph->bitmap.width && face->glyph->bitmap.rows; +} + +/* + * Render 'g', or else the first of the face's characters that leaves ink: the + * first one a font maps is often the space, whose bitmap is empty. + */ +static int render(FT_Library library, FT_Open_Args *args, const char *name) +{ + FT_Face face; + FT_UInt index; + FT_ULong code = 'g'; + FT_Error error; + int tries; + + error = FT_Open_Face(library, args, 0, &face); + if (error) { + printf("font %s fail open error %d\n", name, error); + return 1; + } + if (FT_IS_SCALABLE(face)) + error = FT_Set_Pixel_Sizes(face, 0, 48); + else + error = FT_Select_Size(face, 0); + if (!error && !inked(face, code, &error)) { + code = FT_Get_First_Char(face, &index); + for (tries = 0; index && tries < 256; tries++) { + if (inked(face, code, &error)) + break; + code = FT_Get_Next_Char(face, code, &index); + } + if (!error && (!index || tries == 256)) + error = -1; + } + if (error) { + printf("font %s fail no glyph rendered, error %d\n", name, error); + FT_Done_Face(face); + return 1; + } + printf("font %s ok %s %s %s: U+%04lX as %ux%u LCD bitmap\n", name, + FT_Get_Font_Format(face), face->family_name, face->style_name, + code, face->glyph->bitmap.width, face->glyph->bitmap.rows); + FT_Done_Face(face); + return 0; +} + +static void *memory_alloc(FT_Memory memory, long size) +{ + return malloc(size); +} + +static void memory_free(FT_Memory memory, void *block) +{ + free(block); +} + +static void *memory_realloc(FT_Memory memory, long cur_size, long new_size, + void *block) +{ + return realloc(block, new_size); +} + +/* + * Open a font file through FreeType's gzip or bzip2 stream. FT_Stream_Open is + * internal API, so the compressed data is handed over as a memory-based stream + * (read == NULL), which needs an allocator of its own for the decompressor. + */ +static int render_compressed(FT_Library library, const char *path, + const char *method) +{ + static struct FT_MemoryRec_ memory = { + NULL, memory_alloc, memory_free, memory_realloc + }; + FT_StreamRec source = { 0 }, stream = { 0 }; + FT_Open_Args args = { 0 }; + unsigned char *data; + FT_Error error; + FILE *file; + long size; + int failed; + + file = fopen(path, "rb"); + if (!file || fseek(file, 0, SEEK_END) || (size = ftell(file)) < 0 || + fseek(file, 0, SEEK_SET) || !(data = malloc(size)) || + fread(data, 1, size, file) != (size_t)size) { + printf("font %s fail cannot read the file\n", path); + return 1; + } + fclose(file); + source.base = data; + source.size = size; + source.memory = &memory; + + error = strcmp(method, "gzip") ? + FT_Stream_OpenBzip2(&stream, &source) : + FT_Stream_OpenGzip(&stream, &source); + if (error) { + printf("font %s fail %s stream error %d\n", path, method, error); + free(data); + return 1; + } + args.flags = FT_OPEN_STREAM; + args.stream = &stream; + /* FT_Done_Face closes an external stream, so stream needs no close. */ + failed = render(library, &args, path); + free(data); + return failed; +} + +int main(int argc, char **argv) +{ + static const char *const modules[] = { MODULES, NULL }; + FT_Library library; + FT_Int major, minor, patch; + FT_Error error; + int i, failed = 0; + + if (FT_Init_FreeType(&library)) + return 2; + FT_Library_Version(library, &major, &minor, &patch); + printf("version %d.%d.%d header %d.%d.%d\n", major, minor, patch, + FREETYPE_MAJOR, FREETYPE_MINOR, FREETYPE_PATCH); + for (i = 0; modules[i]; i++) + printf("module %s %s\n", modules[i], + FT_Get_Module(library, modules[i]) ? "yes" : "no"); + /* Unimplemented without FT_CONFIG_OPTION_SUBPIXEL_RENDERING. */ + error = FT_Library_SetLcdFilter(library, FT_LCD_FILTER_DEFAULT); + printf("lcdfilter %d\n", error); + + for (i = 1; i + 1 < argc; i += 2) { + if (strcmp(argv[i], "plain")) { + failed |= render_compressed(library, argv[i + 1], + argv[i]); + } else { + FT_Open_Args args = { 0 }; + + args.flags = FT_OPEN_PATHNAME; + args.pathname = argv[i + 1]; + failed |= render(library, &args, argv[i + 1]); + } + } + FT_Done_FreeType(library); + return failed; +} +""" + +root = "" +status = {"fail": 0, "warn": 0} + + +def report(state, message): + if state != "ok": + status[state] += 1 + print("%-5s %s" % (state.upper(), message)) + + +def environment(): + """The environment for pkg-config and the test program.""" + env = dict(os.environ) + if root: + # freetype2.pc spells out /usr, so --define-variable cannot move it; + # the sysroot prefixes every -I and -L it hands out instead. + env["PKG_CONFIG_SYSROOT_DIR"] = root + rest = env.get("PKG_CONFIG_PATH") + env["PKG_CONFIG_PATH"] = root + "/usr/lib/pkgconfig" + \ + (os.pathsep + rest if rest else "") + rest = env.get("LD_LIBRARY_PATH") + env["LD_LIBRARY_PATH"] = root + "/usr/lib" + \ + (os.pathsep + rest if rest else "") + return env + + +def check_files(): + """Check the library and headers for what the recipe makes of them.""" + library = root + "/usr/lib/libfreetype.so.6" + if not os.path.exists(library): + report("fail", "%s missing -- is the package installed?" % library) + return + library = os.path.realpath(library) + dynamic = subprocess.run(["readelf", "-d", library], text=True, + capture_output=True).stdout + needed, soname, rpath = [], None, [] + for line in dynamic.splitlines(): + value = line.partition("[")[2].rstrip("]") + if "(NEEDED)" in line: + needed.append(value) + elif "(SONAME)" in line: + soname = value + elif "(RPATH)" in line or "(RUNPATH)" in line: + rpath.append(value) + report("ok" if soname == "libfreetype.so.6" else "fail", + "%s has SONAME %s" % (library, soname)) + report("fail" if rpath else "ok", + "RPATH/RUNPATH %s" % (", ".join(rpath) if rpath else "none")) + for prefix in NEEDED: + match = [name for name in needed if name.startswith(prefix)] + report("ok" if match else "fail", + "NEEDED %s" % (match[0] if match else prefix + " MISSING")) + + # mee_patch turns this on with sed, which fails silently. + header = root + "/usr/include/freetype2/freetype/config/ftoption.h" + try: + with open(header) as options: + lines = [line.strip() for line in options] + except OSError as error: + report("fail", "%s: %s" % (header, error.strerror)) + return + define = "#define FT_CONFIG_OPTION_SUBPIXEL_RENDERING" + report("ok" if define in lines else "fail", + "%s %s" % (define, "in ftoption.h" if define in lines + else "MISSING from ftoption.h")) + + +def find_fonts(): + """Return {format: path} with one font file per format on the host.""" + try: + files = subprocess.run(["fc-list", "-f", "%{file}\\n"], text=True, + capture_output=True).stdout.split() + except OSError: + files = [] + fonts = {} + for name, extensions in FORMATS.items(): + match = sorted(path for path in files if path.endswith(extensions)) + if match: + fonts[name] = match[0] + else: + report("warn", "no %s font on this host" % name) + return fonts + + +def build(directory): + """Compile the test program against freetype2.pc; return its path.""" + env = environment() + try: + flags = subprocess.run(["pkg-config", "--cflags", "--libs", + "freetype2"], text=True, capture_output=True, + env=env, check=True).stdout.split() + version = subprocess.run(["pkg-config", "--modversion", "freetype2"], + text=True, capture_output=True, env=env, + check=True).stdout.strip() + except (OSError, subprocess.CalledProcessError) as error: + report("fail", "pkg-config freetype2: %s" % error) + return None + report("ok", "freetype2.pc reports %s" % version) + + source = os.path.join(directory, "ft-smoke.c") + binary = os.path.join(directory, "ft-smoke") + with open(source, "w") as program: + program.write(PROGRAM) + modules = ", ".join('"%s"' % name for name in MODULES) + compiler = os.environ.get("CC", "cc") + result = subprocess.run([compiler, "-Wall", "-Werror", + "-DMODULES=" + modules, source, "-o", binary] + + flags, text=True, capture_output=True) + if result.returncode: + report("fail", "compiling against freetype2.pc failed:\n%s" + % result.stderr) + return None + report("ok", "test program compiles against freetype2.pc") + return binary + + +def check_program(binary, directory, fonts): + """Run the test program and check what it reports.""" + arguments = [] + for path in fonts.values(): + arguments += ["plain", path] + sample = fonts.get("TrueType") or next(iter(fonts.values()), None) + if sample: + with open(sample, "rb") as font: + data = font.read() + for method, module in (("gzip", gzip), ("bzip2", bz2)): + copy = os.path.join(directory, os.path.basename(sample) + "." + + {"gzip": "gz", "bzip2": "bz2"}[method]) + with module.open(copy, "wb") as compressed: + compressed.write(data) + arguments += [method, copy] + + result = subprocess.run([binary] + arguments, text=True, + capture_output=True, env=environment()) + if result.returncode not in (0, 1): + report("fail", "test program exited with %d:\n%s" + % (result.returncode, result.stderr)) + return + for line in result.stdout.splitlines(): + kind, _, rest = line.partition(" ") + if kind == "version": + words = rest.split() + report("ok" if words[0] == words[2] else "fail", + "library %s, headers %s%s" + % (words[0], words[2], "" if words[0] == words[2] + else " -- wrong library loaded?")) + elif kind == "module": + name, present = rest.split() + report("ok" if present == "yes" else "fail", + "module %s %s" % (name, "present" if present == "yes" + else "MISSING")) + elif kind == "lcdfilter": + report("ok" if rest == "0" else "fail", + "FT_Library_SetLcdFilter returns %s%s" + % (rest, "" if rest == "0" else + " -- built without SUBPIXEL_RENDERING")) + elif kind == "font": + path, state, message = rest.split(" ", 2) + report("ok" if state == "ok" else "fail", + "%s: %s" % (path, message)) + else: + report("fail", "unexpected output %r" % line) + + +def check_fcscan(fonts): + """Let fontconfig, the main consumer, parse a font with the library.""" + if not shutil.which("fc-scan") or not fonts: + report("warn", "skipping fc-scan check (no fc-scan or no font)") + return + path = next(iter(fonts.values())) + result = subprocess.run(["fc-scan", "-f", "%{family[0]} %{fontformat}", + path], text=True, capture_output=True, + env=environment()) + report("ok" if result.returncode == 0 and result.stdout else "fail", + "fc-scan %s: %s" % (path, result.stdout or result.stderr.strip())) + + +def main(): + global root + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prefix", metavar="DIR", + help="test the image directory DIR of a build that is" + " not installed") + parser.add_argument("--font", action="append", default=[], metavar="FILE", + help="also render FILE, a WOFF2 font for instance") + parser.add_argument("--out", metavar="DIR", + help="write the test program and fonts to DIR") + arguments = parser.parse_args() + if arguments.prefix: + root = os.path.abspath(arguments.prefix.rstrip("/")) + print(" testing the uninstalled %s" % root) + + check_files() + fonts = find_fonts() + for number, path in enumerate(arguments.font): + fonts["--font %d" % number] = path + + directory = arguments.out or tempfile.mkdtemp(prefix="freetype-smoke-") + os.makedirs(directory, exist_ok=True) + binary = build(directory) + if binary: + check_program(binary, directory, fonts) + check_fcscan(fonts) + + print("%s (%d failed, %d warned), files in %s" + % ("FAIL" if status["fail"] else "PASS", + status["fail"], status["warn"], directory)) + return 1 if status["fail"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 2f404cf2fdb2874a78eb34bd5e4c57816176220a Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Tue, 13 Oct 2026 16:13:10 +0200 Subject: [PATCH 04/10] smoke-tests: Pick FreeType test fonts by format, not extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file name extension does not tell the font format: the SyrCOM*.otf fonts on our hosts, for instance, have TrueType outlines. The CFF check only ran on a CFF font because the alphabetically first .otf file, GohaTibebZemen.otf, happens to be one. Ask fontconfig for the format instead, which is what FT_Get_Font_Format reports. $ smoke-tests/freetype-smoke-test --prefix .../freetype-2.15.0-0 […] OK /usr/share/fonts/Fantasque-Sans-Mono/FantasqueSansMono-Bold.ttf: TrueType Fantasque Sans Mono Bold: U+0067 as 69x33 LCD bitmap OK /usr/share/fonts/X11/OTF/GohaTibebZemen.otf: CFF Goha-Tibeb Zemen Regular: U+1200 as 66x27 LCD bitmap OK /usr/share/fonts/X11/Type1/UTBI____.pfa: Type 1 Utopia Bold Italic: U+0067 as 96x38 LCD bitmap […] PASS (0 failed, 0 warned), files in …/run8 Assisted-by: claude-opus-5.5 (Claude Code) --- scripts/smoke-tests/freetype-smoke-test | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/smoke-tests/freetype-smoke-test b/scripts/smoke-tests/freetype-smoke-test index 84e1892fe..d8da2cb34 100755 --- a/scripts/smoke-tests/freetype-smoke-test +++ b/scripts/smoke-tests/freetype-smoke-test @@ -39,8 +39,9 @@ MODULES = ["truetype", "type1", "cff", "t1cid", "type42", "pfr", "winfonts", "pcf", "bdf", "sfnt", "autofitter", "pshinter", "psaux", "psnames", "smooth", "raster1", "gxvalid", "otvalid"] -# Font formats to look for on the host, by file name extension. -FORMATS = {"TrueType": (".ttf",), "CFF": (".otf",), "Type 1": (".pfb", ".pfa")} +# Font formats to look for on the host, as fontconfig names them; the file name +# extension does not tell, .otf files may well have TrueType outlines. +FORMATS = ["TrueType", "CFF", "Type 1"] PROGRAM = r""" #include @@ -278,14 +279,20 @@ def check_files(): def find_fonts(): """Return {format: path} with one font file per format on the host.""" - try: - files = subprocess.run(["fc-list", "-f", "%{file}\\n"], text=True, - capture_output=True).stdout.split() - except OSError: - files = [] + def fc_list(*pattern): + try: + lines = subprocess.run(["fc-list", "-f", + "%{fontformat}\\t%{file}\\n"] + + list(pattern), text=True, + capture_output=True).stdout.splitlines() + except OSError: + return [] + return [line.split("\t", 1) for line in lines if "\t" in line] + + files = fc_list() fonts = {} - for name, extensions in FORMATS.items(): - match = sorted(path for path in files if path.endswith(extensions)) + for name in FORMATS: + match = sorted(path for form, path in files if form == name) if match: fonts[name] = match[0] else: From 71749628562a72279eb085f809c3a35ffc97e8ff Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Tue, 13 Oct 2026 16:13:28 +0200 Subject: [PATCH 05/10] smoke-tests: Show FreeType rendering against a reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether glyphs come out right, only a human can tell. So the FreeType smoke test now renders a fixed text with every font, in 10 to 48 px, grayscale and LCD with light hinting (fontconfig's hintslight) and monochrome, and compares it with a reference: * the installed FreeType by default with --prefix, * --reference, an image directory, / or a released .bee.tar.bz2, and * --reference-images, the --out directory of an earlier run. The same test program is built against the reference, and FREETYPE_PROPERTIES is unset for both runs. Overlay (reference red, new cyan), difference and side-by-side images are written to --out/compare, and with $DISPLAY set, a Tk viewer shows them with blinking and zoom (--no-view to skip). Differences are reported as INFO and do not fail the test, as new versions change the rendering on purpose. Fonts covering German are preferred now, so the sample text is not all .notdef boxes, as it was with the Ethiopic GohaTibebZemen.otf. Compared with the installed 2.10.4, the released freetype-2.15.0-0 renders the CFF and Type 1 fonts identically in grayscale, LCD differs by at most two levels along all edges, and monochrome hinting moves single pixels. The TrueType font also differs in grayscale and LCD, but beyond three levels only in diacritics: light hinting runs TrueType fonts through the auto-hinter, which since 2.14.0 moves diacritics away from the base glyph at small sizes: $ smoke-tests/freetype-smoke-test --prefix .../freetype-2.15.0-0 […] OK /usr/share/fonts/Fantasque-Sans-Mono/FantasqueSansMono-Bold.ttf: TrueType Fantasque Sans Mono Bold: U+0067 as 69x33 LCD bitmap OK /usr/share/fonts/cantarell/Cantarell-Bold.otf: CFF Cantarell Bold: U+0067 as 75x37 LCD bitmap OK /usr/share/fonts/X11/Type1/UTBI____.pfa: Type 1 Utopia Bold Italic: U+0067 as 96x38 LCD bitmap […] OK freetype2.pc of the reference / reports 23.4.17 OK test program compiles against freetype2.pc of the reference / INFO FantasqueSansMono-Bold.ttf: 18331 pixels (1.97 %) differ from 2.10.4 (/) INFO Cantarell-Bold.otf: 18647 pixels (1.92 %) differ from 2.10.4 (/) INFO UTBI____.pfa: 22049 pixels (2.49 %) differ from 2.10.4 (/) […] PASS (0 failed, 0 warned), files in … With `--reference .../freetype-2.14.3-0.x86_64.bee.tar.bz2`, all three fonts differ in 0 pixels, and a 10x20.pcf.gz bitmap font passed with --font renders one strike per mode. Assisted-by: claude-opus-5.5 (Claude Code) --- scripts/smoke-tests/freetype-smoke-test | 557 +++++++++++++++++++++++- 1 file changed, 538 insertions(+), 19 deletions(-) diff --git a/scripts/smoke-tests/freetype-smoke-test b/scripts/smoke-tests/freetype-smoke-test index d8da2cb34..22366ae58 100755 --- a/scripts/smoke-tests/freetype-smoke-test +++ b/scripts/smoke-tests/freetype-smoke-test @@ -19,10 +19,22 @@ # build; the NEEDED entries of the library are checked for that reason. zlib # and bzip2 are also exercised for real, on compressed copies of a font. # Fonts that the host does not have are reported as WARN. +# +# Whether glyphs come out right only a human can tell, so every font is also +# rendered to an image -- a fixed text in several sizes, grayscale, LCD and +# monochrome -- and compared with a reference: by default the installed FreeType +# when --prefix is given, or any image directory or released package passed to +# --reference, and the images of an earlier run passed to --reference-images. +# With $DISPLAY set, a viewer shows them laid over each other. Differences do +# not fail the test, as new versions change the rendering on purpose. +# +# smoke-tests/freetype-smoke-test --prefix .../image --reference \ +# /src/mariux/beeroot/packages/freetype-2.14.3-0.x86_64.bee.tar.bz2 import argparse import bz2 import gzip +import importlib.util import os import shutil import subprocess @@ -55,6 +67,7 @@ PROGRAM = r""" #include #include #include +#include /* Render the character code for an LCD; true if that left any ink. */ static int inked(FT_Face face, FT_ULong code, FT_Error *error) @@ -173,6 +186,189 @@ static int render_compressed(FT_Library library, const char *path, return failed; } +/* The sample text, the same for every font, version and run. */ +static const char32_t text[] = + U"The quick brown fox jumps over the lazy dog 0123456789 ÄÖÜß éèç ŁŒ"; + +/* How fontconfig and cairo render with hintslight, and monochrome. */ +static const struct { + const char *name; + FT_Int32 load; + FT_Render_Mode render; +} modes[] = { + { "gray", FT_LOAD_TARGET_LIGHT, FT_RENDER_MODE_NORMAL }, + { "lcd", FT_LOAD_TARGET_LIGHT, FT_RENDER_MODE_LCD }, + { "mono", FT_LOAD_TARGET_MONO, FT_RENDER_MODE_MONO }, +}; + +static const int sizes[] = { 10, 13, 16, 24, 48 }; + +#define COUNT(array) (sizeof(array) / sizeof((array)[0])) +#define MARGIN 8 + +struct canvas { + int width, height; + unsigned char *rgb; +}; + +/* + * Darken the canvas by a glyph bitmap, black on white, with one ink value per + * subpixel for LCD bitmaps. Rendered bitmaps flow down, so the pitch is + * positive. + */ +static void draw(struct canvas *canvas, FT_Bitmap *bitmap, int left, int top) +{ + int lcd = bitmap->pixel_mode == FT_PIXEL_MODE_LCD; + unsigned int row, column, c, pixels = lcd ? bitmap->width / 3 : + bitmap->width; + unsigned char ink[3], *line, *pixel; + int x, y; + + for (row = 0; row < bitmap->rows; row++) { + y = top + row; + if (y < 0 || y >= canvas->height) + continue; + line = bitmap->buffer + row * bitmap->pitch; + for (column = 0; column < pixels; column++) { + x = left + column; + if (x < 0 || x >= canvas->width) + continue; + for (c = 0; c < 3; c++) { + if (lcd) + ink[c] = line[3 * column + c]; + else if (bitmap->pixel_mode == FT_PIXEL_MODE_MONO) + ink[c] = line[column >> 3] & + (0x80 >> (column & 7)) ? 255 : 0; + else + ink[c] = line[column]; + } + pixel = canvas->rgb + 3 * (y * canvas->width + x); + for (c = 0; c < 3; c++) + if (255 - ink[c] < pixel[c]) + pixel[c] = 255 - ink[c]; + } + } +} + +/* + * Lay out the text on one line with its baseline at y, glyph by glyph with + * kerning but without shaping, and draw it unless canvas is NULL. Return the + * width the line needs, or 0 with *error set. + */ +static int typeset(FT_Face face, int mode, struct canvas *canvas, int y, + FT_Error *error) +{ + FT_Long pen = 0; + FT_UInt index, previous = 0; + FT_GlyphSlot slot = face->glyph; + FT_Vector kerning; + int i, x, right = 0, ink = 0; + + for (i = 0; text[i]; i++) { + index = FT_Get_Char_Index(face, text[i]); + if (previous && FT_HAS_KERNING(face) && + !FT_Get_Kerning(face, previous, index, FT_KERNING_DEFAULT, + &kerning)) + pen += kerning.x; + *error = FT_Load_Glyph(face, index, modes[mode].load); + if (!*error) + *error = FT_Render_Glyph(slot, modes[mode].render); + if (*error) + return 0; + x = MARGIN + (pen >> 6) + slot->bitmap_left; + if (canvas) + draw(canvas, &slot->bitmap, x, y - slot->bitmap_top); + if (slot->bitmap.width && slot->bitmap.rows) + ink = 1; + x += slot->bitmap.pixel_mode == FT_PIXEL_MODE_LCD ? + slot->bitmap.width / 3 : slot->bitmap.width; + if (x > right) + right = x; + pen += slot->advance.x; + previous = index; + } + if (!ink) { + *error = -1; + return 0; + } + x = MARGIN + (pen >> 6); + return (x > right ? x : right) + MARGIN; +} + +/* + * Render the text in every mode and size to a PPM image, and print the row of + * each. The rows only depend on the size, so the images of two FreeType + * versions line up. + */ +static int sample(FT_Library library, const char *path, const char *output) +{ + struct canvas canvas = { 0 }; + FT_Face face; + FT_Error error; + FILE *file; + int pass, mode, size, count, pixels, y, width; + + error = FT_New_Face(library, path, 0, &face); + if (error) { + printf("sample fail open error %d\n", error); + return 1; + } + count = FT_IS_SCALABLE(face) ? COUNT(sizes) : 1; + for (pass = 0; pass < 2; pass++) { + y = 0; + for (mode = 0; mode < COUNT(modes); mode++) { + for (size = 0; size < count; size++) { + if (FT_IS_SCALABLE(face)) + error = FT_Set_Pixel_Sizes(face, 0, + sizes[size]); + else + error = FT_Select_Size(face, 0); + pixels = face->size->metrics.y_ppem; + width = error ? 0 : typeset(face, mode, + pass ? &canvas : NULL, + y + pixels * 6 / 5 + 2, &error); + if (!width) { + printf("sample fail %s %d px error %d\n", + modes[mode].name, pixels, error); + FT_Done_Face(face); + free(canvas.rgb); + return 1; + } + if (pass) + printf("row %d %d %s %d\n", y, + pixels * 3 / 2 + 4, + modes[mode].name, pixels); + else if (width > canvas.width) + canvas.width = width; + y += pixels * 3 / 2 + 4; + } + } + if (!pass) { + canvas.height = y; + canvas.rgb = malloc(3 * canvas.width * canvas.height); + if (!canvas.rgb) { + FT_Done_Face(face); + return 2; + } + memset(canvas.rgb, 255, 3 * canvas.width * canvas.height); + } + } + FT_Done_Face(face); + + file = fopen(output, "wb"); + if (!file || fprintf(file, "P6\n%d %d\n255\n", canvas.width, + canvas.height) < 0 || + fwrite(canvas.rgb, 3 * canvas.width, canvas.height, file) != + canvas.height || fclose(file)) { + printf("sample fail cannot write %s\n", output); + free(canvas.rgb); + return 1; + } + free(canvas.rgb); + printf("sample ok\n"); + return 0; +} + int main(int argc, char **argv) { static const char *const modules[] = { MODULES, NULL }; @@ -193,6 +389,11 @@ int main(int argc, char **argv) error = FT_Library_SetLcdFilter(library, FT_LCD_FILTER_DEFAULT); printf("lcdfilter %d\n", error); + if (argc == 4 && !strcmp(argv[1], "sample")) { + failed = sample(library, argv[2], argv[3]); + FT_Done_FreeType(library); + return failed; + } for (i = 1; i + 1 < argc; i += 2) { if (strcmp(argv[i], "plain")) { failed |= render_compressed(library, argv[i + 1], @@ -215,23 +416,27 @@ status = {"fail": 0, "warn": 0} def report(state, message): - if state != "ok": + if state in status: status[state] += 1 print("%-5s %s" % (state.upper(), message)) -def environment(): - """The environment for pkg-config and the test program.""" +def environment(tree=None): + """The environment for pkg-config and the test program of FreeType in tree, + the tested one by default.""" + tree = root if tree is None else tree env = dict(os.environ) - if root: + # Changes the rendering, which must only depend on the library. + env.pop("FREETYPE_PROPERTIES", None) + if tree: # freetype2.pc spells out /usr, so --define-variable cannot move it; # the sysroot prefixes every -I and -L it hands out instead. - env["PKG_CONFIG_SYSROOT_DIR"] = root + env["PKG_CONFIG_SYSROOT_DIR"] = tree rest = env.get("PKG_CONFIG_PATH") - env["PKG_CONFIG_PATH"] = root + "/usr/lib/pkgconfig" + \ + env["PKG_CONFIG_PATH"] = tree + "/usr/lib/pkgconfig" + \ (os.pathsep + rest if rest else "") rest = env.get("LD_LIBRARY_PATH") - env["LD_LIBRARY_PATH"] = root + "/usr/lib" + \ + env["LD_LIBRARY_PATH"] = tree + "/usr/lib" + \ (os.pathsep + rest if rest else "") return env @@ -278,7 +483,8 @@ def check_files(): def find_fonts(): - """Return {format: path} with one font file per format on the host.""" + """Return {format: path} with one font file per format on the host, + preferring fonts that can render the German sample text.""" def fc_list(*pattern): try: lines = subprocess.run(["fc-list", "-f", @@ -289,10 +495,11 @@ def find_fonts(): return [] return [line.split("\t", 1) for line in lines if "\t" in line] - files = fc_list() + preferred, files = fc_list(":lang=de"), fc_list() fonts = {} for name in FORMATS: - match = sorted(path for form, path in files if form == name) + match = sorted(path for form, path in preferred if form == name) \ + or sorted(path for form, path in files if form == name) if match: fonts[name] = match[0] else: @@ -300,9 +507,11 @@ def find_fonts(): return fonts -def build(directory): - """Compile the test program against freetype2.pc; return its path.""" - env = environment() +def build(directory, tree=None, state="fail", what=""): + """Compile the test program against freetype2.pc of FreeType in tree, the + tested one by default; return its path. Problems are reported as state, and + what names the FreeType in the messages.""" + env = environment(tree) try: flags = subprocess.run(["pkg-config", "--cflags", "--libs", "freetype2"], text=True, capture_output=True, @@ -311,13 +520,14 @@ def build(directory): text=True, capture_output=True, env=env, check=True).stdout.strip() except (OSError, subprocess.CalledProcessError) as error: - report("fail", "pkg-config freetype2: %s" % error) + report(state, "pkg-config freetype2%s: %s" % (what, error)) return None - report("ok", "freetype2.pc reports %s" % version) + report("ok", "freetype2.pc%s reports %s" % (what, version)) + os.makedirs(directory, exist_ok=True) source = os.path.join(directory, "ft-smoke.c") binary = os.path.join(directory, "ft-smoke") - with open(source, "w") as program: + with open(source, "w", encoding="utf-8") as program: program.write(PROGRAM) modules = ", ".join('"%s"' % name for name in MODULES) compiler = os.environ.get("CC", "cc") @@ -325,10 +535,10 @@ def build(directory): "-DMODULES=" + modules, source, "-o", binary] + flags, text=True, capture_output=True) if result.returncode: - report("fail", "compiling against freetype2.pc failed:\n%s" - % result.stderr) + report(state, "compiling against freetype2.pc%s failed:\n%s" + % (what, result.stderr)) return None - report("ok", "test program compiles against freetype2.pc") + report("ok", "test program compiles against freetype2.pc%s" % what) return binary @@ -393,6 +603,296 @@ def check_fcscan(fonts): "fc-scan %s: %s" % (path, result.stdout or result.stderr.strip())) +def reference_tree(argument, directory): + """Return the root of the FreeType that --reference names: an image + directory, / for the installed one, or a released .bee.tar.bz2 package, + which is unpacked into directory.""" + if not argument.endswith(".tar.bz2"): + return os.path.abspath(argument).rstrip("/") + tree = os.path.join(directory, "reference-package") + os.makedirs(tree, exist_ok=True) + # GNU tar names every member it strips the leading / from on stderr. + subprocess.run(["tar", "-xjf", argument, "-C", tree], check=True, + stderr=subprocess.DEVNULL) + return tree + + +def render_samples(binary, tree, fonts, directory, state): + """Render the sample of every font with the test program built against the + FreeType in tree into directory; return {name: (image, rows, version)}.""" + from PIL import Image + + os.makedirs(directory, exist_ok=True) + samples = {} + for path in fonts.values(): + name = os.path.basename(path) + ppm = os.path.join(directory, name + ".ppm") + result = subprocess.run([binary, "sample", path, ppm], text=True, + capture_output=True, env=environment(tree)) + rows, version, failure = [], None, result.stderr.strip() + for line in result.stdout.splitlines(): + kind, _, rest = line.partition(" ") + if kind == "version": + version = rest.split()[0] + elif kind == "row": + y, height, mode, size = rest.split() + rows.append((int(y), int(height), mode, size)) + elif kind == "sample" and rest != "ok": + failure = rest.partition(" ")[2] + if result.returncode: + report(state, "%s: rendering the sample with FreeType %s failed:" + " %s" % (path, version, failure)) + continue + image = Image.open(ppm).convert("RGB") + image.save(ppm[:-len(".ppm")] + ".png") + os.remove(ppm) + samples[name] = (image, rows, version) + return samples + + +def earlier_samples(directory, names): + """Load the samples of an earlier run from its directory.""" + from PIL import Image + + samples = {} + for name in names: + path = os.path.join(directory, "new", name + ".png") + try: + samples[name] = (Image.open(path).convert("RGB"), None, None) + except OSError as error: + report("warn", "no earlier sample %s: %s" % (path, error)) + return samples + + +class Comparison: + """The sample of one font next to that of a reference, if there is one.""" + + def __init__(self, name, new, rows, label, reference=None, + reference_label=None): + from PIL import Image, ImageChops + + self.name, self.rows = name, rows + self.label, self.reference_label = label, reference_label + if reference is None: + self.new, self.reference, self.differ = new, None, 0 + return + # The versions may need different widths for the same text. + size = (max(new.width, reference.width), + max(new.height, reference.height)) + self.new, self.reference = (Image.new("RGB", size, "white"), + Image.new("RGB", size, "white")) + self.new.paste(new) + self.reference.paste(reference) + red, green, blue = ImageChops.difference(self.new, + self.reference).split() + self.difference = ImageChops.lighter(ImageChops.lighter(red, green), + blue) + self.differ = size[0] * size[1] - self.difference.histogram()[0] + + def percent(self): + return 100.0 * self.differ / (self.new.width * self.new.height) + + def view(self, mode): + """Return the image to show in mode, with the rows labelled.""" + from PIL import Image + + if self.reference is None or mode == "new": + image = self.new + elif mode == "reference": + image = self.reference + elif mode == "overlay": + # Reference ink without new ink is red, the other way round cyan, + # and where both have ink it is gray or black. + image = Image.merge("RGB", (self.new.convert("L"), + self.reference.convert("L"), + self.reference.convert("L"))) + elif mode == "difference": + # Even the smallest difference shows as light gray. + image = self.difference.point( + lambda value: max(0, 200 - value) if value else 255 + ).convert("RGB") + elif mode == "side": + # Above each other, as the lines are wide. + image = Image.new("RGB", (self.new.width, + 2 * self.new.height + MARGIN), "gray") + image.paste(self.labelled(self.reference), (0, 0)) + image.paste(self.labelled(self.new), + (0, self.new.height + MARGIN)) + return image + return self.labelled(image) + + def labelled(self, image): + """Return image with the mode and size of each row in a left margin.""" + from PIL import Image, ImageDraw + + result = Image.new("RGB", (LABELS + image.width, image.height), + "white") + result.paste(image, (LABELS, 0)) + draw = ImageDraw.Draw(result) + for y, height, mode, size in self.rows: + draw.text((4, y + height // 2), "%s %s" % (mode, size), + fill="gray", anchor="lm") + return result + + def save(self, directory): + """Write the comparison images to directory; return their paths.""" + if self.reference is None: + return [] + os.makedirs(directory, exist_ok=True) + paths = [] + for mode in ("overlay", "difference", "side"): + paths.append(os.path.join(directory, "%s-%s-%s.png" + % (self.name, self.reference_tag, mode))) + self.view(mode).save(paths[-1]) + return paths + + +# Width of the margin for the row labels, and the gap between images. +LABELS = 72 +MARGIN = 8 + + +def check_rendering(binary, directory, fonts, arguments): + """Render a sample of every font with the tested FreeType and with the + references; return the comparisons for the viewer.""" + if importlib.util.find_spec("PIL") is None: + report("warn", "skipping the rendered samples (no PIL)") + return [] + new = render_samples(binary, None, fonts, os.path.join(directory, "new"), + "fail") + if not new: + return [] + label = "%s (%s)" % (next(iter(new.values()))[2], root or "/") + + references = [] + tree = None + if arguments.reference: + tree = reference_tree(arguments.reference, directory) + elif root and os.path.exists("/usr/lib/libfreetype.so.6"): + tree = "" + if tree is not None: + what = " of the reference %s" % (tree or "/") + program = build(os.path.join(directory, "reference-program"), tree, + "warn", what) + if program: + samples = render_samples(program, tree, fonts, + os.path.join(directory, "reference"), + "warn") + if samples: + version = next(iter(samples.values()))[2] + references.append(("reference", "%s (%s)" % ( + version, arguments.reference or "/"), samples)) + if arguments.reference_images: + references.append(("earlier", "earlier run %s" + % arguments.reference_images, + earlier_samples(arguments.reference_images, new))) + + comparisons = [] + for name, (image, rows, version) in new.items(): + if not references: + comparisons.append(Comparison(name, image, rows, label)) + report("info", "%s: no reference, sample in %s" + % (name, os.path.join(directory, "new", name + ".png"))) + for tag, reference_label, samples in references: + if name not in samples: + continue + comparison = Comparison(name, image, rows, label, + samples[name][0], reference_label) + comparison.reference_tag = tag + comparison.save(os.path.join(directory, "compare")) + comparisons.append(comparison) + report("info", "%s: %d pixels (%.2f %%) differ from %s" + % (name, comparison.differ, comparison.percent(), + reference_label)) + return comparisons + + +def view(comparisons): + """Show the comparisons in a window until it is closed.""" + import tkinter + from PIL import Image, ImageTk + + modes = {"o": "overlay", "d": "difference", "s": "side", "n": "new", + "r": "reference"} + state = {"index": 0, "mode": "overlay", "zoom": 1, "blink": None} + + window = tkinter.Tk() + status = tkinter.Label(window, anchor="w", justify="left") + status.pack(side="bottom", fill="x") + vertical = tkinter.Scrollbar(window, orient="vertical") + vertical.pack(side="right", fill="y") + horizontal = tkinter.Scrollbar(window, orient="horizontal") + horizontal.pack(side="bottom", fill="x") + canvas = tkinter.Canvas(window, background="white", + xscrollcommand=horizontal.set, + yscrollcommand=vertical.set) + canvas.pack(side="left", fill="both", expand=True) + vertical.config(command=canvas.yview) + horizontal.config(command=canvas.xview) + + def show(): + comparison = comparisons[state["index"]] + image = comparison.view(state["mode"]) + zoom = state["zoom"] + if zoom > 1: + image = image.resize((image.width * zoom, image.height * zoom), + Image.NEAREST) + canvas.image = ImageTk.PhotoImage(image) + canvas.delete("all") + canvas.create_image(0, 0, anchor="nw", image=canvas.image) + canvas.config(scrollregion=(0, 0, image.width, image.height)) + if comparison.reference is None: + versus = "FreeType %s, no reference" % comparison.label + else: + versus = "reference (red) FreeType %s vs. new (cyan) %s, " \ + "%d pixels (%.2f %%) differ" % ( + comparison.reference_label, comparison.label, + comparison.differ, comparison.percent()) + window.title("%s (%d/%d): %s" % (comparison.name, state["index"] + 1, + len(comparisons), state["mode"])) + status.config(text="%s, zoom %dx\n[o]verlay [b]link space: flip " + "[d]ifference [s]ide by side [n]ew [r]eference " + "+/-: zoom left/right: font [q]uit" % (versus, zoom)) + + def blink(): + state["mode"] = "new" if state["mode"] == "reference" else "reference" + show() + state["blink"] = window.after(500, blink) + + def key(event): + if state["blink"]: + window.after_cancel(state["blink"]) + state["blink"] = None + if event.keysym == "b": + return + if event.keysym in modes: + state["mode"] = modes[event.keysym] + elif event.keysym == "b": + blink() + return + elif event.keysym == "space": + state["mode"] = "new" if state["mode"] == "reference" \ + else "reference" + elif event.keysym in ("plus", "equal", "KP_Add"): + state["zoom"] = min(16, state["zoom"] * 2) + elif event.keysym in ("minus", "KP_Subtract"): + state["zoom"] = max(1, state["zoom"] // 2) + elif event.keysym in ("Right", "Left"): + step = 1 if event.keysym == "Right" else -1 + state["index"] = (state["index"] + step) % len(comparisons) + elif event.keysym in ("q", "Escape"): + window.destroy() + return + show() + + window.bind("", key) + first = comparisons[0].view("overlay") + canvas.config(width=min(first.width, window.winfo_screenwidth() - 100), + height=min(first.height, window.winfo_screenheight() - 150)) + show() + window.mainloop() + + def main(): global root @@ -404,6 +904,17 @@ def main(): help="also render FILE, a WOFF2 font for instance") parser.add_argument("--out", metavar="DIR", help="write the test program and fonts to DIR") + parser.add_argument("--reference", metavar="DIR|PACKAGE", + help="compare the rendering with the FreeType in the" + " image directory DIR, / for the installed one," + " or in the .bee.tar.bz2 PACKAGE (default: / with" + " --prefix)") + parser.add_argument("--reference-images", metavar="DIR", + help="also compare the rendering with the --out" + " directory DIR of an earlier run") + parser.add_argument("--no-view", action="store_true", + help="do not show the rendering, even with $DISPLAY" + " set") arguments = parser.parse_args() if arguments.prefix: root = os.path.abspath(arguments.prefix.rstrip("/")) @@ -417,13 +928,21 @@ def main(): directory = arguments.out or tempfile.mkdtemp(prefix="freetype-smoke-") os.makedirs(directory, exist_ok=True) binary = build(directory) + comparisons = [] if binary: check_program(binary, directory, fonts) + comparisons = check_rendering(binary, directory, fonts, arguments) check_fcscan(fonts) print("%s (%d failed, %d warned), files in %s" % ("FAIL" if status["fail"] else "PASS", status["fail"], status["warn"], directory)) + if comparisons and os.environ.get("DISPLAY") and not arguments.no_view: + try: + view(comparisons) + # ImportError without tkinter, tkinter.TclError without a display. + except Exception as error: + print("cannot show the rendering: %s" % error) return 1 if status["fail"] else 0 From 503dee34dd78ce9f90fc4de68e7a6897f3ffa92d Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Tue, 13 Oct 2026 16:21:10 +0200 Subject: [PATCH 06/10] smoke-tests: Show the FreeType test description in --help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script passes `__doc__` as the argparse description, but the text is a comment, so `__doc__` is None and `--help` shows none: $ smoke-tests/freetype-smoke-test --help usage: freetype-smoke-test [-h] [--prefix DIR] [--font FILE] [--out DIR] [--reference DIR|PACKAGE] [--reference-images DIR] [--no-view] options: -h, --help show this help message and exit […] Make the comment the module docstring, raw because of the backslash in the last example, and keep its layout with RawDescriptionHelpFormatter. Assisted-by: claude-opus-5.5 (Claude Code) --- scripts/smoke-tests/freetype-smoke-test | 65 +++++++++++++------------ 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/scripts/smoke-tests/freetype-smoke-test b/scripts/smoke-tests/freetype-smoke-test index 22366ae58..3db32e571 100755 --- a/scripts/smoke-tests/freetype-smoke-test +++ b/scripts/smoke-tests/freetype-smoke-test @@ -1,35 +1,36 @@ #! /usr/bin/env python3 -# Smoke-test an installed FreeType package. -# -# smoke-tests/freetype-smoke-test -# smoke-tests/freetype-smoke-test --prefix .../freetype-2.15.0-0/image -# smoke-tests/freetype-smoke-test --font /usr/share/fonts/foo.woff2 -# -# FreeType's own test suite needs font files that the release tarball does not -# ship, so freetype.be0 is verified here instead: this script checks the -# library for what the recipe expects of it, compiles a small program against -# it through freetype2.pc and runs it on one font of every format the host has. -# -# --prefix takes the image directory of a build instead, so a recipe can also be -# checked before installing it, which is the only way without root. -# -# configure enables zlib, bzip2, libpng, HarfBuzz and Brotli silently when it -# finds them, so a missing dependency drops a feature instead of failing the -# build; the NEEDED entries of the library are checked for that reason. zlib -# and bzip2 are also exercised for real, on compressed copies of a font. -# Fonts that the host does not have are reported as WARN. -# -# Whether glyphs come out right only a human can tell, so every font is also -# rendered to an image -- a fixed text in several sizes, grayscale, LCD and -# monochrome -- and compared with a reference: by default the installed FreeType -# when --prefix is given, or any image directory or released package passed to -# --reference, and the images of an earlier run passed to --reference-images. -# With $DISPLAY set, a viewer shows them laid over each other. Differences do -# not fail the test, as new versions change the rendering on purpose. -# -# smoke-tests/freetype-smoke-test --prefix .../image --reference \ -# /src/mariux/beeroot/packages/freetype-2.14.3-0.x86_64.bee.tar.bz2 +r"""Smoke-test an installed FreeType package. + + smoke-tests/freetype-smoke-test + smoke-tests/freetype-smoke-test --prefix .../freetype-2.15.0-0/image + smoke-tests/freetype-smoke-test --font /usr/share/fonts/foo.woff2 + +FreeType's own test suite needs font files that the release tarball does not +ship, so freetype.be0 is verified here instead: this script checks the +library for what the recipe expects of it, compiles a small program against +it through freetype2.pc and runs it on one font of every format the host has. + +--prefix takes the image directory of a build instead, so a recipe can also be +checked before installing it, which is the only way without root. + +configure enables zlib, bzip2, libpng, HarfBuzz and Brotli silently when it +finds them, so a missing dependency drops a feature instead of failing the +build; the NEEDED entries of the library are checked for that reason. zlib +and bzip2 are also exercised for real, on compressed copies of a font. +Fonts that the host does not have are reported as WARN. + +Whether glyphs come out right only a human can tell, so every font is also +rendered to an image -- a fixed text in several sizes, grayscale, LCD and +monochrome -- and compared with a reference: by default the installed FreeType +when --prefix is given, or any image directory or released package passed to +--reference, and the images of an earlier run passed to --reference-images. +With $DISPLAY set, a viewer shows them laid over each other. Differences do +not fail the test, as new versions change the rendering on purpose. + + smoke-tests/freetype-smoke-test --prefix .../image --reference \ + /src/mariux/beeroot/packages/freetype-2.14.3-0.x86_64.bee.tar.bz2 +""" import argparse import bz2 @@ -896,7 +897,9 @@ def view(comparisons): def main(): global root - parser = argparse.ArgumentParser(description=__doc__) + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--prefix", metavar="DIR", help="test the image directory DIR of a build that is" " not installed") From 85a73776601dd8866df2afd852319dce2f6801b4 Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Tue, 13 Oct 2026 16:23:14 +0200 Subject: [PATCH 07/10] smoke-tests: Fix FreeType test path in the usage examples The script lives in scripts/smoke-tests/, but its usage examples, now also shown by `--help`, start it from smoke-tests/: $ smoke-tests/freetype-smoke-test bash: smoke-tests/freetype-smoke-test: No such file or directory Add the scripts/ prefix, and wrap the one example that grows to 80 characters. Assisted-by: claude-opus-5.5 (Claude Code) --- scripts/smoke-tests/freetype-smoke-test | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/smoke-tests/freetype-smoke-test b/scripts/smoke-tests/freetype-smoke-test index 3db32e571..c9a06c65f 100755 --- a/scripts/smoke-tests/freetype-smoke-test +++ b/scripts/smoke-tests/freetype-smoke-test @@ -2,9 +2,10 @@ r"""Smoke-test an installed FreeType package. - smoke-tests/freetype-smoke-test - smoke-tests/freetype-smoke-test --prefix .../freetype-2.15.0-0/image - smoke-tests/freetype-smoke-test --font /usr/share/fonts/foo.woff2 + scripts/smoke-tests/freetype-smoke-test + scripts/smoke-tests/freetype-smoke-test \ + --prefix .../freetype-2.15.0-0/image + scripts/smoke-tests/freetype-smoke-test --font /usr/share/fonts/foo.woff2 FreeType's own test suite needs font files that the release tarball does not ship, so freetype.be0 is verified here instead: this script checks the @@ -28,7 +29,7 @@ when --prefix is given, or any image directory or released package passed to With $DISPLAY set, a viewer shows them laid over each other. Differences do not fail the test, as new versions change the rendering on purpose. - smoke-tests/freetype-smoke-test --prefix .../image --reference \ + scripts/smoke-tests/freetype-smoke-test --prefix .../image --reference \ /src/mariux/beeroot/packages/freetype-2.14.3-0.x86_64.bee.tar.bz2 """ From 518eca806ee68eb679ac0cd3a475bb4c748be2a7 Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Wed, 14 Oct 2026 16:59:27 +0200 Subject: [PATCH 08/10] smoke-tests: Take FreeType test fonts only from /usr/share/fonts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fc-list also lists the fonts in the home directory of whoever runs the test, and as the candidates are sorted by path, /home/… wins over /usr/…. On *maleficent* the test picked a private Microsoft Sans Serif copy: OK /home/pmenzel/.fonts/WLM-dialog: TrueType Microsoft Sans Serif Regular: U+0067 as 72x37 LCD bitmap Only consider fonts from packages, so the result does not depend on the user; --font still adds any other font. With a font in ~/.fonts of a test home directory, the test now picks the packaged font again: $ HOME=…/fakehome scripts/smoke-tests/freetype-smoke-test […] OK /usr/share/fonts/Fantasque-Sans-Mono/FantasqueSansMono-Bold.ttf: TrueType Fantasque Sans Mono Bold: U+0067 as 69x33 LCD bitmap Assisted-by: claude-opus-5.5 (Claude Code) --- scripts/smoke-tests/freetype-smoke-test | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/smoke-tests/freetype-smoke-test b/scripts/smoke-tests/freetype-smoke-test index c9a06c65f..0996d5fe6 100755 --- a/scripts/smoke-tests/freetype-smoke-test +++ b/scripts/smoke-tests/freetype-smoke-test @@ -10,7 +10,9 @@ r"""Smoke-test an installed FreeType package. FreeType's own test suite needs font files that the release tarball does not ship, so freetype.be0 is verified here instead: this script checks the library for what the recipe expects of it, compiles a small program against -it through freetype2.pc and runs it on one font of every format the host has. +it through freetype2.pc and runs it on one font of every format that the +host has in /usr/share/fonts -- not in $HOME, which would make the result +depend on who runs the test. --prefix takes the image directory of a build instead, so a recipe can also be checked before installing it, which is the only way without root. @@ -19,7 +21,7 @@ configure enables zlib, bzip2, libpng, HarfBuzz and Brotli silently when it finds them, so a missing dependency drops a feature instead of failing the build; the NEEDED entries of the library are checked for that reason. zlib and bzip2 are also exercised for real, on compressed copies of a font. -Fonts that the host does not have are reported as WARN. +Font formats that the host does not have are reported as WARN. Whether glyphs come out right only a human can tell, so every font is also rendered to an image -- a fixed text in several sizes, grayscale, LCD and @@ -57,6 +59,10 @@ MODULES = ["truetype", "type1", "cff", "t1cid", "type42", "pfr", "winfonts", # extension does not tell, .otf files may well have TrueType outlines. FORMATS = ["TrueType", "CFF", "Type 1"] +# Where to look for them: only fonts from packages, so that the result does not +# depend on who runs the test. --font adds others. +FONTS = "/usr/share/fonts/" + PROGRAM = r""" #include #include FT_FREETYPE_H @@ -485,7 +491,7 @@ def check_files(): def find_fonts(): - """Return {format: path} with one font file per format on the host, + """Return {format: path} with one font file per format in FONTS, preferring fonts that can render the German sample text.""" def fc_list(*pattern): try: @@ -495,7 +501,8 @@ def find_fonts(): capture_output=True).stdout.splitlines() except OSError: return [] - return [line.split("\t", 1) for line in lines if "\t" in line] + return [line.split("\t", 1) for line in lines + if "\t" + FONTS in line] preferred, files = fc_list(":lang=de"), fc_list() fonts = {} @@ -505,7 +512,7 @@ def find_fonts(): if match: fonts[name] = match[0] else: - report("warn", "no %s font on this host" % name) + report("warn", "no %s font in %s" % (name, FONTS)) return fonts From 6aeb5b59ec4546ae061d0de5d914b8108c1f928e Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Wed, 14 Oct 2026 17:01:50 +0200 Subject: [PATCH 09/10] smoke-tests: Compare FreeType with the last installed release by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without --prefix, the FreeType smoke test had no reference, so on *maleficent*, which already runs 2.15.0, every view of the viewer showed the same image, and only the terminal said why: $ scripts/smoke-tests/freetype-smoke-test […] INFO WLM-dialog: no reference, sample in /scratch/local/freetype-smoke-3rzwodhm/new/WLM-dialog.png bee only records the installed packages, not the ones they replaced. So when nothing is given, use the installed FreeType if it is older than the tested one, and else the last release before the tested one in the git history of freetype.be0, which is what got installed, unlike some packages in /src/mariux/beeroot/packages (2.12.1 to 2.14.3 were never in git). The test reports the reference and why it was chosen, and the viewer shows it in the status line, as well as why there is none: reference (red): FreeType 2.10.1 (freetype-2.10.1-0, the last release before 2.10.4) new (cyan): FreeType 2.10.4 (installed), 23 pixels (0.00 %) differ, zoom 1x reference: none, the releases are unknown outside a bee-files checkout -- only the new rendering is shown, pass --reference DIR|PACKAGE to compare With 2.15.0 installed and no --prefix, as on *maleficent*, the reference is freetype-2.10.4-1 (checked by replacing header_version), and with --prefix and the installed 2.10.4 it stays the installed one: $ scripts/smoke-tests/freetype-smoke-test --prefix …/freetype-2.15.0-0 […] INFO reference /: the installed one […] PASS (0 failed, 0 warned), files in … Assisted-by: claude-opus-5.5 (Claude Code) --- scripts/smoke-tests/freetype-smoke-test | 129 ++++++++++++++++++++---- 1 file changed, 109 insertions(+), 20 deletions(-) diff --git a/scripts/smoke-tests/freetype-smoke-test b/scripts/smoke-tests/freetype-smoke-test index 0996d5fe6..0999c75f5 100755 --- a/scripts/smoke-tests/freetype-smoke-test +++ b/scripts/smoke-tests/freetype-smoke-test @@ -25,11 +25,13 @@ Font formats that the host does not have are reported as WARN. Whether glyphs come out right only a human can tell, so every font is also rendered to an image -- a fixed text in several sizes, grayscale, LCD and -monochrome -- and compared with a reference: by default the installed FreeType -when --prefix is given, or any image directory or released package passed to ---reference, and the images of an earlier run passed to --reference-images. -With $DISPLAY set, a viewer shows them laid over each other. Differences do -not fail the test, as new versions change the rendering on purpose. +monochrome -- and compared with a reference: any image directory or released +package passed to --reference, and the images of an earlier run passed to +--reference-images. By default, the reference is the last installed release: +the installed FreeType if it is older than the tested one, and else the last +release before the tested one in the git history of freetype.be0. With +$DISPLAY set, a viewer shows them laid over each other. Differences do not +fail the test, as new versions change the rendering on purpose. scripts/smoke-tests/freetype-smoke-test --prefix .../image --reference \ /src/mariux/beeroot/packages/freetype-2.14.3-0.x86_64.bee.tar.bz2 @@ -63,6 +65,9 @@ FORMATS = ["TrueType", "CFF", "Type 1"] # depend on who runs the test. --font adds others. FONTS = "/usr/share/fonts/" +# The released packages, for the default reference. +PACKAGES = "/src/mariux/beeroot/packages" + PROGRAM = r""" #include #include FT_FREETYPE_H @@ -612,6 +617,84 @@ def check_fcscan(fonts): "fc-scan %s: %s" % (path, result.stdout or result.stderr.strip())) +def header_version(tree): + """Return the version of the FreeType headers in tree as a tuple, or + None.""" + numbers = {} + path = tree + "/usr/include/freetype2/freetype/freetype.h" + try: + with open(path) as header: + for line in header: + words = line.split() + if len(words) == 3 and words[0] == "#define" and \ + words[1] in ("FREETYPE_MAJOR", "FREETYPE_MINOR", + "FREETYPE_PATCH"): + numbers[words[1]] = int(words[2]) + except (OSError, ValueError): + return None + if len(numbers) != 3: + return None + return (numbers["FREETYPE_MAJOR"], numbers["FREETYPE_MINOR"], + numbers["FREETYPE_PATCH"]) + + +def releases(): + """Return the package names of freetype.be0, freetype-2.10.4-1 for + instance, newest first, from the history of the bee-files checkout this + script is in, or None outside of one. That is what got installed, unlike + some packages in PACKAGES.""" + repository = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.realpath(__file__)))) + try: + commits = subprocess.run(["git", "-C", repository, "log", + "--format=%H", "--", "freetype.be0"], + text=True, capture_output=True, + check=True).stdout.split() + except (OSError, subprocess.CalledProcessError): + return None + names = [] + for commit in commits: + recipe = subprocess.run(["git", "-C", repository, "show", + commit + ":freetype.be0"], text=True, + capture_output=True).stdout + for line in recipe.splitlines(): + if line.startswith("# BEE_VERSION "): + name = line.split()[2] + if name not in names: + names.append(name) + break + return names + + +def default_reference(): + """Return the --reference argument to use when none is given, and why, or + (None, why not): the installed FreeType when it is older than the tested + one, and else the last release before the tested one.""" + tested = header_version(root) + if tested is None: + return None, "the version of the tested FreeType is unknown" + installed = header_version("") if root else None + if installed and installed < tested: + return "/", "the installed one" + text = ".".join(map(str, tested)) + names = releases() + if names is None: + return None, "the releases are unknown outside a bee-files checkout" + for name in names: + try: + version = tuple(int(number) + for number in name.split("-")[1].split(".")) + except (IndexError, ValueError): + continue + if version < tested: + package = os.path.join(PACKAGES, name + ".x86_64.bee.tar.bz2") + if not os.path.exists(package): + return None, "%s, the last release before %s, is missing" \ + % (package, text) + return package, "%s, the last release before %s" % (name, text) + return None, "no release before %s in the history of freetype.be0" % text + + def reference_tree(argument, directory): """Return the root of the FreeType that --reference names: an image directory, / for the installed one, or a released .bee.tar.bz2 package, @@ -771,15 +854,17 @@ def check_rendering(binary, directory, fonts, arguments): "fail") if not new: return [] - label = "%s (%s)" % (next(iter(new.values()))[2], root or "/") + label = "%s (%s)" % (next(iter(new.values()))[2], root or "installed") references = [] - tree = None - if arguments.reference: - tree = reference_tree(arguments.reference, directory) - elif root and os.path.exists("/usr/lib/libfreetype.so.6"): - tree = "" - if tree is not None: + reference = arguments.reference + why = "--reference %s" % reference + if not reference: + reference, why = default_reference() + report("info" if reference else "warn", "reference %s: %s" + % (reference or "none", why)) + if reference: + tree = reference_tree(reference, directory) what = " of the reference %s" % (tree or "/") program = build(os.path.join(directory, "reference-program"), tree, "warn", what) @@ -789,8 +874,8 @@ def check_rendering(binary, directory, fonts, arguments): "warn") if samples: version = next(iter(samples.values()))[2] - references.append(("reference", "%s (%s)" % ( - version, arguments.reference or "/"), samples)) + references.append(("reference", "%s (%s)" % (version, why), + samples)) if arguments.reference_images: references.append(("earlier", "earlier run %s" % arguments.reference_images, @@ -799,7 +884,8 @@ def check_rendering(binary, directory, fonts, arguments): comparisons = [] for name, (image, rows, version) in new.items(): if not references: - comparisons.append(Comparison(name, image, rows, label)) + comparisons.append(Comparison(name, image, rows, label, + reference_label=why)) report("info", "%s: no reference, sample in %s" % (name, os.path.join(directory, "new", name + ".png"))) for tag, reference_label, samples in references: @@ -851,10 +937,13 @@ def view(comparisons): canvas.create_image(0, 0, anchor="nw", image=canvas.image) canvas.config(scrollregion=(0, 0, image.width, image.height)) if comparison.reference is None: - versus = "FreeType %s, no reference" % comparison.label + versus = "reference: none, %s -- only the new rendering is " \ + "shown, pass --reference DIR|PACKAGE to compare\n" \ + "new: FreeType %s" % (comparison.reference_label, + comparison.label) else: - versus = "reference (red) FreeType %s vs. new (cyan) %s, " \ - "%d pixels (%.2f %%) differ" % ( + versus = "reference (red): FreeType %s\n" \ + "new (cyan): FreeType %s, %d pixels (%.2f %%) differ" % ( comparison.reference_label, comparison.label, comparison.differ, comparison.percent()) window.title("%s (%d/%d): %s" % (comparison.name, state["index"] + 1, @@ -918,8 +1007,8 @@ def main(): parser.add_argument("--reference", metavar="DIR|PACKAGE", help="compare the rendering with the FreeType in the" " image directory DIR, / for the installed one," - " or in the .bee.tar.bz2 PACKAGE (default: / with" - " --prefix)") + " or in the .bee.tar.bz2 PACKAGE (default: the" + " last installed release)") parser.add_argument("--reference-images", metavar="DIR", help="also compare the rendering with the --out" " directory DIR of an earlier run") From 780048ce569f10cbb0ce5ac9a3aeed751345da8b Mon Sep 17 00:00:00 2001 From: Paul Menzel Date: Wed, 14 Oct 2026 17:03:50 +0200 Subject: [PATCH 10/10] smoke-tests: Rename the FreeType side-by-side view to stacked The `s` view of the FreeType smoke test viewer never put the renderings side by side, as a 48 px line is already about 1700 px wide, but the whole reference image above the whole new one. That needs scrolling down a full image to see the second, and as the images were pasted with their label margin into a canvas without it, the last 72 pixels of every line were cut off. Call it stacked, and stack row by row instead: every row of the reference, labelled `ref`, right above the same row of the new rendering, labelled `new`, so the pairs are on the first screen. The key help says so: [o]verlay [b]link space: flip [d]ifference [s]tacked (reference above new) [n]ew [r]eference +/-: zoom left/right: font [q]uit The written images are named *-stacked.png now, and are as wide as the overlay ones (1812 px for Cantarell-Bold.otf) and twice as high. Assisted-by: claude-opus-5.5 (Claude Code) --- scripts/smoke-tests/freetype-smoke-test | 39 +++++++++++++++---------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/scripts/smoke-tests/freetype-smoke-test b/scripts/smoke-tests/freetype-smoke-test index 0999c75f5..b7158a0cf 100755 --- a/scripts/smoke-tests/freetype-smoke-test +++ b/scripts/smoke-tests/freetype-smoke-test @@ -803,17 +803,24 @@ class Comparison: image = self.difference.point( lambda value: max(0, 200 - value) if value else 255 ).convert("RGB") - elif mode == "side": - # Above each other, as the lines are wide. - image = Image.new("RGB", (self.new.width, - 2 * self.new.height + MARGIN), "gray") - image.paste(self.labelled(self.reference), (0, 0)) - image.paste(self.labelled(self.new), - (0, self.new.height + MARGIN)) - return image + elif mode == "stacked": + # Every row of the reference right above the same row of the new + # rendering, as whole images above each other only fit on a screen + # with scrolling. + image = Image.new("RGB", (self.new.width, 2 * self.new.height), + "white") + rows = [] + for y, height, kind, size in self.rows: + for number, (source, name) in enumerate( + ((self.reference, "ref"), (self.new, "new"))): + top = 2 * y + number * height + image.paste(source.crop((0, y, source.width, y + height)), + (0, top)) + rows.append((top, height, kind, "%s %s" % (size, name))) + return self.labelled(image, rows) return self.labelled(image) - def labelled(self, image): + def labelled(self, image, rows=None): """Return image with the mode and size of each row in a left margin.""" from PIL import Image, ImageDraw @@ -821,7 +828,7 @@ class Comparison: "white") result.paste(image, (LABELS, 0)) draw = ImageDraw.Draw(result) - for y, height, mode, size in self.rows: + for y, height, mode, size in rows or self.rows: draw.text((4, y + height // 2), "%s %s" % (mode, size), fill="gray", anchor="lm") return result @@ -832,16 +839,15 @@ class Comparison: return [] os.makedirs(directory, exist_ok=True) paths = [] - for mode in ("overlay", "difference", "side"): + for mode in ("overlay", "difference", "stacked"): paths.append(os.path.join(directory, "%s-%s-%s.png" % (self.name, self.reference_tag, mode))) self.view(mode).save(paths[-1]) return paths -# Width of the margin for the row labels, and the gap between images. +# Width of the margin for the row labels. LABELS = 72 -MARGIN = 8 def check_rendering(binary, directory, fonts, arguments): @@ -907,7 +913,7 @@ def view(comparisons): import tkinter from PIL import Image, ImageTk - modes = {"o": "overlay", "d": "difference", "s": "side", "n": "new", + modes = {"o": "overlay", "d": "difference", "s": "stacked", "n": "new", "r": "reference"} state = {"index": 0, "mode": "overlay", "zoom": 1, "blink": None} @@ -949,8 +955,9 @@ def view(comparisons): window.title("%s (%d/%d): %s" % (comparison.name, state["index"] + 1, len(comparisons), state["mode"])) status.config(text="%s, zoom %dx\n[o]verlay [b]link space: flip " - "[d]ifference [s]ide by side [n]ew [r]eference " - "+/-: zoom left/right: font [q]uit" % (versus, zoom)) + "[d]ifference [s]tacked (reference above new) " + "[n]ew [r]eference +/-: zoom left/right: font " + "[q]uit" % (versus, zoom)) def blink(): state["mode"] = "new" if state["mode"] == "reference" else "reference"