Skip to content

Commit 2542e4f

Browse files
committed
a
1 parent f0ced6f commit 2542e4f

File tree

16 files changed

+1319
-0
lines changed

16 files changed

+1319
-0
lines changed

examples/demo.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from examples import assets_dir
2+
from sfnttools.contract import SfntVersion
3+
from sfnttools.font import SfntFont, SfntFontCollection
4+
5+
6+
def main():
7+
example_file_path = assets_dir.joinpath('demo', 'demo.otf.woff2')
8+
example_font = SfntFont.load(example_file_path)
9+
10+
11+
if __name__ == '__main__':
12+
main()

src/sfnttools/contract.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from abc import abstractmethod
2+
from collections.abc import Iterator
3+
from enum import StrEnum
4+
from typing import Protocol, runtime_checkable
5+
6+
from sfnttools.error import SfntError
7+
8+
9+
class SfntVersion(StrEnum):
10+
TRUE_TYPE = '\x00\x01\x00\x00'
11+
OPEN_TYPE = 'OTTO'
12+
MACOS_TRUE_TYPE = 'true'
13+
MACOS_TYPE_1 = 'typ1'
14+
15+
16+
class SfntFileTag(StrEnum):
17+
TTCF = 'ttcf'
18+
WOFF = 'wOFF'
19+
WOFF2 = 'wOF2'
20+
21+
22+
@runtime_checkable
23+
class SfntTable(Protocol):
24+
@staticmethod
25+
@abstractmethod
26+
def parse(data: bytes, reader: 'SfntReader') -> 'SfntTable':
27+
raise NotImplementedError()
28+
29+
@abstractmethod
30+
def copy(self) -> 'SfntTable':
31+
raise NotImplementedError()
32+
33+
@abstractmethod
34+
def dump(self) -> bytes:
35+
raise NotImplementedError()
36+
37+
38+
@runtime_checkable
39+
class SfntReader(Protocol):
40+
@abstractmethod
41+
def get_sfnt_version(self) -> SfntVersion:
42+
raise NotImplementedError()
43+
44+
@abstractmethod
45+
def get_table_tags(self) -> Iterator[str]:
46+
raise NotImplementedError()
47+
48+
@abstractmethod
49+
def get_table_from_cache(self, tag: str) -> SfntTable | None:
50+
raise NotImplementedError()
51+
52+
@abstractmethod
53+
def set_table_to_cache(self, tag: str, table: SfntTable):
54+
raise NotImplementedError()
55+
56+
@abstractmethod
57+
def read_table_data(self, tag: str) -> bytes:
58+
raise NotImplementedError()
59+
60+
def parse_table(self, tag: str) -> SfntTable:
61+
table = self.get_table_from_cache(tag)
62+
if table is None:
63+
from sfnttools.tables import TABLE_TYPE_REGISTRY, DEFAULT_TABLE_TYPE
64+
table_type = TABLE_TYPE_REGISTRY.get(tag, DEFAULT_TABLE_TYPE)
65+
data = self.read_table_data(tag)
66+
table = table_type.parse(data, self)
67+
self.set_table_to_cache(tag, table)
68+
return table
69+
70+
def parse_font(self) -> tuple[SfntVersion, dict[SfntVersion, SfntTable]]:
71+
sfnt_version = self.get_sfnt_version()
72+
tables = {}
73+
for tag in self.get_table_tags():
74+
if tag in tables:
75+
raise SfntError(f'table {repr(tag)} more than one')
76+
table = self.parse_table(tag)
77+
tables[tag] = table
78+
return sfnt_version, tables
79+
80+
81+
@runtime_checkable
82+
class SfntWriter(Protocol):
83+
pass

