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
Metrics Rules (Linked Sidebearings)
Metrics keys system for linking sidebearings between glyphs.
Basic Usage
from ufo_spacing_lib import MetricsRulesManager, SpacingEditor, FontContext
# Create manager
manager = MetricsRulesManager(font)
# Set rules: Aacute.left = A.left, Aacute.right = A.right
manager.set_rule("Aacute", "left", "=A")
manager.set_rule("Aacute", "right", "=A")
# Arithmetic operations
manager.set_rule("B", "left", "=A+10") # A.left + 10
manager.set_rule("C", "right", "=A*0.5") # A.right * 0.5
# Symmetry: right = left of same glyph
manager.set_rule("H", "right", "=|")
# Opposite side from another glyph
manager.set_rule("B", "right", "=A|") # B.right = A.left
Validation
from ufo_spacing_lib import E_CYCLE, W_MISSING_GLYPH
report = manager.validate()
if not report.is_valid:
for error in report.errors:
print(f"Error [{error.code}]: {error.message}")
for warning in report.warnings:
print(f"Warning [{warning.code}]: {warning.message}")
# Filter by specific code
cycles = report.get_issues_by_code(E_CYCLE)
missing = report.get_issues_by_code(W_MISSING_GLYPH)
Cascade Updates
# When A changes, all dependents update automatically
editor = SpacingEditor()
context = FontContext.from_single_font(font)
# This updates A and all glyphs with rules referencing A
cmd = SetMarginCommand(glyph_name="A", side="left", value=50)
editor.execute(cmd, context)
# Deferred sync: change without cascade, then sync all at once
cmd1 = SetMarginCommand(glyph_name="A", side="left", value=50, apply_rules=False)
cmd2 = SetMarginCommand(glyph_name="B", side="left", value=40, apply_rules=False)
editor.execute(cmd1, context)
editor.execute(cmd2, context)
# Sync all rules
from ufo_spacing_lib import SyncRulesCommand
sync_cmd = SyncRulesCommand()
editor.execute(sync_cmd, context)
Generate Rules from Composites
from ufo_spacing_lib import generate_rules_from_composites, I_SINGLE_COMPONENT
# Analyze composite glyphs and generate rules
result = generate_rules_from_composites(font)
# Generated rules based on component 0 (base)
for glyph, sides in result.rules.items():
print(f"{glyph}: left={sides['left']}, right={sides['right']}")
# e.g., "Aacute: left==A, right==A"
# Check issues (warnings, info)
for issue in result.issues:
print(f"[{issue.code}] {issue.glyph}: {issue.message}")
# Filter by severity
warnings = result.warnings # Only warnings
infos = result.infos # Only info messages
# Single component info
single = result.get_issues_by_code(I_SINGLE_COMPONENT)
Issue Codes
| Code | Severity | Description |
|---|---|---|
E01 |
Error | Parse error in rule syntax |
E02 |
Error | Circular dependency detected |
W01 |
Warning | References missing glyph |
W02 |
Warning | Self-reference detected |
W03 |
Warning | Component wider than base |
W04 |
Warning | Component extends left of base |
W05 |
Warning | Component extends right of base |
W06 |
Warning | Base component has zero width |
W07 |
Warning | Mixed contours and components |
W08 |
Warning | Base component does not exist |
I01 |
Info | Single component composite |
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, apply_rules=True |
Set margin to absolute value |
AdjustMarginCommand |
glyph_name, side, delta, propagate_to_composites=True, apply_rules=True |
Adjust margin by delta |
Rules Commands
| Command | Parameters | Description |
|---|---|---|
SetMetricsRuleCommand |
glyph_name, side, rule |
Set a metrics rule |
RemoveMetricsRuleCommand |
glyph_name, side |
Remove a metrics rule |
SyncRulesCommand |
- | Synchronize all rules (batch update) |
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
Metrics Rules
| Class/Function | Description |
|---|---|
MetricsRulesManager |
Manages metrics rules for linked sidebearings |
ValidationReport |
Result of validating rules |
RuleIssue |
Unified issue (error/warning/info) |
RuleGenerationResult |
Result of generating rules from composites |
generate_rules_from_composites(font) |
Generate rules from composite structure |
MetricsRulesManager Methods:
set_rule(glyph, side, rule)- Set a ruleget_rule(glyph, side)- Get rule stringremove_rule(glyph, side)- Remove a rulehas_rule(glyph, side=None)- Check if rule existsvalidate()→ValidationReport- Validate all rulesevaluate(glyph, side)- Calculate value from ruleget_cascade_order(glyph, side)- Get update order for dependentsget_dependents(glyph)- Get glyphs depending on this glyphget_dependencies(glyph)- Get glyphs this glyph depends on
ValidationReport Properties:
is_valid- True if no critical errorsissues- All issues (errors, warnings, info)errors- Critical errors onlywarnings- Warnings onlyget_issues_by_code(code)- Filter by issue codeget_issues_for_glyph(glyph)- Filter by glyph
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
├── rules_core.py # RuleIssue, ValidationReport, issue codes
├── rules_manager.py # MetricsRulesManager for linked sidebearings
├── rules_parser.py # Rule syntax parser
├── rules_generator.py # Generate rules from composites
├── commands/
│ ├── __init__.py
│ ├── base.py # Command ABC, CommandResult
│ ├── kerning.py # Kerning commands
│ ├── groups.py # Group commands (Add, Remove, Delete, Rename)
│ ├── margins.py # Margins commands with rules cascade
│ └── rules.py # Rules commands (Set, Remove, Sync)
└── 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 328 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 |
| Metrics Rules | 63 | MetricsRulesManager, validation, cascade, parser |
| Rules Commands | 20 | SetMetricsRule, RemoveMetricsRule |
| Rules Generator | 19 | generate_rules_from_composites, warnings |
| SyncRulesCommand | 9 | Batch synchronization |
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.4.1.tar.gz.
File metadata
- Download URL: ufo_spacing_lib-0.4.1.tar.gz
- Upload date:
- Size: 78.7 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 |
d1ccac60823105d3e71a1184ff6b7656018e0d888902a67a617a419d4253b818
|
|
| MD5 |
4c441c4a6f341c73c1c1d122fc70552b
|
|
| BLAKE2b-256 |
10d0ef293abe47be70612f49e593ba868112167cff858c611d4c715a201a19d9
|
File details
Details for the file ufo_spacing_lib-0.4.1-py3-none-any.whl.
File metadata
- Download URL: ufo_spacing_lib-0.4.1-py3-none-any.whl
- Upload date:
- Size: 67.9 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 |
dbd7e781b4b06487494a1a568c7549b59a357780deccc517f214fccf23546977
|
|
| MD5 |
0c969725efa66e85d3c4ff4973ebf6d3
|
|
| BLAKE2b-256 |
965f19a759ccdd2f4df84e96f7c07893af0b6abf292a10b582a4584adbcc70aa
|