From 2e5862931f17950c08c4ab999f20d166c2a7b2e1 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 10 Apr 2025 10:34:04 +0100 Subject: [PATCH 1/4] Irrelevant changes introduced by black --- src/diffenator2/__init__.py | 49 +++++---- src/diffenator2/__main__.py | 8 +- src/diffenator2/_diffenator.py | 10 +- src/diffenator2/font.py | 19 ++-- src/diffenator2/html.py | 45 +++++--- src/diffenator2/masters.py | 153 +++++++++++++-------------- src/diffenator2/matcher.py | 66 +++++++++--- src/diffenator2/renderer.py | 46 ++++---- src/diffenator2/screenshot.py | 18 ++-- src/diffenator2/segmenting.py | 37 ++++--- src/diffenator2/shape.py | 28 ++++- src/diffenator2/template_elements.py | 16 +-- src/diffenator2/utils.py | 8 +- src/diffenator2/wordlistbuilder.py | 21 ++-- 14 files changed, 312 insertions(+), 212 deletions(-) diff --git a/src/diffenator2/__init__.py b/src/diffenator2/__init__.py index 4f00787..7399b01 100644 --- a/src/diffenator2/__init__.py +++ b/src/diffenator2/__init__.py @@ -39,7 +39,9 @@ def proof_fonts(self): self.w = Writer(self.ninja_file) self.w.rule("proofing", '_diffbrowsers "$args"') self.w.newline() - self.w.build(self.cli_args["out"], "proofing", variables={"args": repr(self.cli_args)}) + self.w.build( + self.cli_args["out"], "proofing", variables={"args": repr(self.cli_args)} + ) self.run() self.w.close() self.ninja_file.close() @@ -49,7 +51,11 @@ def diff_fonts(self, fonts_before, fonts_after): self.w = Writer(self.ninja_file) if self.cli_args["diffbrowsers"]: self.w.rule("diffbrowsers", '_diffbrowsers "$args"') - self.w.build(self.cli_args["out"], "diffbrowsers", variables={"args": repr(self.cli_args)}) + self.w.build( + self.cli_args["out"], + "diffbrowsers", + variables={"args": repr(self.cli_args)}, + ) self.w.newline() if self.cli_args["diffenator"]: @@ -61,14 +67,23 @@ def diff_fonts(self, fonts_before, fonts_after): coords = new_style.coords style = new_style.name.replace(" ", "-") o = os.path.join(self.cli_args["out"], style.replace(" ", "-")) - self.w.build(o, "diffenator", variables={"args": repr( - {**self.cli_args, **{ - "coords": dict_coords_to_string(coords), - "old_font": old_style.font.ttFont.reader.file.name, - "new_font": new_style.font.ttFont.reader.file.name, - "out": self.cli_args["out"], - }} - )}) + self.w.build( + o, + "diffenator", + variables={ + "args": repr( + { + **self.cli_args, + **{ + "coords": dict_coords_to_string(coords), + "old_font": old_style.font.ttFont.reader.file.name, + "new_font": new_style.font.ttFont.reader.file.name, + "out": self.cli_args["out"], + }, + } + ) + }, + ) self.run() self.w.close() self.ninja_file.close() @@ -95,16 +110,12 @@ def ninja_proof( command="proof", user_wordlist: str = "", diffbrowsers_templates=[], - **kwargs + **kwargs, ): if not os.path.exists(out): os.mkdir(out) - args = { - **locals(), - **locals().pop("kwargs"), - **{"fonts": [f.path for f in fonts]} - } + args = {**locals(), **locals().pop("kwargs"), **{"fonts": [f.path for f in fonts]}} with NinjaBuilder(cli_args=args) as builder: if filter_styles: builder.proof_fonts() @@ -136,19 +147,19 @@ def ninja_diff( precision: int = FONT_SIZE, no_words: bool = False, no_tables: bool = False, - diffenator_template = resource_filename( + diffenator_template=resource_filename( "diffenator2", os.path.join("templates", "diffenator.html") ), command="diff", diffbrowsers_templates=[], debug_gifs: bool = False, - **kwargs + **kwargs, ): args = { **locals(), **locals().pop("kwargs"), **{"fonts_before": [f.path for f in fonts_before]}, - **{"fonts_after": [f.path for f in fonts_after]} + **{"fonts_after": [f.path for f in fonts_after]}, } if not os.path.exists(out): os.mkdir(out) diff --git a/src/diffenator2/__main__.py b/src/diffenator2/__main__.py index d841b78..415603d 100644 --- a/src/diffenator2/__main__.py +++ b/src/diffenator2/__main__.py @@ -57,7 +57,9 @@ def main(**kwargs): "--fonts-after", "-fa", nargs="+", required=True, type=DFont ) diff_parser.add_argument("--no-diffenator", default=False, action="store_true") - diff_parser.add_argument("--no-diffbrowsers", default=False, action="store_true") + diff_parser.add_argument( + "--no-diffbrowsers", default=False, action="store_true" + ) diff_parser.add_argument("--threshold", "-t", type=float, default=THRESHOLD) diff_parser.add_argument("--precision", default=FONT_SIZE, type=int) # TODO this can just be precision @@ -82,8 +84,8 @@ def main(**kwargs): if args.command == "proof": ninja_proof(**vars(args)) elif args.command == "diff": - args.diffbrowsers=False if args.no_diffbrowsers else True - args.diffenator=False if args.no_diffenator else True + args.diffbrowsers = False if args.no_diffbrowsers else True + args.diffenator = False if args.no_diffenator else True ninja_diff(**vars(args)) else: raise NotImplementedError(f"{args.command} not supported") diff --git a/src/diffenator2/_diffenator.py b/src/diffenator2/_diffenator.py index 2ddba63..68db3b4 100755 --- a/src/diffenator2/_diffenator.py +++ b/src/diffenator2/_diffenator.py @@ -22,7 +22,15 @@ class DiffFonts: - def __init__(self, matcher, threshold=0.01, font_size=28, words=True, tables=True, debug_gifs=False): + def __init__( + self, + matcher, + threshold=0.01, + font_size=28, + words=True, + tables=True, + debug_gifs=False, + ): self.old_font = matcher.old_fonts[0] self.new_font = matcher.new_fonts[0] diff --git a/src/diffenator2/font.py b/src/diffenator2/font.py index 7ba5e46..82767ce 100644 --- a/src/diffenator2/font.py +++ b/src/diffenator2/font.py @@ -46,7 +46,7 @@ def get_font_styles(fonts, method, filter_styles=None): for font in fonts: for style in getattr(font, method)(): if filter_styles and not re.match(filter_styles, style.name): - continue + continue results.append(style) return results @@ -94,9 +94,11 @@ def set_variations(self, coords: dict[str, float]): self.ftFont.set_var_design_coords(ft_coords) self.variations = coords self.hbFont.set_variations(coords) - + def closest_style(self, coords): - fvar_axes = {a.axisTag: (a.minValue, a.maxValue) for a in self.ttFont["fvar"].axes} + fvar_axes = { + a.axisTag: (a.minValue, a.maxValue) for a in self.ttFont["fvar"].axes + } found_coords = {} for axis, value in coords.items(): if axis not in fvar_axes: @@ -139,7 +141,7 @@ def instances(self): ) ) return results - + def masters(self): assert self.is_variable(), "Needs to be a variable font" results = [] @@ -147,16 +149,13 @@ def masters(self): for coords in master_coords: results.append(Style(self, coords)) return results - + def cross_product(self): assert self.is_variable(), "Needs to be a variable font" results = [] axis_values = [ - ( - a.minValue, - (a.minValue+a.maxValue)/2, - a.maxValue - ) for a in self.ttFont["fvar"].axes + (a.minValue, (a.minValue + a.maxValue) / 2, a.maxValue) + for a in self.ttFont["fvar"].axes ] axis_tags = [a.axisTag for a in self.ttFont["fvar"].axes] diff --git a/src/diffenator2/html.py b/src/diffenator2/html.py index fa158ad..df19a58 100644 --- a/src/diffenator2/html.py +++ b/src/diffenator2/html.py @@ -1,5 +1,5 @@ -""" -""" +""" """ + from __future__ import annotations from jinja2 import Environment, FileSystemLoader from fontTools.ttLib import TTFont @@ -26,7 +26,6 @@ } - def get_font_styles(ttfonts, suffix="", filters=None): res = [] for ttfont in ttfonts: @@ -90,7 +89,15 @@ def filtered_font_sample_text(ttFont, characters): return " ".join(sample_text) -def proof_rendering(styles, templates, dst="out", filter_styles=None, characters=set(), pt_size=20, user_wordlist=None): +def proof_rendering( + styles, + templates, + dst="out", + filter_styles=None, + characters=set(), + pt_size=20, + user_wordlist=None, +): ttFont = styles[0].font.ttFont font_faces = set(style.font.css_font_face for style in styles) font_styles = [style.css_font_style for style in styles] @@ -109,21 +116,28 @@ def proof_rendering(styles, templates, dst="out", filter_styles=None, characters test_strings=test_strings, pt_size=pt_size, user_strings=user_words, - filter_styles=filter_styles.replace("|", "-") + filter_styles=filter_styles.replace("|", "-"), ) - -def diff_rendering(matcher, templates, dst="out", filter_styles=None, characters=set(), pt_size=20, user_wordlist=None): +def diff_rendering( + matcher, + templates, + dst="out", + filter_styles=None, + characters=set(), + pt_size=20, + user_wordlist=None, +): dFont = matcher.old_styles[0].font ttFont = matcher.old_styles[0].font.ttFont font_faces_old = set(style.font.css_font_face for style in matcher.old_styles) font_styles_old = [style.css_font_style for style in matcher.old_styles] - + font_faces_new = set(style.font.css_font_face for style in matcher.new_styles) font_styles_new = [style.css_font_style for style in matcher.new_styles] - sample_text=filtered_font_sample_text(ttFont, characters) + sample_text = filtered_font_sample_text(ttFont, characters) test_strings = GFTestData.test_strings_in_font(ttFont, 0.1) characters = characters or [chr(c) for c in ttFont.getBestCmap()] characters = list(sorted(characters)) @@ -141,7 +155,7 @@ def diff_rendering(matcher, templates, dst="out", filter_styles=None, characters test_strings=test_strings, pt_size=pt_size, user_strings=user_words, - filter_styles=filter_styles.replace("|", "-") + filter_styles=filter_styles.replace("|", "-"), ) @@ -205,10 +219,15 @@ def _package(templates, dst, **kwargs): if "filter_styles" in kwargs: fp_prefix = kwargs.get("filter_styles") elif "diff" in kwargs: - filename = kwargs.get("font_faces_new")[0].filename.replace("new-", "").strip().replace(" ", "_")[:-4] + filename = ( + kwargs.get("font_faces_new")[0] + .filename.replace("new-", "") + .strip() + .replace(" ", "_")[:-4] + ) style = kwargs.get("font_styles_new")[0].stylename fp_prefix = f"{filename}-{style}" - dst_doc = os.path.join(dst, f'{fp_prefix}-{os.path.basename(template_fp)}') + dst_doc = os.path.join(dst, f"{fp_prefix}-{os.path.basename(template_fp)}") with open(dst_doc, "w", encoding="utf8") as out_file: out_file.write(doc) @@ -218,4 +237,4 @@ def _package(templates, dst, **kwargs): if k in kwargs: for font in kwargs[k]: out_fp = os.path.join(dst, font.filename) - shutil.copy(font.ttfont.reader.file.name, out_fp) \ No newline at end of file + shutil.copy(font.ttfont.reader.file.name, out_fp) diff --git a/src/diffenator2/masters.py b/src/diffenator2/masters.py index 0a4d8a3..f82717b 100644 --- a/src/diffenator2/masters.py +++ b/src/diffenator2/masters.py @@ -10,11 +10,11 @@ from fontTools.varLib import instancer -_TABLES_WITH_ITEM_VARIATIONS = ['MVAR', 'VVAR', 'HVAR', 'GDEF'] +_TABLES_WITH_ITEM_VARIATIONS = ["MVAR", "VVAR", "HVAR", "GDEF"] -_TABLES_WITH_VARIATIONS = ['MVAR', 'VVAR', 'HVAR', 'GDEF', 'gvar', 'cvar'] +_TABLES_WITH_VARIATIONS = ["MVAR", "VVAR", "HVAR", "GDEF", "gvar", "cvar"] -logging.getLogger('fontTools').setLevel(logging.WARNING) +logging.getLogger("fontTools").setLevel(logging.WARNING) def find_masters(ttfont): @@ -23,11 +23,11 @@ def find_masters(ttfont): # add min and max for each axis as well for axis in ttfont["fvar"].axes: - if not axis.axisTag in axis_peaks: - axis_peaks[axis.axisTag] = set() - axis_peaks[axis.axisTag].add(axis.minValue) - axis_peaks[axis.axisTag].add(axis.maxValue) - + if not axis.axisTag in axis_peaks: + axis_peaks[axis.axisTag] = set() + axis_peaks[axis.axisTag].add(axis.minValue) + axis_peaks[axis.axisTag].add(axis.maxValue) + # generate combinations from peaks for combo in itertools.product(*axis_peaks.values()): results.append(dict(zip(axis_peaks.keys(), combo))) @@ -35,100 +35,99 @@ def find_masters(ttfont): def _FindPeaks(ttf): - """Returns peaks for each axis""" - result = {} + """Returns peaks for each axis""" + result = {} - # Collect peak values from all variation tables. - varstore_fn = _GetVarStoreFunction(ttf['fvar'].axes) - for table_name in _TABLES_WITH_VARIATIONS: - if table_name in ttf: - result.update( - _AddPeaksFromVariationStore(ttf[table_name], varstore_fn, table_name)) + # Collect peak values from all variation tables. + varstore_fn = _GetVarStoreFunction(ttf["fvar"].axes) + for table_name in _TABLES_WITH_VARIATIONS: + if table_name in ttf: + result.update( + _AddPeaksFromVariationStore(ttf[table_name], varstore_fn, table_name) + ) - # Drop axes which don't have more than one peak. - result = {k: v for k, v in result.items() if len(v) > 1} + # Drop axes which don't have more than one peak. + result = {k: v for k, v in result.items() if len(v) > 1} - # Apply reverse avar mapping to the peak values of all axes. - if 'avar' in ttf: - result = _ReverseAvarMapping(ttf['avar'], result) + # Apply reverse avar mapping to the peak values of all axes. + if "avar" in ttf: + result = _ReverseAvarMapping(ttf["avar"], result) - # Include the default position(0) in the list of peak values. - for v in result.values(): - v.add(0) + # Include the default position(0) in the list of peak values. + for v in result.values(): + v.add(0) - axes = { - a.axisTag: { - -1: a.minValue, 0: a.defaultValue, 1: a.maxValue - } for a in ttf['fvar'].axes - } + axes = { + a.axisTag: {-1: a.minValue, 0: a.defaultValue, 1: a.maxValue} + for a in ttf["fvar"].axes + } - # Convert normalized values to user scale values (Ex: 0.0 -> 400). - return { - k: {round(piecewiseLinearMap(a, axes[k]), 2) for a in v - } for k, v in result.items() - } + # Convert normalized values to user scale values (Ex: 0.0 -> 400). + return { + k: {round(piecewiseLinearMap(a, axes[k]), 2) for a in v} + for k, v in result.items() + } def _ReverseAvarMapping(avar, axis_values): - """Returns reverse avar mapping value. + """Returns reverse avar mapping value. - Takes input axis value and reverses avar processing to return the original - normalized axis value. If avar in font has a mapping from X -> Y then passing - Y to this function will return X. + Takes input axis value and reverses avar processing to return the original + normalized axis value. If avar in font has a mapping from X -> Y then passing + Y to this function will return X. - Returns: - Reverse avar mapped value. + Returns: + Reverse avar mapped value. - Args: - avar: avar table in font. - axis_values: map containing a list of values for each axis. - """ + Args: + avar: avar table in font. + axis_values: map containing a list of values for each axis. + """ - # Collect avar segments and creates a new mapping by reversing it. - maps = avar.segments - reverse_avar_mapping = { - k: {i[1]: i[0] for i in maps[k].items()} for k in maps.keys() - } + # Collect avar segments and creates a new mapping by reversing it. + maps = avar.segments + reverse_avar_mapping = { + k: {i[1]: i[0] for i in maps[k].items()} for k in maps.keys() + } - # Apply new reversed avar mapping. piecewiseLinearMap helps in mapping values - # which lie in between segments by using a linear function. - return { - k: {piecewiseLinearMap(a, reverse_avar_mapping[k]) for a in v - } for k, v in axis_values.items() - } + # Apply new reversed avar mapping. piecewiseLinearMap helps in mapping values + # which lie in between segments by using a linear function. + return { + k: {piecewiseLinearMap(a, reverse_avar_mapping[k]) for a in v} + for k, v in axis_values.items() + } def _AddPeaksFromVariationStore(table, varstore_fn, table_name): - """Adds axes peaks from Variation Stores: both Tuple and Item.""" + """Adds axes peaks from Variation Stores: both Tuple and Item.""" - result = collections.defaultdict(set) + result = collections.defaultdict(set) - for tuple_store in varstore_fn[table_name](table): - for var in tuple_store: - for key, value in var.axes.items(): - result[key].add(value[1]) + for tuple_store in varstore_fn[table_name](table): + for var in tuple_store: + for key, value in var.axes.items(): + result[key].add(value[1]) - return result + return result def _GetVarStoreFunction(fvar_axes): - """Constructs a mapping from table name to lambdas which return var data.""" + """Constructs a mapping from table name to lambdas which return var data.""" - varstore_fn = { - 'gvar': lambda t: t.variations.values(), - 'cvar': lambda t: [t.variations] - } - # Add lambdas for Item Var Stores. - item_store_lambda = lambda t: _ConvertItemStore(t.table.VarStore, fvar_axes) - varstore_fn.update( - dict.fromkeys(_TABLES_WITH_ITEM_VARIATIONS, item_store_lambda)) - return varstore_fn + varstore_fn = { + "gvar": lambda t: t.variations.values(), + "cvar": lambda t: [t.variations], + } + # Add lambdas for Item Var Stores. + item_store_lambda = lambda t: _ConvertItemStore(t.table.VarStore, fvar_axes) + varstore_fn.update(dict.fromkeys(_TABLES_WITH_ITEM_VARIATIONS, item_store_lambda)) + return varstore_fn def _ConvertItemStore(item_store, fvar_axes): - """Converts an Item Variation Store to a Tuple Store representation.""" + """Converts an Item Variation Store to a Tuple Store representation.""" - return instancer._TupleVarStoreAdapter.fromItemVarStore( - item_store, - fvar_axes, - ).tupleVarData + return instancer._TupleVarStoreAdapter.fromItemVarStore( + item_store, + fvar_axes, + ).tupleVarData diff --git a/src/diffenator2/matcher.py b/src/diffenator2/matcher.py index 70c574b..91fc1f0 100644 --- a/src/diffenator2/matcher.py +++ b/src/diffenator2/matcher.py @@ -1,6 +1,7 @@ """ Match font styles or match vf coordinates """ + from diffenator2.font import get_font_styles, Style, DFont from fontTools.ttLib.scaleUpem import scale_upem from typing import List @@ -14,7 +15,7 @@ def __init__(self, old_fonts: List[DFont], new_fonts: List[DFont]): self.new_fonts = new_fonts self.old_styles = [] self.new_styles = [] - + def _match_fonts(self): old_fonts = [] new_fonts = [] @@ -44,14 +45,18 @@ def _get_names(self, font: DFont): for inst in fvar.instances: results.add(name.getName(inst.subfamilyNameID, 3, 1, 0x409).toUnicode()) return results - + def diffenator(self, coords=None): - assert len(self.old_fonts) == 1 and len(self.new_fonts) == 1, "Multiple fonts found. Diffenator can only do a 1 on 1 comparison" + assert ( + len(self.old_fonts) == 1 and len(self.new_fonts) == 1 + ), "Multiple fonts found. Diffenator can only do a 1 on 1 comparison" old_font = self.old_fonts[0] new_font = self.new_fonts[0] if old_font.is_variable() and new_font.is_variable(): if not coords: - coords = {a.axisTag: a.defaultValue for a in new_font.ttFont["fvar"].axes} + coords = { + a.axisTag: a.defaultValue for a in new_font.ttFont["fvar"].axes + } self.old_styles.append(Style(old_font, coords, "")) self.new_styles.append(Style(new_font, coords, "")) old_font.set_variations(coords) @@ -69,12 +74,22 @@ def diffenator(self, coords=None): self.new_styles.append(Style(new_font, {"wght": 400}, "")) def instances(self, filter_styles=None): - old_styles = {s.name: s for s in get_font_styles(self.old_fonts, "instances", filter_styles)} - new_styles = {s.name: s for s in get_font_styles(self.new_fonts, "instances", filter_styles)} + old_styles = { + s.name: s + for s in get_font_styles(self.old_fonts, "instances", filter_styles) + } + new_styles = { + s.name: s + for s in get_font_styles(self.new_fonts, "instances", filter_styles) + } matching = set(old_styles.keys()) & set(new_styles.keys()) - self.old_styles = sorted([old_styles[s] for s in matching], key=lambda k: k.name) - self.new_styles = sorted([new_styles[s] for s in matching], key=lambda k: k.name) + self.old_styles = sorted( + [old_styles[s] for s in matching], key=lambda k: k.name + ) + self.new_styles = sorted( + [new_styles[s] for s in matching], key=lambda k: k.name + ) def cross_product(self, filter_styles=None): self._match_fonts() @@ -88,7 +103,9 @@ def masters(self, filter_styles=None): def _closest_match(self, styles, filter_styles=None): # TODO work out best matching fonts. Current implementation only works on a single font - assert all(f.is_variable() for f in self.old_fonts+self.new_fonts), "All fonts must be variable fonts" + assert all( + f.is_variable() for f in self.old_fonts + self.new_fonts + ), "All fonts must be variable fonts" old_font = self.old_fonts[0] old_styles = [] new_styles = [] @@ -104,27 +121,42 @@ def _closest_match(self, styles, filter_styles=None): old_styles = [s for s in old_styles if re.match(filter_styles, s.name)] new_styles = [s for s in new_styles if re.match(filter_styles, s.name)] - self.old_styles = sorted([s for s in old_styles], key=lambda k: [v for v in k.coords.values()]) - self.new_styles = sorted([s for s in new_styles], key=lambda k: [v for v in k.coords.values()]) + self.old_styles = sorted( + [s for s in old_styles], key=lambda k: [v for v in k.coords.values()] + ) + self.new_styles = sorted( + [s for s in new_styles], key=lambda k: [v for v in k.coords.values()] + ) def coordinates(self, coords=None): # TODO add validation for font in self.old_fonts: self.old_styles.append(Style(font, coords)) - + for font in self.new_fonts: self.new_styles.append(Style(font, coords)) - + def upms(self): if len(self.old_fonts) == 1 and len(self.new_fonts) == 1: - if self.old_fonts[0].ttFont["head"].unitsPerEm != self.new_fonts[0].ttFont["head"].unitsPerEm: - scale_upem(self.old_fonts[0].ttFont, self.new_fonts[0].ttFont["head"].unitsPerEm) + if ( + self.old_fonts[0].ttFont["head"].unitsPerEm + != self.new_fonts[0].ttFont["head"].unitsPerEm + ): + scale_upem( + self.old_fonts[0].ttFont, + self.new_fonts[0].ttFont["head"].unitsPerEm, + ) return assert self.old_styles + self.new_styles, "match styles first!" seen = set() for old_style, new_style in zip(self.old_styles, self.new_styles): if old_style in seen: continue - if old_style.font.ttFont["head"].unitsPerEm != new_style.font.ttFont["head"].unitsPerEm: - scale_upem(old_style.font.ttFont, new_style.font.ttFont["head"].unitsPerEm) + if ( + old_style.font.ttFont["head"].unitsPerEm + != new_style.font.ttFont["head"].unitsPerEm + ): + scale_upem( + old_style.font.ttFont, new_style.font.ttFont["head"].unitsPerEm + ) seen.add(old_style) diff --git a/src/diffenator2/renderer.py b/src/diffenator2/renderer.py index 4f29f7b..63bf334 100644 --- a/src/diffenator2/renderer.py +++ b/src/diffenator2/renderer.py @@ -25,14 +25,13 @@ @dataclass class Renderer: font: DFont - font_size: int=250 - margin: int=20 + font_size: int = 250 + margin: int = 20 features: dict[str, bool] = None variations: dict[str, float] = None lang: str = None script: str = None - cache: dict[int,any] = field(default_factory=dict) - + cache: dict[int, any] = field(default_factory=dict) def shape(self, text): hb_font = self.font.hbFont @@ -87,9 +86,8 @@ def render_text_cairo(self, text): surface = surfaceClass() # return empty canvas if either width or height == 0. Not doing so # causes Skia to raise a null pointer error - if orig_bounds[0] == orig_bounds[2] or \ - orig_bounds[1] == orig_bounds[3]: - return Image.new("RGBA", (0,0)), 0 + if orig_bounds[0] == orig_bounds[2] or orig_bounds[1] == orig_bounds[3]: + return Image.new("RGBA", (0, 0)), 0 with surface.canvas(bounds) as canvas: canvas.scale(scaleFactor) for glyph in glyphLine: @@ -146,6 +144,7 @@ def render_text_cairo(self, text): # pen.y += pos.y_advance # return Image.fromarray(L) + @dataclass class Bitmap: buffer: any @@ -155,6 +154,7 @@ class Bitmap: left: int pitch: int + def get_cached_bitmap(ft_face, codepoint, cache): if codepoint in cache: return cache[codepoint] @@ -163,11 +163,11 @@ def get_cached_bitmap(ft_face, codepoint, cache): bitmap = ft_face.glyph.bitmap cache[codepoint] = Bitmap( buffer=bitmap.buffer, - width = bitmap.width, - rows = bitmap.rows, - top = ft_face.glyph.bitmap_top, - left = ft_face.glyph.bitmap_left, - pitch = ft_face.glyph.bitmap.pitch, + width=bitmap.width, + rows=bitmap.rows, + top=ft_face.glyph.bitmap_top, + left=ft_face.glyph.bitmap_left, + pitch=ft_face.glyph.bitmap.pitch, ) return cache[codepoint] @@ -176,9 +176,9 @@ def get_cached_bitmap(ft_face, codepoint, cache): class PixelDiffer: font_a: DFont font_b: DFont - script=None - lang=None - features=None + script = None + lang = None + features = None font_size: int = FONT_SIZE def __post_init__(self): @@ -189,7 +189,7 @@ def __post_init__(self): features=self.features, script=self.script, lang=self.lang, - variations=getattr(self.font_a, "variations", None) + variations=getattr(self.font_a, "variations", None), ) self.renderer_b = Renderer( self.font_b, @@ -198,7 +198,7 @@ def __post_init__(self): features=self.features, script=self.script, lang=self.lang, - variations=getattr(self.font_b, "variations", None) + variations=getattr(self.font_b, "variations", None), ) def set_script(self, script): @@ -237,7 +237,7 @@ def diff(self, string): img_a = np.asarray(img_a) img_b = np.asarray(img_b) - diff_map = np.abs(img_a-img_b) + diff_map = np.abs(img_a - img_b) if np.size(diff_map) == 0: return 0, [] pc = np.sum(diff_map) / np.size(diff_map) @@ -247,11 +247,11 @@ def diff(self, string): return pc, diff_map def debug_gif(self, fp): - img_a = self.img_a.convert('RGBA') - img_a_background = Image.new('RGBA', img_a.size, (255,255,255)) + img_a = self.img_a.convert("RGBA") + img_a_background = Image.new("RGBA", img_a.size, (255, 255, 255)) img_a = Image.alpha_composite(img_a_background, img_a) - img_b = self.img_b.convert('RGBA') - img_b_background = Image.new('RGBA', img_b.size, (255,255,255)) + img_b = self.img_b.convert("RGBA") + img_b_background = Image.new("RGBA", img_b.size, (255, 255, 255)) img_b = Image.alpha_composite(img_b_background, img_b) gen_gif(img_a, img_b, fp) @@ -294,6 +294,6 @@ def debug_gif(self, fp): lang=args.lang, script=args.script, font_size=args.pt, - variations=variations + variations=variations, ).render(args.string) img.save(args.out) diff --git a/src/diffenator2/screenshot.py b/src/diffenator2/screenshot.py index b18b290..9cb115f 100644 --- a/src/diffenator2/screenshot.py +++ b/src/diffenator2/screenshot.py @@ -67,7 +67,9 @@ def take_gif(self, url: str, dst_dir: str, font_toggle): time.sleep(1) self.take_png(url, before_fp) time.sleep(1) - self.take_png(url, after_fp, javascript="switchFonts();" if font_toggle else None) + self.take_png( + url, after_fp, javascript="switchFonts();" if font_toggle else None + ) gen_gifs(before_fp, after_fp, dst_dir) def set_width(self, width: int): @@ -113,13 +115,13 @@ def __del__(self): def screenshot_dir( - dir_fp: str, - out: str, - skip=[ - "diffbrowsers_proofer.html", - "diffenator.html", - "diffbrowsers_user_strings.html" - ], + dir_fp: str, + out: str, + skip=[ + "diffbrowsers_proofer.html", + "diffenator.html", + "diffbrowsers_user_strings.html", + ], ): """Screenshot a folder of html docs. Walk the damn things""" if not os.path.exists(out): diff --git a/src/diffenator2/segmenting.py b/src/diffenator2/segmenting.py index 11484da..74a59b8 100644 --- a/src/diffenator2/segmenting.py +++ b/src/diffenator2/segmenting.py @@ -1,4 +1,4 @@ -# Taken from +# Taken from # https://github.com/justvanrossum/fontgoggles/blob/master/Lib/fontgoggles/misc/segmenting.py # TODO: write python bindings for ribraqm instead import itertools @@ -8,14 +8,20 @@ # Monkeypatch bidi to use unicodedata2 import unicodedata2 import bidi.algorithm + bidi.algorithm.bidirectional = unicodedata2.bidirectional bidi.algorithm.category = unicodedata2.category bidi.algorithm.mirrored = unicodedata2.mirrored from bidi.algorithm import ( # noqa: ignore E402 - get_empty_storage, get_base_level, get_embedding_levels, - explicit_embed_and_overrides, resolve_weak_types, - resolve_neutral_types, resolve_implicit_levels, - reorder_resolved_levels, PARAGRAPH_LEVELS, + get_empty_storage, + get_base_level, + get_embedding_levels, + explicit_embed_and_overrides, + resolve_weak_types, + resolve_neutral_types, + resolve_implicit_levels, + reorder_resolved_levels, + PARAGRAPH_LEVELS, ) from bidi.mirror import MIRRORED # noqa: ignore E402 from fontTools.unicodedata.OTTags import SCRIPT_EXCEPTIONS @@ -29,10 +35,10 @@ def textSegments(txt): storage = getBiDiInfo(txt) levels = [None] * len(txt) - for ch in storage['chars']: - levels[ch['index']] = ch['level'] + for ch in storage["chars"]: + levels[ch["index"]] = ch["level"] - prevLevel = storage['base_level'] + prevLevel = storage["base_level"] for i, level in enumerate(levels): if level is None: levels[i] = prevLevel @@ -57,7 +63,7 @@ def textSegments(txt): _, script, bidiLevel = segment[0] segments.append((runChars, script, bidiLevel, index)) index = nextIndex - return segments, storage['base_level'] + return segments, storage["base_level"] def reorderedSegments(segments, baseLevel): @@ -83,7 +89,7 @@ def detectScript(txt): # Non-spacing mark (Mn) should always inherit script if scr in UNKNOWN_SCRIPT or cat == "Mn": if i: - scr = charScript[i-1] + scr = charScript[i - 1] else: scr = None if ch in MIRRORED and cat == "Pe": @@ -114,6 +120,7 @@ def detectScript(txt): # copied from bidi/algorthm.py and modified to be more useful for us. + def getBiDiInfo(text, *, upper_is_rtl=False, base_dir=None, debug=False): """ Set `upper_is_rtl` to True to treat upper case chars as strong 'R' @@ -133,8 +140,8 @@ def getBiDiInfo(text, *, upper_is_rtl=False, base_dir=None, debug=False): else: base_level = PARAGRAPH_LEVELS[base_dir] - storage['base_level'] = base_level - storage['base_dir'] = ('L', 'R')[base_level] + storage["base_level"] = base_level + storage["base_dir"] = ("L", "R")[base_level] get_embedding_levels(text, storage, upper_is_rtl, debug) fix_bidi_type_for_unknown_chars(storage) @@ -156,6 +163,6 @@ def fix_bidi_type_for_unknown_chars(storage): """Set any bidi type of '' (symptom of a character not known by unicode) to 'L', to prevent the other bidi code to fail (issue 313). """ - for _ch in storage['chars']: - if _ch['type'] == '': - _ch['type'] = 'L' \ No newline at end of file + for _ch in storage["chars"]: + if _ch["type"] == "": + _ch["type"] = "L" diff --git a/src/diffenator2/shape.py b/src/diffenator2/shape.py index 99bb503..12c4875 100644 --- a/src/diffenator2/shape.py +++ b/src/diffenator2/shape.py @@ -1,6 +1,7 @@ """ Check fonts for shaping regressions using real words. """ + from __future__ import annotations from dataclasses import dataclass import uharfbuzz as hb @@ -48,12 +49,24 @@ class GlyphItems: modified: list -def test_fonts(font_a, font_b, threshold=THRESHOLD, do_words=True, font_size=FONT_SIZE, debug_gifs=False): +def test_fonts( + font_a, + font_b, + threshold=THRESHOLD, + do_words=True, + font_size=FONT_SIZE, + debug_gifs=False, +): glyphs = test_font_glyphs(font_a, font_b, threshold=threshold, font_size=font_size) skip_glyphs = glyphs.missing + glyphs.new if do_words: words = test_font_words( - font_a, font_b, skip_glyphs, threshold=threshold, font_size=font_size, debug_gifs=debug_gifs + font_a, + font_b, + skip_glyphs, + threshold=threshold, + font_size=font_size, + debug_gifs=debug_gifs, ) else: words = {} @@ -84,7 +97,12 @@ def test_font_glyphs(font_a, font_b, threshold=THRESHOLD, font_size=FONT_SIZE): def test_font_words( - font_a, font_b, skip_glyphs=set(), threshold=THRESHOLD, font_size=FONT_SIZE, debug_gifs=False + font_a, + font_b, + skip_glyphs=set(), + threshold=THRESHOLD, + font_size=FONT_SIZE, + debug_gifs=False, ): from youseedee import ucd_data from collections import defaultdict @@ -213,7 +231,9 @@ def test_words( out_fp = "debug_gifs" if not os.path.exists("debug_gifs"): os.makedirs("debug_gifs") - fp = os.path.join(out_fp, f"{pc:.2f}_{word.string}.gif".replace("/", "_")) + fp = os.path.join( + out_fp, f"{pc:.2f}_{word.string}.gif".replace("/", "_") + ) differ.debug_gif(fp) res.add( ( diff --git a/src/diffenator2/template_elements.py b/src/diffenator2/template_elements.py index 7dfa55b..88ac64d 100644 --- a/src/diffenator2/template_elements.py +++ b/src/diffenator2/template_elements.py @@ -60,8 +60,8 @@ def __hash__(self): @dataclass class Glyph(Renderable): string: str - name: str=None - unicode: str=None + name: str = None + unicode: str = None def __post_init__(self): if self.name is None: @@ -81,8 +81,8 @@ class GlyphDiff(Renderable): string: str changed_pixels: str diff_map: list[int] - name: str=None - unicode: str=None + name: str = None + unicode: str = None def __post_init__(self): if self.name is None: @@ -106,12 +106,12 @@ class CSSFontStyle(Renderable): def __post_init__(self): self.full_name = f"{self.familyname} {self.stylename}" - self.font_variation_settings = ", ".join(f'"{k}" {v}' for k, v in self.coords.items()) + self.font_variation_settings = ", ".join( + f'"{k}" {v}' for k, v in self.coords.items() + ) if self.suffix: self.cssfamilyname = f"{self.suffix} {self.familyname}" - self.class_name = ( - f"{self.suffix} {self.stylename}".replace(" ", "-") - ) + self.class_name = f"{self.suffix} {self.stylename}".replace(" ", "-") else: self.cssfamilyname = self.familyname self.class_name = f"{self.stylename}".replace(" ", "-") diff --git a/src/diffenator2/utils.py b/src/diffenator2/utils.py index 10a7d50..b80e1c3 100644 --- a/src/diffenator2/utils.py +++ b/src/diffenator2/utils.py @@ -95,9 +95,11 @@ def download_google_fonts_family(family, dst=None, ignore_static=True): # TODO (M Foley) update all dl_urls in .ini files. if not google_fonts_has_family(family): raise ValueError(f"Google Fonts does not have the family {family}") - url = "https://fonts.google.com/download?family={}".format(family.replace(" ", "%20")) + url = "https://fonts.google.com/download?family={}".format( + family.replace(" ", "%20") + ) dl_url = url.replace("download?family=", "download/list?family=") - url = dl_url.format(family.replace(' ', '%20')) + url = dl_url.format(family.replace(" ", "%20")) data = json.loads(requests.get(url).text[5:]) res = [] for item in data["manifest"]["fileRefs"]: @@ -149,6 +151,7 @@ def gen_gif(img_a, img_b, dst): def gen_img_difference(img_a, img_b, dst: str): from PIL import ImageChops + img_a = img_a.convert("RGB") img_b = img_b.convert("RGB") img = ImageChops.difference(img_a, img_b) @@ -212,7 +215,6 @@ def characters_in_string(string, characters): return all(g in characters for g in string) - class _TestDocData: def __init__( self, diff --git a/src/diffenator2/wordlistbuilder.py b/src/diffenator2/wordlistbuilder.py index aaf6125..26458fa 100644 --- a/src/diffenator2/wordlistbuilder.py +++ b/src/diffenator2/wordlistbuilder.py @@ -8,29 +8,29 @@ def all_ngrams(word, size=None): if size is None: - size = DEFAULT_NGRAM_SIZE - for i in range(max(1,len(word)-size)): - yield word[i:i+size] + size = DEFAULT_NGRAM_SIZE + for i in range(max(1, len(word) - size)): + yield word[i : i + size] -def maybe_add_word(bank, word, ngram_set, keep_chars: set[str]=None, size=None): +def maybe_add_word(bank, word, ngram_set, keep_chars: set[str] = None, size=None): if word in bank: - return False + return False if keep_chars and not all(c in keep_chars for c in word): - return False + return False if all(ngram in ngram_set for ngram in all_ngrams(word, size=size)): - return False + return False bank.add(word) - for ngram in all_ngrams(word,size=size): + for ngram in all_ngrams(word, size=size): ngram_set.add(ngram) return True -def build_words(fps: list[str], out: str, keep_chars: set[str]=None): +def build_words(fps: list[str], out: str, keep_chars: set[str] = None): keep_chars |= set("'") # used for quoting obscure words in wikipedia bank = set() seen_keep_chars = set() @@ -62,7 +62,7 @@ def build_words(fps: list[str], out: str, keep_chars: set[str]=None): doc.write("\n".join(res)) -def remove_substring_words(words:set[str]) -> set[str]: +def remove_substring_words(words: set[str]) -> set[str]: res = set() auto = ahocorasick.Automaton() for word in sorted(words, key=lambda w: -len(w)): @@ -80,7 +80,6 @@ def remove_substring_words(words:set[str]) -> set[str]: return res - def main(): parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(required=True, dest="cmd") From c00083ab3e989d84b80d4085786f1ffb27c613df Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 10 Apr 2025 10:34:37 +0100 Subject: [PATCH 2/4] CSSFontFaces/FontStyles should know if they're variable or not --- src/diffenator2/font.py | 1 + src/diffenator2/html.py | 10 ++----- src/diffenator2/template_elements.py | 2 ++ tests/test_html.py | 41 +++++++++++++++++++--------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/diffenator2/font.py b/src/diffenator2/font.py index 82767ce..da22938 100644 --- a/src/diffenator2/font.py +++ b/src/diffenator2/font.py @@ -31,6 +31,7 @@ def __init__(self, font, coords, name=None): self.name, self.coords, self.font.suffix, + self.font.is_variable(), ) def _make_name(self): diff --git a/src/diffenator2/html.py b/src/diffenator2/html.py index df19a58..ab445c3 100644 --- a/src/diffenator2/html.py +++ b/src/diffenator2/html.py @@ -39,7 +39,7 @@ def get_font_styles(ttfonts, suffix="", filters=None): coords = inst.coordinates if filters and not re.match(filters, style_name): continue - res.append(CSSFontStyle(family_name, style_name, coords, suffix)) + res.append(CSSFontStyle(family_name, style_name, coords, suffix, True)) else: if filters and not any(re.match(f, style_name) for f in filters): continue @@ -58,6 +58,7 @@ def static_font_style(ttfont, suffix=""): "wdth": WIDTH_CLASS_TO_CSS[ttfont["OS/2"].usWidthClass], }, suffix, + False, ) @@ -75,12 +76,7 @@ def diffenator_font_style(dfont, suffix=""): else: style_name = ttfont["name"].getBestSubFamilyName() coords = {"wght": ttfont["OS/2"].usWeightClass} - return CSSFontStyle( - "font", - "style", - coords, - suffix, - ) + return CSSFontStyle("font", style_name, coords, suffix, dfont.is_variable()) def filtered_font_sample_text(ttFont, characters): diff --git a/src/diffenator2/template_elements.py b/src/diffenator2/template_elements.py index 88ac64d..7bb9270 100644 --- a/src/diffenator2/template_elements.py +++ b/src/diffenator2/template_elements.py @@ -103,6 +103,7 @@ class CSSFontStyle(Renderable): stylename: str coords: dict suffix: str = "" + variable: bool = False def __post_init__(self): self.full_name = f"{self.familyname} {self.stylename}" @@ -137,6 +138,7 @@ def __post_init__(self): self.classname = self.cssfamilyname.replace(" ", "-") self.font_style = "normal" if "Italic" not in self.stylename else "italic" self.font_weight = self.ttfont["OS/2"].usWeightClass + self.variable = "fvar" in self.ttfont if "fvar" in self.ttfont: fvar = self.ttfont["fvar"] diff --git a/tests/test_html.py b/tests/test_html.py index 6bc16ae..af07e36 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -6,10 +6,16 @@ @pytest.mark.parametrize( "fp, expected", [ - (mavenpro_regular, CSSFontStyle("font", "style", {"wght": 400})), - (mavenpro_extra_bold, CSSFontStyle("font", "style", {"wght": 800})), - (mavenpro_black, CSSFontStyle("font", "style", {"wght": 900})), - ] + ( + mavenpro_regular, + CSSFontStyle("font", "style", {"wght": 400}, variable=False), + ), + ( + mavenpro_extra_bold, + CSSFontStyle("font", "style", {"wght": 800}, variable=False), + ), + (mavenpro_black, CSSFontStyle("font", "style", {"wght": 900}, variable=False)), + ], ) def test_diffenator_font_style_static(fp, expected): from diffenator2.html import diffenator_font_style @@ -22,10 +28,18 @@ def test_diffenator_font_style_static(fp, expected): @pytest.mark.parametrize( "fp, coords, expected", [ - (mavenpro_vf, {}, CSSFontStyle("font", "style", {"wght": 400})), - (mavenpro_vf, {"wght": 800}, CSSFontStyle("font", "style", {"wght": 800})), - (mavenpro_vf, {"wght": 900}, CSSFontStyle("font", "style", {"wght": 900})) - ] + (mavenpro_vf, {}, CSSFontStyle("font", "style", {"wght": 400}, variable=True)), + ( + mavenpro_vf, + {"wght": 800}, + CSSFontStyle("font", "style", {"wght": 800}, variable=True), + ), + ( + mavenpro_vf, + {"wght": 900}, + CSSFontStyle("font", "style", {"wght": 900}, variable=True), + ), + ], ) def test_diffenator_font_style_vf(fp, coords, expected): from diffenator2.html import diffenator_font_style @@ -50,7 +64,7 @@ def test_diffenator_font_style_vf(fp, coords, expected): ([mavenpro_extra_bold], "Regular", 0), ([mavenpro_regular, mavenpro_extra_bold], "Regular", 1), ([mavenpro_regular, mavenpro_extra_bold], "Regular|.*", 2), - ] + ], ) def test_get_font_style_filtering(fps, filters, style_count): from fontTools.ttLib import TTFont @@ -71,24 +85,25 @@ def test_get_font_style_filtering(fps, filters, style_count): ], ( "

