Skip to content

Commit 56b8ec6

Browse files
committed
a
1 parent 295748f commit 56b8ec6

40 files changed

+3857
-0
lines changed

examples/demo.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from fontTools.ttLib import TTFont
2+
3+
from examples import assets_dir
4+
from sfnttools.font import SfntFont, SfntFontCollection
5+
6+
7+
def main():
8+
s1 = SfntFont.load(assets_dir.joinpath('source-han-sans', 'SourceHanSansSC-VF.otf'), verify_checksum=True)
9+
s2 = SfntFont.load(assets_dir.joinpath('source-han-sans', 'SourceHanSansSC-VF.otf.woff2'), verify_checksum=True)
10+
s3 = SfntFont.load(assets_dir.joinpath('source-han-sans', 'SourceHanSansSC-VF.ttf'), verify_checksum=True)
11+
s4 = SfntFont.load(assets_dir.joinpath('source-han-sans', 'SourceHanSansSC-VF.ttf.woff2'), verify_checksum=True)
12+
s5 = SfntFontCollection.load(assets_dir.joinpath('source-han-sans', 'SourceHanSans-VF.otf.ttc'), verify_checksum=True)
13+
s6 = SfntFontCollection.load(assets_dir.joinpath('source-han-sans', 'SourceHanSans-VF.ttf.ttc'), verify_checksum=True)
14+
15+
d1 = SfntFont.load(assets_dir.joinpath('demo', 'demo.otf'), verify_checksum=True)
16+
d2 = SfntFont.load(assets_dir.joinpath('demo', 'demo.otf.woff'), verify_checksum=True)
17+
d3 = SfntFont.load(assets_dir.joinpath('demo', 'demo.otf.woff2'), verify_checksum=True)
18+
d4 = SfntFont.load(assets_dir.joinpath('demo', 'demo.ttf'), verify_checksum=True)
19+
d5 = SfntFont.load(assets_dir.joinpath('demo', 'demo.ttf.woff'), verify_checksum=True)
20+
d6 = SfntFont.load(assets_dir.joinpath('demo', 'demo.ttf.woff2'), verify_checksum=True)
21+
d7 = SfntFontCollection.load(assets_dir.joinpath('demo', 'demo.otc'), verify_checksum=True)
22+
d8 = SfntFontCollection.load(assets_dir.joinpath('demo', 'demo.otc.woff2'), verify_checksum=True)
23+
d9 = SfntFontCollection.load(assets_dir.joinpath('demo', 'demo.ttc'), verify_checksum=True)
24+
d10 = SfntFontCollection.load(assets_dir.joinpath('demo', 'demo.ttc.woff2'), verify_checksum=True)
25+
26+
print()
27+
28+
29+
if __name__ == '__main__':
30+
main()

src/sfnttools/configs.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from enum import IntEnum
2+
3+
4+
class GlyfDataOffsetsPaddingMode(IntEnum):
5+
NO_PADDING = 0
6+
ALIGN_TO_2_BYTE = 2
7+
ALIGN_TO_4_BYTE = 4
8+
9+
10+
class SfntConfigs:
11+
glyf_data_offsets_padding_mode: GlyfDataOffsetsPaddingMode
12+
13+
def __init__(
14+
self,
15+
glyf_data_offsets_padding_mode: GlyfDataOffsetsPaddingMode = GlyfDataOffsetsPaddingMode.ALIGN_TO_2_BYTE,
16+
):
17+
self.glyf_data_offsets_padding_mode = glyf_data_offsets_padding_mode

src/sfnttools/error.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
class SfntError(Exception):
3+
pass

src/sfnttools/flags.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from abc import abstractmethod
2+
3+
4+
class SfntFlags:
5+
@staticmethod
6+
@abstractmethod
7+
def parse(value: int) -> 'SfntFlags':
8+
raise NotImplementedError()
9+
10+
def __repr__(self) -> str:
11+
return repr(self.value)
12+
13+
@property
14+
@abstractmethod
15+
def value(self) -> int:
16+
raise NotImplementedError()
17+
18+
@abstractmethod
19+
def copy(self) -> 'SfntFlags':
20+
raise NotImplementedError()

src/sfnttools/font.py

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

src/sfnttools/payload.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from typing import Any
2+
3+
from sfnttools.tables.dsig.table import DsigTable
4+
5+
6+
class TtcPayload:
7+
major_version: int
8+
minor_version: int
9+
dsig_table: DsigTable | None
10+
11+
def __init__(
12+
self,
13+
major_version: int = 1,
14+
minor_version: int = 0,
15+
dsig_table: DsigTable | None = None,
16+
):
17+
self.major_version = major_version
18+
self.minor_version = minor_version
19+
self.dsig_table = dsig_table
20+
21+
def __eq__(self, other: Any) -> bool:
22+
if not isinstance(other, TtcPayload):
23+
return False
24+
return (self.major_version == other.major_version and
25+
self.minor_version == other.minor_version and
26+
self.dsig_table == other.dsig_table)
27+
28+
def copy(self) -> 'TtcPayload':
29+
return TtcPayload(
30+
self.major_version,
31+
self.minor_version,
32+
self.dsig_table.copy(),
33+
)
34+
35+
36+
class WoffPayload:
37+
major_version: int
38+
minor_version: int
39+
metadata: bytes | None
40+
private_data: bytes | None
41+
42+
def __init__(
43+
self,
44+
major_version: int = 0,
45+
minor_version: int = 0,
46+
metadata: bytes | None = None,
47+
private_data: bytes | None = None,
48+
):
49+
self.major_version = major_version
50+
self.minor_version = minor_version
51+
self.metadata = metadata
52+
self.private_data = private_data
53+
54+
def __eq__(self, other: Any) -> bool:
55+
if not isinstance(other, WoffPayload):
56+
return False
57+
return (self.major_version == other.major_version and
58+
self.minor_version == other.minor_version and
59+
self.metadata == other.metadata and
60+
self.private_data == other.private_data)
61+
62+
def copy(self) -> 'WoffPayload':
63+
return WoffPayload(
64+
self.major_version,
65+
self.minor_version,
66+
self.metadata,
67+
self.private_data,
68+
)

0 commit comments

Comments
 (0)