src/sfnttools/font.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
from collections import UserDict, UserList
2+
from io import BytesIO
3+
from os import PathLike
4+
from typing import BinaryIO, Any
5+
6+
from sfnttools.contract import SfntVersion, SfntFileTag, SfntTable
7+
from sfnttools.error import SfntError
8+
from sfnttools.internal.stream import Stream
9+
from sfnttools.tables import TABLE_TYPE_REGISTRY
10+
from sfnttools.woff.headers import WoffHeader
11+
from sfnttools.woff.reader import WoffReader
12+
from sfnttools.woff2.headers import Woff2Header
13+
from sfnttools.woff2.reader import Woff2Reader
14+
from sfnttools.xtf.headers import TableDirectory, TtcHeader
15+
from sfnttools.xtf.reader import XtfReader
16+
17+
18+
class WoffPayload:
19+
major_version: int
20+
minor_version: int
21+
metadata: bytes | None
22+
private_data: bytes | None
23+
24+
def __init__(
25+
self,
26+
major_version: int = 0,
27+
minor_version: int = 0,
28+
metadata: bytes | None = None,
29+
private_data: bytes | None = None,
30+
):
31+
self.major_version = major_version
32+
self.minor_version = minor_version
33+
self.metadata = metadata
34+
self.private_data = private_data
35+
36+
37+
class SfntFont(UserDict[str, SfntTable]):
38+
@staticmethod
39+
def parse(
40+
stream: bytes | BinaryIO,
41+
font_index: int | None = None,
42+
verify_checksum: bool = False,
43+
) -> 'SfntFont':
44+
if isinstance(stream, bytes):
45+
stream = BytesIO(stream)
46+
stream = Stream(stream)
47+
48+
stream.seek(0)
49+
tag = stream.read_tag()
50+
stream.seek(0)
51+
if tag == SfntFileTag.TTCF:
52+
if font_index is None:
53+
raise SfntError(f'must specify a font index in font collection')
54+
header = TtcHeader.parse(stream)
55+
table_directory = header.read_table_directory(stream, font_index)
56+
57+
reader = XtfReader(stream, table_directory, verify_checksum, False, None)
58+
sfnt_version, tables = reader.parse_font()
59+
60+
return SfntFont(sfnt_version, tables)
61+
elif tag == SfntFileTag.WOFF:
62+
header = WoffHeader.parse(stream)
63+
64+
reader = WoffReader(stream, header, verify_checksum)
65+
sfnt_version, tables = reader.parse_font()
66+
67+
metadata = header.read_metadata(stream)
68+
private_data = header.read_private_data(stream)
69+
woff_payload = WoffPayload(
70+
header.major_version,
71+
header.minor_version,
72+
metadata,
73+
private_data,
74+
)
75+
76+
return SfntFont(sfnt_version, tables, woff_payload)
77+
elif tag == SfntFileTag.WOFF2:
78+
stream.seek(4)
79+
flavor = stream.read_tag()
80+
stream.seek(0)
81+
if flavor == SfntFileTag.TTCF:
82+
if font_index is None:
83+
raise SfntError(f'must specify a font index in font collection')
84+
header = Woff2Header.parse(stream)
85+
font_entry = header.collection_header.font_entries[font_index]
86+
elif flavor in [*SfntVersion]:
87+
header = Woff2Header.parse(stream)
88+
font_entry = header.simulate_single_font_entry()
89+
else:
90+
raise SfntError('unsupported SFNT font')
91+
uncompressed_stream = Stream(BytesIO(header.read_uncompressed_data(stream)))
92+
93+
reader = Woff2Reader(uncompressed_stream, header, font_entry, False, None)
94+
sfnt_version, tables = reader.parse_font()
95+
96+
metadata = header.read_metadata(stream)
97+
private_data = header.read_private_data(stream)
98+
woff_payload = WoffPayload(
99+
header.major_version,
100+
header.minor_version,
101+
metadata,
102+
private_data,
103+
)
104+
105+
return SfntFont(sfnt_version, tables, woff_payload)
106+
elif tag in [*SfntVersion]:
107+
table_directory = TableDirectory.parse(stream)
108+
109+
reader = XtfReader(stream, table_directory, verify_checksum, False, None)
110+
sfnt_version, tables = reader.parse_font()
111+
112+
return SfntFont(sfnt_version, tables)
113+
else:
114+
raise SfntError('unsupported SFNT font')
115+
116+
@staticmethod
117+
def load(
118+
file_path: str | PathLike[str],
119+
font_index: int | None = None,
120+
verify_checksum: bool = False,
121+
) -> 'SfntFont':
122+
with open(file_path, 'rb') as file:
123+
return SfntFont.parse(file, font_index, verify_checksum)
124+
125+
sfnt_version: SfntVersion
126+
woff_payload: WoffPayload | None
127+
128+
def __init__(
129+
self,
130+
sfnt_version: SfntVersion,
131+
tables: dict[str, SfntTable] | None = None,
132+
woff_payload: WoffPayload | None = None,
133+
):
134+
super().__init__(tables)
135+
self.sfnt_version = sfnt_version
136+
self.woff_payload = woff_payload
137+
138+
def __setitem__(self, tag: Any, table: Any):
139+
if not isinstance(tag, str):
140+
raise KeyError("tag must be a 'str'")
141+
142+
if len(tag) != 4:
143+
raise KeyError('tag length must be 4')
144+
145+
if any(not 0x20 <= ord(c) <= 0x7E for c in tag):
146+
raise KeyError('tag contains illegal characters')
147+
148+
if tag.startswith(' '):
149+
raise KeyError('tag cannot start with a space')
150+
151+
if ' ' in tag.strip():
152+
raise KeyError('tag cannot contain spaces in between')
153+
154+
table_type = TABLE_TYPE_REGISTRY.get(tag, SfntTable)
155+
if not isinstance(table, table_type):
156+
raise ValueError(f'bad table type for tag {repr(tag)}')
157+
158+
super().__setitem__(tag, table)
159+
160+
def __repr__(self) -> str:
161+
return object.__repr__(self)
162+
163+
164+
class SfntFontCollection(UserList[SfntFont]):
165+
@staticmethod
166+
def parse(
167+
stream: bytes | BinaryIO,
168+
verify_checksum: bool = False,
169+
share_tables: bool = True,
170+
) -> 'SfntFontCollection':
171+
if isinstance(stream, bytes):
172+
stream = BytesIO(stream)
173+
stream = Stream(stream)
174+
175+
stream.seek(0)
176+
tag = stream.read_tag()
177+
stream.seek(0)
178+
if tag == SfntFileTag.TTCF:
179+
header = TtcHeader.parse(stream)
180+
collection_tables_cache = {}
181+
182+
fonts = []
183+
for font_index in range(header.num_fonts):
184+
table_directory = header.read_table_directory(stream, font_index)
185+
186+
reader = XtfReader(stream, table_directory, verify_checksum, share_tables, collection_tables_cache)
187+
sfnt_version, tables = reader.parse_font()
188+
189+
font = SfntFont(sfnt_version, tables)
190+
fonts.append(font)
191+
192+
return SfntFontCollection(fonts)
193+
elif tag == SfntFileTag.WOFF2:
194+
header = Woff2Header.parse(stream)
195+
if header.collection_header is None:
196+
raise SfntError('not a woff2 collection font')
197+
uncompressed_stream = Stream(BytesIO(header.read_uncompressed_data(stream)))
198+
collection_tables_cache = {}
199+
200+
fonts = []
201+
for font_entry in header.collection_header.font_entries:
202+
reader = Woff2Reader(uncompressed_stream, header, font_entry, share_tables, collection_tables_cache)
203+
sfnt_version, tables = reader.parse_font()
204+
205+
font = SfntFont(sfnt_version, tables)
206+
fonts.append(font)
207+
208+
metadata = header.read_metadata(stream)
209+
private_data = header.read_private_data(stream)
210+
woff_payload = WoffPayload(
211+
header.major_version,
212+
header.minor_version,
213+
metadata,
214+
private_data,
215+
)
216+
217+
return SfntFontCollection(fonts, woff_payload)
218+
else:
219+
raise SfntError('unsupported SFNT collection font')
220+
221+
@staticmethod
222+
def load(
223+
file_path: str | PathLike[str],
224+
verify_checksum: bool = False,
225+
share_tables: bool = True,
226+
) -> 'SfntFontCollection':
227+
with open(file_path, 'rb') as file:
228+
return SfntFontCollection.parse(file, verify_checksum, share_tables)
229+
230+
woff_payload: WoffPayload | None
231+
232+
def __init__(
233+
self,
234+
fonts: list[SfntFont] | None = None,
235+
woff_payload: WoffPayload | None = None,
236+
):
237+
super().__init__(fonts)
238+
self.woff_payload = woff_payload
239+
240+
def __repr__(self) -> str:
241+
return object.__repr__(self)

src/sfnttools/tables/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from typing import Final
2+
3+
from sfnttools.tables.default import DefaultTable
4+
from sfnttools.tables.dsig import DsigTable
5+
from sfnttools.tables.head import HeadTable
6+
7+
TABLE_TYPE_REGISTRY: Final = {
8+
'DSIG': DsigTable,
9+
'head': HeadTable,
10+
}
11+
12+
DEFAULT_TABLE_TYPE: Final = DefaultTable

src/sfnttools/tables/default.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from sfnttools.contract import SfntTable, SfntReader
2+
3+
4+
class DefaultTable(SfntTable):
5+
@staticmethod
6+
def parse(data: bytes, reader: SfntReader) -> 'DefaultTable':
7+
return DefaultTable(data)
8+
9+
data: bytes
10+
11+
def __init__(self, data: bytes = b''):
12+
self.data = data
13+
14+
def copy(self) -> 'DefaultTable':
15+
return DefaultTable(self.data)
16+
17+
def dump(self) -> bytes:
18+
return self.data

0 commit comments

Comments
 (0)