Font spacing management library for UFO-compatible fonts (kerning, margins, groups)
Project description
UFO Spacing Library
A framework-agnostic Python library for managing font spacing (kerning and margins) with full undo/redo support. Designed to work with UFO-compatible font objects.
Features
- Framework Independent: Works with any font editor that provides compatible font objects
- Undo/Redo Support: Full command pattern implementation with unlimited history
- Multi-Font Operations: Support for linked/interpolated fonts with per-font scaling
- Preview/Simulation: VirtualFont wrapper for testing changes without modifying real font
- Groups Management: Full kerning groups support with automatic exception handling
- Composite Propagation: Automatic margin propagation to composite glyphs
- Well Documented: Comprehensive docstrings and type hints throughout
Installation
# From PyPI
pip install ufo-spacing-lib
# From source
pip install -e .
# Or with uv
uv pip install -e .
Quick Start
Kerning Operations
from ufo_spacing_lib import (
KerningEditor,
FontContext,
AdjustKerningCommand,
SetKerningCommand,
RemoveKerningCommand,
)
# Create an editor
editor = KerningEditor()
# Create a context for your font
context = FontContext.from_single_font(font)
# Adjust kerning by a delta
cmd = AdjustKerningCommand(pair=('A', 'V'), delta=-10)
result = editor.execute(cmd, context)
# Set kerning to absolute value
cmd = SetKerningCommand(pair=('A', 'V'), value=-50)
editor.execute(cmd, context)
# Remove a kerning pair
cmd = RemoveKerningCommand(pair=('A', 'V'))
editor.execute(cmd, context)
# Undo/Redo
editor.undo()
editor.redo()
Margins Operations
from ufo_spacing_lib import (
MarginsEditor,
FontContext,
AdjustMarginCommand,
SetMarginCommand,
)
editor = MarginsEditor()
context = FontContext.from_single_font(font)
# Adjust left margin (propagates to composites by default)
cmd = AdjustMarginCommand(
glyph_name='A',
side='left',
delta=10,
propagate_to_composites=True
)
editor.execute(cmd, context)
# Set right margin to absolute value
cmd = SetMarginCommand(
glyph_name='A',
side='right',
value=50
)
editor.execute(cmd, context)
Multi-Font Operations (Interpolation)
# Create context for multiple fonts with scaling
context = FontContext.from_linked_fonts(
fonts=[light_master, regular_master, bold_master],
primary=regular_master,
scales={
light_master: 0.8,
regular_master: 1.0,
bold_master: 1.3
}
)
# Command applies to all fonts with appropriate scaling
cmd = AdjustKerningCommand(pair=('A', 'V'), delta=-10)
editor.execute(cmd, context)
# light_master: -8, regular_master: -10, bold_master: -13
Group Operations with Undo/Redo
from ufo_spacing_lib import (
SpacingEditor,
FontContext,
FontGroupsManager,
AddGlyphsToGroupCommand,
RemoveGlyphsFromGroupCommand,
AdjustKerningCommand,
)
# SpacingEditor provides unified undo/redo for kerning AND groups
editor = SpacingEditor()
context = FontContext.from_single_font(font)
manager = FontGroupsManager(font)
# Execute kerning command
cmd1 = AdjustKerningCommand(pair=('A', 'V'), delta=-10)
editor.execute(cmd1, context)
# Execute group command (same editor, same history!)
cmd2 = AddGlyphsToGroupCommand(
group_name='public.kern1.A',
glyphs=['Aacute', 'Agrave'],
groups_manager=manager,
check_kerning=True, # Automatically handles kerning exceptions
)
editor.execute(cmd2, context)
# Undo works across both kerning and groups
editor.undo() # Undoes group command
editor.undo() # Undoes kerning command
editor.redo() # Redoes kerning command
editor.redo() # Redoes group command
Event Callbacks
def on_kerning_change(command, result):
print(f"Kerning changed: {command.description}")
refresh_ui()
editor.on_change = on_kerning_change
editor.on_undo = on_kerning_change
editor.on_redo = on_kerning_change
Groups Management
FontGroupsManager
Manages kerning and margins groups with O(1) reverse lookups and automatic exception handling.
from ufo_spacing_lib import FontGroupsManager, SIDE_LEFT, SIDE_RIGHT
# Create manager from font
manager = FontGroupsManager(font)
# Get group for a glyph
group = manager.get_group_for_glyph('A', SIDE_LEFT)
# Returns: 'public.kern1.A' or 'A' if not in group
# Check if glyph is in a group
is_grouped = manager.is_glyph_in_group('A', SIDE_LEFT)
# Get key glyph for a group (first glyph)
key_glyph = manager.get_key_glyph('public.kern1.A')
# Check if name is a kerning group
is_group = manager.is_kerning_group('public.kern1.A') # True
is_group = manager.is_kerning_group('A') # False
Adding/Removing Glyphs from Groups
# Add glyphs to a group
manager.add_glyphs_to_group(
'public.kern1.A',
['Aacute', 'Agrave', 'Atilde'],
check_kerning=True # Handles kerning exceptions automatically
)
# Remove glyphs from a group (creates exceptions for existing kerning)
manager.remove_glyphs_from_group(
'public.kern1.A',
['Aacute'],
create_exceptions=True
)
# Delete entire group
manager.delete_group('public.kern1.A', keep_kerning=False)
# Rename group
manager.rename_group('public.kern1.A', 'public.kern1.A_new')
Kerning Resolution
from ufo_spacing_lib import resolve_kern_pair, KernPairInfo
# Resolve a kerning pair to get full information
info: KernPairInfo = resolve_kern_pair(font, manager, ('A', 'V'))
# KernPairInfo fields:
info.left # Actual left key in kerning ('A' or 'public.kern1.A')
info.right # Actual right key in kerning
info.value # Kerning value (int or None)
info.is_exception # True if this is an exception to group kerning
info.left_group # Group name for left glyph
info.right_group # Group name for right glyph
# Computed properties:
info.exception_side # ExceptionSide enum
info.is_left_exception # True if only left side is exception
info.is_right_exception # True if only right side is exception
info.is_orphan # True if both sides are exceptions
info.has_value # True if kerning value exists
ExceptionSide Enum
Describes the exception status of a resolved kerning pair.
from ufo_spacing_lib import ExceptionSide
class ExceptionSide(Enum):
NONE # Normal group-group pair, not an exception
LEFT # Left side is exception (glyph used instead of group)
RIGHT # Right side is exception
BOTH # Both sides are exceptions (orphan pair)
DIRECT_KEY # Input was already kerning keys (group names)
Usage Example
info = resolve_kern_pair(font, manager, ('Aacute', 'V'))
match info.exception_side:
case ExceptionSide.NONE:
print("Normal group kerning")
case ExceptionSide.LEFT:
print(f"Left exception: {info.left} breaks out of {info.left_group}")
case ExceptionSide.RIGHT:
print(f"Right exception: {info.right} breaks out of {info.right_group}")
case ExceptionSide.BOTH:
print("Orphan pair - both sides are exceptions")
case ExceptionSide.DIRECT_KEY:
print("Input was already group names, not glyph names")
Exception Detection for UI
def get_pair_status(font, manager, left, right):
info = resolve_kern_pair(font, manager, (left, right))
if not info.is_exception:
return "normal"
if info.is_orphan:
return "orphan" # Both sides differ from groups
if info.is_left_exception:
return f"exception_left:{info.left}"
if info.is_right_exception:
return f"exception_right:{info.right}"
return "normal"
VirtualFont (Preview/Simulation)
VirtualFont wraps a real font for testing changes without modifying the source.
from ufo_spacing_lib import VirtualFont, FontContext, AdjustKerningCommand
# Create virtual copy - isolates kerning/groups changes
virtual = VirtualFont.from_font(font)
# Work as usual - changes only affect virtual.kerning/groups
context = FontContext.from_single_font(virtual)
cmd = AdjustKerningCommand(pair=('A', 'V'), delta=-10)
editor.execute(cmd, context)
# Glyphs are live references - changes in font visible through virtual
print(virtual['A'].leftMargin) # Same as font['A'].leftMargin
# Check what changed
if virtual.has_changes():
# Get kerning differences
for pair, (old, new) in virtual.get_kerning_diff().items():
print(f"{pair}: {old} -> {new}")
# Get groups differences
for group, (old, new) in virtual.get_groups_diff().items():
print(f"{group}: {old} -> {new}")
# Apply to real font when ready
virtual.apply_to(font)
# Or reset to discard all changes
virtual.reset()
virtual.reset_kerning() # Reset only kerning
virtual.reset_groups() # Reset only groups
API Reference
Editors
| Class | Description |
|---|---|
SpacingEditor |
Unified editor for kerning, margins, and groups (recommended) |
KerningEditor |
Editor with undo/redo for kerning operations |
MarginsEditor |
Editor with undo/redo for margins operations |
Editor Methods:
execute(command, context)→CommandResult- Execute a commandundo()→CommandResult | None- Undo last commandredo()→CommandResult | None- Redo last undone commandcan_undo/can_redo- Check if undo/redo availableundo_description/redo_description- Get description of next undo/redoget_history()→list[Command]- Get command historyclear_history()- Clear undo/redo stacks
Callbacks:
on_change- Called after executeon_undo- Called after undoon_redo- Called after redo
Commands
Kerning Commands
| Command | Parameters | Description |
|---|---|---|
SetKerningCommand |
pair, value |
Set kerning to absolute value |
AdjustKerningCommand |
pair, delta, remove_zero=True |
Adjust kerning by delta |
RemoveKerningCommand |
pair |
Remove a kerning pair |
CreateExceptionCommand |
pair, value=0, side='left' |
Create kerning exception |
Group Commands
| Command | Parameters | Description |
|---|---|---|
AddGlyphsToGroupCommand |
group_name, glyphs, groups_manager, check_kerning=True |
Add glyphs to a kerning group |
RemoveGlyphsFromGroupCommand |
group_name, glyphs, groups_manager, check_kerning=True |
Remove glyphs from a group |
DeleteGroupCommand |
group_name, groups_manager, check_kerning=True |
Delete entire group |
RenameGroupCommand |
old_name, new_name, groups_manager, check_kerning=True |
Rename a group |
Margins Commands
| Command | Parameters | Description |
|---|---|---|
SetMarginCommand |
glyph_name, side, value |
Set margin to absolute value |
AdjustMarginCommand |
glyph_name, side, delta, propagate_to_composites=True |
Adjust margin by delta |
Contexts
| Class | Description |
|---|---|
FontContext |
Wrapper for single or multiple fonts with scaling |
Factory Methods:
FontContext.from_single_font(font)- Single font contextFontContext.from_linked_fonts(fonts, primary, scales)- Multi-font with scaling
Groups Management
| Class/Function | Description |
|---|---|
FontGroupsManager |
Main class for groups management |
resolve_kern_pair(font, manager, pair) |
Resolve pair and get full info |
KernPairInfo |
Dataclass with resolved pair information |
ExceptionSide |
Enum for exception status |
FontGroupsManager Methods:
get_group_for_glyph(glyph, side)- Get group name for glyphis_glyph_in_group(glyph, side)- Check if glyph is in a groupget_key_glyph(group)- Get first glyph of a groupis_kerning_group(name)- Check if name is a kerning groupadd_glyphs_to_group(group, glyphs, check_kerning)- Add glyphsremove_glyphs_from_group(group, glyphs, create_exceptions)- Remove glyphsdelete_group(group, keep_kerning)- Delete a grouprename_group(old_name, new_name)- Rename a group
Virtual Font
| Class | Description |
|---|---|
VirtualFont |
Font wrapper for preview/simulation |
VirtualKerning |
Isolated kerning dict |
VirtualGroups |
Isolated groups dict |
VirtualFont Methods:
VirtualFont.from_font(font)- Create from real fonthas_changes()- Check if there are changesget_kerning_diff()- Get kerning changes dictget_groups_diff()- Get groups changes dictapply_to(font)- Apply changes to real fontreset()/reset_kerning()/reset_groups()- Discard changes
Constants
| Constant | Value | Description |
|---|---|---|
SIDE_LEFT |
1 |
Left side (kern1, margins1) |
SIDE_RIGHT |
2 |
Right side (kern2, margins2) |
EDITMODE_OFF |
0 |
Editing disabled |
EDITMODE_KERNING |
1 |
Kerning editing mode |
EDITMODE_MARGINS |
2 |
Margins editing mode |
Architecture
ufo_spacing_lib/
├── __init__.py # Main exports
├── contexts.py # FontContext class
├── groups_core.py # FontGroupsManager, KernPairInfo, resolve_kern_pair
├── virtual.py # VirtualFont for preview/simulation
├── commands/
│ ├── __init__.py
│ ├── base.py # Command ABC, CommandResult
│ ├── kerning.py # Kerning commands
│ ├── groups.py # Group commands (Add, Remove, Delete, Rename)
│ └── margins.py # Margins commands
└── editors/
├── __init__.py
├── spacing.py # SpacingEditor (unified, recommended)
├── kerning.py # KerningEditor
└── margins.py # MarginsEditor
Font Object Interface
The library is designed to work with any font object that implements this interface:
For Kerning Operations
class FontKerning:
"""Dict-like kerning access."""
def __getitem__(self, pair: tuple[str, str]) -> int: ...
def __setitem__(self, pair: tuple[str, str], value: int): ...
def __delitem__(self, pair: tuple[str, str]): ...
def __contains__(self, pair: tuple[str, str]) -> bool: ...
def get(self, pair: tuple[str, str], default=None) -> int | None: ...
class FontGroups:
"""Dict-like groups access."""
def __getitem__(self, name: str) -> list[str]: ...
def __setitem__(self, name: str, glyphs: list[str]): ...
def __delitem__(self, name: str): ...
def __contains__(self, name: str) -> bool: ...
def keys(self) -> Iterable[str]: ...
class Font:
kerning: FontKerning
groups: FontGroups
For Margins Operations
class Glyph:
leftMargin: int | None
rightMargin: int | None
width: int
components: list[Component] # Optional
def moveBy(self, delta: tuple[int, int]): ...
def changed(self): ... # Optional
class Component:
offset: tuple[int, int]
def moveBy(self, delta: tuple[int, int]): ...
class Font:
def __getitem__(self, glyph_name: str) -> Glyph: ...
def __contains__(self, glyph_name: str) -> bool: ...
def getReverseComponentMapping(self) -> dict[str, list[str]]: ... # Optional
Testing
The library includes 128 unit tests covering all components.
# Run all tests
uv run pytest
# Run with verbose output
uv run pytest -v
# Run specific test file
uv run pytest tests/test_kerning_commands.py -v
# Run single test
uv run pytest tests/test_groups_manager.py::TestResolvePair -v
Test Coverage
| Module | Tests | Coverage |
|---|---|---|
| Kerning Commands | 25 | SetKerning, AdjustKerning, RemoveKerning, CreateException |
| Group Commands | 26 | AddGlyphsToGroup, RemoveGlyphsFromGroup, DeleteGroup, RenameGroup |
| Editors | 20 | KerningEditor, MarginsEditor, SpacingEditor, undo/redo, callbacks |
| Groups Manager | 30 | FontGroupsManager, add/remove/delete/rename groups |
| VirtualFont | 27 | Creation, isolation, glyph access, diff tracking, apply/reset |
License
MIT License
Author
Alexander Lubovenko lubovenko@gmail.com github.com/typedev
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ufo_spacing_lib-0.2.0.tar.gz.
File metadata
- Download URL: ufo_spacing_lib-0.2.0.tar.gz
- Upload date:
- Size: 44.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.9 {"installer":{"name":"uv","version":"0.9.9"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"43","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
710eb739635afc691ac666c67f859f03609efc8835b5e7ff59cfe58a35c9a580
|
|
| MD5 |
b357d2542c674a52b1656caa71ba80dc
|
|
| BLAKE2b-256 |
8790d2440f6cc99e2b3172352e76b6730d82e45b2a4ac9ee24d2d6cf44e28283
|
File details
Details for the file ufo_spacing_lib-0.2.0-py3-none-any.whl.
File metadata
- Download URL: ufo_spacing_lib-0.2.0-py3-none-any.whl
- Upload date:
- Size: 44.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.9 {"installer":{"name":"uv","version":"0.9.9"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"43","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a8b0d04bc51ba46600edf77946fc2ccadef0a3131718e32b7a22ab6e6bf74d9b
|
|
| MD5 |
42dd4c8148a0a5b6471cbcb32c9ff236
|
|
| BLAKE2b-256 |
f6ba7e1731b0b88570732ef02f42daaedb506e9dc59b3e949aaa9a9227cf4f19
|