diff --git a/Lib/gftools/scripts/font_tags.py b/Lib/gftools/scripts/font_tags.py new file mode 100644 index 000000000..d54fac2e3 --- /dev/null +++ b/Lib/gftools/scripts/font_tags.py @@ -0,0 +1,57 @@ +""" +gftools font-tags + +Export Font classification tags to csv, or check the spreadsheet +is still structured correctly. + +Usage: +# Write tags csv file to google/fonts/tags/all/families.csv +gftools font-tags write path/to/google/fonts + +# Check Google Sheet is still structured correctly +gftools font-tags lint path/to/google/fonts +""" +import os +from pathlib import Path +import sys +from gftools.tags import GFTags +from argparse import ArgumentParser +from gftools.utils import is_google_fonts_repo + + +def main(args=None): + parser = ArgumentParser() + subparsers = parser.add_subparsers( + dest="command", required=True, metavar='"write" or "lint"' + ) + universal_options_parser = ArgumentParser(add_help=False) + universal_options_parser.add_argument("gf_path", type=Path) + + write_parser = subparsers.add_parser( + "write", + parents=[universal_options_parser], + help="Write Google Sheet to google/fonts csv file", + ) + lint_parser = subparsers.add_parser( + "lint", + parents=[universal_options_parser], + help="Check Google Sheet is structured correctly", + ) + args = parser.parse_args(args) + + is_google_fonts_repo(args.gf_path) + + gf_tags = GFTags() + + if args.command == "write": + out_dir = args.gf_path / "tags" / "all" + if not out_dir.exists(): + os.makedirs(out_dir) + out = out_dir / "families.csv" + gf_tags.to_csv(out) + elif args.command == "lint": + gf_tags.check_structure() + + +if __name__ == "__main__": + main() diff --git a/Lib/gftools/tags.py b/Lib/gftools/tags.py new file mode 100644 index 000000000..9d1db5427 --- /dev/null +++ b/Lib/gftools/tags.py @@ -0,0 +1,382 @@ +""" +tags.py + +This module contains objects to work with the "Google Fonts 2023 +Typographic Categories" Google Sheet, +https://docs.google.com/spreadsheets/d/1Nc5DUsgVLbJ3P58Ttyhr5r-KYVnJgrj47VvUm1Rs8Fw/edit#gid=0 + +This sheet contains all the font tagging information which is used in +the Google Fonts website to help users find font families. +""" +import csv +import requests +from io import StringIO +from functools import lru_cache + + +class SheetStructureChange(Exception): pass + + +class GFTags(object): + SHEET_URL = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQVM--FKzKTWL-8w0l5AE1e087uU_OaQNHR3_kkxxymoZV5XUnHzv9TJIdy7vcd0Saf4m8CMTMFqGcg/pub?gid=1193923458&single=true&output=csv" + CATEGORIES = { + "Serif": [ + "Humanist Venetian", + "Old Style Garalde", + "Transitional", + "Modern", + "Scotch", + "Didone", + "Fat Face", + ], + "Sans": [ + "Humanist", + "Grotesque", + "Neo Grotesque", + "Geometric", + "Rounded", + "Superelipse", + "Glyphic", + ], + "Slab": ["Geometric", "Humanist", "Clarendon"], + "Script": ["Formal", "Informal", "Handwritten", "Upright Script"], + "Display": [ + "Blackletter", + "Wacky", + "Blobby", + "Woodtype", + "Stencil", + "Inline", + "Distressed", + "Shaded", + "Techno", + "Art Nouveau", + "Tuscan", + "Art Deco", + "Medieval", + "Brush/Marker", + ], + "Arabic": [ + "Kufi", + "Naskh", + "Nastaliq", + "Maghribi", + "Ruqah", + "Diwani", + "Bihari", + "Warsh", + "Sudani", + "West African", + ], + "Hebrew": ["Normal", "Ashurit", "Cursive", "Rashi"], + "South East Asian (Thai, Khmer, Lao)": [ + "Looped", + "Loopless", + "Moul (Khmer)", + "Chrieng (Khmer)", + ], + "Sinhala": [ + "Traditional/High contrast", + "Contemporary/High contrast", + "Low contrast", + ], + "Indic": [ + "Traditional/High contrast", + "Contemporary/High contrast", + "Low contrast", + "Sign Painting/vernacular", + "Reverse-contrast", + ], + } + + def __init__(self): + self.data = self._get_sheet_data() + + @lru_cache + def _get_sheet_data(self): + req = requests.get(self.SHEET_URL) + return list(csv.reader(StringIO(req.text))) + + def _parse_csv(self): + """Convert the tabular sheet data into + [ + {"Family": str, "Group/Tag": str, "Weight": int}, + ... + ]""" + columns = [] + res = [] + for row_idx, row in enumerate(self.data): + if row_idx == 1: + columns = row + # Some rows have been used as padding so skip them. + if row_idx < 4: + continue + for col_idx, cell in enumerate(row): + # Doc also contains columns used for padding... meh! + if cell == "" or columns[col_idx] == "": + continue + # Group names are on row 0 and tags are on row 1. To find a + # tag's group name, we iterate backwards on row 0 until we + # hit a value e.g: + # Sans, , ,Serif, + # ,Humanist,Grotesk, ,Garalde,Didone + # + # ["Sans/Humanist", "Sans/Grotesk", "Serif/Garalde", "Serif/Didone"] + group = next( + self.data[0][i] + for i in range(col_idx, 0, -1) + if self.data[0][i] != "" + ) + if group not in self.CATEGORIES: + raise ValueError( + f"{group} isn't a know category, {self.CATEGORIES.keys()}" + ) + + tag = columns[col_idx] + if tag not in self.CATEGORIES[group]: + raise ValueError(f"{tag} isn't in {self.CATEGORIES[group]}") + res.append( + { + "Family": row[0], + "Group/Tag": f"/{group}/{tag}", + "Weight": int(cell), + } + ) + res.sort(key=lambda k: (k["Family"], k["Group/Tag"])) + return res + + def to_csv(self, fp): + """Export the Google Sheet into a csv format suitable for the + google/fonts git repo.""" + munged_data = self._parse_csv() + with open(fp, "w", encoding="utf-8") as out_doc: + out_csv = csv.DictWriter(out_doc, ["Family", "Group/Tag", "Weight"]) + out_csv.writeheader() + out_csv.writerows(munged_data) + + def check_structure(self): + # Check Google Sheet columns haven't changed. + # Personally, I wouldn't have used a Google Sheet since the data + # isn't tabular. However, using a Google Sheet does mean we can all + # edit the data collaboratively and it does mean users don't need to + # know git or install other tools. + # Please don't cry about all the empty columns below ;-). + columns_0 = [ + "Family", + "Family Dir", + "Existing Category", + "Sample Image", + "", + "Eli's Quality Score", + "Eben's Quality Score", + "UT's Quality Score", + " Type \n Categories", + "Serif", + "", + "", + "", + "", + "", + "", + "", + "", + "Sans", + "", + "", + "", + "", + "", + "", + "", + "", + "Slab", + "", + "", + "", + "", + "Script", + "", + "", + "", + "", + "", + "Display", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "Arabic", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "Hebrew", + "", + "", + "", + "", + "", + "South East Asian (Thai, Khmer, Lao)", + "", + "", + "", + "", + "", + "Sinhala", + "", + "", + "", + "", + "Indic", + "", + "", + "", + "", + "", + ] + columns_1 = [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "Humanist Venetian", + "Old Style Garalde", + "Transitional", + "Modern", + "Scotch", + "Didone", + "Fat Face", + "", + "", + "Humanist", + "Grotesque", + "Neo Grotesque", + "Geometric", + "Rounded", + "Superelipse", + "Glyphic", + "", + "", + "Geometric", + "Humanist", + "Clarendon", + "", + "", + "Formal", + "Informal", + "Handwritten", + "Upright Script", + "", + "", + "Blackletter", + "Wacky", + "Blobby", + "Woodtype", + "Stencil", + "Inline", + "Distressed", + "Shaded", + "Techno", + "Art Nouveau", + "Tuscan", + "Art Deco", + "Medieval", + "Brush/Marker", + "", + "", + "Kufi", + "Naskh", + "Nastaliq", + "Maghribi", + "Ruqah", + "Diwani", + "Bihari", + "Warsh", + "Sudani", + "West African", + "", + "", + "Normal", + "Ashurit", + "Cursive", + "Rashi", + "", + "", + "Looped", + "Loopless", + "Moul (Khmer)", + "Chrieng (Khmer)", + "", + "", + "Traditional/High contrast", + "Contemporary/High contrast", + "Low contrast", + "", + "", + "Traditional/High contrast", + "Contemporary/High contrast", + "Low contrast", + "Sign Painting/vernacular", + "Reverse-contrast", + ] + if self.data[0] != columns_0: + raise SheetStructureChange( + "Sheet's first row of columns has changed. If intentional, " + "please update columns_0 variable." + ) + if self.data[1] != columns_1: + raise SheetStructureChange( + "Sheet's second row of columns have changed. If intentional, " + "please update columns_1 variable." + ) + + # Check a few families + munged_data = self._parse_csv() + test_tags = [ + # row 0 + {"Family": "ABeeZee", "Group/Tag": "/Sans/Geometric", "Weight": 10}, + # row 330 + {"Family": "Bonbon", "Group/Tag": "/Script/Handwritten", "Weight": 100}, + # row 577 + { + "Family": "Cormorant SC", + "Group/Tag": "/Serif/Old Style Garalde", + "Weight": 100, + }, + # row 900 + {"Family": "Gochi Hand", "Group/Tag": "/Script/Informal", "Weight": 100}, + # row 1354 + { + "Family": "Zilla Slab Highlight", + "Group/Tag": "/Slab/Geometric", + "Weight": 20, + }, + ] + for tag in test_tags: + if tag not in munged_data: + raise ValueError(f"{tag} should exist spreadsheet") + print("Google Sheet's structure is intact") diff --git a/Lib/gftools/utils.py b/Lib/gftools/utils.py index 08593c306..5c6ec916e 100644 --- a/Lib/gftools/utils.py +++ b/Lib/gftools/utils.py @@ -34,6 +34,7 @@ from ufo2ft.util import classifyGlyphs from collections import Counter from collections import defaultdict +from pathlib import Path if sys.version_info[0] == 3: from configparser import ConfigParser else: @@ -560,4 +561,10 @@ def font_version(font: TTFont): version = str(font["head"].fontRevision) else: version = version_id.toUnicode() - return version \ No newline at end of file + return version + + +def is_google_fonts_repo(fp: Path): + if "fonts" not in fp.parts: + raise ValueError(f"'{fp}' is not a path to a valid google/fonts repo") + return True \ No newline at end of file diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 000000000..615cc7e65 --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,33 @@ +import pytest +import tempfile +import subprocess +import os +from pathlib import Path +import csv + + +@pytest.fixture(scope="session") +def items(): + with tempfile.TemporaryDirectory() as tmp_dir: + gf_path = Path(tmp_dir) / "google" / "fonts" + os.makedirs(gf_path) + subprocess.run(["gftools", "font-tags", "write", gf_path]) + + csv_path = gf_path / "tags" / "all" / "families.csv" + with open(csv_path) as doc: + return list( + csv.DictReader(doc, ["Family", "Group/Tag", "Weight"], strict=True) + ) + + +@pytest.mark.parametrize( + "item", + [ + {"Family": "Handlee", "Group/Tag": "/Script/Handwritten", "Weight": "100"}, + {"Family": "Karla", "Group/Tag": "/Sans/Grotesque", "Weight": "100"}, + {"Family": "Family", "Group/Tag": "Group/Tag", "Weight": "Weight"}, + {"Family": "Aleo", "Group/Tag": "/Slab/Humanist", "Weight": "100"}, + ], +) +def test_write_font_tags(items, item): + assert item in items