|
| 1 | +# Copyright 2020 Google Sans Authors |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Update a regression test file with the shaping output of a list of fonts.""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import enum |
| 20 | +import sys |
| 21 | +from pathlib import Path |
| 22 | +from typing import Any, Dict, List, Optional, TypedDict |
| 23 | + |
| 24 | +import vharfbuzz as vhb # type: ignore |
| 25 | +from fontTools.ttLib import TTFont # type: ignore |
| 26 | +from fontTools.ttLib.tables._f_v_a_r import table__f_v_a_r # type: ignore |
| 27 | + |
| 28 | +if sys.version_info >= (3, 11): |
| 29 | + import tomllib |
| 30 | + from typing import NotRequired |
| 31 | + |
| 32 | + TOMLDecodeError = tomllib.TOMLDecodeError |
| 33 | +else: |
| 34 | + import toml as tomllib |
| 35 | + from typing_extensions import NotRequired |
| 36 | + |
| 37 | + TOMLDecodeError = tomllib.TomlDecodeError |
| 38 | + |
| 39 | + |
| 40 | +def main(args: List[str] | None = None) -> None: |
| 41 | + import argparse |
| 42 | + import json |
| 43 | + |
| 44 | + parser = argparse.ArgumentParser() |
| 45 | + parser.add_argument( |
| 46 | + "shaping_file", type=Path, help="The .toml shaping definition input file path." |
| 47 | + ) |
| 48 | + parser.add_argument( |
| 49 | + "output_file", |
| 50 | + type=Path, |
| 51 | + help="The .json shaping expectations output file path.", |
| 52 | + ) |
| 53 | + parser.add_argument( |
| 54 | + "fonts", |
| 55 | + nargs="+", |
| 56 | + type=Path, |
| 57 | + help="The fonts to update the testing file with.", |
| 58 | + ) |
| 59 | + parsed_args = parser.parse_args(args) |
| 60 | + |
| 61 | + input_path: Path = parsed_args.shaping_file |
| 62 | + output_path: Path = parsed_args.output_file |
| 63 | + fonts: List[Path] = parsed_args.fonts |
| 64 | + |
| 65 | + shaping_input = load_shaping_input(input_path) |
| 66 | + shaping_output = update_shaping_output(shaping_input, fonts) |
| 67 | + output_path.write_text(json.dumps(shaping_output, indent=2, ensure_ascii=False)) |
| 68 | + |
| 69 | + |
| 70 | +def update_shaping_output( |
| 71 | + shaping_input: ShapingInput, font_paths: List[Path] |
| 72 | +) -> ShapingOutput: |
| 73 | + tests: List[TestDefinition] = [] |
| 74 | + |
| 75 | + for font_path in font_paths: |
| 76 | + shaper = vhb.Vharfbuzz(font_path) |
| 77 | + font = TTFont(font_path) |
| 78 | + for text in shaping_input["text"]: |
| 79 | + if "fvar" in font: |
| 80 | + fvar: table__f_v_a_r = font["fvar"] # type: ignore |
| 81 | + for instance in fvar.instances: |
| 82 | + run = shape_run( |
| 83 | + shaper, |
| 84 | + font_path, |
| 85 | + text, |
| 86 | + shaping_input, |
| 87 | + instance.coordinates, |
| 88 | + ) |
| 89 | + tests.append(run) |
| 90 | + else: |
| 91 | + run = shape_run(shaper, font_path, text, shaping_input) |
| 92 | + tests.append(run) |
| 93 | + |
| 94 | + return {"tests": tests} |
| 95 | + |
| 96 | + |
| 97 | +def shape_run( |
| 98 | + shaper: vhb.Vharfbuzz, |
| 99 | + font_path: Path, |
| 100 | + text: str, |
| 101 | + shaping_input: ShapingInput, |
| 102 | + variations: Optional[Dict[str, float]] = None, |
| 103 | +) -> TestDefinition: |
| 104 | + parameters: VHarfbuzzParameters = {} |
| 105 | + if (script := shaping_input.get("script")) is not None: |
| 106 | + parameters["script"] = script |
| 107 | + if (direction := shaping_input.get("direction")) is not None: |
| 108 | + parameters["direction"] = direction.value |
| 109 | + if (language := shaping_input.get("language")) is not None: |
| 110 | + parameters["language"] = language |
| 111 | + if features := shaping_input.get("features"): |
| 112 | + parameters["features"] = features |
| 113 | + if variations: |
| 114 | + parameters["variations"] = variations |
| 115 | + buffer = shaper.shape(text, parameters) |
| 116 | + |
| 117 | + shaping_comparison_mode = shaping_input["comparison_mode"] |
| 118 | + if shaping_comparison_mode is ComparisonMode.FULL: |
| 119 | + glyphsonly = False |
| 120 | + elif shaping_comparison_mode is ComparisonMode.GLYPHSTREAM: |
| 121 | + glyphsonly = True |
| 122 | + else: |
| 123 | + raise ValueError(f"Unknown comparison mode {shaping_comparison_mode}.") |
| 124 | + expectation = shaper.serialize_buf(buffer, glyphsonly) |
| 125 | + |
| 126 | + test_definition: TestDefinition = { |
| 127 | + "only": font_path.name, |
| 128 | + "input": text, |
| 129 | + "expectation": expectation, |
| 130 | + **parameters, |
| 131 | + } |
| 132 | + |
| 133 | + return test_definition |
| 134 | + |
| 135 | + |
| 136 | +def load_shaping_input(input_path: Path) -> ShapingInput: |
| 137 | + with input_path.open("rb") as tf: |
| 138 | + try: |
| 139 | + shaping_input: ShapingInputToml = tomllib.load(tf) # type: ignore |
| 140 | + except TOMLDecodeError as e: |
| 141 | + raise ValueError( |
| 142 | + f"{input_path} does not contain a parseable shaping input." |
| 143 | + ) from e |
| 144 | + |
| 145 | + if "input" not in shaping_input: |
| 146 | + raise ValueError(f"{input_path} does not contain a valid shaping input.") |
| 147 | + |
| 148 | + input_definition = shaping_input["input"] |
| 149 | + input_definition["text"] = input_definition.get("text", []) |
| 150 | + input_definition["script"] = input_definition.get("script") |
| 151 | + input_definition["language"] = input_definition.get("language") |
| 152 | + input_definition["direction"] = ( |
| 153 | + Direction(input_definition["direction"]) |
| 154 | + if "direction" in input_definition |
| 155 | + else None |
| 156 | + ) |
| 157 | + input_definition["features"] = input_definition.get("features", {}) |
| 158 | + input_definition["comparison_mode"] = ComparisonMode( |
| 159 | + input_definition.get("comparison_mode", "full") |
| 160 | + ) |
| 161 | + |
| 162 | + return input_definition |
| 163 | + |
| 164 | + |
| 165 | +class ShapingInputToml(TypedDict): |
| 166 | + input: ShapingInput |
| 167 | + |
| 168 | + |
| 169 | +class ShapingInput(TypedDict): |
| 170 | + text: List[str] |
| 171 | + script: Optional[str] |
| 172 | + language: Optional[str] |
| 173 | + direction: Optional[Direction] |
| 174 | + features: Dict[str, bool] |
| 175 | + comparison_mode: ComparisonMode |
| 176 | + |
| 177 | + |
| 178 | +class ComparisonMode(enum.Enum): |
| 179 | + FULL = "full" # Record glyph names, offsets and advance widths. |
| 180 | + GLYPHSTREAM = "glyphstream" # Just glyph names. |
| 181 | + |
| 182 | + |
| 183 | +class Direction(enum.Enum): |
| 184 | + LEFT_TO_RIGHT = "ltr" |
| 185 | + RIGHT_TO_LEFT = "rtl" |
| 186 | + TOP_TO_BOTTOM = "ttb" |
| 187 | + BOTTOM_TO_TOP = "btt" |
| 188 | + |
| 189 | + |
| 190 | +class ShapingOutput(TypedDict): |
| 191 | + configuration: NotRequired[Dict[str, Any]] |
| 192 | + tests: List[TestDefinition] |
| 193 | + |
| 194 | + |
| 195 | +class VHarfbuzzParameters(TypedDict, total=False): |
| 196 | + script: str |
| 197 | + direction: str |
| 198 | + language: str |
| 199 | + features: Dict[str, bool] |
| 200 | + variations: Dict[str, float] |
| 201 | + |
| 202 | + |
| 203 | +class TestDefinition(VHarfbuzzParameters): |
| 204 | + input: str |
| 205 | + expectation: str |
| 206 | + only: NotRequired[str] |
| 207 | + |
| 208 | + |
| 209 | +if __name__ == "__main__": |
| 210 | + main() |
0 commit comments