diff --git a/freetype.be0 b/freetype.be0 index 1fe78c0d3..f90779309 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+=() @@ -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 } diff --git a/scripts/smoke-tests/freetype-smoke-test b/scripts/smoke-tests/freetype-smoke-test new file mode 100755 index 000000000..b7158a0cf --- /dev/null +++ b/scripts/smoke-tests/freetype-smoke-test @@ -0,0 +1,1057 @@ +#! /usr/bin/env python3 + +r"""Smoke-test an installed FreeType package. + + 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 +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 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. + +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. +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: 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 +""" + +import argparse +import bz2 +import gzip +import importlib.util +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, as fontconfig names them; the file name +# 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/" + +# The released packages, for the default reference. +PACKAGES = "/src/mariux/beeroot/packages" + +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 +#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; +} + +/* 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 }; + 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); + + 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], + 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 in status: + status[state] += 1 + print("%-5s %s" % (state.upper(), message)) + + +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) + # 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"] = tree + rest = env.get("PKG_CONFIG_PATH") + env["PKG_CONFIG_PATH"] = tree + "/usr/lib/pkgconfig" + \ + (os.pathsep + rest if rest else "") + rest = env.get("LD_LIBRARY_PATH") + env["LD_LIBRARY_PATH"] = tree + "/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 in FONTS, + preferring fonts that can render the German sample text.""" + 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" + FONTS in line] + + preferred, files = fc_list(":lang=de"), fc_list() + fonts = {} + for name in FORMATS: + 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: + report("warn", "no %s font in %s" % (name, FONTS)) + return fonts + + +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, + 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(state, "pkg-config freetype2%s: %s" % (what, error)) + return None + 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", encoding="utf-8") 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(state, "compiling against freetype2.pc%s failed:\n%s" + % (what, result.stderr)) + return None + report("ok", "test program compiles against freetype2.pc%s" % what) + 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 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, + 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 == "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, rows=None): + """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 rows or 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", "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. +LABELS = 72 + + +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 "installed") + + references = [] + 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) + 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, why), + 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, + 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: + 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": "stacked", "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 = "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\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, + len(comparisons), state["mode"])) + status.config(text="%s, zoom %dx\n[o]verlay [b]link space: flip " + "[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" + 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 + + 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") + 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") + 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: 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") + 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("/")) + 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) + 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 + + +if __name__ == "__main__": + sys.exit(main())