This commit is contained in:
Endre Szabo 2026-05-16 18:28:17 +10:00 committed by GitHub
commit 031dbdbbd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 914 additions and 4 deletions

106
Tests/fonts/test_yaff.yaff Normal file
View File

@ -0,0 +1,106 @@
# Test YAFF font for Pillow test suite.
# Contains a small set of ASCII glyphs with kerning.
name: Test Font 8px
family: Test
spacing: proportional
ascent: 7
descent: 2
default-char: 0x3f
u+0020:
'A':
.......
.......
.......
.......
.......
.......
.......
.......
.......
left-bearing: 0
right-bearing: 0
shift-up: -2
u+0041:
.......
.......
.@@@@..
@....@.
@@@@@@.
@....@.
@....@.
.......
.......
left-bearing: 0
right-bearing: 0
shift-up: -2
right-kerning:
u+0056 -2
u+0054 -1
u+0056:
.......
.......
@....@.
@....@.
@....@.
.@..@..
..@@...
.......
.......
left-bearing: 0
right-bearing: 0
shift-up: -2
left-kerning:
u+0041 -1
u+0054:
........
........
@@@@@@@@
...@@...
...@@...
...@@...
...@@...
........
........
left-bearing: 0
right-bearing: 0
shift-up: -2
u+002e:
...
...
...
...
...
...
.@.
...
...
left-bearing: 0
right-bearing: 0
shift-up: -2
u+003f:
.......
.......
.@@@@..
@....@.
..@@@..
.......
..@....
.......
.......
left-bearing: 0
right-bearing: 0
shift-up: -2

153
Tests/test_font_yaff.py Normal file
View File

