diff --git a/.gitignore b/.gitignore index c8dcb3ba..3c7fe98c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /master_ufo/ /Lilex (Autosaved).glyphs /generator/__pycache__ +.ruff_cache diff --git a/Makefile b/Makefile index 9bd020d5..952bb3ab 100644 --- a/Makefile +++ b/Makefile @@ -12,35 +12,43 @@ VTTF_FILE = $(VTTF_DIR)/Lilex-VF.ttf OS := $(shell uname) define build_font - $(VENV) fontmake \ - -g $(GLYPHS_FILE) \ - -a \ - -o "$(1)" \ - --output-dir "$(2)" + $(VENV) python scripts/lilex.py build $(1) endef configure: requirements.txt rm -rf $(VENV_DIR) make $(VENV_DIR) +.PHONY: lint +lint: + $(VENV) ruff scripts/ + $(VENV) pylint scripts/ + +.PHONY: regenerate regenerate: - python3 scripts/apply-features.py + $(VENV) python scripts/lilex.py regenerate -build: ttf otf variable_ttf +.PHONY: build +build: + $(call build_font) +.PHONY: bundle bundle: rm -rf "$(BUILD_DIR)" make build cd "$(BUILD_DIR)"; zip -r Lilex.zip ./* +.PHONY: ttf ttf: - $(call build_font,ttf,$(TTF_DIR)) + $(call build_font,ttf) +.PHONY: otf otf: - $(call build_font,otf,$(OTF_DIR)) + $(call build_font,otf) +.PHONY: variable_ttf variable_ttf: - $(call build_font,variable,$(VTTF_DIR)) + $(call build_font,variable) install: make install_$(OS) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0a8afca6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.ruff] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear +] +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "C901", # too complex +] + +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f92b1e82..289773e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ fontmake==3.5.1 cu2qu==1.6.7 gftools==0.9.27 -glyphsLib \ No newline at end of file +glyphsLib==6.2.1 +arrrgs==0.0.5 +ruff==0.0.259 +pylint==2.17.1 diff --git a/scripts/apply-features.py b/scripts/apply-features.py deleted file mode 100644 index 927add3e..00000000 --- a/scripts/apply-features.py +++ /dev/null @@ -1,44 +0,0 @@ -from os.path import join -from utils import ( - FeatureFile, - ClassFile, - GlyphsFile, - list_files, - ligature_lookups -) - -FONT_FILE = "Lilex.glyphs" -CLASSES_DIR = "./classes" -FEATURES_DIR = "./features" -CALT_DIR = join(FEATURES_DIR, "calt") - -font = GlyphsFile(FONT_FILE) - -# Find ligatures -ligatures = [] -print("Ligatures:") -for ligature in font.glyphs_with_suffix(".liga"): - name = ligature.split('.')[0] - print(f" - {name}") - ligatures.append(name) -ligatures.sort(key=lambda x: len(x.split('_')), reverse=True) - -# Build calt feature -calt = FeatureFile(name="calt") -calt.append(ligature_lookups(ligatures)) -for f in list_files(CALT_DIR): - calt.append_file(f) - -# Load features -features = [calt] -for f in list_files(FEATURES_DIR): - features.append(FeatureFile(path=f)) - -# Load classes -classes = [] -for f in list_files(CLASSES_DIR): - classes.append(ClassFile(path=f)) - -font.set_classes(classes) -font.set_features(features) -font.write() \ No newline at end of file diff --git a/scripts/builder/__init__.py b/scripts/builder/__init__.py new file mode 100644 index 00000000..4dcfab8f --- /dev/null +++ b/scripts/builder/__init__.py @@ -0,0 +1,3 @@ +"""Lilex font builder module""" +from .const import SUPPORTED_FORMATS +from .font import GlyphsFont diff --git a/scripts/builder/const.py b/scripts/builder/const.py new file mode 100644 index 00000000..f3f54dee --- /dev/null +++ b/scripts/builder/const.py @@ -0,0 +1,8 @@ +"""Lilex builder constants""" + +UFO_PATH = "master_ufo" +SUPPORTED_FORMATS = [ + "ttf", + "otf", + "variable" +] diff --git a/scripts/builder/font.py b/scripts/builder/font.py new file mode 100644 index 00000000..5feca82e --- /dev/null +++ b/scripts/builder/font.py @@ -0,0 +1,77 @@ +"""Glyphs helper""" +from __future__ import annotations + +import sys +from typing import Callable, List + +from glyphsLib import ( + GSClass, + GSFeature, + GSFont, + GSGlyph, + build_masters, +) + +from .const import SUPPORTED_FORMATS, UFO_PATH +from .make import make + +LIGATURE_SUFFIX = ".liga" +GlyphFilter = Callable[[GSGlyph], bool] + +class GlyphsFont: + """Glyphs font builder""" + _font: GSFont = None + _path: str + + def __init__(self, path: str): + self._font = GSFont(path) + self._path = path + + def ligatures(self) -> str: + glyphs = self.glyphs(lambda x: x.name.endswith(LIGATURE_SUFFIX)) + ligatures = [] + for glyph in glyphs: + ligatures.append(glyph.name.replace(LIGATURE_SUFFIX, "")) + return ligatures + + def glyphs(self, _filter: GlyphFilter) -> List[str]: + """Returns a list of glyphs that match filter""" + result = [] + for glyph in self._font.glyphs: + if _filter(glyph): + result.append(glyph) + return result + + def save(self): + """Saves the file to the same path from which it was opened""" + self._font.save(self._path) + + def save_to(self, path: str) -> None: + """Saves the file to the specified path""" + self._font.save(path) + + def set_classes(self, classes: List[GSClass]): + """Sets the font classes""" + for cls in classes: + if cls.name in self._font.classes: + self._font.classes[cls.name] = cls + else: + self._font.classes.append(cls) + + def set_features(self, features: List[GSFeature]): + """Sets the font features""" + for fea in features: + if fea.name in self._font.features: + self._font.features[fea.name] = fea + else: + self._font.features.append(fea) + + def build(self, formats: List[str], out_dir: str): + print("Generating master UFOs") + build_masters(self._path, UFO_PATH, write_skipexportglyphs=True) + ds_path = f"{UFO_PATH}/{self._font.familyName}.designspace" + for fmt in formats: + if fmt not in SUPPORTED_FORMATS: + print(f"Unsupported format '{fmt}'") + sys.exit(1) + make(ds_path, fmt, f"{out_dir}/{fmt}") diff --git a/scripts/builder/make.py b/scripts/builder/make.py new file mode 100644 index 00000000..548f8b48 --- /dev/null +++ b/scripts/builder/make.py @@ -0,0 +1,16 @@ +"""Make helpers""" +import subprocess as sp + + +def make(ds_path: str, fmt: str, out_dir: str) -> bool: + """Wrapper for fontmake""" + cmd = " ".join([ + "fontmake", + f"-m '{ds_path}'", + f"-o '{fmt}'", + f"--output-dir '{out_dir}'", + "--autohint" + ]) + with sp.Popen(cmd, shell=True, stdout=sp.PIPE) as child: + child.communicate() + return child.returncode == 0 diff --git a/scripts/generator/__init__.py b/scripts/generator/__init__.py new file mode 100644 index 00000000..75985d86 --- /dev/null +++ b/scripts/generator/__init__.py @@ -0,0 +1,2 @@ +"""Lilex font generator""" +from .ligatures import render_ligatures diff --git a/scripts/generator/const.py b/scripts/generator/const.py new file mode 100644 index 00000000..9b40bebe --- /dev/null +++ b/scripts/generator/const.py @@ -0,0 +1,51 @@ +"""Generator constants""" + +IGNORE_PREFIXES = { + 'parenleft question': [ + 'colon', + 'equal' + 'exclaim' + ], + 'less question': ['equal'], + 'parenleft question less': [ + 'equal', + 'exclaim' + ], +} + +# Replacement ignore templates map +# ignore sub +IGNORE_TEMPLATES = { + 2: [ + "1 1' 2", + "1' 2 2" + ], + 3: [ + "1 1' 2 3", + "1' 2 3 3" + ], + 4: [ + "1 1' 2 3 4", + "1' 2 3 4 4" + ] +} + +# Replacement templates map +# sub +REPLACE_TEMPLATES = { + 2: [ + "LIG 2' by 1_2.liga", + "1' 2 by LIG" + ], + 3: [ + "LIG LIG 3' by 1_2_3.liga", + "LIG 2' 3 by LIG", + "1' 2 3 by LIG" + ], + 4: [ + "LIG LIG LIG 4' by 1_2_3_4.liga", + "LIG LIG 3' 4 by LIG", + "LIG 2' 3 4 by LIG", + "1' 2 3 4 by LIG" + ] +} diff --git a/scripts/generator/ligatures.py b/scripts/generator/ligatures.py new file mode 100644 index 00000000..1886edd9 --- /dev/null +++ b/scripts/generator/ligatures.py @@ -0,0 +1,55 @@ +"""Ligatures feature generator module""" +from typing import List + +from .const import IGNORE_PREFIXES, IGNORE_TEMPLATES, REPLACE_TEMPLATES + + +def render_statements(statements: List[str], prefix: str) -> str: + """Renders fea statements""" + return '\n'.join(map(lambda x: f' {prefix} {x};', statements)) + +def render_template(template: str, glyphs: List[str]) -> str: + result = template + for i, glyph in enumerate(glyphs): + result = result.replace(str(i + 1), glyph) + return result + +def get_ignore_prefixes(name: str, count: int) -> List[str]: + ignores: List[str] = [] + tail = '' + for i in range(count - 1): + tail += f' {i + 1}' + for statement, starts in IGNORE_PREFIXES.items(): + for start in starts: + if name.startswith(start): + ignores.append(f"{statement} 1' {tail}") + return ignores + +def render_lookup(replace: List[str], ignore: List[str], glyphs: List[str]) -> str: + name = '_'.join(glyphs) + template = ( + f"lookup {name}" + " { \n" + f"{render_statements(ignore, 'ignore sub')}" + f"{render_statements(replace, 'sub')}" + "\n} " + f"{name};" + ) + return render_template(template, glyphs) + +def render_ligature(name: str) -> str: + """Generates an OpenType feature code that replaces characters with ligatures. + The `name` must be in the format `_`""" + glyphs = name.split('_') + count = len(glyphs) + ignores = IGNORE_TEMPLATES[count] + get_ignore_prefixes(name, count) + replaces = REPLACE_TEMPLATES[count] + return render_lookup(replaces, ignores, glyphs) + +def render_ligatures(items: List[str]) -> str: + """Renders the list of ligatures in the OpenType feature""" + result = "" + # For the generated code to work correctly, + # it is necessary to sort the list in descending order of the number of glyphs + ligatures = sorted(items, key=lambda x: len(x.split('_')), reverse=True) + for name in ligatures: + result += render_ligature(name) + "\n" + return result diff --git a/scripts/lilex.py b/scripts/lilex.py new file mode 100644 index 00000000..c948b16b --- /dev/null +++ b/scripts/lilex.py @@ -0,0 +1,44 @@ +"""Lilex helper entrypoint""" +from arrrgs import arg, command, run +from builder import SUPPORTED_FORMATS, GlyphsFont +from generator import render_ligatures +from glyphsLib import GSFeature +from utils import read_classes, read_features, read_files + +FONT_FILE = "Lilex.glyphs" +CLASSES_DIR = "./classes" +FEATURES_DIR = "./features" +OUT_DIR = "./build" + +@command() +def regenerate(_, font: GlyphsFont): + """Saves the generated source file with features and classes""" + font.save() + print("☺️ Font source successfully regenerated") + +@command( + arg("formats", nargs="*", help="Format list", default=SUPPORTED_FORMATS) +) +def build(args, font: GlyphsFont): + """Builds a binary font file""" + font.build(args.formats, OUT_DIR) + print("☺️ Font binaries successfully builded") + +def generate_calt(font: GlyphsFont) -> GSFeature: + glyphs = font.ligatures() + code = render_ligatures(glyphs) + read_files(f"{FEATURES_DIR}/calt") + return GSFeature("calt", code) + +def prepare(args): + font = GlyphsFont(FONT_FILE) + + cls = read_classes(CLASSES_DIR) + fea = read_features(FEATURES_DIR) + fea.append(generate_calt(font)) + + font.set_classes(cls) + font.set_features(fea) + return args, font + +if __name__ == "__main__": + run(prepare=prepare) diff --git a/scripts/utils/__init__.py b/scripts/utils/__init__.py index 4c22f64d..c1743940 100644 --- a/scripts/utils/__init__.py +++ b/scripts/utils/__init__.py @@ -1,4 +1,3 @@ -from .feature import FeatureFile, ClassFile -from .files import list_files -from .glyphs import GlyphsFile -from .ligatures import ligature_lookups +"""Lilex utilities module""" +from .cli import print_gs +from .files import read_classes, read_features, read_files diff --git a/scripts/utils/cli.py b/scripts/utils/cli.py new file mode 100644 index 00000000..97e38f84 --- /dev/null +++ b/scripts/utils/cli.py @@ -0,0 +1,10 @@ +"""CLI utilities""" +from typing import List + +from glyphsLib import GSFeature + + +def print_gs(title: str, items: List[GSFeature]): + print(f"{title}:") + for item in items: + print(f" - {item.name}") diff --git a/scripts/utils/feature.py b/scripts/utils/feature.py deleted file mode 100644 index 78cfc49c..00000000 --- a/scripts/utils/feature.py +++ /dev/null @@ -1,55 +0,0 @@ -from re import search -from os.path import basename - -from glyphsLib import GSFeature, GSClass - -def format_feature (feature: str) -> str: - result = search(r'Name:(.*)', feature) - try: - name = result.group(1).strip() - return ( - 'featureNames {\n' - f'name 3 1 0x0409 "{name}";\n' - f'name 1 0 0 "{name}";\n' - '};\n' + feature - ) - except AttributeError: - return feature - -class FeatureFile: - _content: str = "" - name: str = None - - def __init__(self, name: str = None, path: str = None) -> None: - if name is None: - self.name = basename(path).split('.')[0] - else: - self.name = name - if path is not None: - self.append_file(path) - - def append(self, code: str) -> None: - self._content += self._format(code + "\n") - - def append_file(self, path: str) -> None: - with open(path, mode="r", encoding="utf-8") as file: - self._content += self._format(file.read()) + "\n" - - def GS(self) -> GSFeature: - return GSFeature(self.name, self._content) - - def _format(self, content: str) -> str: - if self.name.startswith("ss"): - return format_feature(content) - return content - - def __str__(self) -> str: - return self._content - -class ClassFile(FeatureFile): - - def GS(self) -> GSClass: - return GSClass(self.name, self._content) - - def _format(self, content: str) -> str: - return content \ No newline at end of file diff --git a/scripts/utils/files.py b/scripts/utils/files.py index 3773c51b..89fd0905 100644 --- a/scripts/utils/files.py +++ b/scripts/utils/files.py @@ -1,7 +1,14 @@ -"""File utils""" +"""File utilities""" +from __future__ import annotations + from os import listdir -from os.path import isfile, join -from typing import List +from os.path import basename, isfile, join +from re import search +from typing import List, TypeVar + +from glyphsLib import GSClass, GSFeature + +T = TypeVar("T") def list_files(dir_path: str) -> List[str]: files = [] @@ -10,3 +17,40 @@ def list_files(dir_path: str) -> List[str]: if isfile(file_path) and not file.startswith('.'): files.append(file_path) return files + +def read_classes(dir_path: str) -> List[GSClass]: + classes = [] + for path in list_files(dir_path): + cls = _read_gs_file(path, GSClass) + classes.append(cls) + return classes + +def _extract_name(content: str) -> str | None: + result = search(r'Name:(.*)', content) + try: + return result.group(1).strip() + except AttributeError: + return None + +def read_features(dir_path: str) -> List[GSFeature]: + features = [] + for path in list_files(dir_path): + fea = _read_gs_file(path, GSFeature) + name = _extract_name(fea.code) + if name is not None: + fea.notes = f"Name: {name}" + features.append(fea) + return features + +def _read_gs_file(path: str, constructor: T) -> T: + name = basename(path).split('.')[0] + with open(path, mode="r", encoding="utf-8") as file: + return constructor(name, file.read()) + +def read_files(dir_path: str) -> str: + """Reads all files in the directory and returns a summing string""" + result = "" + for path in list_files(dir_path): + with open(path, mode="r", encoding="utf-8") as file: + result += file.read() + "\n" + return result diff --git a/scripts/utils/glyphs.py b/scripts/utils/glyphs.py deleted file mode 100644 index c3d86925..00000000 --- a/scripts/utils/glyphs.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import List -from glyphsLib import GSFont -from .feature import FeatureFile, ClassFile - -class GlyphsFile: - _font: GSFont = None - _path: str - - def __init__(self, path: str): - self._font = GSFont(path) - self._path = path - - def glyphs_with_suffix(self, suf: str) -> List[str]: - """Returns a list of glyph names that end with the specified string""" - glyphs = [] - for g in self._font.glyphs: - if g.name.endswith(suf): - glyphs.append(g.name) - return glyphs - - def write(self): - c_classes = len(self._font.classes) - c_features = len(self._font.features) - print(f"Writing {c_classes} classes and {c_features} features") - self._font.save(self._path) - - def set_classes(self, classes: List[ClassFile]): - classes.sort(key=lambda x: x.name) - for c in classes: - if c.name in self._font.classes: - self._font.classes[c.name] = c.GS() - else: - self._font.classes.append(c.GS()) - - def set_features(self, features: List[FeatureFile]): - for c in features: - if c.name in self._font.features: - self._font.features[c.name] = c.GS() - else: - self._font.features.append(c.GS()) \ No newline at end of file diff --git a/scripts/utils/ligatures.py b/scripts/utils/ligatures.py deleted file mode 100644 index e41bf440..00000000 --- a/scripts/utils/ligatures.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Ligatures feature generator module""" - -from typing import List - -ignore_prefixes = { - 'parenleft question': [ - 'colon', - 'equal' - 'exclaim' - ], - 'less question': ['equal'], - 'parenleft question less': [ - 'equal', - 'exclaim' - ], -} - -# Replacement ignore templates map -# ignore sub -ignore_templates = { - 2: [ - "1 1' 2", - "1' 2 2" - ], - 3: [ - "1 1' 2 3", - "1' 2 3 3" - ], - 4: [ - "1 1' 2 3 4", - "1' 2 3 4 4" - ] -} - -# Replacement templates map -# sub -replace_templates = { - 2: [ - "LIG 2' by 1_2.liga", - "1' 2 by LIG" - ], - 3: [ - "LIG LIG 3' by 1_2_3.liga", - "LIG 2' 3 by LIG", - "1' 2 3 by LIG" - ], - 4: [ - "LIG LIG LIG 4' by 1_2_3_4.liga", - "LIG LIG 3' 4 by LIG", - "LIG 2' 3 4 by LIG", - "1' 2 3 4 by LIG" - ] -} - -def render_statements (statements: List[str], prefix: str) -> str: - return '\n'.join(map(lambda x: f' {prefix} {x};', statements)) - -def render_template (template: str, glyphs: List[str]) -> str: - for i, _ in enumerate(glyphs): - template = template.replace(str(i + 1), glyphs[i]) - return template - -def render_lookup (replace: List[str], ignore: List[str], glyphs: List[str]) -> str: - name = '_'.join(glyphs) - template = ( - f"lookup {name}" + " { \n" - f"{render_statements(ignore, 'ignore sub')}" - f"{render_statements(replace, 'sub')}" - "\n} " + f"{name};" - ) - return render_template(template, glyphs) - -def get_ignore_prefixes(name: str, length: int) -> List[str]: - ignores: List[str] = [] - tail = '' - for i in range(length - 1): - tail += f' {i + 1}' - for statement, starts in ignore_prefixes.items(): - for start in starts: - if name.startswith(start): - ignores.append(f"{statement} 1' {tail}") - return ignores - -def ligature_lookups (ligatures: List[str]) -> str: - result = "" - for name in ligatures: - glyphs = name.split('_') - length = len(glyphs) - ignores = ignore_templates[length] + get_ignore_prefixes(name, length) - replaces = replace_templates[length] - result += render_lookup(replaces, ignores, glyphs) + "\n" - return result