1from io import BytesIO
2import sys
3import array
4import struct
5from collections import OrderedDict
6from fontTools.misc import sstruct
7from fontTools.misc.arrayTools import calcIntBounds
8from fontTools.misc.textTools import Tag, bytechr, byteord, bytesjoin, pad
9from fontTools.ttLib import (
10 TTFont,
11 TTLibError,
12 getTableModule,
13 getTableClass,
14 getSearchRange,
15)
16from fontTools.ttLib.sfnt import (
17 SFNTReader,
18 SFNTWriter,
19 DirectoryEntry,
20 WOFFFlavorData,
21 sfntDirectoryFormat,
22 sfntDirectorySize,
23 SFNTDirectoryEntry,
24 sfntDirectoryEntrySize,
25 calcChecksum,
26)
27from fontTools.ttLib.tables import ttProgram, _g_l_y_f
28import logging
29
30
31log = logging.getLogger("fontTools.ttLib.woff2")
32
33haveBrotli = False
34try:
35 try:
36 import brotlicffi as brotli
37 except ImportError:
38 import brotli
39 haveBrotli = True
40except ImportError:
41 pass
42
43
44class WOFF2Reader(SFNTReader):
45 flavor = "woff2"
46
47 def __init__(self, file, checkChecksums=0, fontNumber=-1):
48 if not haveBrotli:
49 log.error(
50 "The WOFF2 decoder requires the Brotli Python extension, available at: "
51 "https://github.com/google/brotli"
52 )
53 raise ImportError("No module named brotli")
54
55 self.file = file
56
57 signature = Tag(self.file.read(4))
58 if signature != b"wOF2":
59 raise TTLibError("Not a WOFF2 font (bad signature)")
60
61 self.file.seek(0)
62 self.DirectoryEntry = WOFF2DirectoryEntry
63 data = self.file.read(woff2DirectorySize)
64 if len(data) != woff2DirectorySize:
65 raise TTLibError("Not a WOFF2 font (not enough data)")
66 sstruct.unpack(woff2DirectoryFormat, data, self)
67
68 self.tables = OrderedDict()
69 offset = 0
70 for i in range(self.numTables):
71 entry = self.DirectoryEntry()
72 entry.fromFile(self.file)
73 tag = Tag(entry.tag)
74 self.tables[tag] = entry
75 entry.offset = offset
76 offset += entry.length
77
78 totalUncompressedSize = offset
79 compressedData = self.file.read(self.totalCompressedSize)
80 decompressedData = brotli.decompress(compressedData)
81 if len(decompressedData) != totalUncompressedSize:
82 raise TTLibError(
83 "unexpected size for decompressed font data: expected %d, found %d"
84 % (totalUncompressedSize, len(decompressedData))
85 )
86 self.transformBuffer = BytesIO(decompressedData)
87
88 self.file.seek(0, 2)
89 if self.length != self.file.tell():
90 raise TTLibError("reported 'length' doesn't match the actual file size")
91
92 self.flavorData = WOFF2FlavorData(self)
93
94 # make empty TTFont to store data while reconstructing tables
95 self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False)
96
97 def __getitem__(self, tag):
98 """Fetch the raw table data. Reconstruct transformed tables."""
99 entry = self.tables[Tag(tag)]
100 if not hasattr(entry, "data"):
101 if entry.transformed:
102 entry.data = self.reconstructTable(tag)
103 else:
104 entry.data = entry.loadData(self.transformBuffer)
105 return entry.data
106
107 def reconstructTable(self, tag):
108 """Reconstruct table named 'tag' from transformed data."""
109 entry = self.tables[Tag(tag)]
110 rawData = entry.loadData(self.transformBuffer)
111 if tag == "glyf":
112 # no need to pad glyph data when reconstructing
113 padding = self.padding if hasattr(self, "padding") else None
114 data = self._reconstructGlyf(rawData, padding)
115 elif tag == "loca":
116 data = self._reconstructLoca()
117 elif tag == "hmtx":
118 data = self._reconstructHmtx(rawData)
119 else:
120 raise TTLibError("transform for table '%s' is unknown" % tag)
121 return data
122
123 def _reconstructGlyf(self, data, padding=None):
124 """Return recostructed glyf table data, and set the corresponding loca's
125 locations. Optionally pad glyph offsets to the specified number of bytes.
126 """
127 self.ttFont["loca"] = WOFF2LocaTable()
128 glyfTable = self.ttFont["glyf"] = WOFF2GlyfTable()
129 glyfTable.reconstruct(data, self.ttFont)
130 if padding:
131 glyfTable.padding = padding
132 data = glyfTable.compile(self.ttFont)
133 return data
134
135 def _reconstructLoca(self):
136 """Return reconstructed loca table data."""
137 if "loca" not in self.ttFont:
138 # make sure glyf is reconstructed first
139 self.tables["glyf"].data = self.reconstructTable("glyf")
140 locaTable = self.ttFont["loca"]
141 data = locaTable.compile(self.ttFont)
142 if len(data) != self.tables["loca"].origLength:
143 raise TTLibError(
144 "reconstructed 'loca' table doesn't match original size: "
145 "expected %d, found %d" % (self.tables["loca"].origLength, len(data))
146 )
147 return data
148
149 def _reconstructHmtx(self, data):
150 """Return reconstructed hmtx table data."""
151 # Before reconstructing 'hmtx' table we need to parse other tables:
152 # 'glyf' is required for reconstructing the sidebearings from the glyphs'
153 # bounding box; 'hhea' is needed for the numberOfHMetrics field.
154 if "glyf" in self.flavorData.transformedTables:
155 # transformed 'glyf' table is self-contained, thus 'loca' not needed
156 tableDependencies = ("maxp", "hhea", "glyf")
157 else:
158 # decompiling untransformed 'glyf' requires 'loca', which requires 'head'
159 tableDependencies = ("maxp", "head", "hhea", "loca", "glyf")
160 for tag in tableDependencies:
161 self._decompileTable(tag)
162 hmtxTable = self.ttFont["hmtx"] = WOFF2HmtxTable()
163 hmtxTable.reconstruct(data, self.ttFont)
164 data = hmtxTable.compile(self.ttFont)
165 return data
166
167 def _decompileTable(self, tag):
168 """Decompile table data and store it inside self.ttFont."""
169 data = self[tag]
170 if self.ttFont.isLoaded(tag):
171 return self.ttFont[tag]
172 tableClass = getTableClass(tag)
173 table = tableClass(tag)
174 self.ttFont.tables[tag] = table
175 table.decompile(data, self.ttFont)
176
177
178class WOFF2Writer(SFNTWriter):
179 flavor = "woff2"
180
181 def __init__(
182 self,
183 file,
184 numTables,
185 sfntVersion="\000\001\000\000",
186 flavor=None,
187 flavorData=None,
188 ):
189 if not haveBrotli:
190 log.error(
191 "The WOFF2 encoder requires the Brotli Python extension, available at: "
192 "https://github.com/google/brotli"
193 )
194 raise ImportError("No module named brotli")
195
196 self.file = file
197 self.numTables = numTables
198 self.sfntVersion = Tag(sfntVersion)
199 self.flavorData = WOFF2FlavorData(data=flavorData)
200
201 self.directoryFormat = woff2DirectoryFormat
202 self.directorySize = woff2DirectorySize
203 self.DirectoryEntry = WOFF2DirectoryEntry
204
205 self.signature = Tag("wOF2")
206
207 self.nextTableOffset = 0
208 self.transformBuffer = BytesIO()
209
210 self.tables = OrderedDict()
211
212 # make empty TTFont to store data while normalising and transforming tables
213 self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False)
214
215 def __setitem__(self, tag, data):
216 """Associate new entry named 'tag' with raw table data."""
217 if tag in self.tables:
218 raise TTLibError("cannot rewrite '%s' table" % tag)
219 if tag == "DSIG":
220 # always drop DSIG table, since the encoding process can invalidate it
221 self.numTables -= 1
222 return
223
224 entry = self.DirectoryEntry()
225 entry.tag = Tag(tag)
226 entry.flags = getKnownTagIndex(entry.tag)
227 # WOFF2 table data are written to disk only on close(), after all tags
228 # have been specified
229 entry.data = data
230
231 self.tables[tag] = entry
232
233 def close(self):
234 """All tags must have been specified. Now write the table data and directory."""
235 if len(self.tables) != self.numTables:
236 raise TTLibError(
237 "wrong number of tables; expected %d, found %d"
238 % (self.numTables, len(self.tables))
239 )
240
241 if self.sfntVersion in ("\x00\x01\x00\x00", "true"):
242 isTrueType = True
243 elif self.sfntVersion == "OTTO":
244 isTrueType = False
245 else:
246 raise TTLibError("Not a TrueType or OpenType font (bad sfntVersion)")
247
248 # The WOFF2 spec no longer requires the glyph offsets to be 4-byte aligned.
249 # However, the reference WOFF2 implementation still fails to reconstruct
250 # 'unpadded' glyf tables, therefore we need to 'normalise' them.
251 # See:
252 # https://github.com/khaledhosny/ots/issues/60
253 # https://github.com/google/woff2/issues/15
254 if (
255 isTrueType
256 and "glyf" in self.flavorData.transformedTables
257 and "glyf" in self.tables
258 ):
259 self._normaliseGlyfAndLoca(padding=4)
260 self._setHeadTransformFlag()
261
262 # To pass the legacy OpenType Sanitiser currently included in browsers,
263 # we must sort the table directory and data alphabetically by tag.
264 # See:
265 # https://github.com/google/woff2/pull/3
266 # https://lists.w3.org/Archives/Public/public-webfonts-wg/2015Mar/0000.html
267 #
268 # 2023: We rely on this in _transformTables where we expect that
269 # "loca" comes after "glyf" table.
270 self.tables = OrderedDict(sorted(self.tables.items()))
271
272 self.totalSfntSize = self._calcSFNTChecksumsLengthsAndOffsets()
273
274 fontData = self._transformTables()
275 compressedFont = brotli.compress(fontData, mode=brotli.MODE_FONT)
276
277 self.totalCompressedSize = len(compressedFont)
278 self.length = self._calcTotalSize()
279 self.majorVersion, self.minorVersion = self._getVersion()
280 self.reserved = 0
281
282 directory = self._packTableDirectory()
283 self.file.seek(0)
284 self.file.write(pad(directory + compressedFont, size=4))
285 self._writeFlavorData()
286
287 def _normaliseGlyfAndLoca(self, padding=4):
288 """Recompile glyf and loca tables, aligning glyph offsets to multiples of
289 'padding' size. Update the head table's 'indexToLocFormat' accordingly while
290 compiling loca.
291 """
292 if self.sfntVersion == "OTTO":
293 return
294
295 for tag in ("maxp", "head", "loca", "glyf", "fvar"):
296 if tag in self.tables:
297 self._decompileTable(tag)
298 self.ttFont["glyf"].padding = padding
299 for tag in ("glyf", "loca"):
300 self._compileTable(tag)
301
302 def _setHeadTransformFlag(self):
303 """Set bit 11 of 'head' table flags to indicate that the font has undergone
304 a lossless modifying transform. Re-compile head table data."""
305 self._decompileTable("head")
306 self.ttFont["head"].flags |= 1 << 11
307 self._compileTable("head")
308
309 def _decompileTable(self, tag):
310 """Fetch table data, decompile it, and store it inside self.ttFont."""
311 tag = Tag(tag)
312 if tag not in self.tables:
313 raise TTLibError("missing required table: %s" % tag)
314 if self.ttFont.isLoaded(tag):
315 return
316 data = self.tables[tag].data
317 if tag == "loca":
318 tableClass = WOFF2LocaTable
319 elif tag == "glyf":
320 tableClass = WOFF2GlyfTable
321 elif tag == "hmtx":
322 tableClass = WOFF2HmtxTable
323 else:
324 tableClass = getTableClass(tag)
325 table = tableClass(tag)
326 self.ttFont.tables[tag] = table
327 table.decompile(data, self.ttFont)
328
329 def _compileTable(self, tag):
330 """Compile table and store it in its 'data' attribute."""
331 self.tables[tag].data = self.ttFont[tag].compile(self.ttFont)
332
333 def _calcSFNTChecksumsLengthsAndOffsets(self):
334 """Compute the 'original' SFNT checksums, lengths and offsets for checksum
335 adjustment calculation. Return the total size of the uncompressed font.
336 """
337 offset = sfntDirectorySize + sfntDirectoryEntrySize * len(self.tables)
338 for tag, entry in self.tables.items():
339 data = entry.data
340 entry.origOffset = offset
341 entry.origLength = len(data)
342 if tag == "head":
343 entry.checkSum = calcChecksum(data[:8] + b"\0\0\0\0" + data[12:])
344 else:
345 entry.checkSum = calcChecksum(data)
346 offset += (entry.origLength + 3) & ~3
347 return offset
348
349 def _transformTables(self):
350 """Return transformed font data."""
351 transformedTables = self.flavorData.transformedTables
352 for tag, entry in self.tables.items():
353 data = None
354 if tag in transformedTables:
355 data = self.transformTable(tag)
356 if data is not None:
357 entry.transformed = True
358 if data is None:
359 if tag == "glyf":
360 # Currently we always sort table tags so
361 # 'loca' comes after 'glyf'.
362 transformedTables.discard("loca")
363 # pass-through the table data without transformation
364 data = entry.data
365 entry.transformed = False
366 entry.offset = self.nextTableOffset
367 entry.saveData(self.transformBuffer, data)
368 self.nextTableOffset += entry.length
369 self.writeMasterChecksum()
370 fontData = self.transformBuffer.getvalue()
371 return fontData
372
373 def transformTable(self, tag):
374 """Return transformed table data, or None if some pre-conditions aren't
375 met -- in which case, the non-transformed table data will be used.
376 """
377 if tag == "loca":
378 data = b""
379 elif tag == "glyf":
380 for tag in ("maxp", "head", "loca", "glyf"):
381 self._decompileTable(tag)
382 glyfTable = self.ttFont["glyf"]
383 data = glyfTable.transform(self.ttFont)
384 elif tag == "hmtx":
385 if "glyf" not in self.tables:
386 return
387 for tag in ("maxp", "head", "hhea", "loca", "glyf", "hmtx"):
388 self._decompileTable(tag)
389 hmtxTable = self.ttFont["hmtx"]
390 data = hmtxTable.transform(self.ttFont) # can be None
391 else:
392 raise TTLibError("Transform for table '%s' is unknown" % tag)
393 return data
394
395 def _calcMasterChecksum(self):
396 """Calculate checkSumAdjustment."""
397 checksums = []
398 for tag in self.tables.keys():
399 checksums.append(self.tables[tag].checkSum)
400
401 # Create a SFNT directory for checksum calculation purposes
402 self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(
403 self.numTables, 16
404 )
405 directory = sstruct.pack(sfntDirectoryFormat, self)
406 tables = sorted(self.tables.items())
407 for tag, entry in tables:
408 sfntEntry = SFNTDirectoryEntry()
409 sfntEntry.tag = entry.tag
410 sfntEntry.checkSum = entry.checkSum
411 sfntEntry.offset = entry.origOffset
412 sfntEntry.length = entry.origLength
413 directory = directory + sfntEntry.toString()
414
415 directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize
416 assert directory_end == len(directory)
417
418 checksums.append(calcChecksum(directory))
419 checksum = sum(checksums) & 0xFFFFFFFF
420 # BiboAfba!
421 checksumadjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF
422 return checksumadjustment
423
424 def writeMasterChecksum(self):
425 """Write checkSumAdjustment to the transformBuffer."""
426 checksumadjustment = self._calcMasterChecksum()
427 self.transformBuffer.seek(self.tables["head"].offset + 8)
428 self.transformBuffer.write(struct.pack(">L", checksumadjustment))
429
430 def _calcTotalSize(self):
431 """Calculate total size of WOFF2 font, including any meta- and/or private data."""
432 offset = self.directorySize
433 for entry in self.tables.values():
434 offset += len(entry.toString())
435 offset += self.totalCompressedSize
436 offset = (offset + 3) & ~3
437 offset = self._calcFlavorDataOffsetsAndSize(offset)
438 return offset
439
440 def _calcFlavorDataOffsetsAndSize(self, start):
441 """Calculate offsets and lengths for any meta- and/or private data."""
442 offset = start
443 data = self.flavorData
444 if data.metaData:
445 self.metaOrigLength = len(data.metaData)
446 self.metaOffset = offset
447 self.compressedMetaData = brotli.compress(
448 data.metaData, mode=brotli.MODE_TEXT
449 )
450 self.metaLength = len(self.compressedMetaData)
451 offset += self.metaLength
452 else:
453 self.metaOffset = self.metaLength = self.metaOrigLength = 0
454 self.compressedMetaData = b""
455 if data.privData:
456 # make sure private data is padded to 4-byte boundary
457 offset = (offset + 3) & ~3
458 self.privOffset = offset
459 self.privLength = len(data.privData)
460 offset += self.privLength
461 else:
462 self.privOffset = self.privLength = 0
463 return offset
464
465 def _getVersion(self):
466 """Return the WOFF2 font's (majorVersion, minorVersion) tuple."""
467 data = self.flavorData
468 if data.majorVersion is not None and data.minorVersion is not None:
469 return data.majorVersion, data.minorVersion
470 else:
471 # if None, return 'fontRevision' from 'head' table
472 if "head" in self.tables:
473 return struct.unpack(">HH", self.tables["head"].data[4:8])
474 else:
475 return 0, 0
476
477 def _packTableDirectory(self):
478 """Return WOFF2 table directory data."""
479 directory = sstruct.pack(self.directoryFormat, self)
480 for entry in self.tables.values():
481 directory = directory + entry.toString()
482 return directory
483
484 def _writeFlavorData(self):
485 """Write metadata and/or private data using appropiate padding."""
486 compressedMetaData = self.compressedMetaData
487 privData = self.flavorData.privData
488 if compressedMetaData and privData:
489 compressedMetaData = pad(compressedMetaData, size=4)
490 if compressedMetaData:
491 self.file.seek(self.metaOffset)
492 assert self.file.tell() == self.metaOffset
493 self.file.write(compressedMetaData)
494 if privData:
495 self.file.seek(self.privOffset)
496 assert self.file.tell() == self.privOffset
497 self.file.write(privData)
498
499 def reordersTables(self):
500 return True
501
502
503# -- woff2 directory helpers and cruft
504
505woff2DirectoryFormat = """
506 > # big endian
507 signature: 4s # "wOF2"
508 sfntVersion: 4s
509 length: L # total woff2 file size
510 numTables: H # number of tables
511 reserved: H # set to 0
512 totalSfntSize: L # uncompressed size
513 totalCompressedSize: L # compressed size
514 majorVersion: H # major version of WOFF file
515 minorVersion: H # minor version of WOFF file
516 metaOffset: L # offset to metadata block
517 metaLength: L # length of compressed metadata
518 metaOrigLength: L # length of uncompressed metadata
519 privOffset: L # offset to private data block
520 privLength: L # length of private data block
521"""
522
523woff2DirectorySize = sstruct.calcsize(woff2DirectoryFormat)
524
525woff2KnownTags = (
526 "cmap",
527 "head",
528 "hhea",
529 "hmtx",
530 "maxp",
531 "name",
532 "OS/2",
533 "post",
534 "cvt ",
535 "fpgm",
536 "glyf",
537 "loca",
538 "prep",
539 "CFF ",
540 "VORG",
541 "EBDT",
542 "EBLC",
543 "gasp",
544 "hdmx",
545 "kern",
546 "LTSH",
547 "PCLT",
548 "VDMX",
549 "vhea",
550 "vmtx",
551 "BASE",
552 "GDEF",
553 "GPOS",
554 "GSUB",
555 "EBSC",
556 "JSTF",
557 "MATH",
558 "CBDT",
559 "CBLC",
560 "COLR",
561 "CPAL",
562 "SVG ",
563 "sbix",
564 "acnt",
565 "avar",
566 "bdat",
567 "bloc",
568 "bsln",
569 "cvar",
570 "fdsc",
571 "feat",
572 "fmtx",
573 "fvar",
574 "gvar",
575 "hsty",
576 "just",
577 "lcar",
578 "mort",
579 "morx",
580 "opbd",
581 "prop",
582 "trak",
583 "Zapf",
584 "Silf",
585 "Glat",
586 "Gloc",
587 "Feat",
588 "Sill",
589)
590
591woff2FlagsFormat = """
592 > # big endian
593 flags: B # table type and flags
594"""
595
596woff2FlagsSize = sstruct.calcsize(woff2FlagsFormat)
597
598woff2UnknownTagFormat = """
599 > # big endian
600 tag: 4s # 4-byte tag (optional)
601"""
602
603woff2UnknownTagSize = sstruct.calcsize(woff2UnknownTagFormat)
604
605woff2UnknownTagIndex = 0x3F
606
607woff2Base128MaxSize = 5
608woff2DirectoryEntryMaxSize = (
609 woff2FlagsSize + woff2UnknownTagSize + 2 * woff2Base128MaxSize
610)
611
612woff2TransformedTableTags = ("glyf", "loca")
613
614woff2GlyfTableFormat = """
615 > # big endian
616 version: H # = 0x0000
617 optionFlags: H # Bit 0: we have overlapSimpleBitmap[], Bits 1-15: reserved
618 numGlyphs: H # Number of glyphs
619 indexFormat: H # Offset format for loca table
620 nContourStreamSize: L # Size of nContour stream
621 nPointsStreamSize: L # Size of nPoints stream
622 flagStreamSize: L # Size of flag stream
623 glyphStreamSize: L # Size of glyph stream
624 compositeStreamSize: L # Size of composite stream
625 bboxStreamSize: L # Comnined size of bboxBitmap and bboxStream
626 instructionStreamSize: L # Size of instruction stream
627"""
628
629woff2GlyfTableFormatSize = sstruct.calcsize(woff2GlyfTableFormat)
630
631bboxFormat = """
632 > # big endian
633 xMin: h
634 yMin: h
635 xMax: h
636 yMax: h
637"""
638
639woff2OverlapSimpleBitmapFlag = 0x0001
640
641
642def getKnownTagIndex(tag):
643 """Return index of 'tag' in woff2KnownTags list. Return 63 if not found."""
644 try:
645 return woff2KnownTags.index(tag)
646 except ValueError:
647 return woff2UnknownTagIndex
648
649
650class WOFF2DirectoryEntry(DirectoryEntry):
651 def fromFile(self, file):
652 pos = file.tell()
653 data = file.read(woff2DirectoryEntryMaxSize)
654 left = self.fromString(data)
655 consumed = len(data) - len(left)
656 file.seek(pos + consumed)
657
658 def fromString(self, data):
659 if len(data) < 1:
660 raise TTLibError("can't read table 'flags': not enough data")
661 dummy, data = sstruct.unpack2(woff2FlagsFormat, data, self)
662 if self.flags & 0x3F == 0x3F:
663 # if bits [0..5] of the flags byte == 63, read a 4-byte arbitrary tag value
664 if len(data) < woff2UnknownTagSize:
665 raise TTLibError("can't read table 'tag': not enough data")
666 dummy, data = sstruct.unpack2(woff2UnknownTagFormat, data, self)
667 else:
668 # otherwise, tag is derived from a fixed 'Known Tags' table
669 self.tag = woff2KnownTags[self.flags & 0x3F]
670 self.tag = Tag(self.tag)
671 self.origLength, data = unpackBase128(data)
672 self.length = self.origLength
673 if self.transformed:
674 self.length, data = unpackBase128(data)
675 if self.tag == "loca" and self.length != 0:
676 raise TTLibError("the transformLength of the 'loca' table must be 0")
677 # return left over data
678 return data
679
680 def toString(self):
681 data = bytechr(self.flags)
682 if (self.flags & 0x3F) == 0x3F:
683 data += struct.pack(">4s", self.tag.tobytes())
684 data += packBase128(self.origLength)
685 if self.transformed:
686 data += packBase128(self.length)
687 return data
688
689 @property
690 def transformVersion(self):
691 """Return bits 6-7 of table entry's flags, which indicate the preprocessing
692 transformation version number (between 0 and 3).
693 """
694 return self.flags >> 6
695
696 @transformVersion.setter
697 def transformVersion(self, value):
698 assert 0 <= value <= 3
699 self.flags |= value << 6
700
701 @property
702 def transformed(self):
703 """Return True if the table has any transformation, else return False."""
704 # For all tables in a font, except for 'glyf' and 'loca', the transformation
705 # version 0 indicates the null transform (where the original table data is
706 # passed directly to the Brotli compressor). For 'glyf' and 'loca' tables,
707 # transformation version 3 indicates the null transform
708 if self.tag in {"glyf", "loca"}:
709 return self.transformVersion != 3
710 else:
711 return self.transformVersion != 0
712
713 @transformed.setter
714 def transformed(self, booleanValue):
715 # here we assume that a non-null transform means version 0 for 'glyf' and
716 # 'loca' and 1 for every other table (e.g. hmtx); but that may change as
717 # new transformation formats are introduced in the future (if ever).
718 if self.tag in {"glyf", "loca"}:
719 self.transformVersion = 3 if not booleanValue else 0
720 else:
721 self.transformVersion = int(booleanValue)
722
723
724class WOFF2LocaTable(getTableClass("loca")):
725 """Same as parent class. The only difference is that it attempts to preserve
726 the 'indexFormat' as encoded in the WOFF2 glyf table.
727 """
728
729 def __init__(self, tag=None):
730 self.tableTag = Tag(tag or "loca")
731
732 def compile(self, ttFont):
733 try:
734 max_location = max(self.locations)
735 except AttributeError:
736 self.set([])
737 max_location = 0
738 if "glyf" in ttFont and hasattr(ttFont["glyf"], "indexFormat"):
739 # copile loca using the indexFormat specified in the WOFF2 glyf table
740 indexFormat = ttFont["glyf"].indexFormat
741 if indexFormat == 0:
742 if max_location >= 0x20000:
743 raise TTLibError("indexFormat is 0 but local offsets > 0x20000")
744 if not all(l % 2 == 0 for l in self.locations):
745 raise TTLibError(
746 "indexFormat is 0 but local offsets not multiples of 2"
747 )
748 locations = array.array("H")
749 for location in self.locations:
750 locations.append(location // 2)
751 else:
752 locations = array.array("I", self.locations)
753 if sys.byteorder != "big":
754 locations.byteswap()
755 data = locations.tobytes()
756 else:
757 # use the most compact indexFormat given the current glyph offsets
758 data = super(WOFF2LocaTable, self).compile(ttFont)
759 return data
760
761
762class WOFF2GlyfTable(getTableClass("glyf")):
763 """Decoder/Encoder for WOFF2 'glyf' table transform."""
764
765 subStreams = (
766 "nContourStream",
767 "nPointsStream",
768 "flagStream",
769 "glyphStream",
770 "compositeStream",
771 "bboxStream",
772 "instructionStream",
773 )
774
775 def __init__(self, tag=None):
776 self.tableTag = Tag(tag or "glyf")
777
778 def reconstruct(self, data, ttFont):
779 """Decompile transformed 'glyf' data."""
780 inputDataSize = len(data)
781
782 if inputDataSize < woff2GlyfTableFormatSize:
783 raise TTLibError("not enough 'glyf' data")
784 dummy, data = sstruct.unpack2(woff2GlyfTableFormat, data, self)
785 offset = woff2GlyfTableFormatSize
786
787 for stream in self.subStreams:
788 size = getattr(self, stream + "Size")
789 setattr(self, stream, data[:size])
790 data = data[size:]
791 offset += size
792
793 hasOverlapSimpleBitmap = self.optionFlags & woff2OverlapSimpleBitmapFlag
794 self.overlapSimpleBitmap = None
795 if hasOverlapSimpleBitmap:
796 overlapSimpleBitmapSize = (self.numGlyphs + 7) >> 3
797 self.overlapSimpleBitmap = array.array("B", data[:overlapSimpleBitmapSize])
798 offset += overlapSimpleBitmapSize
799
800 if offset != inputDataSize:
801 raise TTLibError(
802 "incorrect size of transformed 'glyf' table: expected %d, received %d bytes"
803 % (offset, inputDataSize)
804 )
805
806 bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2
807 bboxBitmap = self.bboxStream[:bboxBitmapSize]
808 self.bboxBitmap = array.array("B", bboxBitmap)
809 self.bboxStream = self.bboxStream[bboxBitmapSize:]
810
811 self.nContourStream = array.array("h", self.nContourStream)
812 if sys.byteorder != "big":
813 self.nContourStream.byteswap()
814 assert len(self.nContourStream) == self.numGlyphs
815
816 if "head" in ttFont:
817 ttFont["head"].indexToLocFormat = self.indexFormat
818 try:
819 self.glyphOrder = ttFont.getGlyphOrder()
820 except:
821 self.glyphOrder = None
822 if self.glyphOrder is None:
823 self.glyphOrder = [".notdef"]
824 self.glyphOrder.extend(["glyph%.5d" % i for i in range(1, self.numGlyphs)])
825 else:
826 if len(self.glyphOrder) != self.numGlyphs:
827 raise TTLibError(
828 "incorrect glyphOrder: expected %d glyphs, found %d"
829 % (len(self.glyphOrder), self.numGlyphs)
830 )
831
832 glyphs = self.glyphs = {}
833 for glyphID, glyphName in enumerate(self.glyphOrder):
834 glyph = self._decodeGlyph(glyphID)
835 glyphs[glyphName] = glyph
836
837 def transform(self, ttFont):
838 """Return transformed 'glyf' data"""
839 self.numGlyphs = len(self.glyphs)
840 assert len(self.glyphOrder) == self.numGlyphs
841 if "maxp" in ttFont:
842 ttFont["maxp"].numGlyphs = self.numGlyphs
843 self.indexFormat = ttFont["head"].indexToLocFormat
844
845 for stream in self.subStreams:
846 setattr(self, stream, b"")
847 bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2
848 self.bboxBitmap = array.array("B", [0] * bboxBitmapSize)
849
850 self.overlapSimpleBitmap = array.array("B", [0] * ((self.numGlyphs + 7) >> 3))
851 for glyphID in range(self.numGlyphs):
852 try:
853 self._encodeGlyph(glyphID)
854 except NotImplementedError:
855 return None
856 hasOverlapSimpleBitmap = any(self.overlapSimpleBitmap)
857
858 self.bboxStream = self.bboxBitmap.tobytes() + self.bboxStream
859 for stream in self.subStreams:
860 setattr(self, stream + "Size", len(getattr(self, stream)))
861 self.version = 0
862 self.optionFlags = 0
863 if hasOverlapSimpleBitmap:
864 self.optionFlags |= woff2OverlapSimpleBitmapFlag
865 data = sstruct.pack(woff2GlyfTableFormat, self)
866 data += bytesjoin([getattr(self, s) for s in self.subStreams])
867 if hasOverlapSimpleBitmap:
868 data += self.overlapSimpleBitmap.tobytes()
869 return data
870
871 def _decodeGlyph(self, glyphID):
872 glyph = getTableModule("glyf").Glyph()
873 glyph.numberOfContours = self.nContourStream[glyphID]
874 if glyph.numberOfContours == 0:
875 return glyph
876 elif glyph.isComposite():
877 self._decodeComponents(glyph)
878 else:
879 self._decodeCoordinates(glyph)
880 self._decodeOverlapSimpleFlag(glyph, glyphID)
881 self._decodeBBox(glyphID, glyph)
882 return glyph
883
884 def _decodeComponents(self, glyph):
885 data = self.compositeStream
886 glyph.components = []
887 more = 1
888 haveInstructions = 0
889 while more:
890 component = getTableModule("glyf").GlyphComponent()
891 more, haveInstr, data = component.decompile(data, self)
892 haveInstructions = haveInstructions | haveInstr
893 glyph.components.append(component)
894 self.compositeStream = data
895 if haveInstructions:
896 self._decodeInstructions(glyph)
897
898 def _decodeCoordinates(self, glyph):
899 data = self.nPointsStream
900 endPtsOfContours = []
901 endPoint = -1
902 for i in range(glyph.numberOfContours):
903 ptsOfContour, data = unpack255UShort(data)
904 endPoint += ptsOfContour
905 endPtsOfContours.append(endPoint)
906 glyph.endPtsOfContours = endPtsOfContours
907 self.nPointsStream = data
908 self._decodeTriplets(glyph)
909 self._decodeInstructions(glyph)
910
911 def _decodeOverlapSimpleFlag(self, glyph, glyphID):
912 if self.overlapSimpleBitmap is None or glyph.numberOfContours <= 0:
913 return
914 byte = glyphID >> 3
915 bit = glyphID & 7
916 if self.overlapSimpleBitmap[byte] & (0x80 >> bit):
917 glyph.flags[0] |= _g_l_y_f.flagOverlapSimple
918
919 def _decodeInstructions(self, glyph):
920 glyphStream = self.glyphStream
921 instructionStream = self.instructionStream
922 instructionLength, glyphStream = unpack255UShort(glyphStream)
923 glyph.program = ttProgram.Program()
924 glyph.program.fromBytecode(instructionStream[:instructionLength])
925 self.glyphStream = glyphStream
926 self.instructionStream = instructionStream[instructionLength:]
927
928 def _decodeBBox(self, glyphID, glyph):
929 haveBBox = bool(self.bboxBitmap[glyphID >> 3] & (0x80 >> (glyphID & 7)))
930 if glyph.isComposite() and not haveBBox:
931 raise TTLibError("no bbox values for composite glyph %d" % glyphID)
932 if haveBBox:
933 dummy, self.bboxStream = sstruct.unpack2(bboxFormat, self.bboxStream, glyph)
934 else:
935 glyph.recalcBounds(self)
936
937 def _decodeTriplets(self, glyph):
938 def withSign(flag, baseval):
939 assert 0 <= baseval and baseval < 65536, "integer overflow"
940 return baseval if flag & 1 else -baseval
941
942 nPoints = glyph.endPtsOfContours[-1] + 1
943 flagSize = nPoints
944 if flagSize > len(self.flagStream):
945 raise TTLibError("not enough 'flagStream' data")
946 flagsData = self.flagStream[:flagSize]
947 self.flagStream = self.flagStream[flagSize:]
948 flags = array.array("B", flagsData)
949
950 triplets = array.array("B", self.glyphStream)
951 nTriplets = len(triplets)
952 assert nPoints <= nTriplets
953
954 x = 0
955 y = 0
956 glyph.coordinates = getTableModule("glyf").GlyphCoordinates.zeros(nPoints)
957 glyph.flags = array.array("B")
958 tripletIndex = 0
959 for i in range(nPoints):
960 flag = flags[i]
961 onCurve = not bool(flag >> 7)
962 flag &= 0x7F
963 if flag < 84:
964 nBytes = 1
965 elif flag < 120:
966 nBytes = 2
967 elif flag < 124:
968 nBytes = 3
969 else:
970 nBytes = 4
971 assert (tripletIndex + nBytes) <= nTriplets
972 if flag < 10:
973 dx = 0
974 dy = withSign(flag, ((flag & 14) << 7) + triplets[tripletIndex])
975 elif flag < 20:
976 dx = withSign(flag, (((flag - 10) & 14) << 7) + triplets[tripletIndex])
977 dy = 0
978 elif flag < 84:
979 b0 = flag - 20
980 b1 = triplets[tripletIndex]
981 dx = withSign(flag, 1 + (b0 & 0x30) + (b1 >> 4))
982 dy = withSign(flag >> 1, 1 + ((b0 & 0x0C) << 2) + (b1 & 0x0F))
983 elif flag < 120:
984 b0 = flag - 84
985 dx = withSign(flag, 1 + ((b0 // 12) << 8) + triplets[tripletIndex])
986 dy = withSign(
987 flag >> 1, 1 + (((b0 % 12) >> 2) << 8) + triplets[tripletIndex + 1]
988 )
989 elif flag < 124:
990 b2 = triplets[tripletIndex + 1]
991 dx = withSign(flag, (triplets[tripletIndex] << 4) + (b2 >> 4))
992 dy = withSign(
993 flag >> 1, ((b2 & 0x0F) << 8) + triplets[tripletIndex + 2]
994 )
995 else:
996 dx = withSign(
997 flag, (triplets[tripletIndex] << 8) + triplets[tripletIndex + 1]
998 )
999 dy = withSign(
1000 flag >> 1,
1001 (triplets[tripletIndex + 2] << 8) + triplets[tripletIndex + 3],
1002 )
1003 tripletIndex += nBytes
1004 x += dx
1005 y += dy
1006 glyph.coordinates[i] = (x, y)
1007 glyph.flags.append(int(onCurve))
1008 bytesConsumed = tripletIndex
1009 self.glyphStream = self.glyphStream[bytesConsumed:]
1010
1011 def _encodeGlyph(self, glyphID):
1012 glyphName = self.getGlyphName(glyphID)
1013 glyph = self[glyphName]
1014 self.nContourStream += struct.pack(">h", glyph.numberOfContours)
1015 if glyph.numberOfContours == 0:
1016 return
1017 elif glyph.isComposite():
1018 self._encodeComponents(glyph)
1019 else:
1020 self._encodeCoordinates(glyph)
1021 self._encodeOverlapSimpleFlag(glyph, glyphID)
1022 self._encodeBBox(glyphID, glyph)
1023
1024 def _encodeComponents(self, glyph):
1025 lastcomponent = len(glyph.components) - 1
1026 more = 1
1027 haveInstructions = 0
1028 for i, component in enumerate(glyph.components):
1029 if i == lastcomponent:
1030 haveInstructions = hasattr(glyph, "program")
1031 more = 0
1032 self.compositeStream += component.compile(more, haveInstructions, self)
1033 if haveInstructions:
1034 self._encodeInstructions(glyph)
1035
1036 def _encodeCoordinates(self, glyph):
1037 lastEndPoint = -1
1038 if _g_l_y_f.flagCubic in glyph.flags:
1039 raise NotImplementedError
1040 for endPoint in glyph.endPtsOfContours:
1041 ptsOfContour = endPoint - lastEndPoint
1042 self.nPointsStream += pack255UShort(ptsOfContour)
1043 lastEndPoint = endPoint
1044 self._encodeTriplets(glyph)
1045 self._encodeInstructions(glyph)
1046
1047 def _encodeOverlapSimpleFlag(self, glyph, glyphID):
1048 if glyph.numberOfContours <= 0:
1049 return
1050 if glyph.flags[0] & _g_l_y_f.flagOverlapSimple:
1051 byte = glyphID >> 3
1052 bit = glyphID & 7
1053 self.overlapSimpleBitmap[byte] |= 0x80 >> bit
1054
1055 def _encodeInstructions(self, glyph):
1056 instructions = glyph.program.getBytecode()
1057 self.glyphStream += pack255UShort(len(instructions))
1058 self.instructionStream += instructions
1059
1060 def _encodeBBox(self, glyphID, glyph):
1061 assert glyph.numberOfContours != 0, "empty glyph has no bbox"
1062 if not glyph.isComposite():
1063 # for simple glyphs, compare the encoded bounding box info with the calculated
1064 # values, and if they match omit the bounding box info
1065 currentBBox = glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax
1066 calculatedBBox = calcIntBounds(glyph.coordinates)
1067 if currentBBox == calculatedBBox:
1068 return
1069 self.bboxBitmap[glyphID >> 3] |= 0x80 >> (glyphID & 7)
1070 self.bboxStream += sstruct.pack(bboxFormat, glyph)
1071
1072 def _encodeTriplets(self, glyph):
1073 assert len(glyph.coordinates) == len(glyph.flags)
1074 coordinates = glyph.coordinates.copy()
1075 coordinates.absoluteToRelative()
1076
1077 flags = array.array("B")
1078 triplets = array.array("B")
1079 for i, (x, y) in enumerate(coordinates):
1080 onCurve = glyph.flags[i] & _g_l_y_f.flagOnCurve
1081 absX = abs(x)
1082 absY = abs(y)
1083 onCurveBit = 0 if onCurve else 128
1084 xSignBit = 0 if (x < 0) else 1
1085 ySignBit = 0 if (y < 0) else 1
1086 xySignBits = xSignBit + 2 * ySignBit
1087
1088 if x == 0 and absY < 1280:
1089 flags.append(onCurveBit + ((absY & 0xF00) >> 7) + ySignBit)
1090 triplets.append(absY & 0xFF)
1091 elif y == 0 and absX < 1280:
1092 flags.append(onCurveBit + 10 + ((absX & 0xF00) >> 7) + xSignBit)
1093 triplets.append(absX & 0xFF)
1094 elif absX < 65 and absY < 65:
1095 flags.append(
1096 onCurveBit
1097 + 20
1098 + ((absX - 1) & 0x30)
1099 + (((absY - 1) & 0x30) >> 2)
1100 + xySignBits
1101 )
1102 triplets.append((((absX - 1) & 0xF) << 4) | ((absY - 1) & 0xF))
1103 elif absX < 769 and absY < 769:
1104 flags.append(
1105 onCurveBit
1106 + 84
1107 + 12 * (((absX - 1) & 0x300) >> 8)
1108 + (((absY - 1) & 0x300) >> 6)
1109 + xySignBits
1110 )
1111 triplets.append((absX - 1) & 0xFF)
1112 triplets.append((absY - 1) & 0xFF)
1113 elif absX < 4096 and absY < 4096:
1114 flags.append(onCurveBit + 120 + xySignBits)
1115 triplets.append(absX >> 4)
1116 triplets.append(((absX & 0xF) << 4) | (absY >> 8))
1117 triplets.append(absY & 0xFF)
1118 else:
1119 flags.append(onCurveBit + 124 + xySignBits)
1120 triplets.append(absX >> 8)
1121 triplets.append(absX & 0xFF)
1122 triplets.append(absY >> 8)
1123 triplets.append(absY & 0xFF)
1124
1125 self.flagStream += flags.tobytes()
1126 self.glyphStream += triplets.tobytes()
1127
1128
1129class WOFF2HmtxTable(getTableClass("hmtx")):
1130 def __init__(self, tag=None):
1131 self.tableTag = Tag(tag or "hmtx")
1132
1133 def reconstruct(self, data, ttFont):
1134 (flags,) = struct.unpack(">B", data[:1])
1135 data = data[1:]
1136 if flags & 0b11111100 != 0:
1137 raise TTLibError("Bits 2-7 of '%s' flags are reserved" % self.tableTag)
1138
1139 # When bit 0 is _not_ set, the lsb[] array is present
1140 hasLsbArray = flags & 1 == 0
1141 # When bit 1 is _not_ set, the leftSideBearing[] array is present
1142 hasLeftSideBearingArray = flags & 2 == 0
1143 if hasLsbArray and hasLeftSideBearingArray:
1144 raise TTLibError(
1145 "either bits 0 or 1 (or both) must set in transformed '%s' flags"
1146 % self.tableTag
1147 )
1148
1149 glyfTable = ttFont["glyf"]
1150 headerTable = ttFont["hhea"]
1151 glyphOrder = glyfTable.glyphOrder
1152 numGlyphs = len(glyphOrder)
1153 numberOfHMetrics = min(int(headerTable.numberOfHMetrics), numGlyphs)
1154
1155 assert len(data) >= 2 * numberOfHMetrics
1156 advanceWidthArray = array.array("H", data[: 2 * numberOfHMetrics])
1157 if sys.byteorder != "big":
1158 advanceWidthArray.byteswap()
1159 data = data[2 * numberOfHMetrics :]
1160
1161 if hasLsbArray:
1162 assert len(data) >= 2 * numberOfHMetrics
1163 lsbArray = array.array("h", data[: 2 * numberOfHMetrics])
1164 if sys.byteorder != "big":
1165 lsbArray.byteswap()
1166 data = data[2 * numberOfHMetrics :]
1167 else:
1168 # compute (proportional) glyphs' lsb from their xMin
1169 lsbArray = array.array("h")
1170 for i, glyphName in enumerate(glyphOrder):
1171 if i >= numberOfHMetrics:
1172 break
1173 glyph = glyfTable[glyphName]
1174 xMin = getattr(glyph, "xMin", 0)
1175 lsbArray.append(xMin)
1176
1177 numberOfSideBearings = numGlyphs - numberOfHMetrics
1178 if hasLeftSideBearingArray:
1179 assert len(data) >= 2 * numberOfSideBearings
1180 leftSideBearingArray = array.array("h", data[: 2 * numberOfSideBearings])
1181 if sys.byteorder != "big":
1182 leftSideBearingArray.byteswap()
1183 data = data[2 * numberOfSideBearings :]
1184 else:
1185 # compute (monospaced) glyphs' leftSideBearing from their xMin
1186 leftSideBearingArray = array.array("h")
1187 for i, glyphName in enumerate(glyphOrder):
1188 if i < numberOfHMetrics:
1189 continue
1190 glyph = glyfTable[glyphName]
1191 xMin = getattr(glyph, "xMin", 0)
1192 leftSideBearingArray.append(xMin)
1193
1194 if data:
1195 raise TTLibError("too much '%s' table data" % self.tableTag)
1196
1197 self.metrics = {}
1198 for i in range(numberOfHMetrics):
1199 glyphName = glyphOrder[i]
1200 advanceWidth, lsb = advanceWidthArray[i], lsbArray[i]
1201 self.metrics[glyphName] = (advanceWidth, lsb)
1202 lastAdvance = advanceWidthArray[-1]
1203 for i in range(numberOfSideBearings):
1204 glyphName = glyphOrder[i + numberOfHMetrics]
1205 self.metrics[glyphName] = (lastAdvance, leftSideBearingArray[i])
1206
1207 def transform(self, ttFont):
1208 glyphOrder = ttFont.getGlyphOrder()
1209 glyf = ttFont["glyf"]
1210 hhea = ttFont["hhea"]
1211 numberOfHMetrics = hhea.numberOfHMetrics
1212
1213 # check if any of the proportional glyphs has left sidebearings that
1214 # differ from their xMin bounding box values.
1215 hasLsbArray = False
1216 for i in range(numberOfHMetrics):
1217 glyphName = glyphOrder[i]
1218 lsb = self.metrics[glyphName][1]
1219 if lsb != getattr(glyf[glyphName], "xMin", 0):
1220 hasLsbArray = True
1221 break
1222
1223 # do the same for the monospaced glyphs (if any) at the end of hmtx table
1224 hasLeftSideBearingArray = False
1225 for i in range(numberOfHMetrics, len(glyphOrder)):
1226 glyphName = glyphOrder[i]
1227 lsb = self.metrics[glyphName][1]
1228 if lsb != getattr(glyf[glyphName], "xMin", 0):
1229 hasLeftSideBearingArray = True
1230 break
1231
1232 # if we need to encode both sidebearings arrays, then no transformation is
1233 # applicable, and we must use the untransformed hmtx data
1234 if hasLsbArray and hasLeftSideBearingArray:
1235 return
1236
1237 # set bit 0 and 1 when the respective arrays are _not_ present
1238 flags = 0
1239 if not hasLsbArray:
1240 flags |= 1 << 0
1241 if not hasLeftSideBearingArray:
1242 flags |= 1 << 1
1243
1244 data = struct.pack(">B", flags)
1245
1246 advanceWidthArray = array.array(
1247 "H",
1248 [
1249 self.metrics[glyphName][0]
1250 for i, glyphName in enumerate(glyphOrder)
1251 if i < numberOfHMetrics
1252 ],
1253 )
1254 if sys.byteorder != "big":
1255 advanceWidthArray.byteswap()
1256 data += advanceWidthArray.tobytes()
1257
1258 if hasLsbArray:
1259 lsbArray = array.array(
1260 "h",
1261 [
1262 self.metrics[glyphName][1]
1263 for i, glyphName in enumerate(glyphOrder)
1264 if i < numberOfHMetrics
1265 ],
1266 )
1267 if sys.byteorder != "big":
1268 lsbArray.byteswap()
1269 data += lsbArray.tobytes()
1270
1271 if hasLeftSideBearingArray:
1272 leftSideBearingArray = array.array(
1273 "h",
1274 [
1275 self.metrics[glyphOrder[i]][1]
1276 for i in range(numberOfHMetrics, len(glyphOrder))
1277 ],
1278 )
1279 if sys.byteorder != "big":
1280 leftSideBearingArray.byteswap()
1281 data += leftSideBearingArray.tobytes()
1282
1283 return data
1284
1285
1286class WOFF2FlavorData(WOFFFlavorData):
1287 Flavor = "woff2"
1288
1289 def __init__(self, reader=None, data=None, transformedTables=None):
1290 """Data class that holds the WOFF2 header major/minor version, any
1291 metadata or private data (as bytes strings), and the set of
1292 table tags that have transformations applied (if reader is not None),
1293 or will have once the WOFF2 font is compiled.
1294
1295 Args:
1296 reader: an SFNTReader (or subclass) object to read flavor data from.
1297 data: another WOFFFlavorData object to initialise data from.
1298 transformedTables: set of strings containing table tags to be transformed.
1299
1300 Raises:
1301 ImportError if the brotli module is not installed.
1302
1303 NOTE: The 'reader' argument, on the one hand, and the 'data' and
1304 'transformedTables' arguments, on the other hand, are mutually exclusive.
1305 """
1306 if not haveBrotli:
1307 raise ImportError("No module named brotli")
1308
1309 if reader is not None:
1310 if data is not None:
1311 raise TypeError("'reader' and 'data' arguments are mutually exclusive")
1312 if transformedTables is not None:
1313 raise TypeError(
1314 "'reader' and 'transformedTables' arguments are mutually exclusive"
1315 )
1316
1317 if transformedTables is not None and (
1318 "glyf" in transformedTables
1319 and "loca" not in transformedTables
1320 or "loca" in transformedTables
1321 and "glyf" not in transformedTables
1322 ):
1323 raise ValueError("'glyf' and 'loca' must be transformed (or not) together")
1324 super(WOFF2FlavorData, self).__init__(reader=reader)
1325 if reader:
1326 transformedTables = [
1327 tag for tag, entry in reader.tables.items() if entry.transformed
1328 ]
1329 elif data:
1330 self.majorVersion = data.majorVersion
1331 self.majorVersion = data.minorVersion
1332 self.metaData = data.metaData
1333 self.privData = data.privData
1334 if transformedTables is None and hasattr(data, "transformedTables"):
1335 transformedTables = data.transformedTables
1336
1337 if transformedTables is None:
1338 transformedTables = woff2TransformedTableTags
1339
1340 self.transformedTables = set(transformedTables)
1341
1342 def _decompress(self, rawData):
1343 return brotli.decompress(rawData)
1344
1345
1346def unpackBase128(data):
1347 r"""Read one to five bytes from UIntBase128-encoded input string, and return
1348 a tuple containing the decoded integer plus any leftover data.
1349
1350 >>> unpackBase128(b'\x3f\x00\x00') == (63, b"\x00\x00")
1351 True
1352 >>> unpackBase128(b'\x8f\xff\xff\xff\x7f')[0] == 4294967295
1353 True
1354 >>> unpackBase128(b'\x80\x80\x3f') # doctest: +IGNORE_EXCEPTION_DETAIL
1355 Traceback (most recent call last):
1356 File "<stdin>", line 1, in ?
1357 TTLibError: UIntBase128 value must not start with leading zeros
1358 >>> unpackBase128(b'\x8f\xff\xff\xff\xff\x7f')[0] # doctest: +IGNORE_EXCEPTION_DETAIL
1359 Traceback (most recent call last):
1360 File "<stdin>", line 1, in ?
1361 TTLibError: UIntBase128-encoded sequence is longer than 5 bytes
1362 >>> unpackBase128(b'\x90\x80\x80\x80\x00')[0] # doctest: +IGNORE_EXCEPTION_DETAIL
1363 Traceback (most recent call last):
1364 File "<stdin>", line 1, in ?
1365 TTLibError: UIntBase128 value exceeds 2**32-1
1366 """
1367 if len(data) == 0:
1368 raise TTLibError("not enough data to unpack UIntBase128")
1369 result = 0
1370 if byteord(data[0]) == 0x80:
1371 # font must be rejected if UIntBase128 value starts with 0x80
1372 raise TTLibError("UIntBase128 value must not start with leading zeros")
1373 for i in range(woff2Base128MaxSize):
1374 if len(data) == 0:
1375 raise TTLibError("not enough data to unpack UIntBase128")
1376 code = byteord(data[0])
1377 data = data[1:]
1378 # if any of the top seven bits are set then we're about to overflow
1379 if result & 0xFE000000:
1380 raise TTLibError("UIntBase128 value exceeds 2**32-1")
1381 # set current value = old value times 128 bitwise-or (byte bitwise-and 127)
1382 result = (result << 7) | (code & 0x7F)
1383 # repeat until the most significant bit of byte is false
1384 if (code & 0x80) == 0:
1385 # return result plus left over data
1386 return result, data
1387 # make sure not to exceed the size bound
1388 raise TTLibError("UIntBase128-encoded sequence is longer than 5 bytes")
1389
1390
1391def base128Size(n):
1392 """Return the length in bytes of a UIntBase128-encoded sequence with value n.
1393
1394 >>> base128Size(0)
1395 1
1396 >>> base128Size(24567)
1397 3
1398 >>> base128Size(2**32-1)
1399 5
1400 """
1401 assert n >= 0
1402 size = 1
1403 while n >= 128:
1404 size += 1
1405 n >>= 7
1406 return size
1407
1408
1409def packBase128(n):
1410 r"""Encode unsigned integer in range 0 to 2**32-1 (inclusive) to a string of
1411 bytes using UIntBase128 variable-length encoding. Produce the shortest possible
1412 encoding.
1413
1414 >>> packBase128(63) == b"\x3f"
1415 True
1416 >>> packBase128(2**32-1) == b'\x8f\xff\xff\xff\x7f'
1417 True
1418 """
1419 if n < 0 or n >= 2**32:
1420 raise TTLibError("UIntBase128 format requires 0 <= integer <= 2**32-1")
1421 data = b""
1422 size = base128Size(n)
1423 for i in range(size):
1424 b = (n >> (7 * (size - i - 1))) & 0x7F
1425 if i < size - 1:
1426 b |= 0x80
1427 data += struct.pack("B", b)
1428 return data
1429
1430
1431def unpack255UShort(data):
1432 """Read one to three bytes from 255UInt16-encoded input string, and return a
1433 tuple containing the decoded integer plus any leftover data.
1434
1435 >>> unpack255UShort(bytechr(252))[0]
1436 252
1437
1438 Note that some numbers (e.g. 506) can have multiple encodings:
1439 >>> unpack255UShort(struct.pack("BB", 254, 0))[0]
1440 506
1441 >>> unpack255UShort(struct.pack("BB", 255, 253))[0]
1442 506
1443 >>> unpack255UShort(struct.pack("BBB", 253, 1, 250))[0]
1444 506
1445 """
1446 code = byteord(data[:1])
1447 data = data[1:]
1448 if code == 253:
1449 # read two more bytes as an unsigned short
1450 if len(data) < 2:
1451 raise TTLibError("not enough data to unpack 255UInt16")
1452 (result,) = struct.unpack(">H", data[:2])
1453 data = data[2:]
1454 elif code == 254:
1455 # read another byte, plus 253 * 2
1456 if len(data) == 0:
1457 raise TTLibError("not enough data to unpack 255UInt16")
1458 result = byteord(data[:1])
1459 result += 506
1460 data = data[1:]
1461 elif code == 255:
1462 # read another byte, plus 253
1463 if len(data) == 0:
1464 raise TTLibError("not enough data to unpack 255UInt16")
1465 result = byteord(data[:1])
1466 result += 253
1467 data = data[1:]
1468 else:
1469 # leave as is if lower than 253
1470 result = code
1471 # return result plus left over data
1472 return result, data
1473
1474
1475def pack255UShort(value):
1476 r"""Encode unsigned integer in range 0 to 65535 (inclusive) to a bytestring
1477 using 255UInt16 variable-length encoding.
1478
1479 >>> pack255UShort(252) == b'\xfc'
1480 True
1481 >>> pack255UShort(506) == b'\xfe\x00'
1482 True
1483 >>> pack255UShort(762) == b'\xfd\x02\xfa'
1484 True
1485 """
1486 if value < 0 or value > 0xFFFF:
1487 raise TTLibError("255UInt16 format requires 0 <= integer <= 65535")
1488 if value < 253:
1489 return struct.pack(">B", value)
1490 elif value < 506:
1491 return struct.pack(">BB", 255, value - 253)
1492 elif value < 762:
1493 return struct.pack(">BB", 254, value - 506)
1494 else:
1495 return struct.pack(">BH", 253, value)
1496
1497
1498def compress(input_file, output_file, transform_tables=None):
1499 """Compress OpenType font to WOFF2.
1500
1501 Args:
1502 input_file: a file path, file or file-like object (open in binary mode)
1503 containing an OpenType font (either CFF- or TrueType-flavored).
1504 output_file: a file path, file or file-like object where to save the
1505 compressed WOFF2 font.
1506 transform_tables: Optional[Iterable[str]]: a set of table tags for which
1507 to enable preprocessing transformations. By default, only 'glyf'
1508 and 'loca' tables are transformed. An empty set means disable all
1509 transformations.
1510 """
1511 log.info("Processing %s => %s" % (input_file, output_file))
1512
1513 font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False)
1514 font.flavor = "woff2"
1515
1516 if transform_tables is not None:
1517 font.flavorData = WOFF2FlavorData(
1518 data=font.flavorData, transformedTables=transform_tables
1519 )
1520
1521 font.save(output_file, reorderTables=False)
1522
1523
1524def decompress(input_file, output_file):
1525 """Decompress WOFF2 font to OpenType font.
1526
1527 Args:
1528 input_file: a file path, file or file-like object (open in binary mode)
1529 containing a compressed WOFF2 font.
1530 output_file: a file path, file or file-like object where to save the
1531 decompressed OpenType font.
1532 """
1533 log.info("Processing %s => %s" % (input_file, output_file))
1534
1535 font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False)
1536 font.flavor = None
1537 font.flavorData = None
1538 font.save(output_file, reorderTables=True)
1539
1540
1541def main(args=None):
1542 """Compress and decompress WOFF2 fonts"""
1543 import argparse
1544 from fontTools import configLogger
1545 from fontTools.ttx import makeOutputFileName
1546
1547 class _HelpAction(argparse._HelpAction):
1548 def __call__(self, parser, namespace, values, option_string=None):
1549 subparsers_actions = [
1550 action
1551 for action in parser._actions
1552 if isinstance(action, argparse._SubParsersAction)
1553 ]
1554 for subparsers_action in subparsers_actions:
1555 for choice, subparser in subparsers_action.choices.items():
1556 print(subparser.format_help())
1557 parser.exit()
1558
1559 class _NoGlyfTransformAction(argparse.Action):
1560 def __call__(self, parser, namespace, values, option_string=None):
1561 namespace.transform_tables.difference_update({"glyf", "loca"})
1562
1563 class _HmtxTransformAction(argparse.Action):
1564 def __call__(self, parser, namespace, values, option_string=None):
1565 namespace.transform_tables.add("hmtx")
1566
1567 parser = argparse.ArgumentParser(
1568 prog="fonttools ttLib.woff2", description=main.__doc__, add_help=False
1569 )
1570
1571 parser.add_argument(
1572 "-h", "--help", action=_HelpAction, help="show this help message and exit"
1573 )
1574
1575 parser_group = parser.add_subparsers(title="sub-commands")
1576 parser_compress = parser_group.add_parser(
1577 "compress", description="Compress a TTF or OTF font to WOFF2"
1578 )
1579 parser_decompress = parser_group.add_parser(
1580 "decompress", description="Decompress a WOFF2 font to OTF"
1581 )
1582
1583 for subparser in (parser_compress, parser_decompress):
1584 group = subparser.add_mutually_exclusive_group(required=False)
1585 group.add_argument(
1586 "-v",
1587 "--verbose",
1588 action="store_true",
1589 help="print more messages to console",
1590 )
1591 group.add_argument(
1592 "-q",
1593 "--quiet",
1594 action="store_true",
1595 help="do not print messages to console",
1596 )
1597
1598 parser_compress.add_argument(
1599 "input_file",
1600 metavar="INPUT",
1601 help="the input OpenType font (.ttf or .otf)",
1602 )
1603 parser_decompress.add_argument(
1604 "input_file",
1605 metavar="INPUT",
1606 help="the input WOFF2 font",
1607 )
1608
1609 parser_compress.add_argument(
1610 "-o",
1611 "--output-file",
1612 metavar="OUTPUT",
1613 help="the output WOFF2 font",
1614 )
1615 parser_decompress.add_argument(
1616 "-o",
1617 "--output-file",
1618 metavar="OUTPUT",
1619 help="the output OpenType font",
1620 )
1621
1622 transform_group = parser_compress.add_argument_group()
1623 transform_group.add_argument(
1624 "--no-glyf-transform",
1625 dest="transform_tables",
1626 nargs=0,
1627 action=_NoGlyfTransformAction,
1628 help="Do not transform glyf (and loca) tables",
1629 )
1630 transform_group.add_argument(
1631 "--hmtx-transform",
1632 dest="transform_tables",
1633 nargs=0,
1634 action=_HmtxTransformAction,
1635 help="Enable optional transformation for 'hmtx' table",
1636 )
1637
1638 parser_compress.set_defaults(
1639 subcommand=compress,
1640 transform_tables={"glyf", "loca"},
1641 )
1642 parser_decompress.set_defaults(subcommand=decompress)
1643
1644 options = vars(parser.parse_args(args))
1645
1646 subcommand = options.pop("subcommand", None)
1647 if not subcommand:
1648 parser.print_help()
1649 return
1650
1651 quiet = options.pop("quiet")
1652 verbose = options.pop("verbose")
1653 configLogger(
1654 level=("ERROR" if quiet else "DEBUG" if verbose else "INFO"),
1655 )
1656
1657 if not options["output_file"]:
1658 if subcommand is compress:
1659 extension = ".woff2"
1660 elif subcommand is decompress:
1661 # choose .ttf/.otf file extension depending on sfntVersion
1662 with open(options["input_file"], "rb") as f:
1663 f.seek(4) # skip 'wOF2' signature
1664 sfntVersion = f.read(4)
1665 assert len(sfntVersion) == 4, "not enough data"
1666 extension = ".otf" if sfntVersion == b"OTTO" else ".ttf"
1667 else:
1668 raise AssertionError(subcommand)
1669 options["output_file"] = makeOutputFileName(
1670 options["input_file"], outputDir=None, extension=extension
1671 )
1672
1673 try:
1674 subcommand(**options)
1675 except TTLibError as e:
1676 parser.error(e)
1677
1678
1679if __name__ == "__main__":
1680 sys.exit(main())