@ -0,0 +1,153 @@
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
FONT_PATH = str(Path(__file__).parent / "fonts" / "test_yaff.yaff")
class TestYaffFont:
def test_load_yaff(self) -> None:
font = ImageFont.yaff(FONT_PATH)
assert len(font.glyphs) > 0
assert font.ascent == 7
assert font.descent == 2
def test_load_yaff_pathlib(self) -> None:
font = ImageFont.yaff(Path(FONT_PATH))
assert len(font.glyphs) > 0
def test_load_yaff_fileobj(self) -> None:
with open(FONT_PATH, "rb") as f:
font = ImageFont.yaff(f)
assert len(font.glyphs) > 0
def test_getmetrics(self) -> None:
font = ImageFont.yaff(FONT_PATH)
ascent, descent = font.getmetrics()
assert ascent == 7
assert descent == 2
def test_getlength(self) -> None:
font = ImageFont.yaff(FONT_PATH)
length = font.getlength("A")
assert length > 0
def test_getlength_kerning(self) -> None:
font = ImageFont.yaff(FONT_PATH)
# AV has kerning: right-kerning on A for V is -2, left-kerning on V for A is -1
length_av = font.getlength("AV")
length_a = font.getlength("A")
length_v = font.getlength("V")
assert length_av < length_a + length_v
assert length_av == length_a + length_v - 3 # -2 + -1 = -3
def test_getlength_kerning_at(self) -> None:
font = ImageFont.yaff(FONT_PATH)
# AT has kerning: right-kerning on A for T is -1
length_at = font.getlength("AT")
length_a = font.getlength("A")
length_t = font.getlength("T")
assert length_at < length_a + length_t
assert length_at == length_a + length_t - 1
def test_getbbox(self) -> None:
font = ImageFont.yaff(FONT_PATH)
bbox = font.getbbox("A")
assert bbox[0] == 0
assert bbox[1] == 0
assert bbox[2] > 0
assert bbox[3] == font.ascent + font.descent
def test_getmask(self) -> None:
font = ImageFont.yaff(FONT_PATH)
mask = font.getmask("A")
assert mask is not None
assert mask.size[0] > 0
assert mask.size[1] > 0
def test_getmask_mode_l(self) -> None:
font = ImageFont.yaff(FONT_PATH)
mask = font.getmask("A", mode="L")
assert mask is not None
def test_getmask2(self) -> None:
font = ImageFont.yaff(FONT_PATH)
mask, offset = font.getmask2("A")
assert mask is not None
assert offset == (0, 0)
def test_render_text(self) -> None:
font = ImageFont.yaff(FONT_PATH)
im = Image.new("1", (50, 20))
draw = ImageDraw.Draw(im)
draw.text((5, 5), "AVA", fill=1, font=font)
# Check that some pixels were drawn
assert im.getbbox() is not None
def test_render_text_rgb(self) -> None:
font = ImageFont.yaff(FONT_PATH)
im = Image.new("RGB", (50, 20), "white")
draw = ImageDraw.Draw(im)
draw.text((5, 5), "A", fill="black", font=font)
# Check some pixels changed from white
pixels = list(im.get_flattened_data())
assert any(p != (255, 255, 255) for p in pixels)
def test_default_char(self) -> None:
font = ImageFont.yaff(FONT_PATH)
# Character not in font should use default char (0x3f = '?')
length_unknown = font.getlength("\u00ff")
length_question = font.getlength("?")
assert length_unknown == length_question
def test_empty_text(self) -> None:
font = ImageFont.yaff(FONT_PATH)
length = font.getlength("")
assert length == 0
def test_bytes_text(self) -> None:
font = ImageFont.yaff(FONT_PATH)
length = font.getlength(b"A")
assert length > 0
class TestYaffFontParser:
def test_parse_unicode_label(self) -> None:
from PIL.YaffFontFile import _parse_label
assert _parse_label("u+0041") == [0x41]
assert _parse_label("U+0041") == [0x41]
def test_parse_quoted_label(self) -> None:
from PIL.YaffFontFile import _parse_label
assert _parse_label("'A'") == [0x41]
def test_parse_hex_codepoint(self) -> None:
from PIL.YaffFontFile import _parse_label
assert _parse_label("0x41") == [0x41]
def test_parse_decimal_codepoint(self) -> None:
from PIL.YaffFontFile import _parse_label
assert _parse_label("65") == [0x41]
def test_parse_tag_label(self) -> None:
from PIL.YaffFontFile import _parse_label
assert _parse_label('"some_tag"') == []
def test_kerning_values(self) -> None:
font = ImageFont.yaff(FONT_PATH)
glyph_a = font.glyphs[0x41]
assert 0x56 in glyph_a.right_kerning
assert glyph_a.right_kerning[0x56] == -2
assert 0x54 in glyph_a.right_kerning
assert glyph_a.right_kerning[0x54] == -1
glyph_v = font.glyphs[0x56]
assert 0x41 in glyph_v.left_kerning
assert glyph_v.left_kerning[0x41] == -1

View File

