├── src └── ufoLib2 │ ├── py.typed │ ├── pointPens │ ├── __init__.py │ └── glyphPointPen.py │ ├── __init__.py │ ├── errors.py │ ├── constants.py │ ├── serde │ ├── util.py │ ├── msgpack.py │ ├── json.py │ └── __init__.py │ ├── objects │ ├── anchor.py │ ├── __init__.py │ ├── features.py │ ├── kerning.py │ ├── point.py │ ├── guideline.py │ ├── dataSet.py │ ├── imageSet.py │ ├── image.py │ ├── lib.py │ ├── component.py │ ├── contour.py │ └── info │ │ └── woff.py │ ├── typing.py │ └── converters.py ├── tests ├── data │ ├── MutatorSansBoldCondensed.ufo │ │ ├── features.fea │ │ ├── glyphs │ │ │ ├── space.glif │ │ │ ├── colon.glif │ │ │ ├── semicolon.glif │ │ │ ├── quotedblright.glif │ │ │ ├── quotedblleft.glif │ │ │ ├── dot.glif │ │ │ ├── A_acute.glif │ │ │ ├── acute.glif │ │ │ ├── A_dieresis.glif │ │ │ ├── Q_.glif │ │ │ ├── dieresis.glif │ │ │ ├── layerinfo.plist │ │ │ ├── period.glif │ │ │ ├── arrowup.glif │ │ │ ├── arrowdown.glif │ │ │ ├── arrowleft.glif │ │ │ ├── arrowright.glif │ │ │ ├── L_.glif │ │ │ ├── T_.glif │ │ │ ├── quotesinglbase.glif │ │ │ ├── quotedblbase.glif │ │ │ ├── comma.glif │ │ │ ├── I_.narrow.glif │ │ │ ├── V_.glif │ │ │ ├── H_.glif │ │ │ ├── I_.glif │ │ │ ├── N_.glif │ │ │ ├── Z_.glif │ │ │ ├── F_.glif │ │ │ ├── Y_.glif │ │ │ ├── X_.glif │ │ │ ├── J_.narrow.glif │ │ │ ├── W_.glif │ │ │ ├── K_.glif │ │ │ ├── M_.glif │ │ │ ├── J_.glif │ │ │ ├── U_.glif │ │ │ ├── D_.glif │ │ │ ├── I_J_.glif │ │ │ ├── P_.glif │ │ │ ├── A_.glif │ │ │ ├── O_.glif │ │ │ ├── E_.glif │ │ │ ├── G_.glif │ │ │ ├── B_.glif │ │ │ ├── C_.glif │ │ │ ├── R_.glif │ │ │ ├── S_.closed.glif │ │ │ ├── S_.glif │ │ │ └── contents.plist │ │ ├── glyphs.background │ │ │ ├── contents.plist │ │ │ ├── layerinfo.plist │ │ │ └── S_.closed.glif │ │ ├── metainfo.plist │ │ ├── layercontents.plist │ │ ├── groups.plist │ │ ├── fontinfo.plist │ │ ├── kerning.plist │ │ └── lib.plist │ ├── UbuTestData.ufo │ │ ├── images │ │ │ └── image1.png │ │ ├── data │ │ │ ├── com.github.fonttools.ttx │ │ │ │ ├── T_S_I__0.ttx │ │ │ │ ├── T_S_I__2.ttx │ │ │ │ ├── T_S_I__5.ttx │ │ │ │ └── T_S_I__3.ttx │ │ │ └── com.daltonmaag.vttLib.plist │ │ ├── glyphs │ │ │ ├── contents.plist │ │ │ ├── A_.glif │ │ │ └── a.glif │ │ ├── lib.plist │ │ ├── glyphs.public.background │ │ │ ├── contents.plist │ │ │ ├── A_.glif │ │ │ └── a.glif │ │ ├── metainfo.plist │ │ ├── layercontents.plist │ │ └── fontinfo.plist │ ├── WoffMetadataTest.ufo │ │ ├── glyphs │ │ │ └── contents.plist │ │ ├── layercontents.plist │ │ ├── metainfo.plist │ │ └── fontinfo.plist │ ├── LICENSE_MutatorSans │ └── LICENSE_UbuTestData.txt ├── conftest.py ├── objects │ ├── test_contour.py │ ├── test_datastore.py │ ├── test_component.py │ ├── test_layer.py │ ├── test_object_lib.py │ └── test_font.py └── serde │ ├── test_msgpack.py │ ├── test_serde.py │ └── test_json.py ├── .codecov.yml ├── docs ├── requirements.txt └── source │ ├── index.rst │ ├── conf.py │ ├── reference.rst │ └── explanations.rst ├── requirements-dev.in ├── .pyup.yml ├── .editorconfig ├── .gitattributes ├── .readthedocs.yml ├── README.md ├── requirements.txt ├── .coveragerc ├── tox.ini ├── requirements-dev.txt ├── .gitignore ├── pyproject.toml └── .github └── workflows └── ci.yml /src/ufoLib2/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ufoLib2/pointPens/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/features.fea: -------------------------------------------------------------------------------- 1 | # this is the feature from boldcondensed. 2 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | status: 4 | project: off 5 | patch: off 6 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | typing_extensions; python_version < '3.8' 3 | sphinx>3 4 | sphinx_rtd_theme 5 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/images/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fonttools/ufoLib2/HEAD/tests/data/UbuTestData.ufo/images/image1.png -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | # https://github.com/jazzband/pip-tools/#workflow-for-layered-requirements 2 | -c requirements.txt 3 | 4 | black 5 | coverage 6 | flake8 7 | isort 8 | mypy 9 | pytest 10 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # see https://pyup.io/docs/configuration/ for all available options 2 | 3 | schedule: every week 4 | 5 | search: False 6 | requirements: 7 | - requirements.txt 8 | - requirements-dev.txt 9 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/space.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/data/WoffMetadataTest.ufo/glyphs/contents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/data/com.github.fonttools.ttx/T_S_I__0.ttx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/data/com.github.fonttools.ttx/T_S_I__2.ttx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.{yaml,yml}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/data/com.github.fonttools.ttx/T_S_I__5.ttx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/colon.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs.background/contents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | S.closed 6 | S_.closed.glif 7 | 8 | -------------------------------------------------------------------------------- /src/ufoLib2/__init__.py: -------------------------------------------------------------------------------- 1 | """ufoLib2 -- a package for dealing with UFO fonts.""" 2 | 3 | from __future__ import annotations 4 | 5 | from ufoLib2.objects import Font 6 | 7 | try: 8 | from ._version import version as __version__ 9 | except ImportError: 10 | __version__ = "0.0.0+unknown" 11 | 12 | 13 | __all__ = ["Font"] 14 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/semicolon.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/quotedblright.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=lf 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.cfg text 7 | *.ini text 8 | *.md text 9 | *.py text 10 | *.toml text 11 | *.txt text 12 | *.yaml text 13 | *.yml text 14 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/glyphs/contents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | A 6 | A_.glif 7 | a 8 | a.glif 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/lib.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | public.glyphOrder 6 | 7 | A 8 | a 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/data/WoffMetadataTest.ufo/layercontents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | public.default 7 | glyphs 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/metainfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | creator 6 | org.linebender.norad 7 | formatVersion 8 | 3 9 | 10 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/glyphs.public.background/contents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | A 6 | A_.glif 7 | a 8 | a.glif 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/metainfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | creator 6 | com.github.fonttools.ufoLib 7 | formatVersion 8 | 3 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/data/WoffMetadataTest.ufo/metainfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | creator 6 | com.github.fonttools.ufoLib 7 | formatVersion 8 | 3 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/quotedblleft.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/dot.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/A_acute.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | public.markColor 12 | 0.6567,0.6903,1,1 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/acute.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs.background/layerinfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | color 6 | 0.5,1,0,0.7 7 | lib 8 | 9 | org.unifiedfontobject.normalizer.imageReferences 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/A_dieresis.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | public.markColor 12 | 0.6567,0.6903,1,1 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/layercontents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | foreground 7 | glyphs 8 | 9 | 10 | background 11 | glyphs.background 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/Q_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/dieresis.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | public.markColor 12 | 0.6567,0.6903,1,1 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/layercontents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | public.default 7 | glyphs 8 | 9 | 10 | public.background 11 | glyphs.public.background 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/ufoLib2/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | 6 | class Error(Exception): 7 | """The base exception for ufoLib2.""" 8 | 9 | 10 | class ExtrasNotInstalledError(Error): 11 | """The extras required for this feature are not installed.""" 12 | 13 | def __init__(self, extras: str) -> None: 14 | super().__init__(f"Extras not installed: ufoLib2[{extras}]") 15 | 16 | def __call__(self, *args: Any, **kwargs: Any) -> None: 17 | raise self 18 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-24.04 9 | tools: 10 | python: "3.12" 11 | 12 | # Build documentation in the docs/ directory with Sphinx 13 | sphinx: 14 | configuration: docs/source/conf.py 15 | 16 | # Optionally set the version of Python and requirements required to build your docs 17 | python: 18 | install: 19 | - requirements: docs/requirements.txt 20 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/layerinfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | color 6 | 1,0.75,0,0.7 7 | lib 8 | 9 | com.typemytype.robofont.segmentType 10 | curve 11 | org.unifiedfontobject.normalizer.imageReferences 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shutil 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | import pytest 8 | 9 | import ufoLib2 10 | 11 | 12 | @pytest.fixture 13 | def datadir(request: Any) -> Path: 14 | return Path(__file__).parent / "data" 15 | 16 | 17 | @pytest.fixture 18 | def ufo_UbuTestData(tmp_path: Path, datadir: Path) -> ufoLib2.Font: 19 | ufo_path = tmp_path / "UbuTestData.ufo" 20 | shutil.copytree(datadir / "UbuTestData.ufo", ufo_path) 21 | return ufoLib2.Font.open(ufo_path) 22 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/period.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | public.markColor 16 | 0,0.95,0.95,1 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/groups.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | public.kern1.@MMK_L_A 6 | 7 | A 8 | 9 | public.kern2.@MMK_R_A 10 | 11 | A 12 | 13 | testGroup 14 | 15 | E 16 | F 17 | H 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/arrowup.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ufoLib2 2 | 3 | ufoLib2 is meant to be a thin representation of the Unified Font Object (UFO) version 3 data model, intended for programmatic manipulation and fast batch processing of UFOs. 4 | 5 | It resembles the defcon library, but does without notifications, the layout engine and other support classes. Where useful and possible, ufoLib2 tries to be API-compatible with defcon. 6 | 7 | It does not replace `fontTools.ufoLib` but builds on it. The eventual goal is to merge it into `fontTools.ufoLib.objects`. 8 | 9 | Documentation: https://ufolib2.readthedocs.io/en/latest/ 10 | -------------------------------------------------------------------------------- /src/ufoLib2/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | DEFAULT_LAYER_NAME: str = "public.default" 4 | """The name of the default layer.""" 5 | 6 | OBJECT_LIBS_KEY: str = "public.objectLibs" 7 | """The lib key for object libs. 8 | 9 | See: 10 | 11 | - https://unifiedfontobject.org/versions/ufo3/lib.plist/#publicobjectlibs 12 | - https://unifiedfontobject.org/versions/ufo3/glyphs/glif/#publicobjectlibs 13 | """ 14 | 15 | DATA_LIB_KEY = "com.github.fonttools.ufoLib2.lib.plist.data" 16 | """ 17 | Lib key used for serializing binary data as JSON-encodable string. 18 | """ 19 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/arrowdown.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/arrowleft.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/arrowright.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/L_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/T_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/quotesinglbase.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | com.typemytype.robofont.Image.Brightness 11 | 0 12 | com.typemytype.robofont.Image.Contrast 13 | 1 14 | com.typemytype.robofont.Image.Saturation 15 | 1 16 | com.typemytype.robofont.Image.Sharpness 17 | 0.4 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/quotedblbase.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | com.typemytype.robofont.Image.Brightness 12 | 0 13 | com.typemytype.robofont.Image.Contrast 14 | 1 15 | com.typemytype.robofont.Image.Saturation 16 | 1 17 | com.typemytype.robofont.Image.Sharpness 18 | 0.4 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/comma.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | public.markColor 20 | 0,0.95,0.95,1 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --all-extras --universal --python 3.9 pyproject.toml 3 | attrs==25.3.0 4 | # via 5 | # ufolib2 (pyproject.toml) 6 | # cattrs 7 | cattrs==25.1.1 8 | # via ufolib2 (pyproject.toml) 9 | exceptiongroup==1.3.0 ; python_full_version < '3.11' 10 | # via cattrs 11 | fonttools==4.59.0 12 | # via ufolib2 (pyproject.toml) 13 | lxml==6.0.0 14 | # via ufolib2 (pyproject.toml) 15 | msgpack==1.1.1 16 | # via ufolib2 (pyproject.toml) 17 | orjson==3.11.0 ; platform_python_implementation != 'PyPy' 18 | # via ufolib2 (pyproject.toml) 19 | typing-extensions==4.14.1 20 | # via 21 | # cattrs 22 | # exceptiongroup 23 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/data/com.daltonmaag.vttLib.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | maxp 6 | 7 | maxFunctionDefs 8 | 89 9 | maxInstructionDefs 10 | 0 11 | maxSizeOfInstructions 12 | 1571 13 | maxStackElements 14 | 542 15 | maxStorage 16 | 47 17 | maxTwilightPoints 18 | 16 19 | maxZones 20 | 2 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/ufoLib2/serde/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import BinaryIO, cast 4 | 5 | from ufoLib2.typing import PathLike 6 | 7 | 8 | def read_bytes(fp: PathLike | BinaryIO) -> bytes: 9 | if hasattr(fp, "read"): 10 | fp = cast(BinaryIO, fp) 11 | return fp.read() 12 | else: 13 | fp = cast(PathLike, fp) # type: ignore 14 | with open(fp, "rb") as f: 15 | return f.read() 16 | 17 | 18 | def write_bytes(fp: PathLike | BinaryIO, data: bytes) -> None: 19 | if hasattr(fp, "write"): 20 | fp = cast(BinaryIO, fp) 21 | fp.write(data) 22 | else: 23 | fp = cast(PathLike, fp) # type: ignore 24 | with open(fp, "wb") as f: 25 | f.write(data) 26 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/I_.narrow.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | com.typemytype.robofont.Image.Brightness 15 | 0 16 | com.typemytype.robofont.Image.Contrast 17 | 1 18 | com.typemytype.robofont.Image.Saturation 19 | 1 20 | com.typemytype.robofont.Image.Sharpness 21 | 0.4 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/V_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/H_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/I_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/N_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/Z_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/F_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/Y_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/objects/test_contour.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from ufoLib2.objects import Glyph 6 | from ufoLib2.objects.contour import Contour 7 | 8 | 9 | @pytest.fixture 10 | def contour() -> Contour: 11 | g = Glyph("a") 12 | pen = g.getPen() 13 | pen.moveTo((0, 0)) 14 | pen.curveTo((10, 10), (10, 20), (0, 20)) 15 | pen.closePath() 16 | return g.contours[0] 17 | 18 | 19 | def test_contour_getBounds(contour: Contour) -> None: 20 | assert contour.getBounds() == (0, 0, 7.5, 20) 21 | assert contour.getBounds(layer={}) == (0, 0, 7.5, 20) 22 | assert contour.bounds == (0, 0, 7.5, 20) 23 | 24 | 25 | def test_contour_getControlBounds(contour: Contour) -> None: 26 | assert contour.getControlBounds() == (0, 0, 10, 20) 27 | assert contour.getControlBounds(layer={}) == (0, 0, 10, 20) 28 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to ufoLib2's documentation! 2 | =================================== 3 | 4 | ufoLib2 is meant to be a thin representation of the Unified Font Object (UFO) version 3 data model, intended for programmatic manipulation and fast batch processing of UFOs. 5 | 6 | It resembles the `defcon `_ library, but does without notifications, the layout engine and other support classes. Where useful and possible, ufoLib2 tries to be API-compatible with defcon. 7 | 8 | See http://unifiedfontobject.org/versions/ufo3/ for the specification of the on-disk data structure. 9 | 10 | .. toctree:: 11 | :maxdepth: 3 12 | :caption: Contents: 13 | 14 | explanations 15 | reference 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/X_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/J_.narrow.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | # measure 'branch' coverage in addition to 'statement' coverage 3 | # See: http://coverage.readthedocs.org/en/latest/branch.html#branch 4 | branch = True 5 | parallel = True 6 | 7 | # list of directories or packages to measure 8 | source = ufoLib2 9 | 10 | # these are treated as equivalent when combining data 11 | [paths] 12 | source = 13 | src/ufoLib2 14 | */site-packages/ufoLib2 15 | 16 | [report] 17 | # Regexes for lines to exclude from consideration 18 | exclude_lines = 19 | # keywords to use in inline comments to skip coverage 20 | pragma: no cover 21 | 22 | # don't complain if tests don't hit defensive assertion code 23 | raise AssertionError 24 | raise NotImplementedError 25 | 26 | # don't complain if non-runnable code isn't run 27 | if 0: 28 | if __name__ == .__main__.: 29 | 30 | # ignore source code that can’t be found 31 | ignore_errors = True 32 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/W_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/ufoLib2/serde/msgpack.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, BinaryIO, Type, cast 4 | 5 | import msgpack # type: ignore 6 | 7 | from ufoLib2.converters import binary_converter 8 | from ufoLib2.serde.util import read_bytes, write_bytes 9 | from ufoLib2.typing import PathLike, T 10 | 11 | 12 | def dumps(obj: Any, **kwargs: Any) -> bytes: 13 | data = binary_converter.unstructure(obj) 14 | result = msgpack.packb(data, **kwargs) 15 | return cast(bytes, result) 16 | 17 | 18 | def loads(s: bytes, object_class: Type[T], **kwargs: Any) -> T: 19 | data = msgpack.unpackb(s, **kwargs) 20 | return binary_converter.structure(data, object_class) 21 | 22 | 23 | def dump(obj: Any, fp: PathLike | BinaryIO, **kwargs: Any) -> None: 24 | write_bytes(fp, dumps(obj, **kwargs)) 25 | 26 | 27 | def load(fp: PathLike | BinaryIO, object_class: Type[T], **kwargs: Any) -> T: 28 | return loads(read_bytes(fp), object_class, **kwargs) 29 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/K_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/M_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/anchor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | from attrs import define 6 | 7 | from ufoLib2.objects.misc import AttrDictMixin 8 | from ufoLib2.serde import serde 9 | 10 | 11 | @serde 12 | @define 13 | class Anchor(AttrDictMixin): 14 | """Represents a single anchor. 15 | 16 | See http://unifiedfontobject.org/versions/ufo3/glyphs/glif/#anchor. 17 | """ 18 | 19 | x: float 20 | """The x coordinate of the anchor.""" 21 | 22 | y: float 23 | """The y coordinate of the anchor.""" 24 | 25 | name: Optional[str] = None 26 | """The name of the anchor.""" 27 | 28 | color: Optional[str] = None 29 | """The color of the anchor.""" 30 | 31 | identifier: Optional[str] = None 32 | """The globally unique identifier of the anchor.""" 33 | 34 | def move(self, delta: tuple[float, float]) -> None: 35 | """Moves anchor by (x, y) font units.""" 36 | x, y = delta 37 | self.x += x 38 | self.y += y 39 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/J_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/U_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/glyphs.public.background/A_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ufoLib2.objects.anchor import Anchor 4 | from ufoLib2.objects.component import Component 5 | from ufoLib2.objects.contour import Contour 6 | from ufoLib2.objects.dataSet import DataSet 7 | from ufoLib2.objects.features import Features 8 | from ufoLib2.objects.font import Font 9 | from ufoLib2.objects.glyph import Glyph 10 | from ufoLib2.objects.guideline import Guideline 11 | from ufoLib2.objects.image import Image 12 | from ufoLib2.objects.imageSet import ImageSet 13 | from ufoLib2.objects.info import Info 14 | from ufoLib2.objects.kerning import Kerning 15 | from ufoLib2.objects.layer import Layer 16 | from ufoLib2.objects.layerSet import LayerSet 17 | from ufoLib2.objects.lib import Lib 18 | from ufoLib2.objects.point import Point 19 | 20 | __all__ = [ 21 | "Anchor", 22 | "Component", 23 | "Contour", 24 | "DataSet", 25 | "Features", 26 | "Font", 27 | "Glyph", 28 | "Guideline", 29 | "Image", 30 | "ImageSet", 31 | "Info", 32 | "Kerning", 33 | "Layer", 34 | "LayerSet", 35 | "Lib", 36 | "Point", 37 | ] 38 | -------------------------------------------------------------------------------- /tests/data/LICENSE_MutatorSans: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Erik van Blokland 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/glyphs/A_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /tests/objects/test_datastore.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from ufoLib2 import Font 8 | 9 | 10 | def test_imageset(tmp_path: Path) -> None: 11 | font = Font() 12 | font.images["test.png"] = b"\x89PNG\r\n\x1a\n123" 13 | font.images["test2.png"] = b"\x89PNG\r\n\x1a\n456" 14 | font_path = tmp_path / "a.ufo" 15 | font.save(font_path) 16 | 17 | font = Font.open(font_path) 18 | assert font.images["test.png"] == b"\x89PNG\r\n\x1a\n123" 19 | assert font.images["test2.png"] == b"\x89PNG\r\n\x1a\n456" 20 | 21 | with pytest.raises(ValueError, match=r".*subdirectories.*"): 22 | font.images["directory/test2.png"] = b"\x89PNG\r\n\x1a\n456" 23 | with pytest.raises(KeyError): 24 | font.images["directory/test2.png"] 25 | 26 | 27 | def test_dataset(tmp_path: Path) -> None: 28 | font = Font() 29 | font.data["test.png"] = b"123" 30 | font.data["directory/test2.png"] = b"456" 31 | font_path = tmp_path / "a.ufo" 32 | font.save(font_path) 33 | 34 | font = Font.open(font_path) 35 | assert font.data["test.png"] == b"123" 36 | assert font.data["directory/test2.png"] == b"456" 37 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/D_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/I_J_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/P_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/features.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING, Type 5 | 6 | from attrs import define 7 | 8 | from ufoLib2.serde import serde 9 | 10 | if TYPE_CHECKING: 11 | from cattrs import Converter 12 | 13 | 14 | RE_NEWLINES = re.compile(r"\r\n|\r") 15 | 16 | 17 | @serde 18 | @define 19 | class Features: 20 | """A data class representing UFO features. 21 | 22 | See http://unifiedfontobject.org/versions/ufo3/features.fea/. 23 | """ 24 | 25 | text: str = "" 26 | """Holds the content of the features.fea file.""" 27 | 28 | def __bool__(self) -> bool: 29 | return bool(self.text) 30 | 31 | def __str__(self) -> str: 32 | return self.text 33 | 34 | def normalize_newlines(self) -> Features: 35 | """Normalize CRLF and CR newlines to just LF.""" 36 | self.text = RE_NEWLINES.sub("\n", self.text) 37 | return self 38 | 39 | def _unstructure(self, converter: Converter) -> str: 40 | del converter # unused 41 | return self.text 42 | 43 | @staticmethod 44 | def _structure(data: str, cls: Type[Features], converter: Converter) -> Features: 45 | del converter # unused 46 | return cls(data) 47 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint, py3{9,10,11,12,13}-cov, htmlcov 3 | isolated_build = true 4 | 5 | [testenv] 6 | deps = 7 | -r requirements.txt 8 | -r requirements-dev.txt 9 | commands = 10 | nocattrs: pip uninstall -y cattrs 11 | noorjson: pip uninstall -y orjson 12 | nomsgpack: pip uninstall -y msgpack 13 | cov: coverage run --parallel-mode -m pytest {posargs} 14 | !cov: pytest {posargs} 15 | 16 | [testenv:htmlcov] 17 | basepython = python3 18 | deps = 19 | coverage 20 | skip_install = true 21 | commands = 22 | coverage combine 23 | coverage report 24 | coverage html 25 | 26 | [testenv:lint] 27 | skip_install = true 28 | deps = 29 | -r requirements.txt 30 | -r requirements-dev.txt 31 | commands = 32 | black --check --diff . 33 | isort --skip-gitignore --check-only --diff src tests 34 | mypy --strict src tests 35 | flake8 36 | 37 | [testenv:docs] 38 | deps = 39 | -r docs/requirements.txt 40 | skip_install = true 41 | commands = 42 | sphinx-build -W -j auto docs/source docs/build 43 | 44 | [flake8] 45 | select = C, E, F, W, B, B9 46 | ignore = E203, E266, E501, W503, E701, E704 47 | max-line-length = 88 48 | exclude = .git, __pycache__, build, dist, .eggs, .tox, venv, venv*, .venv, .venv* 49 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/kerning.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Dict, Mapping, Tuple 4 | 5 | from ufoLib2.serde import serde 6 | 7 | if TYPE_CHECKING: 8 | from typing import Type 9 | 10 | from cattrs import Converter 11 | 12 | KerningPair = Tuple[str, str] 13 | 14 | 15 | @serde 16 | class Kerning(Dict[KerningPair, float]): 17 | def as_nested_dicts(self) -> dict[str, dict[str, float]]: 18 | result: dict[str, dict[str, float]] = {} 19 | for (left, right), value in self.items(): 20 | result.setdefault(left, {})[right] = value 21 | return result 22 | 23 | @classmethod 24 | def from_nested_dicts(self, kerning: Mapping[str, Mapping[str, float]]) -> Kerning: 25 | return Kerning( 26 | ((left, right), kerning[left][right]) 27 | for left in kerning 28 | for right in kerning[left] 29 | ) 30 | 31 | def _unstructure(self, converter: Converter) -> dict[str, dict[str, float]]: 32 | del converter # unused 33 | return self.as_nested_dicts() 34 | 35 | @staticmethod 36 | def _structure( 37 | data: Mapping[str, Mapping[str, float]], 38 | cls: Type[Kerning], 39 | converter: Converter, 40 | ) -> Kerning: 41 | del converter # unused 42 | return cls.from_nested_dicts(data) 43 | -------------------------------------------------------------------------------- /tests/objects/test_component.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from ufoLib2.objects import Component, Glyph, Layer 6 | 7 | 8 | @pytest.fixture 9 | def layer() -> Layer: 10 | a = Glyph("a") 11 | pen = a.getPen() 12 | pen.moveTo((0, 0)) 13 | pen.curveTo((10, 10), (10, 20), (0, 20)) 14 | pen.closePath() 15 | 16 | layer = Layer(glyphs=[a]) 17 | return layer 18 | 19 | 20 | def test_component_getBounds(layer: Layer) -> None: 21 | assert Component("a", (1, 0, 0, 1, 0, 0)).getBounds(layer) == (0, 0, 7.5, 20) 22 | assert Component("a", (1, 0, 0, 1, -5, 0)).getBounds(layer) == (-5, 0, 2.5, 20) 23 | assert Component("a", (1, 0, 0, 1, 0, 5)).getBounds(layer) == (0, 5, 7.5, 25) 24 | 25 | 26 | def test_component_getControlBounds(layer: Layer) -> None: 27 | assert Component("a", (1, 0, 0, 1, 0, 0)).getControlBounds(layer) == (0, 0, 10, 20) 28 | assert Component("a", (1, 0, 0, 1, -5, 0)).getControlBounds(layer) == (-5, 0, 5, 20) 29 | assert Component("a", (1, 0, 0, 1, 0, 5)).getControlBounds(layer) == (0, 5, 10, 25) 30 | 31 | 32 | def test_component_not_in_layer(layer: Layer) -> None: 33 | with pytest.raises(KeyError, match="b"): 34 | Component("b", (1, 0, 0, 1, 0, 0)).getBounds(layer) 35 | with pytest.raises(KeyError, match="b"): 36 | Component("b", (1, 0, 0, 1, 0, 0)).getControlBounds(layer) 37 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/A_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | com.typemytype.robofont.Image.Brightness 34 | 0 35 | com.typemytype.robofont.Image.Contrast 36 | 1 37 | com.typemytype.robofont.Image.Saturation 38 | 1 39 | com.typemytype.robofont.Image.Sharpness 40 | 0.4 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/O_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/point.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | from attrs import define 6 | 7 | from ufoLib2.serde import serde 8 | 9 | 10 | @serde 11 | @define 12 | class Point: 13 | """Represents a single point. 14 | 15 | See http://unifiedfontobject.org/versions/ufo3/glyphs/glif/#point. 16 | """ 17 | 18 | x: float 19 | """The x coordinate of the point.""" 20 | 21 | y: float 22 | """The y coordinate of the point.""" 23 | 24 | type: Optional[str] = None 25 | """The type of the point. 26 | 27 | ``None`` means "offcurve". 28 | 29 | See http://unifiedfontobject.org/versions/ufo3/glyphs/glif/#point-types. 30 | """ 31 | 32 | smooth: bool = False 33 | """Whether a smooth curvature should be maintained at this point.""" 34 | 35 | name: Optional[str] = None 36 | """The name of the point, no uniqueness required.""" 37 | 38 | identifier: Optional[str] = None 39 | """The globally unique identifier of the point.""" 40 | 41 | # XXX: Add post_init to check spec-mandated invariants? 42 | 43 | @property 44 | def segmentType(self) -> str | None: 45 | """Returns the type of the point. 46 | 47 | |defcon_compat| 48 | """ 49 | return self.type 50 | 51 | def move(self, delta: tuple[float, float]) -> None: 52 | """Moves point by (x, y) font units.""" 53 | x, y = delta 54 | self.x += x 55 | self.y += y 56 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/E_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | com.typemytype.robofont.Image.Brightness 35 | 0 36 | com.typemytype.robofont.Image.Contrast 37 | 1 38 | com.typemytype.robofont.Image.Saturation 39 | 1 40 | com.typemytype.robofont.Image.Sharpness 41 | 0.4 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --universal --python 3.9 requirements-dev.in 3 | black==25.1.0 4 | # via -r requirements-dev.in 5 | click==8.1.8 ; python_full_version < '3.10' 6 | # via black 7 | click==8.2.1 ; python_full_version >= '3.10' 8 | # via black 9 | colorama==0.4.6 ; sys_platform == 'win32' 10 | # via 11 | # click 12 | # pytest 13 | coverage==7.9.2 14 | # via -r requirements-dev.in 15 | exceptiongroup==1.3.0 ; python_full_version < '3.11' 16 | # via 17 | # -c requirements.txt 18 | # pytest 19 | flake8==7.3.0 20 | # via -r requirements-dev.in 21 | iniconfig==2.1.0 22 | # via pytest 23 | isort==6.0.1 24 | # via -r requirements-dev.in 25 | mccabe==0.7.0 26 | # via flake8 27 | mypy==1.17.0 28 | # via -r requirements-dev.in 29 | mypy-extensions==1.1.0 30 | # via 31 | # black 32 | # mypy 33 | packaging==25.0 34 | # via 35 | # black 36 | # pytest 37 | pathspec==0.12.1 38 | # via 39 | # black 40 | # mypy 41 | platformdirs==4.3.8 42 | # via black 43 | pluggy==1.6.0 44 | # via pytest 45 | pycodestyle==2.14.0 46 | # via flake8 47 | pyflakes==3.4.0 48 | # via flake8 49 | pygments==2.19.2 50 | # via pytest 51 | pytest==8.4.1 52 | # via -r requirements-dev.in 53 | tomli==2.2.1 ; python_full_version < '3.11' 54 | # via 55 | # black 56 | # mypy 57 | # pytest 58 | typing-extensions==4.14.1 59 | # via 60 | # -c requirements.txt 61 | # black 62 | # exceptiongroup 63 | # mypy 64 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/guideline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | from attrs import define 6 | 7 | from ufoLib2.objects.misc import AttrDictMixin 8 | from ufoLib2.serde import serde 9 | 10 | 11 | @serde 12 | @define 13 | class Guideline(AttrDictMixin): 14 | """Represents a single guideline. 15 | 16 | See http://unifiedfontobject.org/versions/ufo3/glyphs/glif/#guideline. Has some 17 | data composition restrictions. 18 | """ 19 | 20 | x: Optional[float] = None 21 | """The origin x coordinate of the guideline.""" 22 | 23 | y: Optional[float] = None 24 | """The origin y coordinate of the guideline.""" 25 | 26 | angle: Optional[float] = None 27 | """The angle of the guideline.""" 28 | 29 | name: Optional[str] = None 30 | """The name of the guideline, no uniqueness required.""" 31 | 32 | color: Optional[str] = None 33 | """The color of the guideline.""" 34 | 35 | identifier: Optional[str] = None 36 | """The globally unique identifier of the guideline.""" 37 | 38 | def __attrs_post_init__(self) -> None: 39 | x, y, angle = self.x, self.y, self.angle 40 | if x is None and y is None: 41 | raise ValueError("x or y must be present") 42 | if x is None or y is None: 43 | if angle is not None: 44 | raise ValueError("if 'x' or 'y' are None, 'angle' must not be present") 45 | if x is not None and y is not None and angle is None: 46 | raise ValueError("if 'x' and 'y' are defined, 'angle' must be defined") 47 | if angle is not None and not (0 <= angle <= 360): 48 | raise ValueError("angle must be between 0 and 360") 49 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/dataSet.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from fontTools.ufoLib import UFOReader, UFOWriter 4 | 5 | from ufoLib2.objects.misc import DataStore 6 | from ufoLib2.serde import serde 7 | 8 | 9 | @serde 10 | class DataSet(DataStore): 11 | """Represents a mapping of POSIX filename strings to arbitrary data bytes. 12 | 13 | Always use forward slahes (/) as directory separators, even on Windows. 14 | 15 | Behavior: 16 | DataSet behaves like a dictionary of type ``Dict[str, bytes]``. 17 | 18 | >>> from ufoLib2 import Font 19 | >>> font = Font() 20 | >>> font.data["test.txt"] = b"123" 21 | >>> font.data["directory/my_binary_blob.bin"] = b"456" 22 | >>> font.data["test.txt"] 23 | b'123' 24 | >>> del font.data["test.txt"] 25 | >>> list(font.data.items()) 26 | [('directory/my_binary_blob.bin', b'456')] 27 | """ 28 | 29 | @staticmethod 30 | def list_contents(reader: UFOReader) -> list[str]: 31 | """Returns a list of POSIX filename strings in the data store.""" 32 | return reader.getDataDirectoryListing() # type: ignore 33 | 34 | @staticmethod 35 | def read_data(reader: UFOReader, filename: str) -> bytes: 36 | """Returns the data at filename within the store.""" 37 | return reader.readData(filename) # type: ignore 38 | 39 | @staticmethod 40 | def write_data(writer: UFOWriter, filename: str, data: bytes) -> None: 41 | """Writes the data to filename within the store.""" 42 | writer.writeData(filename, data) 43 | 44 | @staticmethod 45 | def remove_data(writer: UFOWriter, filename: str) -> None: 46 | """Remove the data at filename within the store.""" 47 | writer.removeData(filename) 48 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/glyphs.public.background/a.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/G_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/data/com.github.fonttools.ttx/T_S_I__3.ttx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | /* Y direction */ 8 | YAnchor(6,66) 9 | YAnchor(11,65) 10 | YAnchor(12,65) 11 | YAnchor(17,66) 12 | YIPAnchor(12,21,18,17) 13 | YDelta(21,1@14..16) 14 | YShift(18,24) 15 | YLink(18,4,121) 16 | YShift(4,3) 17 | 18 | /* X direction */ 19 | XDist(25,6,<) 20 | XDelta(6,1@8..9) 21 | XDelta(26,1@8,-1@12;15;18;21;24) 22 | XDist(26,17,<) 23 | XHalfGrid(21) 24 | XIPAnchor(17,21,6) 25 | Diagonal><(17,0,12,21,115) 26 | XGDelta(0,-1/4@12) 27 | DAlign(21,20,19,18,3,2,1,0) 28 | Diagonal><(6,5,11,21,115) 29 | XGDelta(5,1/2@12..18) 30 | DAlign(21,22,23,24,4,5) 31 | DAlign(11,10,9,8,7,6) 32 | DAlign(12,13,14,15,16,17) 33 | 34 | Smooth() 35 | XBDelta(15,-1/8@13) 36 | 37 | 38 | 39 | 40 | /* Y direction */ 41 | YAnchor(15,80) 42 | YShift(15,51) 43 | YLink(15,47,136,>=) 44 | YShift(47,50) 45 | YAnchor(26,81) 46 | YShift(26,21) 47 | YLink(26,0,136,>=) 48 | YShift(0,3) 49 | YIPAnchor(26,7,15) 50 | YBDelta(7,-1@8,1@17..18) 51 | YGDelta(7,-1@8,1@18) 52 | YShift(7,4) 53 | YLink(7,36,136,>=) 54 | YShift(36,41) 55 | 56 | /* X direction */ 57 | XDist(54,31,<) 58 | XBDelta(31,1@8,5/4@9..10,-1/4@17,-3/8@18) 59 | XGDelta(31,1@8..9) 60 | XLink(31,12,130,>=) 61 | XDist(31,51,<) 62 | XBDelta(51,-1@10..12) 63 | XShift(51,50) 64 | XDelta(55,1@10) 65 | XLink(55,20,170) 66 | XBDelta(20,7/8@8..9,-1/8@10) 67 | XGDelta(20,1@8..9) 68 | XLink(20,41,127) 69 | XBDelta(41,1/8@18) 70 | XDist(41,3,<) 71 | 72 | Smooth() 73 | YBDelta(10,1/8@17) 74 | YBDelta(18,-1/2@8) 75 | YBDelta(34,-1/2@8) 76 | YBDelta(44,1/8@17) 77 | XBDelta(33,1/8@10) 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/B_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/C_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | com.typemytype.robofont.Image.Brightness 42 | 0 43 | com.typemytype.robofont.Image.Contrast 44 | 1 45 | com.typemytype.robofont.Image.Saturation 46 | 1 47 | com.typemytype.robofont.Image.Sharpness 48 | 0.4 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/R_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | com.typemytype.robofont.Image.Brightness 45 | 0 46 | com.typemytype.robofont.Image.Contrast 47 | 1 48 | com.typemytype.robofont.Image.Saturation 49 | 1 50 | com.typemytype.robofont.Image.Sharpness 51 | 0.4 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/ufoLib2/serde/json.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import Any, BinaryIO, Type 5 | 6 | from ufoLib2.converters import structure, unstructure 7 | from ufoLib2.serde.util import read_bytes, write_bytes 8 | from ufoLib2.typing import PathLike, T 9 | 10 | have_orjson = False 11 | try: 12 | import orjson 13 | 14 | have_orjson = True 15 | except ImportError: 16 | pass 17 | 18 | 19 | def dumps( 20 | obj: Any, 21 | indent: int | None = None, 22 | sort_keys: bool = False, 23 | **kwargs: Any, 24 | ) -> bytes: 25 | data = unstructure(obj) 26 | 27 | if have_orjson: 28 | if indent is not None: 29 | if indent != 2: 30 | raise ValueError("indent must be 2 or None for orjson") 31 | kwargs["option"] = kwargs.pop("option", 0) | orjson.OPT_INDENT_2 32 | if sort_keys: 33 | kwargs["option"] = kwargs.pop("option", 0) | orjson.OPT_SORT_KEYS 34 | # orjson.dumps always returns bytes 35 | result = orjson.dumps(data, **kwargs) 36 | else: 37 | # built-in json.dumps returns a string, not bytes, hence the encoding 38 | s = json.dumps(data, indent=indent, sort_keys=sort_keys, **kwargs) 39 | result = s.encode("utf-8") 40 | return result 41 | 42 | 43 | def loads(s: str | bytes, object_class: Type[T], **kwargs: Any) -> T: 44 | if have_orjson: 45 | data = orjson.loads(s, **kwargs) 46 | else: 47 | data = json.loads(s, **kwargs) 48 | return structure(data, object_class) 49 | 50 | 51 | def dump( 52 | obj: Any, 53 | fp: PathLike | BinaryIO, 54 | indent: int | None = None, 55 | sort_keys: bool = False, 56 | **kwargs: Any, 57 | ) -> None: 58 | write_bytes(fp, dumps(obj, indent=indent, sort_keys=sort_keys, **kwargs)) 59 | 60 | 61 | def load(fp: PathLike | BinaryIO, object_class: Type[T], **kwargs: Any) -> T: 62 | return loads(read_bytes(fp), object_class, **kwargs) 63 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs.background/S_.closed.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | venv-* 100 | .venv-* 101 | 102 | # Spyder project settings 103 | .spyderproject 104 | .spyproject 105 | 106 | # Rope project settings 107 | .ropeproject 108 | 109 | # mkdocs documentation 110 | /site 111 | 112 | # mypy 113 | .mypy_cache/ 114 | .dmypy.json 115 | dmypy.json 116 | 117 | # Pyre type checker 118 | .pyre/ 119 | 120 | # version file generated by setuptools_scm 121 | _version.py 122 | 123 | # Visual Studio Code 124 | .vscode 125 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/imageSet.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from fontTools.ufoLib import UFOReader, UFOWriter 4 | 5 | from ufoLib2.objects.misc import DataStore 6 | from ufoLib2.serde import serde 7 | 8 | 9 | @serde 10 | class ImageSet(DataStore): 11 | """Represents a mapping of POSIX filename strings to arbitrary image data. 12 | 13 | Note: 14 | Images cannot be put into subdirectories of the images folder. 15 | 16 | Behavior: 17 | ImageSet behaves like a dictionary of type ``Dict[str, bytes]``. 18 | 19 | >>> from ufoLib2 import Font 20 | >>> font = Font() 21 | >>> # Note: invalid PNG data for demonstration. Use the actual PNG bytes. 22 | >>> font.images["test.png"] = b"123" 23 | >>> font.images["test2.png"] = b"456" 24 | >>> font.images["test.png"] 25 | b'123' 26 | >>> del font.images["test.png"] 27 | >>> list(font.images.items()) 28 | [('test2.png', b'456')] 29 | """ 30 | 31 | @staticmethod 32 | def list_contents(reader: UFOReader) -> list[str]: 33 | """Returns a list of POSIX filename strings in the image data store.""" 34 | return reader.getImageDirectoryListing() # type: ignore 35 | 36 | @staticmethod 37 | def read_data(reader: UFOReader, filename: str) -> bytes: 38 | """Returns the image data at filename within the store.""" 39 | return reader.readImage(filename) # type: ignore 40 | 41 | @staticmethod 42 | def write_data(writer: UFOWriter, filename: str, data: bytes) -> None: 43 | """Writes the image data to filename within the store.""" 44 | writer.writeImage(filename, data) 45 | 46 | @staticmethod 47 | def remove_data(writer: UFOWriter, filename: str) -> None: 48 | """Remove the image data at filename within the store.""" 49 | writer.removeImage(filename) 50 | 51 | def __setitem__(self, fileName: str, data: bytes) -> None: 52 | if "/" in fileName: 53 | raise ValueError( 54 | "Images cannot be put into subdirectories of the images folder." 55 | ) 56 | super().__setitem__(fileName, data) 57 | -------------------------------------------------------------------------------- /src/ufoLib2/pointPens/glyphPointPen.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from fontTools.misc.transform import Transform 6 | from fontTools.pens.pointPen import AbstractPointPen 7 | 8 | from ufoLib2.objects.component import Component 9 | from ufoLib2.objects.contour import Contour 10 | from ufoLib2.objects.point import Point 11 | 12 | if TYPE_CHECKING: 13 | from ufoLib2.objects.glyph import Glyph 14 | 15 | 16 | class GlyphPointPen(AbstractPointPen): # type: ignore 17 | """A point pen. 18 | 19 | See :mod:`fontTools.pens.basePen` and :mod:`fontTools.pens.pointPen` for an 20 | introduction to pens. 21 | """ 22 | 23 | __slots__ = "_glyph", "_contour" 24 | 25 | def __init__(self, glyph: Glyph) -> None: 26 | self._glyph: Glyph = glyph 27 | self._contour: Contour | None = None 28 | 29 | def beginPath(self, identifier: str | None = None, **kwargs: Any) -> None: 30 | self._contour = Contour(identifier=identifier) 31 | 32 | def endPath(self) -> None: 33 | if self._contour is None: 34 | raise ValueError("Call beginPath first.") 35 | self._glyph.contours.append(self._contour) 36 | self._contour = None 37 | 38 | def addPoint( 39 | self, 40 | pt: tuple[float, float], 41 | segmentType: str | None = None, 42 | smooth: bool = False, 43 | name: str | None = None, 44 | identifier: str | None = None, 45 | **kwargs: Any, 46 | ) -> None: 47 | if self._contour is None: 48 | raise ValueError("Call beginPath first.") 49 | x, y = pt 50 | self._contour.append( 51 | Point( 52 | x, y, type=segmentType, smooth=smooth, name=name, identifier=identifier 53 | ) 54 | ) 55 | 56 | def addComponent( 57 | self, 58 | baseGlyph: str, 59 | transformation: Transform, 60 | identifier: str | None = None, 61 | **kwargs: Any, 62 | ) -> None: 63 | component = Component(baseGlyph, transformation, identifier=identifier) 64 | self._glyph.components.append(component) 65 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/glyphs/a.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/ufoLib2/typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import Protocol, TypeVar, Union 5 | 6 | from fontTools.pens.basePen import AbstractPen 7 | from fontTools.pens.pointPen import AbstractPointPen 8 | 9 | T = TypeVar("T") 10 | """Generic variable for mypy for trivial generic function signatures.""" 11 | 12 | PathLike = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] 13 | """Represents a path in various possible forms.""" 14 | 15 | # can be used with isinstance at runtime to check if something is a path 16 | PATH_TYPES = (str, bytes, os.PathLike) 17 | 18 | 19 | class Drawable(Protocol): 20 | """Stand-in for an object that can draw itself with a given pen. 21 | 22 | See :mod:`fontTools.pens.basePen` for an introduction to pens. 23 | """ 24 | 25 | def draw(self, pen: AbstractPen) -> None: ... 26 | 27 | 28 | class DrawablePoints(Protocol): 29 | """Stand-in for an object that can draw its points with a given pen. 30 | 31 | See :mod:`fontTools.pens.pointPen` for an introduction to point pens. 32 | """ 33 | 34 | def drawPoints(self, pen: AbstractPointPen) -> None: ... 35 | 36 | 37 | class HasIdentifier(Protocol): 38 | """Any object that has a unique identifier in some context that can be 39 | used as a key in a public.objectLibs dictionary.""" 40 | 41 | identifier: str | None 42 | 43 | 44 | class GlyphSet(Protocol): 45 | """Any container that holds drawable objects. 46 | 47 | In ufoLib2, this usually refers to :class:`.Font` (referencing glyphs in the 48 | default layer) and :class:`.Layer` (referencing glyphs in that particular layer). 49 | Ideally, this would be a simple subclass of ``Mapping[str, Union[Drawable, DrawablePoints]]``, 50 | but due to historic reasons, the established objects don't conform to ``Mapping`` 51 | exactly. 52 | 53 | The protocol contains what is used in :mod:`fontTools.pens` at v4.18.2 54 | (grep for ``.glyphSet``). 55 | """ 56 | 57 | # "object" instead of "str" because that's what typeshed says a Mapping should have. 58 | def __contains__(self, name: object) -> bool: ... 59 | 60 | def __getitem__(self, name: str) -> Drawable | DrawablePoints: ... 61 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/fontinfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ascender 6 | 800 7 | capHeight 8 | 800 9 | copyright 10 | License same as MutatorMath. BSD 3-clause. [test-token: A] 11 | descender 12 | -200 13 | familyName 14 | MutatorMathTest 15 | guidelines 16 | 17 | italicAngle 18 | 0 19 | openTypeNameLicense 20 | License same as MutatorMath. BSD 3-clause. [test-token: A] 21 | openTypeOS2VendorID 22 | LTTR 23 | postscriptBlueValues 24 | 25 | -10 26 | 0 27 | 800 28 | 810 29 | 30 | postscriptDefaultWidthX 31 | 500 32 | postscriptFamilyBlues 33 | 34 | postscriptFamilyOtherBlues 35 | 36 | postscriptFontName 37 | MutatorMathTest-BoldCondensed 38 | postscriptFullName 39 | MutatorMathTest BoldCondensed 40 | postscriptOtherBlues 41 | 42 | 500 43 | 520 44 | 45 | postscriptSlantAngle 46 | 0 47 | postscriptStemSnapH 48 | 49 | postscriptStemSnapV 50 | 51 | postscriptWindowsCharacterSet 52 | 1 53 | styleMapFamilyName 54 | 55 | styleMapStyleName 56 | regular 57 | styleName 58 | BoldCondensed 59 | unitsPerEm 60 | 1000 61 | versionMajor 62 | 1 63 | versionMinor 64 | 2 65 | xHeight 66 | 500 67 | year 68 | 2004 69 | 70 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath("../../src")) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = "ufoLib2" 22 | copyright = "2020, The FontTools Authors" 23 | author = "The FontTools Authors" 24 | 25 | 26 | # -- General configuration --------------------------------------------------- 27 | 28 | master_doc = "index" 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | "sphinx.ext.autodoc", 35 | "sphinx.ext.doctest", 36 | "sphinx.ext.intersphinx", 37 | "sphinx.ext.napoleon", 38 | "sphinx.ext.viewcode", 39 | "sphinx_rtd_theme", 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ["_templates"] 44 | 45 | # List of patterns, relative to source directory, that match files and 46 | # directories to ignore when looking for source files. 47 | # This pattern also affects html_static_path and html_extra_path. 48 | # exclude_patterns = [] 49 | 50 | intersphinx_mapping = { 51 | "fontTools": ("https://fonttools.readthedocs.io/en/latest/", None), 52 | } 53 | 54 | 55 | # -- Options for HTML output ------------------------------------------------- 56 | 57 | # The theme to use for HTML and HTML Help pages. See the documentation for 58 | # a list of builtin themes. 59 | # 60 | html_theme = "sphinx_rtd_theme" 61 | 62 | # Add any paths that contain custom static files (such as style sheets) here, 63 | # relative to this directory. They are copied after the builtin static files, 64 | # so a file named "default.css" will overwrite the builtin "default.css". 65 | # html_static_path = ["_static"] 66 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/S_.closed.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | com.letterror.skateboard.navigator 55 | 56 | location 57 | 58 | weight 59 | 1000 60 | width 61 | 108.0069405692 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/image.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, ClassVar, Iterator, Mapping, Optional, Tuple 4 | 5 | from attrs import define, field 6 | from fontTools.misc.transform import Identity, Transform 7 | 8 | from ufoLib2.serde import serde 9 | 10 | from .misc import _convert_transform 11 | 12 | 13 | @serde 14 | @define 15 | class Image(Mapping[str, Any]): 16 | """Represents a background image reference. 17 | 18 | See http://unifiedfontobject.org/versions/ufo3/images/ and 19 | http://unifiedfontobject.org/versions/ufo3/glyphs/glif/#image. 20 | """ 21 | 22 | fileName: Optional[str] = None 23 | """The filename of the image.""" 24 | 25 | transformation: Transform = field(default=Identity, converter=_convert_transform) 26 | """The affine transformation applied to the image.""" 27 | 28 | color: Optional[str] = None 29 | """The color applied to the image.""" 30 | 31 | def clear(self) -> None: 32 | """Resets the image reference to factory settings.""" 33 | self.fileName = None 34 | self.transformation = Identity 35 | self.color = None 36 | 37 | def __bool__(self) -> bool: 38 | """Indicates whether fileName is set.""" 39 | return self.fileName is not None 40 | 41 | # implementation of collections.abc.Mapping abstract methods. 42 | # the fontTools.ufoLib.validators.imageValidator requires that image is a 43 | # subclass of Mapping... 44 | 45 | _transformation_keys_: ClassVar[Tuple[str, str, str, str, str, str]] = ( 46 | "xScale", 47 | "xyScale", 48 | "yxScale", 49 | "yScale", 50 | "xOffset", 51 | "yOffset", 52 | ) 53 | _valid_keys_: ClassVar[Tuple[str, str, str, str, str, str, str, str]] = ( 54 | "fileName", 55 | *_transformation_keys_, 56 | "color", 57 | ) 58 | 59 | def __getitem__(self, key: str) -> Any: 60 | try: 61 | i = self._transformation_keys_.index(key) 62 | except ValueError: 63 | try: 64 | return getattr(self, key) 65 | except AttributeError: 66 | raise KeyError(key) 67 | else: 68 | return self.transformation[i] 69 | 70 | def __len__(self) -> int: 71 | return len(self._valid_keys_) 72 | 73 | def __iter__(self) -> Iterator[str]: 74 | return iter(self._valid_keys_) 75 | -------------------------------------------------------------------------------- /tests/serde/test_msgpack.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | import ufoLib2.objects 6 | 7 | # isort: off 8 | pytest.importorskip("cattrs") 9 | pytest.importorskip("msgpack") 10 | 11 | import msgpack # type: ignore # noqa 12 | 13 | import ufoLib2.serde.msgpack # noqa: E402 14 | 15 | 16 | def test_dumps_loads(ufo_UbuTestData: ufoLib2.objects.Font) -> None: 17 | font = ufo_UbuTestData 18 | data = font.msgpack_dumps() # type: ignore 19 | 20 | assert data[:10] == b"\x85\xa6layers\x92\x82" 21 | 22 | font2 = ufoLib2.objects.Font.msgpack_loads(data) # type: ignore 23 | 24 | assert font == font2 25 | 26 | 27 | def test_dump_load(tmp_path: Path, ufo_UbuTestData: ufoLib2.objects.Font) -> None: 28 | font = ufo_UbuTestData 29 | with open(tmp_path / "test.msgpack", "wb") as f: 30 | font.msgpack_dump(f) # type: ignore 31 | 32 | with open(tmp_path / "test.msgpack", "rb") as f: 33 | font2 = ufoLib2.objects.Font.msgpack_load(f) # type: ignore 34 | 35 | assert font == font2 36 | 37 | # laod/dump work with paths too, not just file objects 38 | font3 = ufoLib2.objects.Font.msgpack_load(tmp_path / "test.msgpack") # type: ignore 39 | 40 | assert font == font3 41 | 42 | font.msgpack_dump(tmp_path / "test2.msgpack") # type: ignore 43 | 44 | assert (tmp_path / "test.msgpack").read_bytes() == ( 45 | tmp_path / "test2.msgpack" 46 | ).read_bytes() 47 | 48 | 49 | def test_allow_bytes(ufo_UbuTestData: ufoLib2.objects.Font) -> None: 50 | font = ufo_UbuTestData 51 | 52 | # DataSet values are binary data (bytes) 53 | assert all(isinstance(v, bytes) for v in font.data.values()) 54 | 55 | # bytes *are* allowed in MessagePack (unlike JSON), so its converter should 56 | # keep them as such (not translate them to Base64 str) upon serializig 57 | b = font.data.msgpack_dumps() # type: ignore 58 | 59 | # check that (even before structuring the DataSet object) the msgpack raw data 60 | # contains in fact bytes, not str 61 | raw_data = msgpack.unpackb(b) 62 | assert isinstance(raw_data, dict) 63 | assert all(isinstance(v, bytes) for v in raw_data.values()) 64 | 65 | # of course, also after structuring, the DataSet should contain bytes 66 | data = font.data.msgpack_loads(b) # type: ignore 67 | assert isinstance(data, ufoLib2.objects.DataSet) 68 | assert all(isinstance(v, bytes) for v in data.values()) 69 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/S_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | com.typemytype.robofont.Image.Brightness 56 | 0 57 | com.typemytype.robofont.Image.Contrast 58 | 1 59 | com.typemytype.robofont.Image.Saturation 60 | 1 61 | com.typemytype.robofont.Image.Sharpness 62 | 0.4 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2", "wheel", "setuptools_scm>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "ufoLib2" 7 | description = "ufoLib2 is a UFO font processing library." 8 | authors = [{ name = "Adrien Tétar", email = "adri-from-59@hotmail.fr" }] 9 | license = { text = "Apache 2.0" } 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Operating System :: OS Independent", 13 | "Programming Language :: Python :: 3", 14 | "Intended Audience :: Developers", 15 | "Intended Audience :: End Users/Desktop", 16 | "Topic :: Text Processing :: Fonts", 17 | "License :: OSI Approved :: Apache Software License", 18 | ] 19 | urls = { Homepage = "https://github.com/fonttools/ufoLib2" } 20 | requires-python = ">=3.9" 21 | dependencies = ["attrs >= 22.1.0", "fonttools[ufo] >= 4.58.0"] 22 | dynamic = ["version"] 23 | 24 | [project.readme] 25 | file = "README.md" 26 | content-type = "text/markdown" 27 | 28 | [project.optional-dependencies] 29 | lxml = ["lxml"] 30 | converters = ["cattrs >= 25.1.1"] 31 | json = ["cattrs >= 25.1.1", "orjson ; platform_python_implementation != 'PyPy'"] 32 | msgpack = ["cattrs >= 25.1.1", "msgpack"] 33 | 34 | [tool.setuptools] 35 | package-dir = { "" = "src" } 36 | license-files = ["LICENSE"] 37 | include-package-data = false 38 | 39 | # https://www.python.org/dev/peps/pep-0561 40 | [tool.setuptools.package-data] 41 | "*" = ["py.typed"] 42 | 43 | [tool.setuptools.packages.find] 44 | where = ["src"] 45 | namespaces = false 46 | 47 | [tool.setuptools_scm] 48 | write_to = "src/ufoLib2/_version.py" 49 | 50 | [tool.black] 51 | target-version = ["py39"] 52 | 53 | [tool.isort] 54 | multi_line_output = 3 55 | profile = "black" 56 | float_to_top = true 57 | known_first_party = "ufoLib2" 58 | 59 | [tool.pytest.ini_options] 60 | minversion = "6.0" 61 | testpaths = ["tests", "ufoLib2"] 62 | addopts = "-ra --doctest-modules --doctest-ignore-import-errors --pyargs" 63 | doctest_optionflags = ["ALLOW_UNICODE", "ELLIPSIS"] 64 | filterwarnings = [ 65 | "ignore::DeprecationWarning:fs", 66 | "ignore::DeprecationWarning:pkg_resources", 67 | ] 68 | 69 | [tool.mypy] 70 | python_version = "3.9" 71 | disallow_incomplete_defs = true 72 | no_implicit_optional = true 73 | strict_optional = true 74 | warn_no_return = true 75 | warn_redundant_casts = true 76 | warn_unreachable = true 77 | strict_equality = true 78 | 79 | [[tool.mypy.overrides]] 80 | module = "ufoLib2.*" 81 | disallow_untyped_defs = true 82 | 83 | [[tool.mypy.overrides]] 84 | module = ["fontTools.*", "ufoLib2._version"] 85 | ignore_missing_imports = true 86 | -------------------------------------------------------------------------------- /docs/source/reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. |defcon_compat| replace:: For defcon API compatibility only. 5 | 6 | Core Objects 7 | ------------ 8 | 9 | Font 10 | ^^^^ 11 | 12 | .. autoclass:: ufoLib2.objects.Font 13 | :members: 14 | :undoc-members: 15 | 16 | Info 17 | ^^^^ 18 | 19 | .. autoclass:: ufoLib2.objects.Info 20 | :members: 21 | :undoc-members: 22 | 23 | Features 24 | ^^^^^^^^ 25 | 26 | .. autoclass:: ufoLib2.objects.features.Features 27 | :members: 28 | :undoc-members: 29 | 30 | DataSet 31 | ^^^^^^^ 32 | 33 | .. autoclass:: ufoLib2.objects.dataSet.DataSet 34 | :inherited-members: 35 | :undoc-members: 36 | :exclude-members: list_contents, read_data, write_data, remove_data 37 | 38 | LayerSet 39 | ^^^^^^^^ 40 | 41 | .. autoclass:: ufoLib2.objects.LayerSet 42 | :members: 43 | :undoc-members: 44 | :exclude-members: loadLayer 45 | 46 | Layer 47 | ^^^^^ 48 | 49 | .. autoclass:: ufoLib2.objects.Layer 50 | :members: 51 | :undoc-members: 52 | 53 | Glyph 54 | ^^^^^ 55 | 56 | .. autoclass:: ufoLib2.objects.Glyph 57 | :members: 58 | :undoc-members: 59 | 60 | Component 61 | ^^^^^^^^^ 62 | 63 | .. autoclass:: ufoLib2.objects.Component 64 | :members: 65 | :undoc-members: 66 | 67 | Contour 68 | ^^^^^^^ 69 | 70 | .. autoclass:: ufoLib2.objects.Contour 71 | :members: 72 | :undoc-members: 73 | 74 | Point 75 | ^^^^^ 76 | 77 | .. autoclass:: ufoLib2.objects.Point 78 | :members: 79 | :undoc-members: 80 | 81 | Anchor 82 | ^^^^^^ 83 | 84 | .. autoclass:: ufoLib2.objects.Anchor 85 | :members: 86 | :undoc-members: 87 | 88 | Guideline 89 | ^^^^^^^^^ 90 | 91 | .. autoclass:: ufoLib2.objects.Guideline 92 | :members: 93 | :undoc-members: 94 | 95 | ImageSet 96 | ^^^^^^^^ 97 | 98 | .. autoclass:: ufoLib2.objects.imageSet.ImageSet 99 | :inherited-members: 100 | :undoc-members: 101 | :exclude-members: list_contents, read_data, write_data, remove_data 102 | 103 | Image 104 | ^^^^^ 105 | 106 | .. autoclass:: ufoLib2.objects.Image 107 | :members: 108 | :undoc-members: 109 | 110 | Miscellaneous objects 111 | ^^^^^^^^^^^^^^^^^^^^^ 112 | 113 | .. autoclass:: ufoLib2.objects.misc.BoundingBox 114 | :members: 115 | :undoc-members: 116 | 117 | Types 118 | ----- 119 | 120 | .. automodule:: ufoLib2.typing 121 | :members: 122 | :undoc-members: 123 | 124 | Pens 125 | ---- 126 | 127 | .. automodule:: ufoLib2.pointPens.glyphPointPen 128 | :members: 129 | :undoc-members: 130 | 131 | Constants 132 | --------- 133 | 134 | .. automodule:: ufoLib2.constants 135 | :members: 136 | :undoc-members: 137 | 138 | Exceptions 139 | ---------- 140 | 141 | .. automodule:: ufoLib2.errors 142 | :members: 143 | :undoc-members: 144 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/glyphs/contents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | A 6 | A_.glif 7 | Aacute 8 | A_acute.glif 9 | Adieresis 10 | A_dieresis.glif 11 | B 12 | B_.glif 13 | C 14 | C_.glif 15 | D 16 | D_.glif 17 | E 18 | E_.glif 19 | F 20 | F_.glif 21 | G 22 | G_.glif 23 | H 24 | H_.glif 25 | I 26 | I_.glif 27 | I.narrow 28 | I_.narrow.glif 29 | IJ 30 | I_J_.glif 31 | J 32 | J_.glif 33 | J.narrow 34 | J_.narrow.glif 35 | K 36 | K_.glif 37 | L 38 | L_.glif 39 | M 40 | M_.glif 41 | N 42 | N_.glif 43 | O 44 | O_.glif 45 | P 46 | P_.glif 47 | Q 48 | Q_.glif 49 | R 50 | R_.glif 51 | S 52 | S_.glif 53 | S.closed 54 | S_.closed.glif 55 | T 56 | T_.glif 57 | U 58 | U_.glif 59 | V 60 | V_.glif 61 | W 62 | W_.glif 63 | X 64 | X_.glif 65 | Y 66 | Y_.glif 67 | Z 68 | Z_.glif 69 | acute 70 | acute.glif 71 | arrowdown 72 | arrowdown.glif 73 | arrowleft 74 | arrowleft.glif 75 | arrowright 76 | arrowright.glif 77 | arrowup 78 | arrowup.glif 79 | colon 80 | colon.glif 81 | comma 82 | comma.glif 83 | dieresis 84 | dieresis.glif 85 | dot 86 | dot.glif 87 | period 88 | period.glif 89 | quotedblbase 90 | quotedblbase.glif 91 | quotedblleft 92 | quotedblleft.glif 93 | quotedblright 94 | quotedblright.glif 95 | quotesinglbase 96 | quotesinglbase.glif 97 | semicolon 98 | semicolon.glif 99 | space 100 | space.glif 101 | 102 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ["v*.*.*"] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | - name: Lint 21 | run: | 22 | pip install tox 23 | tox -e lint 24 | 25 | docs: # To see if they build. 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - name: Set up Python 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: "3.x" 34 | - name: Lint 35 | run: | 36 | pip install tox 37 | tox -e docs 38 | 39 | test: 40 | runs-on: ${{ matrix.platform }} 41 | strategy: 42 | matrix: 43 | python-version: ["3.9", "3.13"] 44 | platform: [ubuntu-latest, windows-latest] 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | - name: Set up Python ${{ matrix.python-version }} 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version: ${{ matrix.python-version }} 52 | - name: Install packages 53 | run: pip install tox coverage 54 | - name: Run Tox 55 | run: tox -e py-cov 56 | - name: Re-run Tox without cattrs 57 | if: startsWith(matrix.platform, 'ubuntu-latest') && startsWith(matrix.python-version, '3.13') 58 | run: | 59 | tox -e py-nocattrs 60 | - name: Re-run Tox without msgpack 61 | if: startsWith(matrix.platform, 'ubuntu-latest') && startsWith(matrix.python-version, '3.13') 62 | run: | 63 | tox -e py-nomsgpack 64 | - name: Produce coverage files 65 | run: | 66 | coverage combine 67 | coverage xml 68 | - name: Upload coverage to Codecov 69 | uses: codecov/codecov-action@v1 70 | with: 71 | file: coverage.xml 72 | flags: unittests 73 | name: codecov-umbrella 74 | fail_ci_if_error: false 75 | 76 | deploy: 77 | # only run if the commit is tagged... 78 | if: startsWith(github.ref, 'refs/tags/v') 79 | # ... and the previous jobs completed successfully 80 | needs: 81 | - lint 82 | - docs 83 | - test 84 | runs-on: ubuntu-latest 85 | 86 | steps: 87 | - uses: actions/checkout@v4 88 | - name: Set up Python 89 | uses: actions/setup-python@v5 90 | with: 91 | python-version: "3.x" 92 | - name: Install dependencies 93 | run: | 94 | pip install build twine 95 | - name: Build and publish 96 | env: 97 | TWINE_USERNAME: __token__ 98 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 99 | run: | 100 | python -m build 101 | twine check dist/* 102 | twine upload dist/* 103 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/lib.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Dict, Mapping, Union, cast 4 | 5 | from ufoLib2.constants import DATA_LIB_KEY 6 | from ufoLib2.serde import serde 7 | 8 | if TYPE_CHECKING: 9 | from typing import Type 10 | 11 | from cattrs import Converter 12 | 13 | # unfortunately mypy is not smart enough to support recursive types like plist... 14 | # PlistEncodable = Union[ 15 | # bool, 16 | # bytes, 17 | # datetime, 18 | # float, 19 | # int, 20 | # str, 21 | # Mapping[str, PlistEncodable], 22 | # Sequence[PlistEncodable], 23 | # ] 24 | 25 | 26 | def _convert_Lib(value: Mapping[str, Any]) -> Lib: 27 | return value if isinstance(value, Lib) else Lib(value) 28 | 29 | 30 | # getter/setter properties used by Font, Layer, Glyph 31 | def _get_lib(self: Any) -> Lib: 32 | return cast(Lib, self._lib) 33 | 34 | 35 | def _set_lib(self: Any, value: Mapping[str, Any]) -> None: 36 | self._lib = _convert_Lib(value) 37 | 38 | 39 | def _get_tempLib(self: Any) -> Lib: 40 | return cast(Lib, self._tempLib) 41 | 42 | 43 | def _set_tempLib(self: Any, value: Mapping[str, Any]) -> None: 44 | self._tempLib = _convert_Lib(value) 45 | 46 | 47 | def is_data_dict(value: Any) -> bool: 48 | return ( 49 | isinstance(value, Mapping) 50 | and "type" in value 51 | and value["type"] == DATA_LIB_KEY 52 | and "data" in value 53 | ) 54 | 55 | 56 | def _unstructure_data(value: Any, converter: Converter) -> Any: 57 | if isinstance(value, bytes): 58 | return {"type": DATA_LIB_KEY, "data": converter.unstructure(value)} 59 | elif isinstance(value, (list, tuple)): 60 | return [_unstructure_data(v, converter) for v in value] 61 | elif isinstance(value, Mapping): 62 | return {k: _unstructure_data(v, converter) for k, v in value.items()} 63 | return value 64 | 65 | 66 | def _structure_data_inplace( 67 | key: Union[int, str], value: Any, container: Any, converter: Converter 68 | ) -> None: 69 | if isinstance(value, list): 70 | for i, v in enumerate(value): 71 | _structure_data_inplace(i, v, value, converter) 72 | elif is_data_dict(value): 73 | container[key] = converter.structure(value["data"], bytes) 74 | elif isinstance(value, Mapping): 75 | for k, v in value.items(): 76 | _structure_data_inplace(k, v, value, converter) 77 | 78 | 79 | @serde 80 | class Lib(Dict[str, Any]): 81 | def _unstructure(self, converter: Converter) -> dict[str, Any]: 82 | # avoid encoding if converter supports bytes natively 83 | test = converter.unstructure(b"\0") 84 | if isinstance(test, bytes): 85 | return dict(self) 86 | elif not isinstance(test, str): 87 | raise NotImplementedError(type(test)) 88 | 89 | data: dict[str, Any] = _unstructure_data(self, converter) 90 | return data 91 | 92 | @staticmethod 93 | def _structure( 94 | data: Mapping[str, Any], 95 | cls: Type[Lib], 96 | converter: Converter, 97 | ) -> Lib: 98 | self = cls(data) 99 | for k, v in self.items(): 100 | _structure_data_inplace(k, v, self, converter) 101 | return self 102 | -------------------------------------------------------------------------------- /tests/serde/test_serde.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from typing import Any, Dict, List 3 | 4 | import pytest 5 | from attrs import define 6 | 7 | import ufoLib2.objects 8 | from ufoLib2.errors import ExtrasNotInstalledError 9 | from ufoLib2.serde import _SERDE_FORMATS_, serde 10 | 11 | cattrs = None 12 | try: 13 | import cattrs # type: ignore 14 | except ImportError: 15 | pass 16 | 17 | 18 | msgpack = None 19 | try: 20 | import msgpack # type: ignore 21 | except ImportError: 22 | pass 23 | 24 | 25 | EXTRAS_REQUIREMENTS = { 26 | "json": ["cattrs"], 27 | "msgpack": ["cattrs", "msgpack"], 28 | } 29 | 30 | 31 | def assert_extras_not_installed(extras: str, missing_dependency: str) -> None: 32 | # sanity check that the dependency is not installed 33 | with pytest.raises(ImportError, match=missing_dependency): 34 | importlib.import_module(missing_dependency) 35 | 36 | @serde 37 | @define 38 | class Foo: 39 | a: int 40 | b: str = "bar" 41 | 42 | foo = Foo(1) 43 | 44 | with pytest.raises( 45 | ExtrasNotInstalledError, match=rf"Extras not installed: ufoLib2\[{extras}\]" 46 | ) as exc_info: 47 | dumps_method = getattr(foo, f"{extras}_dumps") 48 | dumps_method() 49 | 50 | assert isinstance(exc_info.value.__cause__, ModuleNotFoundError) 51 | 52 | 53 | @pytest.mark.skipif(cattrs is not None, reason="cattrs installed, not applicable") 54 | def test_json_cattrs_not_installed() -> None: 55 | assert_extras_not_installed("json", "cattrs") 56 | 57 | 58 | @pytest.mark.skipif(cattrs is not None, reason="cattrs installed, not applicable") 59 | def test_msgpack_cattrs_not_installed() -> None: 60 | assert_extras_not_installed("msgpack", "cattrs") 61 | 62 | 63 | @pytest.mark.skipif(msgpack is not None, reason="msgpack installed, not applicable") 64 | def test_msgpack_not_installed() -> None: 65 | assert_extras_not_installed("msgpack", "msgpack") 66 | 67 | 68 | BASIC_EMPTY_OBJECTS: List[Dict[str, Any]] = [ 69 | {"class_name": "Anchor", "args": (0, 0)}, 70 | {"class_name": "Component", "args": ("a",)}, 71 | {"class_name": "Contour", "args": ()}, 72 | {"class_name": "DataSet", "args": ()}, 73 | {"class_name": "Features", "args": ()}, 74 | {"class_name": "Font", "args": ()}, 75 | {"class_name": "Glyph", "args": ()}, 76 | {"class_name": "Guideline", "args": (1,)}, 77 | {"class_name": "Image", "args": ()}, 78 | {"class_name": "ImageSet", "args": ()}, 79 | {"class_name": "Info", "args": ()}, 80 | {"class_name": "Kerning", "args": ()}, 81 | {"class_name": "Layer", "args": ()}, 82 | { 83 | "class_name": "LayerSet", 84 | "args": ({"public.default": ufoLib2.objects.Layer()},), 85 | }, 86 | {"class_name": "Lib", "args": ()}, 87 | {"class_name": "Point", "args": (2, 3)}, 88 | ] 89 | assert {d["class_name"] for d in BASIC_EMPTY_OBJECTS} == set(ufoLib2.objects.__all__) 90 | 91 | 92 | @pytest.mark.parametrize("fmt", _SERDE_FORMATS_) 93 | @pytest.mark.parametrize( 94 | "object_info", 95 | BASIC_EMPTY_OBJECTS, 96 | ids=lambda x: x["class_name"], 97 | ) 98 | def test_serde_all_objects(fmt: str, object_info: Dict[str, Any]) -> None: 99 | for req in EXTRAS_REQUIREMENTS[fmt]: 100 | pytest.importorskip(req) 101 | 102 | klass = getattr(ufoLib2.objects, object_info["class_name"]) 103 | loads = getattr(klass, f"{fmt}_loads") 104 | obj = klass(*object_info["args"]) 105 | dumps = getattr(obj, f"{fmt}_dumps") 106 | obj2 = loads(dumps()) 107 | assert obj == obj2 108 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/component.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from typing import Optional 5 | 6 | from attrs import define, field 7 | from fontTools.misc.transform import Identity, Transform 8 | from fontTools.pens.basePen import AbstractPen 9 | from fontTools.pens.pointPen import AbstractPointPen, PointToSegmentPen 10 | 11 | from ufoLib2.objects.misc import BoundingBox 12 | from ufoLib2.serde import serde 13 | from ufoLib2.typing import GlyphSet 14 | 15 | from .misc import _convert_transform, getBounds, getControlBounds 16 | 17 | 18 | @serde 19 | @define 20 | class Component: 21 | """Represents a reference to another glyph in the same layer. 22 | 23 | See http://unifiedfontobject.org/versions/ufo3/glyphs/glif/#component. 24 | 25 | Note: 26 | Components always refer to glyphs in the same layer. Referencing different 27 | layers is currently not possible in the UFO data model. 28 | """ 29 | 30 | baseGlyph: str 31 | """The name of the glyph in the same layer to insert.""" 32 | 33 | transformation: Transform = field(default=Identity, converter=_convert_transform) 34 | """The affine transformation to apply to the :attr:`.Component.baseGlyph`.""" 35 | 36 | identifier: Optional[str] = None 37 | """The globally unique identifier of the component.""" 38 | 39 | def move(self, delta: tuple[float, float]) -> None: 40 | """Moves this component by (x, y) font units. 41 | 42 | NOTE: This interprets the delta to be the visual delta, as in, it 43 | replaces the x and y offsets of the component's transformation 44 | directly, rather than going through 45 | :meth:`fontTools.misc.transform.Transform.translate`. Otherwise, 46 | composites that use flipped components (imagine a ``quotedblleft`` 47 | composite using two x- and y-inverted ``comma`` components) 48 | would move in the opposite direction of the delta. 49 | """ 50 | x, y = delta 51 | xx, xy, yx, yy, dx, dy = self.transformation 52 | self.transformation = Transform(xx, xy, yx, yy, dx + x, dy + y) 53 | 54 | def getBounds(self, layer: GlyphSet) -> BoundingBox | None: 55 | """Returns the (xMin, yMin, xMax, yMax) bounding box of the component, 56 | taking the actual contours into account. 57 | 58 | Args: 59 | layer: The layer of the containing glyph to look up components. 60 | """ 61 | return getBounds(self, layer) 62 | 63 | def getControlBounds(self, layer: GlyphSet) -> BoundingBox | None: 64 | """Returns the (xMin, yMin, xMax, yMax) bounding box of the component, 65 | taking only the control points into account. 66 | 67 | Args: 68 | layer: The layer of the containing glyph to look up components. 69 | """ 70 | return getControlBounds(self, layer) 71 | 72 | # ----------- 73 | # Pen methods 74 | # ----------- 75 | 76 | def draw(self, pen: AbstractPen) -> None: 77 | """Draws component with given pen.""" 78 | pointPen = PointToSegmentPen(pen) 79 | self.drawPoints(pointPen) 80 | 81 | def drawPoints(self, pointPen: AbstractPointPen) -> None: 82 | """Draws points of component with given point pen.""" 83 | try: 84 | pointPen.addComponent( 85 | self.baseGlyph, self.transformation, identifier=self.identifier 86 | ) 87 | except TypeError: 88 | pointPen.addComponent(self.baseGlyph, self.transformation) 89 | warnings.warn( 90 | "The addComponent method needs an identifier kwarg. " 91 | "The component's identifier value has been discarded.", 92 | UserWarning, 93 | ) 94 | -------------------------------------------------------------------------------- /tests/objects/test_layer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from ufoLib2.objects import Glyph, Layer 6 | 7 | 8 | def test_init_layer_with_glyphs_dict() -> None: 9 | a = Glyph() 10 | b = Glyph() 11 | 12 | layer = Layer("My Layer", {"a": a, "b": b}) 13 | 14 | assert layer.name == "My Layer" 15 | assert "a" in layer 16 | assert layer["a"] is a 17 | assert a.name == "a" 18 | assert "b" in layer 19 | assert layer["b"] is b 20 | assert b.name == "b" 21 | 22 | with pytest.raises( 23 | ValueError, match="glyph has incorrect name: expected 'a', found 'b'" 24 | ): 25 | Layer(glyphs={"a": b}) 26 | 27 | with pytest.raises(KeyError, match=".*Glyph .* can't be added twice"): 28 | Layer(glyphs={"a": a, "b": a}) 29 | 30 | with pytest.raises(TypeError, match="Expected Glyph, found int"): 31 | Layer(glyphs={"a": 1}) # type: ignore 32 | 33 | 34 | def test_init_layer_with_glyphs_list() -> None: 35 | a = Glyph("a") 36 | b = Glyph("b") 37 | layer = Layer(glyphs=[a, b]) 38 | 39 | assert layer["a"] is a 40 | assert layer["b"] is b 41 | 42 | with pytest.raises(KeyError, match="glyph named 'a' already exists"): 43 | Layer(glyphs=[a, a]) 44 | 45 | c = Glyph() 46 | with pytest.raises(ValueError, match=".*Glyph .* has no name"): 47 | Layer(glyphs=[c]) 48 | 49 | with pytest.raises(KeyError, match="glyph named 'b' already exists"): 50 | Layer(glyphs=[a, b, Glyph("b")]) 51 | 52 | with pytest.raises(TypeError, match="Expected Glyph, found int"): 53 | Layer(glyphs=[1]) # type: ignore 54 | 55 | 56 | def test_addGlyph() -> None: 57 | a = Glyph("a") 58 | 59 | layer = Layer() 60 | 61 | layer.addGlyph(a) 62 | 63 | assert "a" in layer 64 | assert layer["a"] is a 65 | 66 | with pytest.raises(KeyError, match="glyph named 'a' already exists"): 67 | layer.addGlyph(a) 68 | 69 | 70 | def test_insertGlyph() -> None: 71 | g = Glyph() 72 | pen = g.getPen() 73 | pen.moveTo((0, 0)) 74 | pen.lineTo((1, 1)) 75 | pen.lineTo((0, 1)) 76 | pen.closePath() 77 | 78 | layer = Layer() 79 | layer.insertGlyph(g, "a") 80 | 81 | assert "a" in layer 82 | assert layer["a"].name == "a" 83 | assert layer["a"].contours == g.contours 84 | assert layer["a"] is not g 85 | 86 | layer.insertGlyph(g, "b") 87 | assert "b" in layer 88 | assert layer["b"].name == "b" 89 | assert layer["b"].contours == layer["a"].contours 90 | assert layer["b"] is not layer["a"] 91 | assert layer["b"] is not g 92 | 93 | assert g.name is None 94 | 95 | with pytest.raises(KeyError, match="glyph named 'a' already exists"): 96 | layer.insertGlyph(g, "a", overwrite=False) 97 | 98 | with pytest.raises(ValueError, match=".*Glyph .* has no name; can't add it"): 99 | layer.insertGlyph(g) 100 | 101 | 102 | def test_newGlyph() -> None: 103 | layer = Layer() 104 | a = layer.newGlyph("a") 105 | 106 | assert "a" in layer 107 | assert layer["a"] is a 108 | 109 | with pytest.raises(KeyError, match="glyph named 'a' already exists"): 110 | layer.newGlyph("a") 111 | 112 | 113 | def test_renameGlyph() -> None: 114 | g = Glyph() 115 | 116 | layer = Layer(glyphs={"a": g}) 117 | assert g.name == "a" 118 | 119 | layer.renameGlyph("a", "a") # no-op 120 | assert g.name == "a" 121 | 122 | layer.renameGlyph("a", "b") 123 | assert g.name == "b" 124 | 125 | layer.insertGlyph(g, "a") 126 | 127 | with pytest.raises(KeyError, match="target glyph named 'a' already exists"): 128 | layer.renameGlyph("b", "a") 129 | -------------------------------------------------------------------------------- /docs/source/explanations.rst: -------------------------------------------------------------------------------- 1 | Explanations 2 | ============ 3 | 4 | How UFO data is read, written and validated 5 | ------------------------------------------- 6 | 7 | ufoLib2 structures UFO data for easy programmatic access, but the actual data is read, 8 | written and validated by :mod:`fontTools.ufoLib`. This helps keep the base code used by 9 | various UFO libraries in one place. 10 | 11 | ufoLib has the concept of lazy loading. Instead of loading everything eagerly up-front, 12 | glyphs, images and data files are loaded into memory as they are accessed. This speeds 13 | up access and saves memory when you only want to access or modify something specific. 14 | 15 | You can choose lazy or eager loading at the top level in :meth:`.Font.open`. You can 16 | load everything eagerly after the fact with :meth:`.Font.unlazify` or selectively with 17 | :meth:`.LayerSet.unlazify`, :meth:`.Layer.unlazify`, :meth:`.ImageSet.unlazify` and 18 | :meth:`.DataSet.unlazify`. Copying a font implicitly loads everything eagerly before. 19 | 20 | You can choose to validate data during loading and saving at the top level in 21 | :meth:`.Font.open` and :meth:`.Font.save`. This may help if you are working with faulty 22 | data you want to fix programmatically. 23 | 24 | Copying objects 25 | --------------- 26 | 27 | All objects are made to support deep copies with the ``copy`` module from the standard 28 | library. Any lazily loaded data on the way down will be loaded into memory for the 29 | copy:: 30 | 31 | import copy 32 | copy.deepcopy(font) 33 | copy.deepcopy(font.layers["myLayer"]) 34 | copy.deepcopy(font["glyphName"]) 35 | copy.deepcopy(font["glyphName"].contours[0]) 36 | copy.deepcopy(font["glyphName"].contours[0].points[0]) 37 | 38 | Since ufoLib2 does not keep track of "parent" objects, the copied objects can be freely 39 | inserted elsewhere:: 40 | 41 | font["glyphNameCopy"] = copy.deepcopy(font["glyphName"]) 42 | 43 | Defcon's API can't be matched exactly 44 | ------------------------------------- 45 | 46 | ufoLib2 is meant to be a thin wrapper around the UFO data model and intentionally does 47 | not implement some of defcon's properties: 48 | 49 | 1. ufoLib2 does not keep track of "parents" of objects like defcon does. This makes it 50 | impossible to implement some methods that implicitly access the parent object, like 51 | defcon's ``bounds`` property on e.g. Glyph objects, which needs access to the 52 | parent layer to resolve components. ufoLib2 then implements similar methods that 53 | ask for a ``layer`` parameter. 54 | 55 | 2. ufoLib2 does not support notifications, as that concerns only font editing 56 | applications. 57 | 58 | Handling of the ``public.objectLibs`` lib key 59 | --------------------------------------------- 60 | 61 | ufoLib2 implements handling of ``public.objectLibs`` (see 62 | https://github.com/unified-font-object/ufo-spec/issues/115), but instead of 63 | attaching libs directly to the objects where they belong, they stay in a font's 64 | and glyph's lib and have to be retrieved with :meth:`.Font.objectLib` and 65 | :meth:`.Glyph.objectLib`, respectively. 66 | 67 | Using a higher-up lib means objects can get libs on UFO v3 without format 68 | changes, but complicates adding libs to the objects directly. Since ufoLib2 69 | intentionally lacks defcon's parent-child object hierarchy, a guideline can't 70 | follow a link to the containing parent and access its lib. Magically loading 71 | and storing the ``public.objectLibs`` lib key on loading and saving a UFO adds 72 | an uncomfortable amount of magic and edge-casiness. What if a client itself 73 | adds a ``public.objectLibs`` entry for an object that differs from what is in 74 | that object's lib? Overwrite, ignore, error out? With the ``.objectLib`` 75 | method, this edge case does not exist. 76 | -------------------------------------------------------------------------------- /tests/objects/test_object_lib.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | from ufoLib2.objects import Anchor, Font, Guideline 6 | 7 | 8 | def test_object_lib_roundtrip(tmp_path: Path) -> None: 9 | ufo = Font() 10 | 11 | ufo.info.guidelines = [Guideline(x=100), Guideline(y=200)] 12 | guideline_lib = ufo.objectLib(ufo.info.guidelines[1]) 13 | guideline_lib["com.test.foo"] = 1234 14 | 15 | ufo.newGlyph("component") 16 | glyph = ufo.newGlyph("test") 17 | 18 | glyph.guidelines = [Guideline(x=300), Guideline(y=400)] 19 | glyph_guideline_lib = glyph.objectLib(glyph.guidelines[1]) 20 | glyph_guideline_lib["com.test.foo"] = 4321 21 | 22 | glyph.anchors = [Anchor(x=1, y=2, name="top"), Anchor(x=3, y=4, name="bottom")] 23 | anchor_lib = glyph.objectLib(glyph.anchors[1]) 24 | anchor_lib["com.test.anchorTool"] = True 25 | 26 | pen = glyph.getPen() 27 | pen.moveTo((0, 0)) 28 | pen.lineTo((100, 200)) 29 | pen.lineTo((200, 400)) 30 | pen.closePath() 31 | pen.moveTo((1000, 1000)) 32 | pen.lineTo((1000, 2000)) 33 | pen.lineTo((2000, 4000)) 34 | pen.closePath() 35 | pen.addComponent("component", (1, 0, 0, 1, 0, 0)) 36 | pen.addComponent("component", (1, 0, 0, 1, 0, 0)) 37 | 38 | contour_lib = glyph.objectLib(glyph.contours[0]) 39 | contour_lib["com.test.foo"] = "abc" 40 | point_lib = glyph.objectLib(glyph.contours[1].points[0]) 41 | point_lib["com.test.foo"] = "abc" 42 | component_lib = glyph.objectLib(glyph.components[0]) 43 | component_lib["com.test.foo"] = "abc" 44 | 45 | ufo.save(tmp_path / "test.ufo") 46 | 47 | # Roundtrip 48 | ufo_reload = Font.open(tmp_path / "test.ufo") 49 | 50 | assert ufo_reload.info.guidelines is not None 51 | reload_guideline_lib = ufo_reload.objectLib(ufo_reload.info.guidelines[1]) 52 | reload_glyph = ufo_reload["test"] 53 | reload_glyph_guideline_lib = reload_glyph.objectLib(reload_glyph.guidelines[1]) 54 | reload_anchor_lib = reload_glyph.objectLib(reload_glyph.anchors[1]) 55 | reload_contour_lib = reload_glyph.objectLib(reload_glyph.contours[0]) 56 | reload_point_lib = reload_glyph.objectLib(reload_glyph.contours[1].points[0]) 57 | reload_component_lib = reload_glyph.objectLib(reload_glyph.components[0]) 58 | 59 | assert reload_guideline_lib == guideline_lib 60 | assert reload_glyph_guideline_lib == glyph_guideline_lib 61 | assert reload_anchor_lib == anchor_lib 62 | assert reload_contour_lib == contour_lib 63 | assert reload_point_lib == point_lib 64 | assert reload_component_lib == component_lib 65 | 66 | 67 | def test_object_lib_prune(tmp_path: Path) -> None: 68 | ufo = Font() 69 | 70 | ufo.info.guidelines = [Guideline(x=100), Guideline(y=200)] 71 | _ = ufo.objectLib(ufo.info.guidelines[0]) 72 | guideline_lib = ufo.objectLib(ufo.info.guidelines[1]) 73 | guideline_lib["com.test.foo"] = 1234 74 | ufo.lib["public.objectLibs"]["aaaa"] = {"1": 1} 75 | 76 | ufo.newGlyph("component") 77 | glyph = ufo.newGlyph("test") 78 | 79 | glyph.guidelines = [Guideline(x=300), Guideline(y=400)] 80 | _ = glyph.objectLib(glyph.guidelines[0]) 81 | glyph_guideline_lib = glyph.objectLib(glyph.guidelines[1]) 82 | glyph_guideline_lib["com.test.foo"] = 4321 83 | glyph.lib["public.objectLibs"]["aaaa"] = {"1": 1} 84 | 85 | ufo.save(tmp_path / "test.ufo") 86 | 87 | # Roundtrip 88 | ufo_reload = Font.open(tmp_path / "test.ufo") 89 | assert set(ufo_reload.lib["public.objectLibs"].keys()) == { 90 | ufo.info.guidelines[1].identifier 91 | } 92 | assert set(ufo_reload["test"].lib["public.objectLibs"].keys()) == { 93 | glyph.guidelines[1].identifier 94 | } 95 | 96 | # Empty object libs are pruned from objectLibs, but the identifiers stay. 97 | assert ufo.info.guidelines[0].identifier is not None 98 | assert glyph.guidelines[0].identifier is not None 99 | -------------------------------------------------------------------------------- /tests/serde/test_json.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | import pytest 8 | 9 | import ufoLib2.objects 10 | 11 | # isort: off 12 | pytest.importorskip("cattrs") 13 | 14 | import ufoLib2.serde.json # noqa: E402 15 | 16 | 17 | @pytest.mark.parametrize("have_orjson", [False, True], ids=["no-orjson", "with-orjson"]) 18 | def test_dumps_loads( 19 | monkeypatch: Any, have_orjson: bool, ufo_UbuTestData: ufoLib2.objects.Font 20 | ) -> None: 21 | if not have_orjson: 22 | monkeypatch.setattr(ufoLib2.serde.json, "have_orjson", have_orjson) 23 | else: 24 | pytest.importorskip("orjson") 25 | 26 | font = ufo_UbuTestData 27 | data = font.json_dumps() # type: ignore 28 | 29 | assert isinstance(data, bytes) 30 | 31 | if have_orjson: 32 | # with default indent=0, orjson adds no space between keys and values 33 | assert data[:21] == b'{"layers":[{"name":"p' 34 | else: 35 | # built-in json always adds space between keys and values 36 | assert data[:21] == b'{"layers": [{"name": ' 37 | 38 | font2 = ufoLib2.objects.Font.json_loads(data) # type: ignore 39 | 40 | assert font == font2 41 | 42 | 43 | @pytest.mark.parametrize("have_orjson", [False, True], ids=["no-orjson", "with-orjson"]) 44 | @pytest.mark.parametrize("indent", [None, 2], ids=["no-indent", "indent-2"]) 45 | @pytest.mark.parametrize("sort_keys", [False, True], ids=["no-sort-keys", "sort-keys"]) 46 | def test_dump_load( 47 | monkeypatch: Any, 48 | tmp_path: Path, 49 | ufo_UbuTestData: ufoLib2.objects.Font, 50 | have_orjson: bool, 51 | indent: int | None, 52 | sort_keys: bool, 53 | ) -> None: 54 | if not have_orjson: 55 | monkeypatch.setattr(ufoLib2.serde.json, "have_orjson", have_orjson) 56 | 57 | font = ufo_UbuTestData 58 | with open(tmp_path / "test.json", "wb") as f: 59 | font.json_dump(f, indent=indent, sort_keys=sort_keys) # type: ignore 60 | 61 | with open(tmp_path / "test.json", "rb") as f: 62 | font2 = ufoLib2.objects.Font.json_load(f) # type: ignore 63 | 64 | assert font == font2 65 | 66 | with open(tmp_path / "test.json", "wb") as f: 67 | font.json_dump(f, indent=indent, sort_keys=sort_keys) # type: ignore 68 | 69 | # load/dump work with paths too, not just file objects 70 | font3 = ufoLib2.objects.Font.json_load(tmp_path / "test.json") # type: ignore 71 | 72 | assert font == font3 73 | 74 | font.json_dump( # type: ignore 75 | tmp_path / "test2.json", 76 | indent=indent, 77 | sort_keys=sort_keys, 78 | ) 79 | 80 | assert (tmp_path / "test.json").read_bytes() == ( 81 | tmp_path / "test2.json" 82 | ).read_bytes() 83 | 84 | 85 | @pytest.mark.parametrize("indent", [1, 3], ids=["indent-1", "indent-3"]) 86 | def test_indent_not_2_orjson(indent: int) -> None: 87 | pytest.importorskip("orjson") 88 | with pytest.raises(ValueError): 89 | ufoLib2.serde.json.dumps(None, indent=indent) 90 | 91 | 92 | def test_not_allow_bytes(ufo_UbuTestData: ufoLib2.objects.Font) -> None: 93 | font = ufo_UbuTestData 94 | 95 | # DataSet values are binary data (bytes) 96 | assert all(isinstance(v, bytes) for v in font.data.values()) 97 | 98 | # bytes are are not allowed in JSON, so our converter automatically 99 | # translates them to Base64 strings upon serializig 100 | s = font.data.json_dumps() # type: ignore 101 | 102 | # check that (before structuring the DataSet object) the json data 103 | # contains in fact str, not bytes 104 | raw_data = json.loads(s) 105 | assert isinstance(raw_data, dict) 106 | assert all(isinstance(v, str) for v in raw_data.values()) 107 | 108 | # check that (after structuring the DataSet object) the json data 109 | # now contains bytes, like the original data 110 | data = font.data.json_loads(s) # type: ignore 111 | assert isinstance(data, ufoLib2.objects.DataSet) 112 | assert all(isinstance(v, bytes) for v in data.values()) 113 | -------------------------------------------------------------------------------- /src/ufoLib2/serde/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import partialmethod 4 | from importlib import import_module 5 | from typing import IO, Any, AnyStr, Callable, Type 6 | 7 | from ufoLib2.errors import ExtrasNotInstalledError 8 | from ufoLib2.typing import PathLike, T 9 | 10 | _SERDE_FORMATS_ = ("json", "msgpack") 11 | 12 | 13 | # the @serde decorator sets these as @classmethods on the class, hence 14 | # the cls argument must appear as the first argument; in the standalone loads/load 15 | # functions the input string or file-like object is the first argument and the second 16 | # argument is the object_class, so below just swap the order of the arguments 17 | def _loads( 18 | cls: Type[T], s: str | bytes, *, __callback: Callable[..., T], **kwargs: Any 19 | ) -> T: 20 | return __callback(s, cls, **kwargs) 21 | 22 | 23 | def _load( 24 | cls: Type[T], 25 | fp: PathLike | IO[AnyStr], 26 | *, 27 | __callback: Callable[..., T], 28 | **kwargs: Any, 29 | ) -> T: 30 | return __callback(fp, cls, **kwargs) 31 | 32 | 33 | def serde(cls: Type[T]) -> Type[T]: 34 | """Decorator to add serialization support to a ufoLib2 class. 35 | 36 | This adds f"{format}_loads" / f"{format}_dumps" (from/to bytes) methods, and 37 | f"{format}_load" / f"{format}_dump" (for file or path) methods to all ufoLib2 38 | objects, not just Font. 39 | 40 | Currently the supported formats are JSON and MessagePack (msgpack), but other 41 | formats may be added in the future. 42 | 43 | E.g.:: 44 | 45 | from ufoLib2 import Font 46 | 47 | font = Font.open("MyFont.ufo") 48 | font.json_dump("MyFont.json") 49 | font2 = Font.json_load("MyFont.json") 50 | font3 = Font.json_loads(font2.json_dumps()) 51 | 52 | font3.msgpack_dump("MyFont.msgpack") 53 | font4 = Font.msgpack_load("MyFont.msgpack") 54 | # etc. 55 | 56 | Note this requires additional extras to be installed: e.g. ufoLib2[json,msgpack]. 57 | In additions to the respective serialization library, these installs the `cattrs` 58 | library for structuring/unstructuring custom objects from/to serializable data 59 | structures (also available separately as ufoLib2[converters] extra). 60 | 61 | If any of the optional dependencies fails to be imported, the methods will raise 62 | an ImportError when called. 63 | 64 | If the faster `orjson` library is present, it will be used in place of the 65 | built-in `json` library on CPython. On PyPy, the `orjson` library is not available, 66 | so the built-in `json` library will be used (though it's pretty fast anyway). 67 | 68 | If you want a serialization format that works out of the box with all ufoLib2 69 | objects (but it's mostly limited to Python) you can use the built-in pickle module, 70 | which doesn't require to use the `cattrs` converters. 71 | 72 | """ 73 | 74 | supported_formats = [] 75 | for fmt in _SERDE_FORMATS_: 76 | try: 77 | serde_submodule = import_module(f"ufoLib2.serde.{fmt}") 78 | except ImportError as exc: 79 | raise_error = ExtrasNotInstalledError(fmt) 80 | raise_error.__cause__ = exc 81 | for method in ("loads", "load", "dumps", "dump"): 82 | setattr(cls, f"{fmt}_{method}", raise_error) 83 | else: 84 | setattr( 85 | cls, 86 | f"{fmt}_loads", 87 | partialmethod(classmethod(_loads), __callback=serde_submodule.loads), # type: ignore[arg-type] 88 | ) 89 | setattr( 90 | cls, 91 | f"{fmt}_load", 92 | partialmethod(classmethod(_load), __callback=serde_submodule.load), # type: ignore[arg-type] 93 | ) 94 | setattr(cls, f"{fmt}_dumps", serde_submodule.dumps) 95 | setattr(cls, f"{fmt}_dump", serde_submodule.dump) 96 | supported_formats.append(fmt) 97 | 98 | setattr(cls, "_SERDE_FORMATS_", tuple(supported_formats)) 99 | 100 | return cls 101 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/kerning.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | A 6 | 7 | J 8 | -20 9 | O 10 | -30 11 | T 12 | -70 13 | U 14 | -30 15 | V 16 | -50 17 | 18 | B 19 | 20 | A 21 | -20 22 | J 23 | -50 24 | O 25 | -20 26 | S 27 | -10 28 | T 29 | -10 30 | U 31 | -20 32 | V 33 | -30 34 | 35 | C 36 | 37 | A 38 | -20 39 | J 40 | -50 41 | T 42 | -20 43 | V 44 | -20 45 | 46 | E 47 | 48 | J 49 | -20 50 | T 51 | -10 52 | V 53 | -10 54 | 55 | F 56 | 57 | A 58 | -40 59 | J 60 | -80 61 | O 62 | -10 63 | S 64 | -20 65 | U 66 | -10 67 | V 68 | -10 69 | 70 | G 71 | 72 | J 73 | -20 74 | S 75 | -10 76 | T 77 | -40 78 | U 79 | -10 80 | V 81 | -30 82 | 83 | H 84 | 85 | J 86 | -30 87 | S 88 | -10 89 | T 90 | -10 91 | 92 | J 93 | 94 | J 95 | -70 96 | 97 | L 98 | 99 | J 100 | -20 101 | O 102 | -20 103 | T 104 | -110 105 | U 106 | -20 107 | V 108 | -60 109 | 110 | O 111 | 112 | A 113 | -30 114 | J 115 | -60 116 | S 117 | -10 118 | T 119 | -30 120 | V 121 | -30 122 | 123 | P 124 | 125 | A 126 | -50 127 | J 128 | -100 129 | S 130 | -10 131 | T 132 | -10 133 | U 134 | -10 135 | V 136 | -20 137 | 138 | R 139 | 140 | H 141 | -10 142 | J 143 | -20 144 | O 145 | -30 146 | S 147 | -20 148 | T 149 | -30 150 | U 151 | -30 152 | V 153 | -40 154 | 155 | S 156 | 157 | A 158 | -20 159 | H 160 | -20 161 | J 162 | -40 163 | O 164 | -10 165 | S 166 | -10 167 | T 168 | -30 169 | U 170 | -10 171 | V 172 | -30 173 | W 174 | -10 175 | 176 | T 177 | 178 | A 179 | -65 180 | H 181 | -10 182 | J 183 | -130 184 | O 185 | -20 186 | 187 | U 188 | 189 | A 190 | -30 191 | J 192 | -60 193 | S 194 | -10 195 | V 196 | -10 197 | 198 | V 199 | 200 | J 201 | -100 202 | O 203 | -30 204 | S 205 | -20 206 | U 207 | -10 208 | 209 | 210 | -------------------------------------------------------------------------------- /tests/data/LICENSE_UbuTestData.txt: -------------------------------------------------------------------------------- 1 | ------------------------------- 2 | UBUNTU FONT LICENCE Version 1.0 3 | ------------------------------- 4 | 5 | PREAMBLE 6 | This licence allows the licensed fonts to be used, studied, modified and 7 | redistributed freely. The fonts, including any derivative works, can be 8 | bundled, embedded, and redistributed provided the terms of this licence 9 | are met. The fonts and derivatives, however, cannot be released under 10 | any other licence. The requirement for fonts to remain under this 11 | licence does not require any document created using the fonts or their 12 | derivatives to be published under this licence, as long as the primary 13 | purpose of the document is not to be a vehicle for the distribution of 14 | the fonts. 15 | 16 | DEFINITIONS 17 | "Font Software" refers to the set of files released by the Copyright 18 | Holder(s) under this licence and clearly marked as such. This may 19 | include source files, build scripts and documentation. 20 | 21 | "Original Version" refers to the collection of Font Software components 22 | as received under this licence. 23 | 24 | "Modified Version" refers to any derivative made by adding to, deleting, 25 | or substituting -- in part or in whole -- any of the components of the 26 | Original Version, by changing formats or by porting the Font Software to 27 | a new environment. 28 | 29 | "Copyright Holder(s)" refers to all individuals and companies who have a 30 | copyright ownership of the Font Software. 31 | 32 | "Substantially Changed" refers to Modified Versions which can be easily 33 | identified as dissimilar to the Font Software by users of the Font 34 | Software comparing the Original Version with the Modified Version. 35 | 36 | To "Propagate" a work means to do anything with it that, without 37 | permission, would make you directly or secondarily liable for 38 | infringement under applicable copyright law, except executing it on a 39 | computer or modifying a private copy. Propagation includes copying, 40 | distribution (with or without modification and with or without charging 41 | a redistribution fee), making available to the public, and in some 42 | countries other activities as well. 43 | 44 | PERMISSION & CONDITIONS 45 | This licence does not grant any rights under trademark law and all such 46 | rights are reserved. 47 | 48 | Permission is hereby granted, free of charge, to any person obtaining a 49 | copy of the Font Software, to propagate the Font Software, subject to 50 | the below conditions: 51 | 52 | 1) Each copy of the Font Software must contain the above copyright 53 | notice and this licence. These can be included either as stand-alone 54 | text files, human-readable headers or in the appropriate machine- 55 | readable metadata fields within text or binary files as long as those 56 | fields can be easily viewed by the user. 57 | 58 | 2) The font name complies with the following: 59 | (a) The Original Version must retain its name, unmodified. 60 | (b) Modified Versions which are Substantially Changed must be renamed to 61 | avoid use of the name of the Original Version or similar names entirely. 62 | (c) Modified Versions which are not Substantially Changed must be 63 | renamed to both (i) retain the name of the Original Version and (ii) add 64 | additional naming elements to distinguish the Modified Version from the 65 | Original Version. The name of such Modified Versions must be the name of 66 | the Original Version, with "derivative X" where X represents the name of 67 | the new work, appended to that name. 68 | 69 | 3) The name(s) of the Copyright Holder(s) and any contributor to the 70 | Font Software shall not be used to promote, endorse or advertise any 71 | Modified Version, except (i) as required by this licence, (ii) to 72 | acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with 73 | their explicit written permission. 74 | 75 | 4) The Font Software, modified or unmodified, in part or in whole, must 76 | be distributed entirely under this licence, and must not be distributed 77 | under any other licence. The requirement for fonts to remain under this 78 | licence does not affect any document created using the Font Software, 79 | except any version of the Font Software extracted from a document 80 | created using the Font Software may only be distributed under this 81 | licence. 82 | 83 | TERMINATION 84 | This licence becomes null and void if any of the above conditions are 85 | not met. 86 | 87 | DISCLAIMER 88 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 89 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 90 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF 91 | COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 92 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 93 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 94 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 95 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER 96 | DEALINGS IN THE FONT SOFTWARE. 97 | -------------------------------------------------------------------------------- /tests/objects/test_font.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pickle 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | import pytest 8 | 9 | from ufoLib2.objects import Font, Glyph, Guideline, Layer 10 | 11 | 12 | def test_font_equality(datadir: Path) -> None: 13 | font1 = Font.open(datadir / "UbuTestData.ufo") 14 | font2 = Font.open(datadir / "UbuTestData.ufo") 15 | 16 | assert font1 == font2 17 | 18 | class SubFont(Font): 19 | pass 20 | 21 | font3 = SubFont.open(datadir / "UbuTestData.ufo") 22 | assert font1 != font3 23 | 24 | 25 | def test_font_mapping_behavior(ufo_UbuTestData: Font) -> None: 26 | font = ufo_UbuTestData 27 | 28 | assert font["a"] is font.layers.defaultLayer["a"] 29 | assert ("a" in font) == ("a" in font.layers.defaultLayer) 30 | assert len(font) == len(font.layers.defaultLayer) 31 | 32 | glyph = Glyph("b") 33 | font["b"] = glyph 34 | assert font["b"] is glyph 35 | assert font.layers.defaultLayer["b"] is glyph 36 | 37 | del font["a"] 38 | assert "a" not in font 39 | assert "a" not in font.layers.defaultLayer 40 | 41 | 42 | def test_font_defcon_behavior(ufo_UbuTestData: Font) -> None: 43 | font = ufo_UbuTestData 44 | 45 | font.newGlyph("b") 46 | assert "b" in font 47 | 48 | glyph = Glyph("c") 49 | font.addGlyph(glyph) 50 | assert font["c"] is glyph 51 | 52 | font.renameGlyph("c", "d") 53 | assert font["d"] is glyph 54 | assert font["d"].name == "d" 55 | 56 | guideline = Guideline(x=1) 57 | font.appendGuideline(guideline) 58 | assert font.info.guidelines is not None 59 | assert font.info.guidelines[-1] is guideline 60 | 61 | font.appendGuideline({"y": 1, "name": "asdf"}) 62 | assert font.info.guidelines[-1].name == "asdf" 63 | 64 | font.newLayer("abc") 65 | assert "abc" in font.layers 66 | 67 | font.renameLayer("abc", "def") 68 | assert "abc" not in font.layers 69 | assert "def" in font.layers 70 | 71 | 72 | def test_nondefault_layer_name(ufo_UbuTestData: Font, tmp_path: Path) -> None: 73 | font = ufo_UbuTestData 74 | 75 | font.layers.renameLayer("public.default", "abc") 76 | font.save(tmp_path / "abc.ufo") 77 | font2 = Font.open(tmp_path / "abc.ufo") 78 | 79 | assert font2.layers.defaultLayer.name == "abc" 80 | assert font2.layers.defaultLayer is font2.layers["abc"] 81 | 82 | 83 | def test_layer_order(ufo_UbuTestData: Font) -> None: 84 | font = ufo_UbuTestData 85 | 86 | assert font.layers.layerOrder == ["public.default", "public.background"] 87 | font.layers.layerOrder = ["public.background", "public.default"] 88 | assert font.layers.layerOrder == ["public.background", "public.default"] 89 | 90 | 91 | def test_bounds(ufo_UbuTestData: Font) -> None: 92 | font = ufo_UbuTestData 93 | 94 | assert font.bounds == (8, -11, 655, 693) 95 | assert font.controlPointBounds == (8, -11, 655, 693) 96 | 97 | 98 | def test_data_images_init() -> None: 99 | font = Font( 100 | data={"aaa": b"123", "bbb/c": b"456"}, 101 | images={"a.png": b"\x89PNG\r\n\x1a\n", "b.png": b"\x89PNG\r\n\x1a\n"}, 102 | ) 103 | 104 | assert font.data["aaa"] == b"123" 105 | assert font.data["bbb/c"] == b"456" 106 | assert font.images["a.png"] == b"\x89PNG\r\n\x1a\n" 107 | assert font.images["b.png"] == b"\x89PNG\r\n\x1a\n" 108 | 109 | 110 | @pytest.mark.parametrize( 111 | "lazy", [None, False, True], ids=["lazy-unset", "non-lazy", "lazy"] 112 | ) 113 | def test_pickle_lazy_font(datadir: Path, lazy: Optional[bool]) -> None: 114 | if lazy is not None: 115 | font = Font.open(datadir / "UbuTestData.ufo", lazy=lazy) 116 | else: 117 | # lazy is None by default for a Font that is not opened from a file 118 | # but created from scratch 119 | font = Font() 120 | 121 | assert lazy is font._lazy 122 | 123 | data = pickle.dumps(font) 124 | 125 | assert isinstance(data, bytes) and len(data) > 0 126 | 127 | # picklying unlazifies 128 | if lazy: 129 | assert font._lazy is False 130 | else: 131 | assert font._lazy is lazy 132 | 133 | font2 = pickle.loads(data) 134 | 135 | assert isinstance(font2, Font) 136 | assert font == font2 137 | # unpickling doesn't initialize the lazy flag or a reader, which reset to default 138 | assert font2._lazy is None 139 | assert font2._reader is None 140 | 141 | 142 | def test_tempLib_not_saved(tmp_path: Path) -> None: 143 | font = Font( 144 | layers=[ 145 | Layer( 146 | glyphs=[Glyph("a", tempLib={"something": 123})], 147 | tempLib={"hello": b"world"}, 148 | ) 149 | ], 150 | tempLib={"foo": {"bar": b"baz"}}, 151 | ) 152 | assert font.tempLib 153 | assert font.layers.defaultLayer.tempLib 154 | assert font["a"].tempLib 155 | 156 | font.save(tmp_path / "Font.ufo") 157 | 158 | font2 = Font.open(tmp_path / "Font.ufo") 159 | assert not font2.tempLib 160 | assert not font2.layers.defaultLayer.tempLib 161 | assert not font2["a"].tempLib 162 | -------------------------------------------------------------------------------- /tests/data/WoffMetadataTest.ufo/fontinfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | woffMajorVersion 6 | 1 7 | woffMetadataCopyright 8 | 9 | text 10 | 11 | 12 | language 13 | en 14 | text 15 | Copyright ©2009 Font Vendor 16 | 17 | 18 | language 19 | ko 20 | text 21 | 저작권 ©2009 Font Vendor 22 | 23 | 24 | 25 | woffMetadataCredits 26 | 27 | credits 28 | 29 | 30 | name 31 | Font Designer 32 | role 33 | Lead 34 | url 35 | http://fontdesigner.example.com 36 | 37 | 38 | name 39 | Another Font Designer 40 | role 41 | Contributor 42 | url 43 | http://anotherdesigner.example.com 44 | 45 | 46 | 47 | woffMetadataDescription 48 | 49 | text 50 | 51 | 52 | language 53 | en 54 | text 55 | A member of the Demo font family... 56 | 57 | 58 | 59 | woffMetadataExtensions 60 | 61 | 62 | id 63 | org.example.fonts.metadata.v1 64 | items 65 | 66 | 67 | id 68 | org.example.fonts.metadata.v1.why 69 | names 70 | 71 | 72 | language 73 | en 74 | text 75 | Purpose 76 | 77 | 78 | language 79 | fr 80 | text 81 | But 82 | 83 | 84 | values 85 | 86 | 87 | language 88 | en 89 | text 90 | An example of WOFF packaging 91 | 92 | 93 | language 94 | fr 95 | text 96 | Un exemple de l'empaquetage de WOFF 97 | 98 | 99 | 100 | 101 | names 102 | 103 | 104 | language 105 | en 106 | text 107 | Additional font information 108 | 109 | 110 | language 111 | fr 112 | text 113 | L'information supplémentaire de fonte 114 | 115 | 116 | 117 | 118 | woffMetadataLicense 119 | 120 | id 121 | fontvendor-Web-corporate-v2 122 | text 123 | 124 | 125 | language 126 | en 127 | text 128 | A license goes here 129 | 130 | 131 | language 132 | fr 133 | text 134 | Un permis va ici 135 | 136 | 137 | url 138 | http://fontvendor.example.com/license 139 | 140 | woffMetadataLicensee 141 | 142 | name 143 | Wonderful Websites, Inc. 144 | 145 | woffMetadataTrademark 146 | 147 | text 148 | 149 | 150 | language 151 | en 152 | text 153 | Demo Font is a trademark of Font Vendor 154 | 155 | 156 | language 157 | fr 158 | text 159 | Demo Font est une marque déposée de Font Vendor 160 | 161 | 162 | 163 | woffMetadataUniqueID 164 | 165 | id 166 | com.example.fontvendor.demofont.rev12345 167 | 168 | woffMetadataVendor 169 | 170 | name 171 | Font Vendor 172 | url 173 | http://fontvendor.example.com 174 | 175 | woffMinorVersion 176 | 0 177 | 178 | 179 | -------------------------------------------------------------------------------- /src/ufoLib2/converters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from functools import partial 5 | from typing import Any, Callable, Tuple, Type, cast 6 | 7 | from attrs import fields, has, resolve_types 8 | from cattrs import Converter 9 | from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override 10 | from cattrs.gen._consts import AttributeOverride 11 | from fontTools.misc.transform import Transform 12 | 13 | is_py37 = sys.version_info[:2] == (3, 7) 14 | 15 | if is_py37: 16 | 17 | def get_origin(cl: Type[Any]) -> Any: 18 | return getattr(cl, "__origin__", None) 19 | 20 | else: 21 | from typing import get_origin # type: ignore 22 | 23 | 24 | __all__ = [ 25 | "register_hooks", 26 | "structure", 27 | "unstructure", 28 | ] 29 | 30 | 31 | def is_ufoLib2_class(cls: Type[Any]) -> bool: 32 | mod: str = getattr(cls, "__module__", "") 33 | return mod.split(".")[0] == "ufoLib2" 34 | 35 | 36 | def is_ufoLib2_attrs_class(cls: Type[Any]) -> bool: 37 | return is_ufoLib2_class(cls) and (has(cls) or has(get_origin(cls))) 38 | 39 | 40 | def is_ufoLib2_class_with_custom_unstructure(cls: Type[Any]) -> bool: 41 | return is_ufoLib2_class(cls) and hasattr(cls, "_unstructure") 42 | 43 | 44 | def is_ufoLib2_class_with_custom_structure(cls: Type[Any]) -> bool: 45 | return is_ufoLib2_class(cls) and hasattr(cls, "_structure") 46 | 47 | 48 | def register_hooks(conv: Converter, allow_bytes: bool = True) -> None: 49 | def attrs_hook_factory( 50 | cls: Type[Any], gen_fn: Callable[..., Callable[..., Any]], structuring: bool 51 | ) -> Callable[..., Any]: 52 | base = get_origin(cls) 53 | if base is None: 54 | base = cls 55 | attribs = fields(base) 56 | # PEP563 postponed annotations need resolving as we check Attribute.type below 57 | resolve_types(base) 58 | kwargs: dict[str, bool | AttributeOverride] = { 59 | "_cattrs_detailed_validation": conv.detailed_validation 60 | } 61 | if structuring: 62 | kwargs["_cattrs_forbid_extra_keys"] = conv.forbid_extra_keys 63 | kwargs["_cattrs_prefer_attrib_converters"] = conv._prefer_attrib_converters 64 | else: 65 | kwargs["_cattrs_omit_if_default"] = conv.omit_if_default 66 | for a in attribs: 67 | if a.type in conv.type_overrides: 68 | # cattrs' gen_(un)structure_attrs_fromdict (used by default for attrs 69 | # classes that don't have a custom hook registered) check for any 70 | # type_overrides (Dict[Type, AttributeOverride]); they allow a custom 71 | # converter to omit specific attributes of given type e.g.: 72 | # >>> conv = Converter(type_overrides={Image: override(omit=True)}) 73 | attrib_override = conv.type_overrides[a.type] 74 | else: 75 | # by default, we omit all Optional attributes (i.e. with None default), 76 | # overriding a Converter's global 'omit_if_default' option. Specific 77 | # attibutes can still define their own 'omit_if_default' behavior in 78 | # the Attribute.metadata dict. 79 | attrib_override = override( 80 | omit_if_default=a.metadata.get( 81 | "omit_if_default", a.default is None or None 82 | ), 83 | rename=a.metadata.get( 84 | "rename_attr", a.name[1:] if a.name[0] == "_" else None 85 | ), 86 | omit=not a.init, 87 | ) 88 | kwargs[a.name] = attrib_override 89 | 90 | return gen_fn(cls, conv, **kwargs) 91 | 92 | def custom_unstructure_hook_factory(cls: Type[Any]) -> Callable[[Any], Any]: 93 | return partial(cls._unstructure, converter=conv) 94 | 95 | def custom_structure_hook_factory(cls: Type[Any]) -> Callable[[Any, Any], Any]: 96 | return partial(cls._structure, converter=conv) 97 | 98 | def unstructure_transform(t: Transform) -> Tuple[float]: 99 | return cast(Tuple[float], tuple(t)) 100 | 101 | def structure_transform(t: Tuple[float], _: Any) -> Transform: 102 | return Transform(*t) 103 | 104 | conv.register_unstructure_hook_factory( 105 | is_ufoLib2_attrs_class, 106 | partial(attrs_hook_factory, gen_fn=make_dict_unstructure_fn, structuring=False), 107 | ) 108 | conv.register_unstructure_hook_factory( 109 | is_ufoLib2_class_with_custom_unstructure, 110 | custom_unstructure_hook_factory, 111 | ) 112 | conv.register_unstructure_hook( 113 | cast(Type[Transform], Transform), unstructure_transform 114 | ) 115 | 116 | conv.register_structure_hook(cast(Type[Transform], Transform), structure_transform) 117 | conv.register_structure_hook_factory( 118 | is_ufoLib2_attrs_class, 119 | partial(attrs_hook_factory, gen_fn=make_dict_structure_fn, structuring=True), 120 | ) 121 | conv.register_structure_hook_factory( 122 | is_ufoLib2_class_with_custom_structure, 123 | custom_structure_hook_factory, 124 | ) 125 | 126 | if not allow_bytes: 127 | from base64 import b64decode, b64encode 128 | 129 | def unstructure_bytes(v: bytes) -> str: 130 | return (b64encode(v) if v else b"").decode("utf8") 131 | 132 | def structure_bytes(v: str, _: Any) -> bytes: 133 | return b64decode(v) 134 | 135 | conv.register_unstructure_hook(bytes, unstructure_bytes) 136 | conv.register_structure_hook(bytes, structure_bytes) 137 | 138 | 139 | default_converter = Converter( 140 | omit_if_default=True, 141 | forbid_extra_keys=True, 142 | prefer_attrib_converters=False, 143 | ) 144 | register_hooks(default_converter, allow_bytes=False) 145 | 146 | structure = default_converter.structure 147 | unstructure = default_converter.unstructure 148 | 149 | # same as default_converter but allows bytes 150 | binary_converter = Converter( 151 | omit_if_default=True, 152 | forbid_extra_keys=True, 153 | prefer_attrib_converters=False, 154 | ) 155 | register_hooks(binary_converter, allow_bytes=True) 156 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/contour.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from typing import Iterable, Iterator, List, MutableSequence, Optional, overload 5 | 6 | from attrs import define, field 7 | from fontTools.pens.basePen import AbstractPen 8 | from fontTools.pens.pointPen import AbstractPointPen, PointToSegmentPen 9 | 10 | from ufoLib2.objects.misc import BoundingBox, getBounds, getControlBounds 11 | from ufoLib2.objects.point import Point 12 | from ufoLib2.serde import serde 13 | from ufoLib2.typing import GlyphSet 14 | 15 | 16 | @serde 17 | @define 18 | class Contour(MutableSequence[Point]): 19 | """Represents a contour as a list of points. 20 | 21 | Behavior: 22 | The Contour object has list-like behavior. This behavior allows you to interact 23 | with point data directly. For example, to get a particular point:: 24 | 25 | point = contour[0] 26 | 27 | To iterate over all points:: 28 | 29 | for point in contour: 30 | ... 31 | 32 | To get the number of points:: 33 | 34 | pointCount = len(contour) 35 | 36 | To delete a particular point:: 37 | 38 | del contour[0] 39 | 40 | To set a particular point to another Point object:: 41 | 42 | contour[0] = anotherPoint 43 | """ 44 | 45 | points: List[Point] = field(factory=list) 46 | """The list of points in the contour.""" 47 | 48 | identifier: Optional[str] = field(default=None, repr=False) 49 | """The globally unique identifier of the contour.""" 50 | 51 | # collections.abc.MutableSequence interface 52 | 53 | def __delitem__(self, index: int | slice) -> None: 54 | del self.points[index] 55 | 56 | @overload 57 | def __getitem__(self, index: int) -> Point: ... 58 | 59 | @overload 60 | def __getitem__(self, index: slice) -> list[Point]: # noqa: F811 61 | ... 62 | 63 | def __getitem__(self, index: int | slice) -> Point | list[Point]: # noqa: F811 64 | return self.points[index] 65 | 66 | def __setitem__( # noqa: F811 67 | self, index: int | slice, point: Point | Iterable[Point] 68 | ) -> None: 69 | if isinstance(index, int) and isinstance(point, Point): 70 | self.points[index] = point 71 | elif ( 72 | isinstance(index, slice) 73 | and isinstance(point, Iterable) 74 | and all(isinstance(p, Point) for p in point) 75 | ): 76 | self.points[index] = point 77 | else: 78 | raise TypeError( 79 | f"Expected Point or Iterable[Point], found {type(point).__name__}." 80 | ) 81 | 82 | def __iter__(self) -> Iterator[Point]: 83 | return iter(self.points) 84 | 85 | def __len__(self) -> int: 86 | return len(self.points) 87 | 88 | def insert(self, index: int, value: Point) -> None: 89 | """Insert Point object ``value`` into the contour at ``index``.""" 90 | if not isinstance(value, Point): 91 | raise TypeError(f"Expected Point, found {type(value).__name__}.") 92 | self.points.insert(index, value) 93 | 94 | # TODO: rotate method? 95 | 96 | @property 97 | def open(self) -> bool: 98 | """Returns whether the contour is open or closed.""" 99 | if not self.points: 100 | return True 101 | return self.points[0].type == "move" 102 | 103 | def move(self, delta: tuple[float, float]) -> None: 104 | """Moves contour by (x, y) font units.""" 105 | for point in self.points: 106 | point.move(delta) 107 | 108 | def getBounds(self, layer: GlyphSet | None = None) -> BoundingBox | None: 109 | """Returns the (xMin, yMin, xMax, yMax) bounding box of the glyph, 110 | taking the actual contours into account. 111 | 112 | Args: 113 | layer: Not applicable to contours, here for API symmetry. 114 | """ 115 | return getBounds(self, layer) 116 | 117 | @property 118 | def bounds(self) -> BoundingBox | None: 119 | """Returns the (xMin, yMin, xMax, yMax) bounding box of the glyph, 120 | taking the actual contours into account. 121 | 122 | |defcon_compat| 123 | """ 124 | return self.getBounds() 125 | 126 | def getControlBounds(self, layer: GlyphSet | None = None) -> BoundingBox | None: 127 | """Returns the (xMin, yMin, xMax, yMax) bounding box of the glyph, 128 | taking only the control points into account. 129 | 130 | Args: 131 | layer: Not applicable to contours, here for API symmetry. 132 | """ 133 | return getControlBounds(self, layer) 134 | 135 | @property 136 | def controlPointBounds(self) -> BoundingBox | None: 137 | """Returns the (xMin, yMin, xMax, yMax) bounding box of the glyph, 138 | taking only the control points into account. 139 | 140 | |defcon_compat| 141 | """ 142 | return self.getControlBounds() 143 | 144 | # ----------- 145 | # Pen methods 146 | # ----------- 147 | 148 | def draw(self, pen: AbstractPen) -> None: 149 | """Draws contour into given pen.""" 150 | pointPen = PointToSegmentPen(pen) 151 | self.drawPoints(pointPen) 152 | 153 | def drawPoints(self, pointPen: AbstractPointPen) -> None: 154 | """Draws points of contour into given point pen.""" 155 | try: 156 | pointPen.beginPath(identifier=self.identifier) 157 | for p in self.points: 158 | pointPen.addPoint( 159 | (p.x, p.y), 160 | segmentType=p.type, 161 | smooth=p.smooth, 162 | name=p.name, 163 | identifier=p.identifier, 164 | ) 165 | except TypeError: 166 | pointPen.beginPath() 167 | for p in self.points: 168 | pointPen.addPoint( 169 | (p.x, p.y), segmentType=p.type, smooth=p.smooth, name=p.name 170 | ) 171 | warnings.warn( 172 | "The pointPen needs an identifier kwarg. " 173 | "Identifiers have been discarded.", 174 | UserWarning, 175 | ) 176 | pointPen.endPath() 177 | -------------------------------------------------------------------------------- /src/ufoLib2/objects/info/woff.py: -------------------------------------------------------------------------------- 1 | """Fontinfo.plist fields for WOFF 1.0 metadata. 2 | 3 | https://unifiedfontobject.org/versions/ufo3/fontinfo.plist/#woff-data 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from typing import Any, List, Mapping, Optional, Sequence, Type, TypeVar 9 | 10 | from attrs import Attribute, define, field 11 | 12 | from ufoLib2.objects.misc import AttrDictMixin 13 | 14 | _T = TypeVar("_T", bound=AttrDictMixin) 15 | 16 | 17 | def _convert_list_of_woff_metadata( 18 | cls: Type[_T], values: Sequence[_T | Mapping[str, Any]] 19 | ) -> list[_T]: 20 | return [cls.coerce_from_dict(v) for v in values] 21 | 22 | 23 | @define 24 | class WoffMetadataUniqueID(AttrDictMixin): 25 | id: str 26 | 27 | 28 | @define 29 | class WoffMetadataVendor(AttrDictMixin): 30 | name: str 31 | url: Optional[str] = None 32 | dir: Optional[str] = None 33 | # 'class' of course is reserved in Python 34 | class_: Optional[str] = field(default=None, metadata={"rename_attr": "class"}) 35 | 36 | 37 | @define 38 | class WoffMetadataCredit(AttrDictMixin): 39 | name: str 40 | url: Optional[str] = None 41 | role: Optional[str] = None 42 | dir: Optional[str] = None 43 | class_: Optional[str] = field(default=None, metadata={"rename_attr": "class"}) 44 | 45 | 46 | def _convert_list_of_woff_metadata_credits( 47 | value: list[WoffMetadataCredit | Mapping[str, Any]], 48 | ) -> list[WoffMetadataCredit]: 49 | return _convert_list_of_woff_metadata(WoffMetadataCredit, value) 50 | 51 | 52 | @define 53 | class WoffMetadataCredits(AttrDictMixin): 54 | credits: List[WoffMetadataCredit] = field( 55 | factory=list, 56 | converter=_convert_list_of_woff_metadata_credits, 57 | ) 58 | 59 | 60 | @define 61 | class WoffMetadataText(AttrDictMixin): 62 | text: str 63 | language: Optional[str] = None 64 | dir: Optional[str] = None 65 | class_: Optional[str] = field(default=None, metadata={"rename_attr": "class"}) 66 | 67 | 68 | def _at_least_one_item( 69 | self: Any, attribute: Attribute[Any], value: Sequence[Any] 70 | ) -> None: 71 | if len(value) == 0: 72 | raise ValueError( 73 | f"{self.__class__.__name__}.{attribute.name} must contain at list 1 item" 74 | ) 75 | 76 | 77 | def _convert_list_of_woff_metadata_texts( 78 | value: list[WoffMetadataText | Mapping[str, Any]], 79 | ) -> list[WoffMetadataText]: 80 | return _convert_list_of_woff_metadata(WoffMetadataText, value) 81 | 82 | 83 | @define 84 | class WoffMetadataDescription(AttrDictMixin): 85 | url: Optional[str] = None 86 | text: List[WoffMetadataText] = field( 87 | factory=list, 88 | validator=_at_least_one_item, 89 | converter=_convert_list_of_woff_metadata_texts, 90 | ) 91 | 92 | 93 | @define 94 | class WoffMetadataLicense(AttrDictMixin): 95 | url: Optional[str] = None 96 | id: Optional[str] = None 97 | text: List[WoffMetadataText] = field( 98 | factory=list, 99 | converter=_convert_list_of_woff_metadata_texts, 100 | ) 101 | 102 | 103 | @define 104 | class WoffMetadataCopyright(AttrDictMixin): 105 | text: List[WoffMetadataText] = field( 106 | factory=list, 107 | validator=_at_least_one_item, 108 | converter=_convert_list_of_woff_metadata_texts, 109 | ) 110 | 111 | 112 | @define 113 | class WoffMetadataTrademark(AttrDictMixin): 114 | text: List[WoffMetadataText] = field( 115 | factory=list, 116 | validator=_at_least_one_item, 117 | converter=_convert_list_of_woff_metadata_texts, 118 | ) 119 | 120 | 121 | @define 122 | class WoffMetadataLicensee(AttrDictMixin): 123 | name: str 124 | dir: Optional[str] = None 125 | class_: Optional[str] = field(default=None, metadata={"rename_attr": "class"}) 126 | 127 | 128 | @define 129 | class WoffMetadataExtensionName(AttrDictMixin): 130 | text: str 131 | language: Optional[str] = None 132 | dir: Optional[str] = None 133 | class_: Optional[str] = field(default=None, metadata={"rename_attr": "class"}) 134 | 135 | 136 | @define 137 | class WoffMetadataExtensionValue(AttrDictMixin): 138 | text: str 139 | language: Optional[str] = None 140 | dir: Optional[str] = None 141 | class_: Optional[str] = field(default=None, metadata={"rename_attr": "class"}) 142 | 143 | 144 | def _convert_list_of_woff_metadata_extension_name( 145 | value: list[WoffMetadataExtensionName | Mapping[str, Any]], 146 | ) -> list[WoffMetadataExtensionName]: 147 | return _convert_list_of_woff_metadata(WoffMetadataExtensionName, value) 148 | 149 | 150 | def _convert_list_of_woff_metadata_extension_value( 151 | value: list[WoffMetadataExtensionValue | Mapping[str, Any]], 152 | ) -> list[WoffMetadataExtensionValue]: 153 | return _convert_list_of_woff_metadata(WoffMetadataExtensionValue, value) 154 | 155 | 156 | @define 157 | class WoffMetadataExtensionItem(AttrDictMixin): 158 | id: Optional[str] = None 159 | names: List[WoffMetadataExtensionName] = field( 160 | factory=list, 161 | validator=_at_least_one_item, 162 | converter=_convert_list_of_woff_metadata_extension_name, 163 | ) 164 | # 'values()' is the name of the dict method, hence the attribute named 'values_' 165 | values_: List[WoffMetadataExtensionValue] = field( 166 | factory=list, 167 | validator=_at_least_one_item, 168 | converter=_convert_list_of_woff_metadata_extension_value, 169 | metadata={"rename_attr": "values"}, 170 | ) 171 | 172 | 173 | def _convert_list_of_woff_metadata_extension_item( 174 | value: list[WoffMetadataExtensionItem | Mapping[str, Any]], 175 | ) -> list[WoffMetadataExtensionItem]: 176 | return _convert_list_of_woff_metadata(WoffMetadataExtensionItem, value) 177 | 178 | 179 | @define 180 | class WoffMetadataExtension(AttrDictMixin): 181 | id: Optional[str] 182 | names: List[WoffMetadataExtensionName] = field( 183 | factory=list, 184 | converter=_convert_list_of_woff_metadata_extension_name, 185 | ) 186 | # 'items()' is the name of the dict method, hence the attribute named 'items_' 187 | items_: List[WoffMetadataExtensionItem] = field( 188 | factory=list, 189 | validator=_at_least_one_item, 190 | converter=_convert_list_of_woff_metadata_extension_item, 191 | metadata={"rename_attr": "items"}, 192 | ) 193 | -------------------------------------------------------------------------------- /tests/data/MutatorSansBoldCondensed.ufo/lib.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.defcon.sortDescriptor 6 | 7 | 8 | allowPseudoUnicode 9 | 10 | ascending 11 | 12 | type 13 | alphabetical 14 | 15 | 16 | allowPseudoUnicode 17 | 18 | ascending 19 | 20 | type 21 | category 22 | 23 | 24 | allowPseudoUnicode 25 | 26 | ascending 27 | 28 | type 29 | unicode 30 | 31 | 32 | allowPseudoUnicode 33 | 34 | ascending 35 | 36 | type 37 | script 38 | 39 | 40 | allowPseudoUnicode 41 | 42 | ascending 43 | 44 | type 45 | suffix 46 | 47 | 48 | allowPseudoUnicode 49 | 50 | ascending 51 | 52 | type 53 | decompositionBase 54 | 55 | 56 | com.letterror.lightMeter.prefs 57 | 58 | chunkSize 59 | 5 60 | diameter 61 | 200 62 | drawTail 63 | 64 | invert 65 | 66 | toolDiameter 67 | 30 68 | toolStyle 69 | fluid 70 | 71 | com.typemytype.robofont.background.layerStrokeColor 72 | 73 | 0 74 | 0.8 75 | 0.2 76 | 0.7 77 | 78 | com.typemytype.robofont.compileSettings.autohint 79 | 80 | com.typemytype.robofont.compileSettings.checkOutlines 81 | 82 | com.typemytype.robofont.compileSettings.createDummyDSIG 83 | 84 | com.typemytype.robofont.compileSettings.decompose 85 | 86 | com.typemytype.robofont.compileSettings.generateFormat 87 | 0 88 | com.typemytype.robofont.compileSettings.releaseMode 89 | 90 | com.typemytype.robofont.foreground.layerStrokeColor 91 | 92 | 0.5 93 | 0 94 | 0.5 95 | 0.7 96 | 97 | com.typemytype.robofont.italicSlantOffset 98 | 0 99 | com.typemytype.robofont.segmentType 100 | curve 101 | com.typemytype.robofont.shouldAddPointsInSplineConversion 102 | 1 103 | com.typesupply.defcon.sortDescriptor 104 | 105 | 106 | ascending 107 | 108 | space 109 | A 110 | B 111 | C 112 | D 113 | E 114 | F 115 | G 116 | H 117 | I 118 | J 119 | K 120 | L 121 | M 122 | N 123 | O 124 | P 125 | Q 126 | R 127 | S 128 | T 129 | U 130 | V 131 | W 132 | X 133 | Y 134 | Z 135 | a 136 | b 137 | c 138 | d 139 | e 140 | f 141 | g 142 | h 143 | i 144 | j 145 | k 146 | l 147 | m 148 | n 149 | ntilde 150 | o 151 | p 152 | q 153 | r 154 | s 155 | t 156 | u 157 | v 158 | w 159 | x 160 | y 161 | z 162 | zcaron 163 | zero 164 | one 165 | two 166 | three 167 | four 168 | five 169 | six 170 | seven 171 | eight 172 | nine 173 | underscore 174 | hyphen 175 | endash 176 | emdash 177 | parenleft 178 | parenright 179 | bracketleft 180 | bracketright 181 | braceleft 182 | braceright 183 | numbersign 184 | percent 185 | period 186 | comma 187 | colon 188 | semicolon 189 | exclam 190 | question 191 | slash 192 | backslash 193 | bar 194 | at 195 | ampersand 196 | paragraph 197 | bullet 198 | dollar 199 | trademark 200 | fi 201 | fl 202 | .notdef 203 | a_b_c 204 | Atilde 205 | Adieresis 206 | Acircumflex 207 | Aring 208 | Ccedilla 209 | Agrave 210 | Aacute 211 | quotedblright 212 | quotedblleft 213 | 214 | type 215 | glyphList 216 | 217 | 218 | public.glyphOrder 219 | 220 | A 221 | Aacute 222 | Adieresis 223 | B 224 | C 225 | D 226 | E 227 | F 228 | G 229 | H 230 | I 231 | J 232 | K 233 | L 234 | M 235 | N 236 | O 237 | P 238 | Q 239 | R 240 | S 241 | T 242 | U 243 | V 244 | W 245 | X 246 | Y 247 | Z 248 | IJ 249 | S.closed 250 | I.narrow 251 | J.narrow 252 | quotesinglbase 253 | quotedblbase 254 | quotedblleft 255 | quotedblright 256 | comma 257 | period 258 | colon 259 | semicolon 260 | dot 261 | dieresis 262 | acute 263 | space 264 | arrowdown 265 | arrowleft 266 | arrowright 267 | arrowup 268 | 269 | 270 | -------------------------------------------------------------------------------- /tests/data/UbuTestData.ufo/fontinfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ascender 6 | 776 7 | capHeight 8 | 693 9 | copyright 10 | Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0 11 | descender 12 | -185 13 | familyName 14 | UbuTest 15 | guidelines 16 | 17 | 18 | y 19 | 256 20 | 21 | 22 | y 23 | 343 24 | 25 | 26 | y 27 | 932 28 | 29 | 30 | y 31 | 2107 32 | 33 | 34 | y 35 | 2332 36 | 37 | 38 | y 39 | 2540 40 | 41 | 42 | y 43 | 2553 44 | 45 | 46 | italicAngle 47 | 0 48 | macintoshFONDFamilyID 49 | 128 50 | macintoshFONDName 51 | UbuTest 52 | openTypeGaspRangeRecords 53 | 54 | 55 | rangeGaspBehavior 56 | 57 | 1 58 | 59 | rangeMaxPPEM 60 | 8 61 | 62 | 63 | rangeGaspBehavior 64 | 65 | 0 66 | 67 | rangeMaxPPEM 68 | 18 69 | 70 | 71 | rangeGaspBehavior 72 | 73 | 0 74 | 1 75 | 76 | rangeMaxPPEM 77 | 65535 78 | 79 | 80 | openTypeHeadCreated 81 | 2011/02/23 13:04:24 82 | openTypeHeadFlags 83 | 84 | 0 85 | 3 86 | 4 87 | 88 | openTypeHeadLowestRecPPEM 89 | 9 90 | openTypeHheaAscender 91 | 932 92 | openTypeHheaDescender 93 | -189 94 | openTypeHheaLineGap 95 | 28 96 | openTypeNameDesigner 97 | Dalton Maag Ltd 98 | openTypeNameDesignerURL 99 | http://www.daltonmaag.com/ 100 | openTypeNameManufacturer 101 | Dalton Maag Ltd 102 | openTypeNameManufacturerURL 103 | http://www.daltonmaag.com/ 104 | openTypeNameRecords 105 | 106 | openTypeNameVersion 107 | Version 0.83 108 | openTypeOS2CodePageRanges 109 | 110 | 0 111 | 1 112 | 2 113 | 3 114 | 4 115 | 7 116 | 29 117 | 48 118 | 57 119 | 58 120 | 60 121 | 62 122 | 123 | openTypeOS2FamilyClass 124 | 125 | 0 126 | 0 127 | 128 | openTypeOS2Panose 129 | 130 | 2 131 | 11 132 | 5 133 | 4 134 | 3 135 | 6 136 | 2 137 | 3 138 | 2 139 | 4 140 | 141 | openTypeOS2Selection 142 | 143 | openTypeOS2StrikeoutPosition 144 | 250 145 | openTypeOS2StrikeoutSize 146 | 79 147 | openTypeOS2SubscriptXOffset 148 | 0 149 | openTypeOS2SubscriptXSize 150 | 700 151 | openTypeOS2SubscriptYOffset 152 | 140 153 | openTypeOS2SubscriptYSize 154 | 650 155 | openTypeOS2SuperscriptXOffset 156 | 0 157 | openTypeOS2SuperscriptXSize 158 | 700 159 | openTypeOS2SuperscriptYOffset 160 | 477 161 | openTypeOS2SuperscriptYSize 162 | 650 163 | openTypeOS2Type 164 | 165 | openTypeOS2TypoAscender 166 | 776 167 | openTypeOS2TypoDescender 168 | -185 169 | openTypeOS2TypoLineGap 170 | 56 171 | openTypeOS2UnicodeRanges 172 | 173 | 0 174 | 1 175 | 2 176 | 3 177 | 4 178 | 5 179 | 6 180 | 7 181 | 9 182 | 29 183 | 30 184 | 31 185 | 32 186 | 33 187 | 35 188 | 36 189 | 38 190 | 45 191 | 60 192 | 62 193 | 194 | openTypeOS2VendorID 195 | DAMA 196 | openTypeOS2WeightClass 197 | 400 198 | openTypeOS2WidthClass 199 | 5 200 | openTypeOS2WinAscent 201 | 932 202 | openTypeOS2WinDescent 203 | 189 204 | postscriptBlueFuzz 205 | 1 206 | postscriptBlueScale 207 | 0.039625 208 | postscriptBlueShift 209 | 7 210 | postscriptBlueValues 211 | 212 | postscriptDefaultCharacter 213 | .notdef 214 | postscriptDefaultWidthX 215 | 500 216 | postscriptFamilyBlues 217 | 218 | postscriptFamilyOtherBlues 219 | 220 | postscriptFontName 221 | UbuTest-Regular 222 | postscriptForceBold 223 | 224 | postscriptFullName 225 | UbuTest 226 | postscriptIsFixedPitch 227 | 228 | postscriptOtherBlues 229 | 230 | postscriptSlantAngle 231 | 0 232 | postscriptStemSnapH 233 | 234 | postscriptStemSnapV 235 | 236 | postscriptUnderlinePosition 237 | -123 238 | postscriptUnderlineThickness 239 | 79 240 | postscriptUniqueID 241 | -1 242 | postscriptWeightName 243 | Normal 244 | postscriptWindowsCharacterSet 245 | 1 246 | styleMapFamilyName 247 | UbuTest 248 | styleMapStyleName 249 | regular 250 | styleName 251 | Regular 252 | trademark 253 | Ubuntu and Canonical are registered trademarks of Canonical Ltd. 254 | unitsPerEm 255 | 1000 256 | versionMajor 257 | 0 258 | versionMinor 259 | 830 260 | xHeight 261 | 520 262 | year 263 | 2011 264 | 265 | 266 | --------------------------------------------------------------------------------