├── 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 |
--------------------------------------------------------------------------------