| #!/usr/bin/env python3 | |
| import hashlib | |
| import math | |
| import os | |
| import shlex | |
| import struct | |
| import sys | |
| HB_FUZZING_EXTENDED_MAGIC = b"HBSUBFZ2" | |
| HB_SUBSET_SETS_GLYPH_INDEX = 0 | |
| HB_SUBSET_SETS_UNICODE = 1 | |
| HB_SUBSET_SETS_DROP_TABLE_TAG = 3 | |
| HB_SUBSET_SETS_NAME_ID = 4 | |
| HB_SUBSET_SETS_NAME_LANG_ID = 5 | |
| HB_SUBSET_SETS_LAYOUT_FEATURE_TAG = 6 | |
| HB_SUBSET_SETS_LAYOUT_SCRIPT_TAG = 7 | |
| HB_FUZZING_OP_SET_FLAGS = 1 | |
| HB_FUZZING_OP_KEEP_EVERYTHING = 2 | |
| HB_FUZZING_OP_SET_CLEAR = 3 | |
| HB_FUZZING_OP_SET_INVERT = 4 | |
| HB_FUZZING_OP_SET_ADD_RANGES = 5 | |
| HB_FUZZING_OP_SET_DEL_RANGES = 6 | |
| HB_FUZZING_OP_TEXT_ADD = 7 | |
| HB_FUZZING_OP_TEXT_DEL = 8 | |
| HB_FUZZING_OP_AXIS_PIN_ALL_TO_DEFAULT = 9 | |
| HB_FUZZING_OP_AXIS_SET = 10 | |
| HB_FUZZING_AXIS_PIN_TO_DEFAULT = 0 | |
| HB_FUZZING_AXIS_SET_RANGE = 1 | |
| FLAG_BITS = { | |
| "--no-hinting": 0x00000001, | |
| "--retain-gids": 0x00000002, | |
| "--desubroutinize": 0x00000004, | |
| "--name-legacy": 0x00000008, | |
| "--set-overlaps-flag": 0x00000010, | |
| "--passthrough-tables": 0x00000020, | |
| "--notdef-outline": 0x00000040, | |
| "--glyph-names": 0x00000080, | |
| "--no-prune-unicode-ranges": 0x00000100, | |
| "--no-layout-closure": 0x00000200, | |
| "--optimize": 0x00000400, | |
| "--no-bidi-closure": 0x00000800, | |
| "--iftb-requirements": 0x00001000, | |
| "--retain-num-glyphs": 0x00002000, | |
| "--downgrade-cff2": 0x00004000, | |
| } | |
| class UserError(Exception): | |
| pass | |
| def fail(msg: str) -> None: | |
| raise UserError(msg) | |
| def pack_u32(value: int) -> bytes: | |
| return struct.pack("<I", value & 0xFFFFFFFF) | |
| def pack_f32(value: float) -> bytes: | |
| return struct.pack("<f", value) | |
| def emit_set_flags(ops: bytearray, flags: int) -> None: | |
| ops.append(HB_FUZZING_OP_SET_FLAGS) | |
| ops += pack_u32(flags) | |
| def emit_keep_everything(ops: bytearray) -> None: | |
| ops.append(HB_FUZZING_OP_KEEP_EVERYTHING) | |
| def emit_set_clear(ops: bytearray, set_type: int) -> None: | |
| ops += bytes((HB_FUZZING_OP_SET_CLEAR, set_type)) | |
| def emit_set_invert(ops: bytearray, set_type: int) -> None: | |
| ops += bytes((HB_FUZZING_OP_SET_INVERT, set_type)) | |
| def emit_set_ranges(ops: bytearray, opcode: int, set_type: int, ranges: list[tuple[int, int]]) -> None: | |
| ops += bytes((opcode, set_type)) | |
| ops += pack_u32(len(ranges)) | |
| for start, end in ranges: | |
| ops += pack_u32(start) | |
| ops += pack_u32(end) | |
| def emit_text(ops: bytearray, opcode: int, codepoints: list[int]) -> None: | |
| ops.append(opcode) | |
| ops += pack_u32(len(codepoints)) | |
| for cp in codepoints: | |
| ops += pack_u32(cp) | |
| def emit_pin_all_axes_to_default(ops: bytearray) -> None: | |
| ops.append(HB_FUZZING_OP_AXIS_PIN_ALL_TO_DEFAULT) | |
| def emit_axis_records(ops: bytearray, records: list[tuple[int, int, float, float, float]]) -> None: | |
| ops.append(HB_FUZZING_OP_AXIS_SET) | |
| ops += pack_u32(len(records)) | |
| for tag, mode, minimum, middle, maximum in records: | |
| ops += pack_u32(tag) | |
| ops.append(mode) | |
| ops += pack_f32(minimum) | |
| ops += pack_f32(middle) | |
| ops += pack_f32(maximum) | |
| def parse_decimal_ranges(value: str) -> list[tuple[int, int]]: | |
| ranges: list[tuple[int, int]] = [] | |
| for chunk in value.replace(" ", ",").split(","): | |
| if not chunk: | |
| continue | |
| if chunk == "*": | |
| fail("Internal error: wildcard range should be handled before parsing.") | |
| if "-" in chunk: | |
| start_text, end_text = chunk.split("-", 1) | |
| try: | |
| start = int(start_text, 10) | |
| end = int(end_text, 10) | |
| except ValueError: | |
| fail(f"Failed parsing decimal range '{chunk}'.") | |
| else: | |
| try: | |
| start = end = int(chunk, 10) | |
| except ValueError: | |
| fail(f"Failed parsing decimal value '{chunk}'.") | |
| if start < 0 or end < start: | |
| fail(f"Invalid decimal range '{chunk}'.") | |
| ranges.append((start, end)) | |
| return ranges | |
| def parse_hex_ranges(value: str) -> list[tuple[int, int]]: | |
| ranges: list[tuple[int, int]] = [] | |
| for chunk in value.replace(" ", ",").split(","): | |
| if not chunk: | |
| continue | |
| if chunk == "*": | |
| fail("Internal error: wildcard range should be handled before parsing.") | |
| if "-" in chunk: | |
| start_text, end_text = chunk.split("-", 1) | |
| try: | |
| start = int(start_text, 16) | |
| end = int(end_text, 16) | |
| except ValueError: | |
| fail(f"Failed parsing unicode range '{chunk}'.") | |
| else: | |
| try: | |
| start = end = int(chunk, 16) | |
| except ValueError: | |
| fail(f"Failed parsing unicode value '{chunk}'.") | |
| if start < 0 or end < start or end > 0x10FFFF: | |
| fail(f"Invalid unicode range '{chunk}'.") | |
| ranges.append((start, end)) | |
| return ranges | |
| def parse_tags(value: str) -> list[tuple[int, int]]: | |
| tags: list[tuple[int, int]] = [] | |
| for chunk in value.replace(",", " ").split(): | |
| if chunk == "*": | |
| fail("Internal error: wildcard tag set should be handled before parsing.") | |
| if len(chunk) > 4: | |
| fail(f"Tag '{chunk}' is longer than 4 bytes.") | |
| try: | |
| tag_value = int.from_bytes(chunk.ljust(4, " ").encode("ascii"), byteorder="big") | |
| except UnicodeEncodeError: | |
| fail(f"Tag '{chunk}' is not ASCII.") | |
| tags.append((tag_value, tag_value)) | |
| return tags | |
| def parse_text_codepoints(value: str) -> list[int]: | |
| codepoints = [ord(ch) for ch in value] | |
| for cp in codepoints: | |
| if cp < 0 or cp > 0x10FFFF: | |
| fail(f"Invalid codepoint value {cp}.") | |
| return codepoints | |
| def parse_tag_value(tag_text: str, kind: str) -> int: | |
| if len(tag_text) > 4: | |
| fail(f"{kind} '{tag_text}' is longer than 4 bytes.") | |
| try: | |
| return int.from_bytes(tag_text.ljust(4, " ").encode("ascii"), byteorder="big") | |
| except UnicodeEncodeError: | |
| fail(f"{kind} '{tag_text}' is not ASCII.") | |
| def parse_axis_number(text: str) -> float: | |
| try: | |
| return float(text) | |
| except ValueError: | |
| fail(f"Failed parsing axis value '{text}'.") | |
| def parse_axis_range(value: str) -> tuple[int, float, float, float]: | |
| if value == "drop": | |
| return HB_FUZZING_AXIS_PIN_TO_DEFAULT, 0.0, 0.0, 0.0 | |
| parts = value.split(":") | |
| if len(parts) == 1: | |
| v = parse_axis_number(parts[0]) | |
| return HB_FUZZING_AXIS_SET_RANGE, v, v, v | |
| if len(parts) == 2: | |
| minimum = math.nan if parts[0] == "" else parse_axis_number(parts[0]) | |
| maximum = math.nan if parts[1] == "" else parse_axis_number(parts[1]) | |
| return HB_FUZZING_AXIS_SET_RANGE, minimum, math.nan, maximum | |
| if len(parts) == 3: | |
| minimum = math.nan if parts[0] == "" else parse_axis_number(parts[0]) | |
| middle = math.nan if parts[1] == "" else parse_axis_number(parts[1]) | |
| maximum = math.nan if parts[2] == "" else parse_axis_number(parts[2]) | |
| return HB_FUZZING_AXIS_SET_RANGE, minimum, middle, maximum | |
| fail(f"Failed parsing axis range '{value}'.") | |
| def parse_variations(value: str) -> tuple[bool, list[tuple[int, int, float, float, float]]]: | |
| pin_all = False | |
| records: list[tuple[int, int, float, float, float]] = [] | |
| for item in value.replace(" ", ",").split(","): | |
| if not item: | |
| continue | |
| if "=" not in item: | |
| fail(f"Unsupported variation item '{item}'. Expected tag=value.") | |
| tag_text, axis_value_text = item.split("=", 1) | |
| if tag_text == "*": | |
| if axis_value_text != "drop": | |
| fail("Only *=drop is supported for wildcard axis pinning.") | |
| pin_all = True | |
| continue | |
| tag = parse_tag_value(tag_text, "Axis tag") | |
| mode, minimum, middle, maximum = parse_axis_range(axis_value_text) | |
| records.append((tag, mode, minimum, middle, maximum)) | |
| return pin_all, records | |
| def add_set_option(ops: bytearray, set_type: int, modifier: str, value: str, parser) -> None: | |
| if modifier == "": | |
| emit_set_clear(ops, set_type) | |
| if value == "*": | |
| if modifier == "-": | |
| emit_set_clear(ops, set_type) | |
| else: | |
| emit_set_clear(ops, set_type) | |
| emit_set_invert(ops, set_type) | |
| return | |
| ranges = parser(value) | |
| if ranges: | |
| emit_set_ranges( | |
| ops, | |
| HB_FUZZING_OP_SET_DEL_RANGES if modifier == "-" else HB_FUZZING_OP_SET_ADD_RANGES, | |
| set_type, | |
| ranges, | |
| ) | |
| def parse_output_arg(argv: list[str], usage_text: str) -> tuple[str, list[str]]: | |
| out = "" | |
| args = argv[1:] | |
| while args: | |
| arg = args[0] | |
| if arg == "-o": | |
| if len(args) < 2: | |
| print(usage_text, end="", file=sys.stderr) | |
| raise SystemExit(1) | |
| out = args[1] | |
| args = args[2:] | |
| continue | |
| if arg in ("-h", "--help"): | |
| print(usage_text, end="") | |
| raise SystemExit(0) | |
| if arg == "--": | |
| args = args[1:] | |
| break | |
| break | |
| return out, args | |
| def read_text_file(path: str) -> list[int]: | |
| with open(path, "r", encoding="utf-8") as fp: | |
| return parse_text_codepoints(fp.read()) | |
| def read_range_file(path: str, parser) -> list[tuple[int, int]]: | |
| ranges = [] | |
| with open(path, "r", encoding="utf-8") as fp: | |
| for line in fp: | |
| line = line.split("#", 1)[0].strip() | |
| if line: | |
| ranges.extend(parser(line)) | |
| return ranges | |
| def resolve_seed_output_path(font_path: str, font_bytes: bytes, out_path: str, default_suffix: str) -> tuple[str, str]: | |
| digest = hashlib.sha1(font_bytes).hexdigest() | |
| default_name = digest + default_suffix | |
| default_dir = os.path.join(os.path.dirname(__file__), "fonts") | |
| if not out_path: | |
| display_path = os.path.join(default_dir, default_name) | |
| return display_path, os.path.abspath(display_path) | |
| if out_path.endswith(os.sep): | |
| display_path = os.path.join(out_path, default_name) | |
| return display_path, os.path.abspath(display_path) | |
| abs_out_path = os.path.abspath(out_path) | |
| if os.path.isdir(abs_out_path): | |
| if os.path.isabs(out_path): | |
| display_path = os.path.join(out_path, default_name) | |
| else: | |
| display_path = os.path.join(out_path, default_name) | |
| return display_path, os.path.join(abs_out_path, default_name) | |
| return out_path, abs_out_path | |
| def format_leading_comment(argv: list[str]) -> bytes: | |
| return ("$ " + " ".join(shlex.quote(arg) for arg in argv) + "\n").encode("utf-8") | |
| def write_seed_file(font_file: str, | |
| out_path: str, | |
| ops: bytearray, | |
| default_suffix: str = ".susbset-seed", | |
| leading_comment: bytes = b"") -> str: | |
| font_path = os.path.abspath(font_file) | |
| if not os.path.isfile(font_path): | |
| fail(f"Font file not found: '{font_path}'.") | |
| with open(font_path, "rb") as fp: | |
| font_bytes = fp.read() | |
| display_path, seed_path = resolve_seed_output_path(font_path, font_bytes, out_path, default_suffix) | |
| parent = os.path.dirname(seed_path) | |
| if parent: | |
| os.makedirs(parent, exist_ok=True) | |
| with open(seed_path, "wb") as fp: | |
| fp.write(leading_comment) | |
| fp.write(font_bytes) | |
| fp.write(ops) | |
| fp.write(pack_u32(len(ops))) | |
| fp.write(HB_FUZZING_EXTENDED_MAGIC) | |
| return display_path | |
| def print_recorded_seed_path(seed_path: str) -> None: | |
| print(seed_path) | |
| def main_with_user_errors(main_fn, argv: list[str]) -> int: | |
| try: | |
| return main_fn(argv) | |
| except UserError as e: | |
| print(e, file=sys.stderr) | |
| return 1 |