Skip to content

Commit

Permalink
Implement a basic tool to update regression test files.
Browse files Browse the repository at this point in the history
See #4589 for details.
Run like `python -m fontbakery.update_shaping_tests input.toml output.json path/to/*.ttf`.

(PR #4603)
  • Loading branch information
madig authored and felipesanches committed Mar 19, 2024
1 parent 085799a commit 43c3b56
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A more detailed list of changes is available in the corresponding milestones for
- v0.12.0a2 (2024-Feb-21)
- v0.12.0a3 (2024-Mar-13)
- v0.12.0a4 (2024-Mar-15)
- Implement a basic tool to update regression test files. See https://github.com/fonttools/fontbakery/discussions/4589 for details. Run like `python -m fontbakery.update_shaping_tests input.toml output.json path/to/*.ttf`.


## 0.12.0a4 (2024-Mar-15)
Expand Down
210 changes: 210 additions & 0 deletions Lib/fontbakery/update_shaping_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# Copyright 2020 Google Sans Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Update a regression test file with the shaping output of a list of fonts."""

from __future__ import annotations

import enum
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, TypedDict

import vharfbuzz as vhb # type: ignore
from fontTools.ttLib import TTFont # type: ignore
from fontTools.ttLib.tables._f_v_a_r import table__f_v_a_r # type: ignore

if sys.version_info >= (3, 11):
import tomllib
from typing import NotRequired

TOMLDecodeError = tomllib.TOMLDecodeError
else:
import toml as tomllib
from typing_extensions import NotRequired

TOMLDecodeError = tomllib.TomlDecodeError


def main(args: List[str] | None = None) -> None:
import argparse
import json

parser = argparse.ArgumentParser()
parser.add_argument(
"shaping_file", type=Path, help="The .toml shaping definition input file path."
)
parser.add_argument(
"output_file",
type=Path,
help="The .json shaping expectations output file path.",
)
parser.add_argument(
"fonts",
nargs="+",
type=Path,
help="The fonts to update the testing file with.",
)
parsed_args = parser.parse_args(args)

input_path: Path = parsed_args.shaping_file
output_path: Path = parsed_args.output_file
fonts: List[Path] = parsed_args.fonts

shaping_input = load_shaping_input(input_path)
shaping_output = update_shaping_output(shaping_input, fonts)
output_path.write_text(json.dumps(shaping_output, indent=2, ensure_ascii=False))


def update_shaping_output(
shaping_input: ShapingInput, font_paths: List[Path]
) -> ShapingOutput:
tests: List[TestDefinition] = []

for font_path in font_paths:
shaper = vhb.Vharfbuzz(font_path)
font = TTFont(font_path)
for text in shaping_input["text"]:
if "fvar" in font:
fvar: table__f_v_a_r = font["fvar"] # type: ignore
for instance in fvar.instances:
run = shape_run(
shaper,
font_path,
text,
shaping_input,
instance.coordinates,
)
tests.append(run)
else:
run = shape_run(shaper, font_path, text, shaping_input)
tests.append(run)

return {"tests": tests}


def shape_run(
shaper: vhb.Vharfbuzz,
font_path: Path,
text: str,
shaping_input: ShapingInput,
variations: Optional[Dict[str, float]] = None,
) -> TestDefinition:
parameters: VHarfbuzzParameters = {}
if (script := shaping_input.get("script")) is not None:
parameters["script"] = script
if (direction := shaping_input.get("direction")) is not None:
parameters["direction"] = direction.value
if (language := shaping_input.get("language")) is not None:
parameters["language"] = language
if features := shaping_input.get("features"):
parameters["features"] = features
if variations:
parameters["variations"] = variations
buffer = shaper.shape(text, parameters)

shaping_comparison_mode = shaping_input["comparison_mode"]
if shaping_comparison_mode is ComparisonMode.FULL:
glyphsonly = False
elif shaping_comparison_mode is ComparisonMode.GLYPHSTREAM:
glyphsonly = True
else:
raise ValueError(f"Unknown comparison mode {shaping_comparison_mode}.")
expectation = shaper.serialize_buf(buffer, glyphsonly)

test_definition: TestDefinition = {
"only": font_path.name,
"input": text,
"expectation": expectation,
**parameters,
}

return test_definition


def load_shaping_input(input_path: Path) -> ShapingInput:
with input_path.open("rb") as tf:
try:
shaping_input: ShapingInputToml = tomllib.load(tf) # type: ignore
except TOMLDecodeError as e:
raise ValueError(
f"{input_path} does not contain a parseable shaping input."
) from e

if "input" not in shaping_input:
raise ValueError(f"{input_path} does not contain a valid shaping input.")

input_definition = shaping_input["input"]
input_definition["text"] = input_definition.get("text", [])
input_definition["script"] = input_definition.get("script")
input_definition["language"] = input_definition.get("language")
input_definition["direction"] = (
Direction(input_definition["direction"])
if "direction" in input_definition
else None
)
input_definition["features"] = input_definition.get("features", {})
input_definition["comparison_mode"] = ComparisonMode(
input_definition.get("comparison_mode", "full")
)

return input_definition


class ShapingInputToml(TypedDict):
input: ShapingInput


class ShapingInput(TypedDict):
text: List[str]
script: Optional[str]
language: Optional[str]
direction: Optional[Direction]
features: Dict[str, bool]
comparison_mode: ComparisonMode


class ComparisonMode(enum.Enum):
FULL = "full" # Record glyph names, offsets and advance widths.
GLYPHSTREAM = "glyphstream" # Just glyph names.


class Direction(enum.Enum):
LEFT_TO_RIGHT = "ltr"
RIGHT_TO_LEFT = "rtl"
TOP_TO_BOTTOM = "ttb"
BOTTOM_TO_TOP = "btt"


class ShapingOutput(TypedDict):
configuration: NotRequired[Dict[str, Any]]
tests: List[TestDefinition]


class VHarfbuzzParameters(TypedDict, total=False):
script: str
direction: str
language: str
features: Dict[str, bool]
variations: Dict[str, float]


class TestDefinition(VHarfbuzzParameters):
input: str
expectation: str
only: NotRequired[str]


if __name__ == "__main__":
main()
4 changes: 2 additions & 2 deletions examples/shaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ com.google.fonts/check/shaping:
After saving this file — as `shaping.yml`, for example — you can then run
the Shaping profile checks using the following command:

fontbakery shaping --config shaping.yml Font.ttf
fontbakery check-shaping --config shaping.yml Font.ttf

For best results, generate an HTML report using the `--html` option.

fontbakery shaping --config shaping.yml --html shaping.html Font.ttf
fontbakery check-shaping --config shaping.yml --html shaping.html Font.ttf

The report will include SVG illustrations for any failing tests.

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ build-backend = "setuptools.build_meta"
name = "fontbakery"
dynamic = ["version"]
description = "A font quality assurance tool for everyone"
requires-python = ">=3.8"
readme = { file = "README.md", content-type = "text/markdown" }
authors = [
{ name = "Chris Simpkins", email = "[email protected]" },
Expand Down Expand Up @@ -43,6 +44,7 @@ dependencies = [
"beziers >= 0.5.0, == 0.5.*",
"uharfbuzz",
"vharfbuzz >= 0.2.0, == 0.2.*",
"typing_extensions ; python_version < '3.11'",
]

[project.optional-dependencies]
Expand Down

0 comments on commit 43c3b56

Please sign in to comment.