@ -60,7 +60,11 @@ directly.
class ImageDraw:
font: (
ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| ImageFont.YaffImageFont
| None
) = None
def __init__(self, im: Image.Image, mode: str | None = None) -> None:
@ -105,7 +109,12 @@ class ImageDraw:
def getfont(
self,
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont:
) -> (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| ImageFont.YaffImageFont
):
"""
Get the current default font.
@ -132,7 +141,12 @@ class ImageDraw:
def _getfont(
self, font_size: float | None
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont:
) -> (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| ImageFont.YaffImageFont
):
if font_size is not None:
from . import ImageFont

View File

@ -769,6 +769,159 @@ class TransposedFont:
return self.font.getlength(text, *args, **kwargs)
class YaffImageFont:
"""Bitmap font loaded from a YAFF format file, with kerning support.
To load a YAFF font::
font = ImageFont.yaff("path/to/font.yaff")
.. versionadded:: 12.3.0
"""
def __init__(self, font: StrOrBytesPath | BinaryIO) -> None:
from . import YaffFontFile
self.path = font if is_path(font) else ""
data = YaffFontFile.load(font)
self.glyphs = data.glyphs
self.ascent = data.ascent
self.descent = data.descent
self.line_height = data.line_height
self.default_char = data.default_char
self._global_left_bearing = data.global_left_bearing
self._global_right_bearing = data.global_right_bearing
self._global_shift_up = data.global_shift_up
def _get_glyph(self, codepoint: int) -> Any:
glyph = self.glyphs.get(codepoint)
if glyph is not None:
return glyph
if self.default_char is not None:
return self.glyphs.get(self.default_char)
return None
def _advance_width(self, glyph: Any) -> int:
lb = glyph.left_bearing + self._global_left_bearing
rb = glyph.right_bearing + self._global_right_bearing
return lb + glyph.image.width + rb
def _kern(self, cp_a: int, cp_b: int) -> int:
"""Compute kerning adjustment between two codepoints."""
total = 0
glyph_a = self.glyphs.get(cp_a)
glyph_b = self.glyphs.get(cp_b)
if glyph_a is not None:
total += glyph_a.right_kerning.get(cp_b, 0)
if glyph_b is not None:
total += glyph_b.left_kerning.get(cp_a, 0)
return total
def getmetrics(self) -> tuple[int, int]:
"""Return font ascent and descent.
:return: A ``(ascent, descent)`` tuple.
"""
return self.ascent, self.descent
def getlength(self, text: str | bytes, *args: Any, **kwargs: Any) -> int:
"""Return the advance width of the text in pixels, including kerning.
:param text: Text to measure.
:return: Width in pixels.
"""
if isinstance(text, bytes):
text = text.decode("utf-8")
total = 0
prev_cp: int | None = None
for char in text:
cp = ord(char)
glyph = self._get_glyph(cp)
if glyph is None:
prev_cp = None
continue
if prev_cp is not None:
total += self._kern(prev_cp, cp)
total += self._advance_width(glyph)
prev_cp = cp
return max(total, 0)
def getbbox(
self, text: str | bytes, *args: Any, **kwargs: Any
) -> tuple[int, int, int, int]:
"""Return bounding box of the text.
:param text: Text to measure.
:return: A ``(left, top, right, bottom)`` bounding box tuple.
"""
width = self.getlength(text)
return 0, 0, width, self.ascent + self.descent
def getmask(
self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any
) -> Image.core.ImagingCore:
"""Create a bitmap for the text.
:param text: Text to render.
:param mode: Font rendering mode (``"1"`` or ``"L"``).
:return: An internal PIL storage memory instance.
"""
return self.getmask2(text, mode, *args, **kwargs)[0]
def getmask2(
self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any
) -> tuple[Image.core.ImagingCore, tuple[int, int]]:
"""Create a bitmap for the text with positioning offset.
:param text: Text to render.
:param mode: Font rendering mode (``"1"`` or ``"L"``).
:return: A ``(mask, offset)`` tuple.
"""
if isinstance(text, bytes):
text = text.decode("utf-8")
width = self.getlength(text)
height = self.ascent + self.descent
if width == 0 or height == 0:
im = Image.new("1", (max(width, 1), max(height, 1)))
if mode == "L":
im = im.convert("L")
return im.im, (0, 0)
im = Image.new("1", (width, height))
x = 0
prev_cp: int | None = None
for char in text:
cp = ord(char)
glyph = self._get_glyph(cp)
if glyph is None:
prev_cp = None
continue
if prev_cp is not None:
x += self._kern(prev_cp, cp)
if glyph.image.width > 0 and glyph.image.height > 0:
lb = glyph.left_bearing + self._global_left_bearing
su = glyph.shift_up + self._global_shift_up
gx = x + lb
gy = self.ascent - su - glyph.image.height
# Use OR compositing so overlapping glyphs combine
im.paste(glyph.image, (gx, gy), glyph.image)
x += self._advance_width(glyph)
prev_cp = cp
if mode == "L":
im = im.convert("L")
return im.im, (0, 0)
def load(filename: str) -> ImageFont:
"""
Load a font file. This function loads a font object from the given
@ -916,6 +1069,23 @@ def truetype(
raise
def yaff(font: StrOrBytesPath | BinaryIO) -> YaffImageFont:
"""Load a YAFF bitmap font file.
This function loads a font from a YAFF format file and returns a font
object that supports kerning and proportional glyph widths. For loading
TrueType or OpenType fonts, see :py:func:`~PIL.ImageFont.truetype`.
:param font: A filename or file-like object containing a YAFF font.
:return: A font object.
:exception OSError: If the file could not be read.
:exception SyntaxError: If the file is not a valid YAFF font.
.. versionadded:: 12.3.0
"""
return YaffImageFont(font)
def load_path(filename: str | bytes) -> ImageFont:
"""
Load font file. Same as :py:func:`~PIL.ImageFont.load`, but searches for a

View File

@ -7,7 +7,12 @@ from typing import AnyStr, Generic, NamedTuple
from . import ImageFont
from ._typing import _Ink
Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont
Font = (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| ImageFont.YaffImageFont
)
class _Line(NamedTuple):

462
src/PIL/YaffFontFile.py Normal file
View File

@ -0,0 +1,462 @@
"""YAFF bitmap font file parser.
Parses .yaff font files into structured glyph data with kerning support.
See https://github.com/robhagemans/monobit for the YAFF specification.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import BinaryIO
from . import Image
from ._typing import StrOrBytesPath
@dataclass
class YaffGlyph:
"""A single glyph from a YAFF font."""
image: Image.Image
left_bearing: int = 0
right_bearing: int = 0
shift_up: int = 0
right_kerning: dict[int, int] = field(default_factory=dict)
left_kerning: dict[int, int] = field(default_factory=dict)
@dataclass
class YaffFontData:
"""Parsed data from a YAFF font file."""
glyphs: dict[int, YaffGlyph]
properties: dict[str, str]
ascent: int
descent: int
line_height: int
default_char: int | None
global_left_bearing: int
global_right_bearing: int
global_shift_up: int
def _parse_label(label_str: str) -> list[int]:
"""Parse a glyph label string into a list of Unicode codepoints.
Returns a list of codepoints. For single-codepoint labels, returns a
one-element list. Returns an empty list for tag labels or unparseable labels.
"""
label_str = label_str.strip()
# Character label: u+XXXX or U+XXXX, possibly comma-separated
if label_str.startswith(("u+", "U+")):
parts = [p.strip() for p in label_str.split(",")]
codepoints = []
for part in parts:
part = part.strip()
if part.startswith(("u+", "U+")):
try:
codepoints.append(int(part[2:], 16))
except ValueError:
return []
else:
return []
return codepoints
# Character label: single-quoted string
if label_str.startswith("'") and label_str.endswith("'") and len(label_str) >= 2:
chars = label_str[1:-1]
return [ord(c) for c in chars]
# Tag label: double-quoted string — skip (no codepoint mapping)
if label_str.startswith('"') and label_str.endswith('"'):
return []
# Codepoint label: starts with digit
if label_str and label_str[0].isdigit():
parts = [p.strip() for p in label_str.split(",")]
codepoints = []
for part in parts:
part = part.strip()
try:
if part.startswith(("0x", "0X")):
codepoints.append(int(part, 16))
elif part.startswith(("0o", "0O")):
codepoints.append(int(part, 8))
else:
codepoints.append(int(part))
except ValueError:
return []
return codepoints
# Bare character (deprecated but supported)
if len(label_str) == 1:
return [ord(label_str)]
return []
def _parse_kerning_label(label_str: str) -> int | None:
"""Parse a label in a kerning property value to a single codepoint."""
cps = _parse_label(label_str)
if len(cps) == 1:
return cps[0]
return None
def _parse_bitmap(rows: list[str]) -> Image.Image:
"""Convert rows of '@' and '.' characters to a mode '1' PIL Image."""
if not rows:
return Image.new("1", (0, 0))
height = len(rows)
width = len(rows[0])
pixels: list[int] = []
for row in rows:
pixels.extend(1 if ch == "@" else 0 for ch in row)
img = Image.new("1", (width, height))
img.putdata(pixels)
return img
def _parse_kerning_block(lines: list[str]) -> dict[int, int]:
"""Parse a multiline kerning property value.
Each line has the format: <label> <integer>
e.g., "u+0056 -2" or "0x69 -2"
"""
kerning: dict[int, int] = {}
for line in lines:
line = line.strip()
if not line:
continue
# Split from the right to find the integer value
match = re.match(r"(.+)\s+(-?\d+)$", line)
if match:
label_str = match.group(1).strip()
value = int(match.group(2))
cp = _parse_kerning_label(label_str)
if cp is not None:
kerning[cp] = value
return kerning
def _is_label_line(line: str) -> bool:
"""Check if a line is a glyph label (ends with ':' and starts at column 0)."""
stripped = line.rstrip()
if not stripped or not stripped.endswith(":"):
return False
# Must not start with whitespace
if line[0] in (" ", "\t"):
return False
# The part before ':' should look like a label
content = stripped[:-1].strip()
# Empty label (bare colon) is valid
if not content:
return True
# Not a property — properties have values after ':'
# Labels either: start with u+/U+, start with ', start with ", start with digit,
# or are a single non-ASCII char
if content.startswith(("u+", "U+", "'", '"')):
return True
if content and content[0].isdigit():
return True
# Single character (deprecated bare label)
if len(content) == 1 and not content[0].isalpha():
return True
return False
def _normalize_key(key: str) -> str:
"""Normalize a property key: lowercase, replace '-' with '_'."""
return key.lower().replace("-", "_")
def load(
font: StrOrBytesPath | BinaryIO,
) -> YaffFontData:
"""Load a YAFF font file.
:param font: Path to a .yaff file or a file-like object.
:return: Parsed font data.
:raises SyntaxError: If the file is not a valid YAFF font.
"""
if hasattr(font, "read"):
data = font.read()
if isinstance(data, bytes):
text = data.decode("utf-8-sig")
else:
text = data
else:
with open(font, encoding="utf-8-sig") as f:
text = f.read()
lines = text.splitlines()
return _parse_yaff(lines)
def _parse_yaff(lines: list[str]) -> YaffFontData:
"""Parse YAFF content from a list of lines."""
glyphs: dict[int, YaffGlyph] = {}
properties: dict[str, str] = {}
in_glyphs = False
i = 0
# First pass: separate global properties from glyph definitions
while i < len(lines):
line = lines[i]
stripped = line.strip()
# Skip blank lines and comments
if not stripped or stripped.startswith("#"):
i += 1
continue
# Check if this is a label line (start of glyph section)
if _is_label_line(line):
in_glyphs = True
break
# Global property
if not line[0].isspace() and ":" in line:
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
# Check for multiline value
if not value:
ml_lines = []
i += 1
while i < len(lines) and lines[i] and lines[i][0] in (" ", "\t"):
ml_lines.append(lines[i].strip())
i += 1
value = "\n".join(ml_lines)
else:
i += 1
properties[_normalize_key(key)] = value
continue
i += 1
# Second pass: parse glyph definitions
if in_glyphs:
_parse_glyphs(lines, i, glyphs, properties)
# Extract font metrics
ascent = int(properties.get("ascent", "0"))
descent = int(properties.get("descent", "0"))
line_height = int(properties.get("line_height", "0"))
global_left_bearing = int(properties.get("left_bearing", "0"))
global_right_bearing = int(properties.get("right_bearing", "0"))
global_shift_up = int(properties.get("shift_up", "0"))
# Derive ascent/descent from glyph heights if not specified
if not ascent and not descent and glyphs:
max_height = max(g.image.height for g in glyphs.values() if g.image.height > 0)
ascent = max_height
descent = 0
if not line_height:
line_height = ascent + descent
# Parse default-char
default_char: int | None = None
dc = properties.get("default_char")
if dc is not None:
cps = _parse_label(dc)
if len(cps) == 1:
default_char = cps[0]
return YaffFontData(
glyphs=glyphs,
properties=properties,
ascent=ascent,
descent=descent,
line_height=line_height,
default_char=default_char,
global_left_bearing=global_left_bearing,
global_right_bearing=global_right_bearing,
global_shift_up=global_shift_up,
)
def _parse_glyphs(
lines: list[str],
start: int,
glyphs: dict[int, YaffGlyph],
properties: dict[str, str],
) -> None:
"""Parse all glyph definitions starting from line index `start`."""
i = start
n = len(lines)
while i < n:
line = lines[i]
stripped = line.strip()
# Skip blank lines and comments
if not stripped or stripped.startswith("#"):
i += 1
continue
# Collect label lines
if not _is_label_line(line):
i += 1
continue
labels: list[str] = []
while i < n and _is_label_line(lines[i]):
label_part = lines[i].rstrip()[:-1].strip() # Remove trailing ':'
labels.append(label_part)
i += 1
# Collect indented glyph bitmap lines
bitmap_rows: list[str] = []
indent: str | None = None
while i < n:
raw = lines[i]
if not raw or not raw[0].isspace():
break
content = raw.strip()
if not content:
break
# Detect indent
if indent is None:
indent = raw[: len(raw) - len(raw.lstrip())]
bitmap_rows.append(content)
i += 1
# Check for empty glyph
is_empty = len(bitmap_rows) == 1 and bitmap_rows[0] == "-"
# Skip blank lines between bitmap and per-glyph properties
while i < n and not lines[i].strip():
i += 1
# Collect per-glyph properties (indented lines with ':')
glyph_props: dict[str, str | list[str]] = {}
while i < n:
raw = lines[i]
if not raw or not raw[0].isspace():
break
content = raw.strip()
if not content:
# Blank line within properties section — might separate property blocks
# Peek ahead to see if more indented content follows
j = i + 1
while j < n and not lines[j].strip():
j += 1
if j < n and lines[j] and lines[j][0].isspace():
i = j
continue
break
if ":" in content:
key, _, value = content.partition(":")
key = _normalize_key(key.strip())
value = value.strip()
if value:
glyph_props[key] = value
else:
# Multiline property value (e.g., kerning)
ml_lines: list[str] = []
i += 1
while i < n and lines[i] and lines[i][0].isspace():
inner = lines[i].strip()
if not inner:
break
# Check if this is a new property (contains ':' with alpha key)
if ":" in inner and re.match(r"[a-zA-Z_-]", inner):
break
ml_lines.append(inner)
i += 1
glyph_props[key] = ml_lines
continue
i += 1
# Build glyph
if is_empty:
image = Image.new("1", (0, 0))
else:
if bitmap_rows:
image = _parse_bitmap(bitmap_rows)
else:
image = Image.new("1", (0, 0))
left_bearing = 0
right_bearing = 0
shift_up = 0
if "left_bearing" in glyph_props:
v = glyph_props["left_bearing"]
if isinstance(v, str):
left_bearing = int(v)
if "right_bearing" in glyph_props:
v = glyph_props["right_bearing"]
if isinstance(v, str):
right_bearing = int(v)
if "shift_up" in glyph_props:
v = glyph_props["shift_up"]
if isinstance(v, str):
shift_up = int(v)
# Handle deprecated 'offset' property (left-bearing, shift-up pair)
if "offset" in glyph_props:
v = glyph_props["offset"]
if isinstance(v, str):
parts = v.split()
if len(parts) == 2:
left_bearing = int(parts[0])
shift_up = int(parts[1])
# Handle deprecated 'tracking' (= right-bearing)
if "tracking" in glyph_props:
v = glyph_props["tracking"]
if isinstance(v, str):
right_bearing = int(v)
# Parse kerning
right_kerning: dict[int, int] = {}
left_kerning: dict[int, int] = {}
if "right_kerning" in glyph_props:
v = glyph_props["right_kerning"]
if isinstance(v, list):
right_kerning = _parse_kerning_block(v)
elif isinstance(v, str):
right_kerning = _parse_kerning_block([v])
# Handle deprecated 'kern_to' (= right-kerning)
if "kern_to" in glyph_props:
v = glyph_props["kern_to"]
if isinstance(v, list):
right_kerning.update(_parse_kerning_block(v))
elif isinstance(v, str):
right_kerning.update(_parse_kerning_block([v]))
if "left_kerning" in glyph_props:
v = glyph_props["left_kerning"]
if isinstance(v, list):
left_kerning = _parse_kerning_block(v)
elif isinstance(v, str):
left_kerning = _parse_kerning_block([v])
glyph = YaffGlyph(
image=image,
left_bearing=left_bearing,
right_bearing=right_bearing,
shift_up=shift_up,
right_kerning=right_kerning,
left_kerning=left_kerning,
)
# Register glyph under all its label codepoints
for label in labels:
codepoints = _parse_label(label)
if len(codepoints) == 1:
glyphs[codepoints[0]] = glyph