diff options
Diffstat (limited to 'text')
| -rw-r--r-- | text/harfbuzz-suite.known | 40 | ||||
| -rw-r--r-- | text/harfbuzz-suite.py | 152 |
2 files changed, 192 insertions, 0 deletions
diff --git a/text/harfbuzz-suite.known b/text/harfbuzz-suite.known new file mode 100644 index 0000000..a0436bb --- /dev/null +++ b/text/harfbuzz-suite.known @@ -0,0 +1,40 @@ +# Cases of HarfBuzz's test suite (text/harfbuzz-suite.py) known to shape +# otherwise in Vega: a case ID or a pattern of them, then why. + +# AAT (morx, kerx, trak): Apple's shaping tables; Vega shapes with +# OpenType's alone and refuses a font that has them. +text-rendering-tests/MORX-* # AAT +in-house/aat-* # AAT + +# The character map made from what HarfBuzz lists (hb_face_collect_unicodes), +# not from how it maps: a format 13 map it lists cut short, a symbol map +# whose Latin and Arabic it maps to the private use area. +in-house/emoji-clusters.tests:* # cmap format 13 +text-rendering-tests/CMAP-3.tests:1 # cmap +text-rendering-tests/CMAP-3.tests:11 # cmap +in-house/arabic-fallback-shaping.tests:7 # symbol cmap, Arabic PUA +in-house/arabic-fallback-shaping.tests:8 # symbol cmap, Arabic PUA +in-house/arabic-fallback-shaping.tests:9 # symbol cmap, Arabic PUA +in-house/arabic-fallback-shaping.tests:10 # symbol cmap, Arabic PUA +in-house/arabic-fallback-shaping.tests:11 # symbol cmap, Arabic PUA + +# Variation selectors (cmap format 14). +text-rendering-tests/CMAP-1.tests:2 # cmap format 14 +text-rendering-tests/CMAP-1.tests:3 # cmap format 14 +text-rendering-tests/CMAP-2.tests:2 # cmap format 14 + +# Arabic fallback shaping where two characters share a glyph (refused). +in-house/arabic-fallback-shaping.tests:2 +in-house/arabic-fallback-shaping.tests:3 +in-house/arabic-fallback-shaping.tests:4 +in-house/arabic-fallback-shaping.tests:5 +in-house/arabic-fallback-shaping.tests:6 + +# A kern table of class pairs (format 2; refused). +in-house/kern-format2.tests:* + +# Myanmar as Zawgyi encodes it (Qaag; refused). +in-house/myanmar-zawgyi.tests:* + +# No plan for the run. +text-rendering-tests/GSUB-3.tests:1 diff --git a/text/harfbuzz-suite.py b/text/harfbuzz-suite.py new file mode 100644 index 0000000..aa25195 --- /dev/null +++ b/text/harfbuzz-suite.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""HarfBuzz's own shaping tests, as cases for Vega's shaping: + + python3 text/harfbuzz-suite.py HARFBUZZ_SRC VEGA_SYMBOLS [--known FILE] [--show N] + +HARFBUZZ_SRC is a HarfBuzz release's source; its test/shape/data holds +fonts and inputs (in-house, text-rendering-tests, aots), many of them added +when a font or a shaper once went wrong. Their expected outputs are +HarfBuzz's under options Vega has no counterpart for (font functions, +sizes, output formats), so the oracle is HarfBuzz itself run on the same +font and input (vega-symbols shape-cases, linked with the HarfBuzz the +build uses): each case must shape the same, glyph for glyph and unit for +unit. A case asking for what Vega does not do (user features, variations, +a language, context, cluster levels, buffer flags, vertical text) is left +out and counted by why. + +Cases known to differ are listed in the known file (text/harfbuzz-suite.known: +an ID or a pattern of IDs a line, then # and why). The run fails on any +other difference, and names known ones that now agree. It prints one JSON +line: what was compared, what was left out and why. +""" +import argparse +import fnmatch +import json +import shlex +import subprocess +import sys +from collections import Counter, defaultdict +from pathlib import Path + +# Options that change only what hb-shape prints, or how it gets a font's +# metrics (the oracle uses HarfBuzz's own, as Vega's build does), or buffer +# flags about default ignorables: the input still tells. +IGNORED = {'--font-funcs', '--font-size', '--ned', '--no-glyph-names', '--no-positions', '--no-clusters', + '--no-advances', '--single-par', '--show-flags', '--show-extents', '--show-text', '--show-unicode', + '--show-line-num', '--unsafe-to-concat', '--safe-to-insert-tatweel', '--verify', '--utf8-clusters', + '--normalize-glyphs', '--remove-default-ignorables', '--preserve-default-ignorables', '--glyphs', + '--no-glyph-extents'} +VALUED = {'--font-funcs', '--font-size', '--features', '--variations', '--direction', '--language', '--script', + '--unicodes-before', '--unicodes-after', '--cluster-level', '--shaper', '--shapers', '--font-ptem', + '--face-index', '--not-found-variation-selector-glyph', '--invisible-glyph', '--named-instance', + '--font-slant', '--font-bold', '--font-grade'} + + +def cases(root): + """(id, font, face, script, direction, codes) of each case, or (id, None, why) for one left out.""" + data = root / 'test' / 'shape' / 'data' + for tests in sorted(data.glob('*/tests/*.tests')): + for number, line in enumerate(tests.read_text(encoding='utf-8').splitlines(), 1): + if not line.strip() or line.startswith('#'): + continue + case = f'{tests.parent.parent.name}/{tests.name}:{number}' + fields = line.split(';') + if len(fields) < 4: + yield case, None, 'malformed' + continue + font, options, text = fields[0], fields[1], fields[2] + tokens, why = shlex.split(options), None + face, script, direction = 0, '-', '-' + i = 0 + while i < len(tokens) and not why: + name, _, value = tokens[i].partition('=') + if name in VALUED and not _: + i += 1 + value = tokens[i] if i < len(tokens) else '' + i += 1 + if name in IGNORED: + continue + if name == '--direction': + direction = {'l': 'ltr', 'ltr': 'ltr', 'r': 'rtl', 'rtl': 'rtl'}.get(value.lower()) + why = None if direction else 'vertical text' + elif name == '--script': + script = value + elif name == '--face-index': + face = int(value) + else: + why = {'--features': 'user features', '--variations': 'variations', '--language': 'a language', + '--unicodes-before': 'context', '--unicodes-after': 'context', + '--cluster-level': 'cluster levels', '--bot': 'buffer flags', '--eot': 'buffer flags', + '--font-ptem': 'point size (trak)'}.get(name, f'option {name}') + if why: + yield case, None, why + continue + # as hb-shape's --unicodes reads them: hex, U+ or 0x before or not + codes = [int(part[2:] if part[:2].lower() in ('u+', '0x') else part, 16) + for part in text.replace(',', ' ').split()] + if not codes: + yield case, None, 'no text' + continue + yield case, (tests.parent / font).resolve(), face, script, direction, codes + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('harfbuzz', type=Path) + parser.add_argument('tool', type=Path) + parser.add_argument('--known', type=Path, default=Path(__file__).with_name('harfbuzz-suite.known')) + parser.add_argument('--show', type=int, default=10) + args = parser.parse_args() + known = [] + if args.known.exists(): + for line in args.known.read_text(encoding='utf-8').splitlines(): + pattern = line.split('#', 1)[0].strip() + if pattern: + known.append(pattern) + left_out, by_font = Counter(), defaultdict(list) + for case in cases(args.harfbuzz): + if case[1] is None: + left_out[case[2]] += 1 + else: + by_font[(case[1], case[2])].append(case) + outcome, details = {}, {} + for (font, face), group in sorted(by_font.items()): + if not font.exists(): # a system font of another system (macOS) + left_out['font not here'] += len(group) + continue + lines = ''.join(f'{case[0]} {case[3]} {case[4]} {",".join(f"{c:04X}" for c in case[5])}\n' for case in group) + command = [str(args.tool), 'shape-cases', str(font)] + (['--face', str(face)] if face else []) + run = subprocess.run(command, input=lines, capture_output=True, text=True, timeout=600) + if run.returncode: + for case in group: + outcome[case[0]] = f'tool failed: {run.stderr.strip()[:80]}' + continue + last = None + for line in run.stdout.splitlines(): + if line.startswith(' '): + details[last] = details.get(last, '') + line + '\n' + continue + case, verdict = line.split(' ', 1) + outcome[case], last = verdict, case + for case in group: + outcome.setdefault(case[0], 'no answer') + counts = Counter(verdict.split(' ', 1)[0] for verdict in outcome.values()) + is_known = lambda case: any(fnmatch.fnmatchcase(case, pattern) for pattern in known) + unexpected = sorted(case for case, verdict in outcome.items() if verdict != 'same' and not is_known(case)) + now_same = sorted(case for case, verdict in outcome.items() if verdict == 'same' and is_known(case) and + case in known) + for case in unexpected[:args.show]: + print(f'{case}: {outcome[case]}', file=sys.stderr) + print(details.get(case, ''), end='', file=sys.stderr) + for case in now_same: + print(f'{case}: known to differ, now the same', file=sys.stderr) + print(json.dumps({'cases': len(outcome) + sum(left_out.values()), 'compared': len(outcome), 'same': counts['same'], + 'differ': counts['differs'], 'failed': counts['failed'], 'refused': counts['refused'], + 'other': len(outcome) - counts['same'] - counts['differs'] - counts['failed'] - counts['refused'], + 'unexpected': len(unexpected), 'known_now_same': len(now_same), + 'left_out': dict(left_out.most_common())})) + return 1 if unexpected else 0 + + +if __name__ == '__main__': + sys.exit(main()) |