text.html

\n", - "

waterfall.html

" + "

waterfall.html

", ), ), ( [ os.path.join("foo", "bar", "waterfall.html"), - os.path.join("cat", "baz", "fee", "text.html") + os.path.join("cat", "baz", "fee", "text.html"), ], ( "

cat/baz/fee/text.html

\n", - "

foo/bar/waterfall.html

" + "

foo/bar/waterfall.html

", ), ), - ] + ], ) def test_build_index_page(pages, expected): import tempfile from diffenator2.html import build_index_page + with tempfile.TemporaryDirectory() as tmp: for path in pages: fp = os.path.join(tmp, path) From 4037576aa7c635c1c00d51eb7e89ec132da4b3d1 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 10 Apr 2025 10:34:52 +0100 Subject: [PATCH 3/4] Set CSS separately for variable and static fonts --- src/diffenator2/templates/CSSFontFace.partial.html | 2 ++ src/diffenator2/templates/CSSFontStyle.partial.html | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/diffenator2/templates/CSSFontFace.partial.html b/src/diffenator2/templates/CSSFontFace.partial.html index dd09d3a..86d41ae 100644 --- a/src/diffenator2/templates/CSSFontFace.partial.html +++ b/src/diffenator2/templates/CSSFontFace.partial.html @@ -1,7 +1,9 @@ @font-face{ src: url("{{ filename | super_escape }}"); font-family: "{{ cssfamilyname }}"; + {% if not variable %} font-weight: {{ font_weight }}; {% if font_stretch %}font-stretch: {{ font_stretch }};{% endif %} font-style: {{ font_style }}; + {% endif %} } \ No newline at end of file diff --git a/src/diffenator2/templates/CSSFontStyle.partial.html b/src/diffenator2/templates/CSSFontStyle.partial.html index 56a7428..0fabbe1 100644 --- a/src/diffenator2/templates/CSSFontStyle.partial.html +++ b/src/diffenator2/templates/CSSFontStyle.partial.html @@ -1,7 +1,10 @@ .{{class_name}}{ font-family: "{{ cssfamilyname }}", "Adobe NotDef"; + {% if variable %} + font-variation-settings: {{ font_variation_settings }}; + {% else %} {% if "wght" in coords %}font-weight: {{coords['wght']}};{% endif %} {% if "wdth" in coords %}font-stretch: {{coords['wdth']}}%;{% endif %} {% if "Italic" in stylename %}font-style: italic;{% else %}font-style: normal;{% endif %} - font-variation-settings: {{ font_variation_settings }}; + {% endif %} } From dee3fc3d0f6fbc45961692c5ff24345a5a2292fb Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 10 Apr 2025 12:22:18 +0100 Subject: [PATCH 4/4] Test expectations were weird, fix them --- tests/test_html.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_html.py b/tests/test_html.py index af07e36..7cda117 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -8,13 +8,13 @@ [ ( mavenpro_regular, - CSSFontStyle("font", "style", {"wght": 400}, variable=False), + CSSFontStyle("font", "Regular", {"wght": 400}, variable=False), ), ( mavenpro_extra_bold, - CSSFontStyle("font", "style", {"wght": 800}, variable=False), + CSSFontStyle("font", "ExtraBold", {"wght": 800}, variable=False), ), - (mavenpro_black, CSSFontStyle("font", "style", {"wght": 900}, variable=False)), + (mavenpro_black, CSSFontStyle("font", "Black", {"wght": 900}, variable=False)), ], ) def test_diffenator_font_style_static(fp, expected): @@ -28,16 +28,16 @@ def test_diffenator_font_style_static(fp, expected): @pytest.mark.parametrize( "fp, coords, expected", [ - (mavenpro_vf, {}, CSSFontStyle("font", "style", {"wght": 400}, variable=True)), + (mavenpro_vf, {}, CSSFontStyle("font", "Regular", {"wght": 400}, variable=True)), ( mavenpro_vf, {"wght": 800}, - CSSFontStyle("font", "style", {"wght": 800}, variable=True), + CSSFontStyle("font", "Regular", {"wght": 800}, variable=True), ), ( mavenpro_vf, {"wght": 900}, - CSSFontStyle("font", "style", {"wght": 900}, variable=True), + CSSFontStyle("font", "Regular", {"wght": 900}, variable=True), ), ], )