diff --git a/harfrust_capi/SUBSETTING.md b/harfrust_capi/SUBSETTING.md index e8f4d07d..5ebf1374 100644 --- a/harfrust_capi/SUBSETTING.md +++ b/harfrust_capi/SUBSETTING.md @@ -90,3 +90,41 @@ optimization are not implemented by the dependency and remain unsupported. `tests/subset/compare_controls_hb.py` takes the same library arguments and checks 15 additional cases, input-set defaults, keep-everything, inverted sets, custom table passthrough, overlap flags, and preprocessing ownership. + +### Actual Skia runtime checks + +`tests/subset/compare_skia_runtime.py` compiles Skia's actual HarfBuzz shaper and PDF +subsetter twice, using either HarfBuzz or the HarfRust compatibility headers. +It requires a matching Linux GN static Skia build with PDF, FreeType, ICU, +and HarfBuzz enabled, plus fontTools and Poppler's `pdffonts`, `pdftotext`, and +`pdftoppm`. Apply [Skia change 1387436](https://skia-review.googlesource.com/c/skia/+/1387436) +to remove the UPEM setter first. Build the GN targets `skia`, +`modules/skshaper:skshaper`, and `modules/skunicode:skunicode_icu`. + +```sh +python3 harfrust_capi/tests/subset/compare_skia_runtime.py \ + --skia /path/to/patched/skia --skia-build /path/to/skia/out/Runtime \ + --hb-source /path/to/harfbuzz --hb-build /path/to/harfbuzz/build \ + --hr "$PWD/target/debug/libharfrust_c.so" \ + --output /path/to/runtime-results +``` + +Eight cases cover three fonts, stream and table-callback access, and a +nondefault variable instance. Each checks four font size/horizontal scale +combinations, glyph IDs, clusters, positions, both glyph-0 subset policies, +hint programs, retained GIDs, and actual PDF embedding, text extraction, +and raster output. Skia's variable-font PDFs use its Type3 fallback. + +The callback proxy disables both stream methods for shaping and direct +subsetting. During PDF creation it supplies the stream required by Skia's +font-descriptor code; the PDF subsetter still uses table callbacks because +`openExistingStream` remains unavailable. + +The nondefault Linefont instance exposes fractional GPOS positioning drift +of about 0.00024 pixels and PDF raster differences. Glyph/cluster checks stay +exact; variable positions allow an accumulated 16.16 position unit per glyph. +Pixel comparisons fail by default. `--allow-variable-pixel-differences` +acknowledges and reports those two known raster exceptions while checking +the other cases. Optional `--hb-upem-shaper /path/to/original/SkShaper_harfbuzz.cpp` +also checks HarfBuzz's original setter against the removal, with strict +position and pixel comparisons for all eight cases. diff --git a/harfrust_capi/tests/subset/compare_skia_runtime.py b/harfrust_capi/tests/subset/compare_skia_runtime.py new file mode 100644 index 00000000..91668dd4 --- /dev/null +++ b/harfrust_capi/tests/subset/compare_skia_runtime.py @@ -0,0 +1,179 @@ +"""Run Skia's real shaper and PDF subsetter against HarfBuzz and HarfRust. + +Requires a matching Linux GN static Skia build with FreeType, ICU, HarfBuzz, +and PDF enabled, plus fontTools and Poppler's pdffonts/pdftotext/pdftoppm. The Skia +checkout must include removal of hb_face_set_upem (Skia change 1387436). +This script builds consumers in its output directory without modifying Skia. +""" + +import argparse +from collections import Counter +import math +import re +import shlex +import subprocess +import unicodedata +from pathlib import Path + +from fontTools.ttLib import TTFont + +from compare_skia_hb import compare + +ROOT = Path(__file__).resolve().parents[3] + + +def run(command): + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode: + print(result.stderr, end="") + result.check_returncode() + return result.stdout + + +def build(args, backend): + includes = [args.hb_build / "src", args.hb_source / "src"] + if backend == "hr": + includes = [args.output / "include", ROOT / "harfrust_capi/include"] + shaper = args.skia / "modules/skshaper/src/SkShaper_harfbuzz.cpp" + if backend == "hb-upem": + shaper = args.hb_upem_shaper + objects = [] + for name, source, ninja in [ + ("shaper", shaper, + args.skia_build / "obj/modules/skshaper/skshaper.ninja"), + ("pdf", args.skia / "src/pdf/SkPDFSubsetFont.cpp", args.skia_build / "obj/pdf.ninja"), + ("consumer", Path(__file__).with_name("skia_runtime.cc"), + args.skia_build / "obj/modules/skshaper/skshaper.ninja"), + ]: + defines = next(line.split("=", 1)[1] for line in ninja.read_text().splitlines() + if line.startswith("defines =")) + obj = args.output / f"{backend}-{name}.o" + run([args.compiler, "-std=c++20", "-O1", "-fno-rtti", "-fno-exceptions", + "-Wno-attributes", *shlex.split(defines), + *["-I" + str(p) for p in includes], "-I" + str(args.skia), + "-c", str(source), "-o", str(obj)]) + objects.append(str(obj)) + if backend != "hr": + libraries = [str(args.hb_build / "src/libharfbuzz-subset.so"), + str(args.hb_build / "src/libharfbuzz.so")] + else: + libraries = [str(args.hr)] + rpaths = sorted({str(Path(lib).parent) for lib in libraries}) + executable = args.output / ("skia-" + backend) + run([args.compiler, "-Wl,--gc-sections", "-Wl,--start-group", *objects, + *[str(args.skia_build / name) for name in ["libskia.a", "libskshaper.a", + "libskunicode_core.a", "libskunicode_icu.a"]], + "-Wl,--end-group", *libraries, *["-Wl,-rpath," + p for p in rpaths], + "-lpthread", "-lfontconfig", "-ldl", "-lfreetype", "-licuuc", "-licui18n", + "-lz", "-o", str(executable)]) + print(f"Built actual Skia {backend} consumer", flush=True) + return executable + + +def compare_shaping(actual, expected, variable=False): + actual, expected = actual.splitlines(), expected.splitlines() + assert actual and len(actual) == len(expected) + tolerance = 1e-5 + for a, b in zip(actual, expected): + a, b = a.split(), b.split() + assert len(a) == len(b) + integer_fields = 4 if a[0] == "run" else 3 + if a[0] == "run": + # At wdth=150/wght=800, fractional GPOS variation deltas differ + # by a 16.16 position unit. Bound accumulated drift to one unit + # per glyph, including the largest horizontal scale tested. + tolerance = int(a[3]) * 1.75 / 65536 + 1e-5 if variable else 1e-5 + assert a[:integer_fields] == b[:integer_fields], (a, b) + for av, bv in zip(a[integer_fields:], b[integer_fields:]): + assert math.isclose(float(av), float(bv), rel_tol=0, abs_tol=tolerance), (a, b) + + +def font_result(path): + font = TTFont(path) + names = [font.getBestCmap()[ord(c)] for c in "ac"] + return font, [font.getGlyphID(name) for name in names], [font["hmtx"][name][0] for name in names] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ["skia", "skia-build", "hb-source", "hb-build", "hr", "output"]: + parser.add_argument("--" + name, type=lambda p: Path(p).resolve(), required=True) + parser.add_argument("--compiler", default="c++") + parser.add_argument("--allow-variable-pixel-differences", action="store_true", + help="Acknowledge the known fractional GPOS variation raster difference") + parser.add_argument("--hb-upem-shaper", type=lambda p: Path(p).resolve(), + help="Optional original Skia shaper source to validate UPEM removal") + args = parser.parse_args() + shaper_source = args.skia / "modules/skshaper/src/SkShaper_harfbuzz.cpp" + assert "hb_face_set_upem(" not in shaper_source.read_text(), "Apply Skia change 1387436 first" + args.output.mkdir(parents=True, exist_ok=True) + include = args.output / "include" + include.mkdir(exist_ok=True) + for name, target in [("hb.h", "hr-hb.h"), ("hb-ot.h", "hr-hb-ot.h"), + ("hb-subset.h", "hr-hb-subset.h")]: + (include / name).write_text('#include "' + target + '"\n') + backends = ["hb", "hr"] + if args.hb_upem_shaper: + assert "hb_face_set_upem(" in args.hb_upem_shaper.read_text() + backends.append("hb-upem") + executables = {backend: build(args, backend) for backend in backends} + fixtures = ROOT / "harfrust/tests/fonts/rb_custom" + cases = 0 + pixel_differences = [] + for name in ["PT_Sans-Caption-Web-Regular.ttf", "LaBelleAurore.ttf", "Linefont.ttf"]: + path = fixtures / name + for mode in ["stream", "callback"]: + for instance in (["default", "variable"] if name == "Linefont.ttf" else ["default"]): + label = f"{path.stem}-{mode}-{instance}" + outputs = {} + for backend, executable in executables.items(): + prefix = args.output / f"{label}-{backend}" + outputs[backend] = (prefix, run([str(executable), str(path), str(prefix), mode, instance])) + compare_shaping(outputs["hr"][1], outputs["hb"][1], instance == "variable") + if "hb-upem" in outputs: + compare_shaping(outputs["hb"][1], outputs["hb-upem"][1]) + for suffix, flags in [(".ttf", 2), (".zero.ttf", 66)]: + compare(font_result(str(outputs["hr"][0]) + suffix), + font_result(str(outputs["hb"][0]) + suffix), TTFont(path), flags) + extracted, embeddings, images = [], [], {} + for backend, (prefix, _) in outputs.items(): + pdf = str(prefix) + ".pdf" + fonts = run(["pdffonts", pdf]).splitlines()[2:] + assert fonts + statuses = [re.search(r"(yes|no)\s+(yes|no)\s+(yes|no)\s+\d+\s+\d+$", row) + for row in fonts] + assert all(statuses), fonts + expected = ("yes", "yes", "yes") + if name == "Linefont.ttf": + assert all("Type 3" in row for row in fonts), fonts + assert all(status.groups() == expected for status in statuses), fonts + embeddings.append([status.groups() for status in statuses]) + extracted.append(run(["pdftotext", pdf, "-"])) + image = str(prefix) + "-render" + run(["pdftoppm", "-singlefile", "-r", "144", pdf, image]) + images[backend] = Path(image + ".ppm").read_bytes() + assert all(text == extracted[0] for text in extracted), extracted + text = unicodedata.normalize("NFC", "".join(c for c in extracted[0] if not c.isspace())) + expected_text = unicodedata.normalize("NFC", "officecaf\u00e9a\u0301ac") + assert Counter(text) == Counter(expected_text), extracted + assert all(status == embeddings[0] for status in embeddings), embeddings + if "hb-upem" in images: + assert images["hb-upem"] == images["hb"], "UPEM removal changed pixels: " + label + if images["hr"] != images["hb"]: + assert instance == "variable", "Unexpected raster difference: " + label + hb_pixels = images["hb"].split(b"\n", 3)[3] + hr_pixels = images["hr"].split(b"\n", 3)[3] + assert len(hb_pixels) == len(hr_pixels) + changed = sum(hb_pixels[i:i + 3] != hr_pixels[i:i + 3] + for i in range(0, len(hb_pixels), 3)) + pixel_differences.append(label) + print(f"Known variable GPOS raster difference: {label} ({changed} pixels)", flush=True) + cases += 1 + print(f"Checked shaping, glyph-0 subsets and PDF: {label}", flush=True) + print(f"{cases} actual Skia cases checked (four shaping sizes/scales, two subsets and a PDF each)") + print(f"HarfRust PDF pixel mismatches: {len(pixel_differences)}") + assert not pixel_differences or args.allow_variable_pixel_differences, pixel_differences + + +if __name__ == "__main__": + main() diff --git a/harfrust_capi/tests/subset/repro_variable_gpos.py b/harfrust_capi/tests/subset/repro_variable_gpos.py new file mode 100644 index 00000000..36f79e94 --- /dev/null +++ b/harfrust_capi/tests/subset/repro_variable_gpos.py @@ -0,0 +1,47 @@ +"""Print the fractional GPOS advance mismatch without Skia or subsetting. + +Usage: python3 repro_variable_gpos.py HR_CORE HB_CORE +""" +import ctypes as c +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +P, U, I = c.c_void_p, c.c_uint, c.c_int +class Var(c.Structure): + _fields_ = [('tag', U), ('value', c.c_float)] +class Pos(c.Structure): + _fields_ = [('x_advance', I), ('y_advance', I), ('x_offset', I), ('y_offset', I), ('private', U)] +ADV = c.CFUNCTYPE(I, P, P, U, P) +@ADV +def zero(*_): return 0 +for path, prefix in [(sys.argv[1], 'hr_'), (sys.argv[2], 'hb_')]: + lib = c.CDLL(path) + def call(name, result, args, *values): + fn = getattr(lib, prefix + name) + fn.restype, fn.argtypes = result, args + return fn(*values) + blob = call('blob_create_from_file', P, [c.c_char_p], str(ROOT / 'harfrust/tests/fonts/rb_custom/Linefont.ttf').encode()) + face = call('face_create', P, [P, U], blob, 0) + parent = call('font_create', P, [P], face) + settings = (Var * 2)(Var(int.from_bytes(b'wdth', 'big'), 150), Var(int.from_bytes(b'wght', 'big'), 800)) + call('font_set_variations', None, [P, c.POINTER(Var), U], parent, settings, 2) + font = call('font_create_sub_font', P, [P], parent) + funcs = call('font_funcs_create', P, []) + call('font_funcs_set_glyph_h_advance_func', None, [P, ADV, P, P], funcs, zero, None, None) + call('font_set_funcs', None, [P, P, P, P], font, funcs, None, None) + coords_n = U() + coords = call('font_get_var_coords_normalized', c.POINTER(I), [P, c.POINTER(U)], font, c.byref(coords_n)) + print(prefix, 'coords', list(coords[:coords_n.value])) + for size in [12, 36]: + call('font_set_scale', None, [P, I, I], font, size * 65536, size * 65536) + buf = call('buffer_create', P, []) + call('buffer_add_utf8', None, [P, c.c_char_p, I, U, I], buf, b'office', 6, 0, 6) + call('buffer_guess_segment_properties', None, [P], buf) + call('shape', None, [P, P, P, U], font, buf, None, 0) + length = U() + positions = call('buffer_get_glyph_positions', c.POINTER(Pos), [P, c.POINTER(U)], buf, c.byref(length)) + print(size, [(p.x_advance, p.x_offset, p.y_offset) for p in positions[:length.value]]) + call('buffer_destroy', None, [P], buf) + for name, obj in [('font_funcs', funcs), ('font', font), ('font', parent), ('face', face), ('blob', blob)]: + call(name + '_destroy', None, [P], obj) diff --git a/harfrust_capi/tests/subset/skia_runtime.cc b/harfrust_capi/tests/subset/skia_runtime.cc new file mode 100644 index 00000000..44bd2678 --- /dev/null +++ b/harfrust_capi/tests/subset/skia_runtime.cc @@ -0,0 +1,170 @@ +// Compile with Skia's actual HarfBuzz shaper and PDF subsetter, substituting +// HarfRust compatibility headers for the second executable. +#include "include/core/SkCanvas.h" +#include "include/core/SkData.h" +#include "include/core/SkDocument.h" +#include "include/core/SkFont.h" +#include "include/core/SkFontArguments.h" +#include "include/core/SkFontMgr.h" +#include "include/core/SkPaint.h" +#include "include/core/SkStream.h" +#include "include/docs/SkPDFDocument.h" +#include "include/ports/SkFontMgr_empty.h" +#include "modules/skshaper/include/SkShaper_harfbuzz.h" +#include "modules/skunicode/include/SkUnicode_icu.h" +#include "src/pdf/SkPDFGlyphUse.h" +#include "src/pdf/SkPDFSubsetFont.h" +#include "src/ports/SkTypeface_proxy.h" + +// Keep Skia headers in the prebuilt libraries' release configuration, but +// always enable the consumer checks. +#undef NDEBUG +#include +#include +#include +#include +#include + +class TableTypeface final : public SkTypeface_proxy { +public: + explicit TableTypeface(sk_sp face) + : SkTypeface_proxy(face, face->fontStyle(), face->isFixedPitch()) {} + mutable unsigned tableReads = 0; + mutable unsigned tagReads = 0; + mutable unsigned charMaps = 0; + bool allowPdfStream = false; + +protected: + std::unique_ptr onOpenStream(int* index) const override { + return allowPdfStream ? SkTypeface_proxy::onOpenStream(index) : nullptr; + } + std::unique_ptr onOpenExistingStream(int*) const override { return nullptr; } + int onGetTableTags(SkSpan tags) const override { + ++tagReads; + return SkTypeface_proxy::onGetTableTags(tags); + } + size_t onGetTableData(SkFontTableTag tag, size_t offset, size_t size, void* data) const override { + ++tableReads; + return SkTypeface_proxy::onGetTableData(tag, offset, size, data); + } + void onCharsToGlyphs(SkSpan chars, SkSpan glyphs) const override { + ++charMaps; + SkTypeface_proxy::onCharsToGlyphs(chars, glyphs); + } +}; + +class RecordingHandler final : public SkShaper::RunHandler { +public: + explicit RecordingHandler(const char* text) : blob(text, {0, 0}) {} + SkTextBlobBuilderRunHandler blob; + + void beginLine() override { blob.beginLine(); } + void runInfo(const RunInfo& info) override { blob.runInfo(info); } + void commitRunInfo() override { blob.commitRunInfo(); } + Buffer runBuffer(const RunInfo& info) override { + buffer = blob.runBuffer(info); + assert(buffer.glyphs && buffer.positions && buffer.clusters); + return buffer; + } + void commitRunBuffer(const RunInfo& info) override { + std::printf("run %u %u %zu %.6f %.6f\n", info.fBidiLevel, info.fScript, + info.glyphCount, info.fAdvance.x(), info.fAdvance.y()); + for (size_t i = 0; i < info.glyphCount; ++i) { + std::printf("glyph %u %u %.6f %.6f\n", buffer.glyphs[i], buffer.clusters[i], + buffer.positions[i].x(), buffer.positions[i].y()); + } + blob.commitRunBuffer(info); + } + void commitLine() override { blob.commitLine(); } + +private: + Buffer buffer{}; +}; + +static void save(const std::string& path, const SkData& data) { + SkFILEWStream file(path.c_str()); + assert(file.isValid() && file.write(data.data(), data.size())); +} + +int main(int argc, char** argv) { + assert(argc == 5); + const char* fontPath = argv[1]; + const std::string output = argv[2]; + const bool callback = std::strcmp(argv[3], "callback") == 0; + const bool variable = std::strcmp(argv[4], "variable") == 0; + auto manager = SkFontMgr_New_Custom_Empty(); + assert(manager); + auto face = manager->makeFromFile(fontPath); + assert(face && face->countGlyphs()); + if (variable) { + SkFontArguments::VariationPosition::Coordinate axes[] = { + {SkSetFourByteTag('w', 'd', 't', 'h'), 150}, + {SkSetFourByteTag('w', 'g', 'h', 't'), 800}, + }; + SkFontArguments args; + args.setVariationDesignPosition({axes, 2}); + face = face->makeClone(args); + assert(face); + } + sk_sp proxy; + if (callback) { + proxy = sk_make_sp(face); + face = proxy; + } + auto shaper = SkShapers::HB::ShapeThenWrap(SkUnicodes::ICU::Make(), manager); + assert(shaper && SkPDFCanSubsetTableBasedFonts()); + const char* text = "office caf\xC3\xA9 a\xCC\x81 ac"; + for (float size : {12.f, 36.f}) { + for (float scale : {1.f, 1.75f}) { + SkFont font(face, size); + font.setScaleX(scale); + RecordingHandler handler(text); + shaper->shape(text, std::strlen(text), font, true, 180.f, &handler); + auto blob = handler.blob.makeBlob(); + assert(blob); + if (size == 36.f && scale == 1.f) { + // PDF font-descriptor creation needs the complete font stream. + // Its subsetter still takes table callbacks because existing + // streams remain unavailable. Shaping and direct subset tests + // run with both stream methods disabled. + if (proxy) proxy->allowPdfStream = true; + SkFILEWStream file((output + ".pdf").c_str()); + assert(file.isValid()); + SkPDF::Metadata metadata; + metadata.allowNoJpegs = true; // This document contains only text. + auto document = SkPDF::MakeDocument(&file, metadata); + assert(document); + auto canvas = document->beginPage(300, 300); + SkPaint paint; + canvas->drawTextBlob(blob, 20, 60, paint); + document->endPage(); + document->close(); + if (proxy) proxy->allowPdfStream = false; + } + } + } + SkFont font(face, 36); + SkGlyphID selected[2]; + assert(font.textToGlyphs("ac", 2, SkTextEncoding::kUTF8, {selected, 2}) == 2); + assert(selected[0] && selected[1]); + for (bool zero : {false, true}) { + SkPDFGlyphUse usage(1, face->countGlyphs() - 1); + usage.set(selected[0]); + usage.set(selected[1]); + if (zero) usage.set(0); + auto subset = SkPDFSubsetFont(*face, usage); + assert(subset && subset->size()); + save(output + (zero ? ".zero.ttf" : ".ttf"), *subset); + auto subsetFace = manager->makeFromData(subset); + assert(subsetFace); + SkFont subsetFont(subsetFace, 36); + SkGlyphID retained[2]; + assert(subsetFont.textToGlyphs("ac", 2, SkTextEncoding::kUTF8, {retained, 2}) == 2); + assert(retained[0] == selected[0] && retained[1] == selected[1]); + assert(subsetFace->countGlyphs() < face->countGlyphs()); + } + if (proxy) { + assert(proxy->tableReads && proxy->tagReads && proxy->charMaps); + } + SkShapers::HB::PurgeCaches(); +}