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 tags = list(self.tables.keys())
398 checksums = []
399 for i in range(len(tags)):
400 checksums.append(self.tables[tags[i]].checkSum)
401
402 # Create a SFNT directory for checksum calculation purposes
403 self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(
404 self.numTables, 16
405 )
406 directory = sstruct.pack(sfntDirectoryFormat, self)
407 tables = sorted(self.tables.items())
408 for tag, entry in tables:
409 sfntEntry = SFNTDirectoryEntry()
410 sfntEntry.tag = entry.tag
411 sfntEntry.checkSum = entry.checkSum
412 sfntEntry.offset = entry.origOffset
413 sfntEntry.length = entry.origLength
414 directory = directory + sfntEntry.toString()
415
416 directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize
417 assert directory_end == len(directory)
418
419 checksums.append(calcChecksum(directory))
420 checksum = sum(checksums) & 0xFFFFFFFF
421 # BiboAfba!
422 checksumadjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF
423 return checksumadjustment
424
425 def writeMasterChecksum(self):
426 """Write checkSumAdjustment to the transformBuffer."""
427 checksumadjustment = self._calcMasterChecksum()
428 self.transformBuffer.seek(self.tables["head"].offset + 8)
429 self.transformBuffer.write(struct.pack(">L", checksumadjustment))
430
431 def _calcTotalSize(self):
432 """Calculate total size of WOFF2 font, including any meta- and/or private data."""
433 offset = self.directorySize
434 for entry in self.tables.values():
435 offset += len(entry.toString())
436 offset += self.totalCompressedSize
437 offset = (offset + 3) & ~3
438 offset = self._calcFlavorDataOffsetsAndSize(offset)
439 return offset
440
441 def _calcFlavorDataOffsetsAndSize(self, start):
442 """Calculate offsets and lengths for any meta- and/or private data."""
443 offset = start
444 data = self.flavorData
445 if data.metaData:
446 self.metaOrigLength = len(data.metaData)
447 self.metaOffset = offset
448 self.compressedMetaData = brotli.compress(
449 data.metaData, mode=brotli.MODE_TEXT
450 )
451 self.metaLength = len(self.compressedMetaData)
452 offset += self.metaLength
453 else:
454 self.metaOffset = self.metaLength = self.metaOrigLength = 0
455 self.compressedMetaData = b""
456 if data.privData:
457 # make sure private data is padded to 4-byte boundary
458 offset = (offset + 3) & ~3
459 self.privOffset = offset
460 self.privLength = len(data.privData)
461 offset += self.privLength
462 else:
463 self.privOffset = self.privLength = 0
464 return offset
465
466 def _getVersion(self):
467 """Return the WOFF2 font's (majorVersion, minorVersion) tuple."""
468 data = self.flavorData
469 if data.majorVersion is not None and data.minorVersion is not None:
470 return data.majorVersion, data.minorVersion
471 else:
472 # if None, return 'fontRevision' from 'head' table
473 if "head" in self.tables:
474 return struct.unpack(">HH", self.tables["head"].data[4:8])
475 else:
476 return 0, 0
477
478 def _packTableDirectory(self):
479 """Return WOFF2 table directory data."""
480 directory = sstruct.pack(self.directoryFormat, self)
481 for entry in self.tables.values():
482 directory = directory + entry.toString()
483 return directory
484
485 def _writeFlavorData(self):
486 """Write metadata and/or private data using appropiate padding."""
487 compressedMetaData = self.compressedMetaData
488 privData = self.flavorData.privData
489 if compressedMetaData and privData:
490 compressedMetaData = pad(compressedMetaData, size=4)
491 if compressedMetaData:
492 self.file.seek(self.metaOffset)
493 assert self.file.tell() == self.metaOffset
494 self.file.write(compressedMetaData)
495 if privData:
496 self.file.seek(self.privOffset)
497 assert self.file.tell() == self.privOffset
498 self.file.write(privData)
499
500 def reordersTables(self):
501 return True
502
503
504# -- woff2 directory helpers and cruft
505
506woff2DirectoryFormat = """
507 > # big endian
508 signature: 4s # "wOF2"
509 sfntVersion: 4s
510 length: L # total woff2 file size
511 numTables: H # number of tables
512 reserved: H # set to 0
513 totalSfntSize: L # uncompressed size
514 totalCompressedSize: L # compressed size
515 majorVersion: H # major version of WOFF file
516 minorVersion: H # minor version of WOFF file
517 metaOffset: L # offset to metadata block
518 metaLength: L # length of compressed metadata
519 metaOrigLength: L # length of uncompressed metadata
520 privOffset: L # offset to private data block
521 privLength: L # length of private data block
522"""
523
524woff2DirectorySize = sstruct.calcsize(woff2DirectoryFormat)
525
526woff2KnownTags = (
527 "cmap",
528 "head",
529 "hhea",
530 "hmtx",
531 "maxp",
532 "name",
533 "OS/2",
534 "post",
535 "cvt ",
536 "fpgm",
537 "glyf",
538 "loca",
539 "prep",
540 "CFF ",
541 "VORG",
542 "EBDT",
543 "EBLC",
544 "gasp",
545 "hdmx",
546 "kern",
547 "LTSH",
548 "PCLT",
549 "VDMX",
550 "vhea",
551 "vmtx",
552 "BASE",
553 "GDEF",
554 "GPOS",
555 "GSUB",
556 "EBSC",
557 "JSTF",
558 "MATH",
559 "CBDT",
560 "CBLC",
561 "COLR",
562 "CPAL",
563 "SVG ",
564 "sbix",
565 "acnt",
566 "avar",
567 "bdat",
568 "bloc",
569 "bsln",
570 "cvar",
571 "fdsc",
572 "feat",
573 "fmtx",
574 "fvar",
575 "gvar",
576 "hsty",
577 "just",
578 "lcar",
579 "mort",
580 "morx",
581 "opbd",
582 "prop",
583 "trak",
584 "Zapf",
585 "Silf",
586 "Glat",
587 "Gloc",
588 "Feat",
589 "Sill",
590)
591
592woff2FlagsFormat = """
593 > # big endian
594 flags: B # table type and flags
595"""
596
597woff2FlagsSize = sstruct.calcsize(woff2FlagsFormat)
598
599woff2UnknownTagFormat = """
600 > # big endian
601 tag: 4s # 4-byte tag (optional)
602"""
603
604woff2UnknownTagSize = sstruct.calcsize(woff2UnknownTagFormat)
605
606woff2UnknownTagIndex = 0x3F
607
608woff2Base128MaxSize = 5
609woff2DirectoryEntryMaxSize = (
610 woff2FlagsSize + woff2UnknownTagSize + 2 * woff2Base128MaxSize
611)
612
613woff2TransformedTableTags = ("glyf", "loca")
614
615woff2GlyfTableFormat = """
616 > # big endian
617 version: H # = 0x0000
618 optionFlags: H # Bit 0: we have overlapSimpleBitmap[], Bits 1-15: reserved
619 numGlyphs: H # Number of glyphs
620 indexFormat: H # Offset format for loca table
621 nContourStreamSize: L # Size of nContour stream
622 nPointsStreamSize: L # Size of nPoints stream
623 flagStreamSize: L # Size of flag stream
624 glyphStreamSize: L # Size of glyph stream
625 compositeStreamSize: L # Size of composite stream
626 bboxStreamSize: L # Comnined size of bboxBitmap and bboxStream
627 instructionStreamSize: L # Size of instruction stream
628"""
629
630woff2GlyfTableFormatSize = sstruct.calcsize(woff2GlyfTableFormat)
631
632bboxFormat = """
633 > # big endian
634 xMin: h
635 yMin: h
636 xMax: h
637 yMax: h
638"""
639
640woff2OverlapSimpleBitmapFlag = 0x0001
641
642
643def getKnownTagIndex(tag):
644 """Return index of 'tag' in woff2KnownTags list. Return 63 if not found."""
645 for i in range(len(woff2KnownTags)):
646 if tag == woff2KnownTags[i]:
647 return i
648 return woff2UnknownTagIndex
649
650
651class WOFF2DirectoryEntry(DirectoryEntry):
652 def fromFile(self, file):
653 pos = file.tell()
654 data = file.read(woff2DirectoryEntryMaxSize)
655 left = self.fromString(data)
656 consumed = len(data) - len(left)
657 file.seek(pos + consumed)
658
659 def fromString(self, data):
660 if len(data) < 1:
661 raise TTLibError("can't read table 'flags': not enough data")
662 dummy, data = sstruct.unpack2(woff2FlagsFormat, data, self)
663 if self.flags & 0x3F == 0x3F:
664 # if bits [0..5] of the flags byte == 63, read a 4-byte arbitrary tag value
665 if len(data) < woff2UnknownTagSize:
666 raise TTLibError("can't read table 'tag': not enough data")
667 dummy, data = sstruct.unpack2(woff2UnknownTagFormat, data, self)
668 else:
669 # otherwise, tag is derived from a fixed 'Known Tags' table
670 self.tag = woff2KnownTags[self.flags & 0x3F]
671 self.tag = Tag(self.tag)
672 self.origLength, data = unpackBase128(data)
673 self.length = self.origLength
674 if self.transformed:
675 self.length, data = unpackBase128(data)
676 if self.tag == "loca" and self.length != 0:
677 raise TTLibError("the transformLength of the 'loca' table must be 0")
678 # return left over data
679 return data
680
681 def toString(self):
682 data = bytechr(self.flags)
683 if (self.flags & 0x3F) == 0x3F:
684 data += struct.pack(">4s", self.tag.tobytes())
685 data += packBase128(self.origLength)
686 if self.transformed:
687 data += packBase128(self.length)
688 return data
689
690 @property
691 def transformVersion(self):
692 """Return bits 6-7 of table entry's flags, which indicate the preprocessing
693 transformation version number (between 0 and 3).
694 """
695 return self.flags >> 6
696
697 @transformVersion.setter
698 def transformVersion(self, value):
699 assert 0 <= value <= 3
700 self.flags |= value << 6
701
702 @property
703 def transformed(self):
704 """Return True if the table has any transformation, else return False."""
705 # For all tables in a font, except for 'glyf' and 'loca', the transformation
706 # version 0 indicates the null transform (where the original table data is
707 # passed directly to the Brotli compressor). For 'glyf' and 'loca' tables,
708 # transformation version 3 indicates the null transform
709 if self.tag in {"glyf", "loca"}:
710 return self.transformVersion != 3
711 else:
712 return self.transformVersion != 0
713
714 @transformed.setter
715 def transformed(self, booleanValue):
716 # here we assume that a non-null transform means version 0 for 'glyf' and
717 # 'loca' and 1 for every other table (e.g. hmtx); but that may change as
718 # new transformation formats are introduced in the future (if ever).
719 if self.tag in {"glyf", "loca"}:
720 self.transformVersion = 3 if not booleanValue else 0
721 else:
722 self.transformVersion = int(booleanValue)
723
724
725class WOFF2LocaTable(getTableClass("loca")):
726 """Same as parent class. The only difference is that it attempts to preserve
727 the 'indexFormat' as encoded in the WOFF2 glyf table.
728 """
729
730 def __init__(self, tag=None):
731 self.tableTag = Tag(tag or "loca")
732
733 def compile(self, ttFont):
734 try:
735 max_location = max(self.locations)
736 except AttributeError:
737 self.set([])
738 max_location = 0
739 if "glyf" in ttFont and hasattr(ttFont["glyf"], "indexFormat"):
740 # copile loca using the indexFormat specified in the WOFF2 glyf table
741 indexFormat = ttFont["glyf"].indexFormat
742 if indexFormat == 0:
743 if max_location >= 0x20000:
744 raise TTLibError("indexFormat is 0 but local offsets > 0x20000")
745 if not all(l % 2 == 0 for l in self.locations):
746 raise TTLibError(
747 "indexFormat is 0 but local offsets not multiples of 2"
748 )
749 locations = array.array("H")
750 for i in range(len(self.locations)):
751 locations.append(self.locations[i] // 2)
752 else:
753 locations = array.array("I", self.locations)
754 if sys.byteorder != "big":
755 locations.byteswap()
756 data = locations.tobytes()
757 else:
758 # use the most compact indexFormat given the current glyph offsets
759 data = super(WOFF2LocaTable, self).compile(ttFont)
760 return data
761
762
763class WOFF2GlyfTable(getTableClass("glyf")):
764 """Decoder/Encoder for WOFF2 'glyf' table transform."""
765
766 subStreams = (
767 "nContourStream",
768 "nPointsStream",
769 "flagStream",
770 "glyphStream",
771 "compositeStream",
772 "bboxStream",
773 "instructionStream",
774 )
775
776 def __init__(self, tag=None):
777 self.tableTag = Tag(tag or "glyf")
778
779 def reconstruct(self, data, ttFont):
780 """Decompile transformed 'glyf' data."""
781 inputDataSize = len(data)
782
783 if inputDataSize < woff2GlyfTableFormatSize:
784 raise TTLibError("not enough 'glyf' data")
785 dummy, data = sstruct.unpack2(woff2GlyfTableFormat, data, self)
786 offset = woff2GlyfTableFormatSize
787
788 for stream in self.subStreams:
789 size = getattr(self, stream + "Size")
790 setattr(self, stream, data[:size])
791 data = data[size:]
792 offset += size
793
794 hasOverlapSimpleBitmap = self.optionFlags & woff2OverlapSimpleBitmapFlag
795 self.overlapSimpleBitmap = None
796 if hasOverlapSimpleBitmap:
797 overlapSimpleBitmapSize = (self.numGlyphs + 7) >> 3
798 self.overlapSimpleBitmap = array.array("B", data[:overlapSimpleBitmapSize])
799 offset += overlapSimpleBitmapSize
800
801 if offset != inputDataSize:
802 raise TTLibError(
803 "incorrect size of transformed 'glyf' table: expected %d, received %d bytes"
804 % (offset, inputDataSize)
805 )
806
807 bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2
808 bboxBitmap = self.bboxStream[:bboxBitmapSize]
809 self.bboxBitmap = array.array("B", bboxBitmap)
810 self.bboxStream = self.bboxStream[bboxBitmapSize:]
811
812 self.nContourStream = array.array("h", self.nContourStream)
813 if sys.byteorder != "big":
814 self.nContourStream.byteswap()
815 assert len(self.nContourStream) == self.numGlyphs
816
817 if "head" in ttFont:
818 ttFont["head"].indexToLocFormat = self.indexFormat
819 try:
820 self.glyphOrder = ttFont.getGlyphOrder()
821 except:
822 self.glyphOrder = None
823 if self.glyphOrder is None:
824 self.glyphOrder = [".notdef"]
825 self.glyphOrder.extend(["glyph%.5d" % i for i in range(1, self.numGlyphs)])
826 else:
827 if len(self.glyphOrder) != self.numGlyphs:
828 raise TTLibError(
829 "incorrect glyphOrder: expected %d glyphs, found %d"
830 % (len(self.glyphOrder), self.numGlyphs)
831 )
832
833 glyphs = self.glyphs = {}
834 for glyphID, glyphName in enumerate(self.glyphOrder):
835 glyph = self._decodeGlyph(glyphID)
836 glyphs[glyphName] = glyph
837
838 def transform(self, ttFont):
839 """Return transformed 'glyf' data"""
840 self.numGlyphs = len(self.glyphs)
841 assert len(self.glyphOrder) == self.numGlyphs
842 if "maxp" in ttFont:
843 ttFont["maxp"].numGlyphs = self.numGlyphs
844 self.indexFormat = ttFont["head"].indexToLocFormat
845
846 for stream in self.subStreams:
847 setattr(self, stream, b"")
848 bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2
849 self.bboxBitmap = array.array("B", [0] * bboxBitmapSize)
850
851 self.overlapSimpleBitmap = array.array("B", [0] * ((self.numGlyphs + 7) >> 3))
852 for glyphID in range(self.numGlyphs):
853 try:
854 self._encodeGlyph(glyphID)
855 except NotImplementedError:
856 return None
857 hasOverlapSimpleBitmap = any(self.overlapSimpleBitmap)
858
859 self.bboxStream = self.bboxBitmap.tobytes() + self.bboxStream
860 for stream in self.subStreams:
861 setattr(self, stream + "Size", len(getattr(self, stream)))
862 self.version = 0
863 self.optionFlags = 0
864 if hasOverlapSimpleBitmap:
865 self.optionFlags |= woff2OverlapSimpleBitmapFlag
866 data = sstruct.pack(woff2GlyfTableFormat, self)
867 data += bytesjoin([getattr(self, s) for s in self.subStreams])
868 if hasOverlapSimpleBitmap:
869 data += self.overlapSimpleBitmap.tobytes()
870 return data
871
872 def _decodeGlyph(self, glyphID):
873 glyph = getTableModule("glyf").Glyph()
874 glyph.numberOfContours = self.nContourStream[glyphID]
875 if glyph.numberOfContours == 0:
876 return glyph
877 elif glyph.isComposite():
878 self._decodeComponents(glyph)
879 else:
880 self._decodeCoordinates(glyph)
881 self._decodeOverlapSimpleFlag(glyph, glyphID)
882 self._decodeBBox(glyphID, glyph)
883 return glyph
884
885 def _decodeComponents(self, glyph):
886 data = self.compositeStream
887 glyph.components = []
888 more = 1
889 haveInstructions = 0
890 while more:
891 component = getTableModule("glyf").GlyphComponent()
892 more, haveInstr, data = component.decompile(data, self)
893 haveInstructions = haveInstructions | haveInstr
894 glyph.components.append(component)
895 self.compositeStream = data
896 if haveInstructions:
897 self._decodeInstructions(glyph)
898
899 def _decodeCoordinates(self, glyph):
900 data = self.nPointsStream
901 endPtsOfContours = []
902 endPoint = -1
903 for i in range(glyph.numberOfContours):
904 ptsOfContour, data = unpack255UShort(data)
905 endPoint += ptsOfContour
906 endPtsOfContours.append(endPoint)
907 glyph.endPtsOfContours = endPtsOfContours
908 self.nPointsStream = data
909 self._decodeTriplets(glyph)
910 self._decodeInstructions(glyph)
911
912 def _decodeOverlapSimpleFlag(self, glyph, glyphID):
913 if self.overlapSimpleBitmap is None or glyph.numberOfContours <= 0:
914 return
915 byte = glyphID >> 3
916 bit = glyphID & 7
917 if self.overlapSimpleBitmap[byte] & (0x80 >> bit):
918 glyph.flags[0] |= _g_l_y_f.flagOverlapSimple
919
920 def _decodeInstructions(self, glyph):
921 glyphStream = self.glyphStream
922 instructionStream = self.instructionStream
923 instructionLength, glyphStream = unpack255UShort(glyphStream)
924 glyph.program = ttProgram.Program()
925 glyph.program.fromBytecode(instructionStream[:instructionLength])
926 self.glyphStream = glyphStream
927 self.instructionStream = instructionStream[instructionLength:]
928
929 def _decodeBBox(self, glyphID, glyph):
930 haveBBox = bool(self.bboxBitmap[glyphID >> 3] & (0x80 >> (glyphID & 7)))
931 if glyph.isComposite() and not haveBBox:
932 raise TTLibError("no bbox values for composite glyph %d" % glyphID)
933 if haveBBox:
934 dummy, self.bboxStream = sstruct.unpack2(bboxFormat, self.bboxStream, glyph)
935 else:
936 glyph.recalcBounds(self)
937
938 def _decodeTriplets(self, glyph):
939 def withSign(flag, baseval):
940 assert 0 <= baseval and baseval < 65536, "integer overflow"
941 return baseval if flag & 1 else -baseval
942
943 nPoints = glyph.endPtsOfContours[-1] + 1
944 flagSize = nPoints
945 if flagSize > len(self.flagStream):
946 raise TTLibError("not enough 'flagStream' data")
947 flagsData = self.flagStream[:flagSize]
948 self.flagStream = self.flagStream[flagSize:]
949 flags = array.array("B", flagsData)
950
951 triplets = array.array("B", self.glyphStream)
952 nTriplets = len(triplets)
953 assert nPoints <= nTriplets
954
955 x = 0
956 y = 0
957 glyph.coordinates = getTableModule("glyf").GlyphCoordinates.zeros(nPoints)
958 glyph.flags = array.array("B")
959 tripletIndex = 0
960 for i in range(nPoints):
961 flag = flags[i]
962 onCurve = not bool(flag >> 7)
963 flag &= 0x7F
964 if flag < 84:
965 nBytes = 1
966 elif flag < 120:
967 nBytes = 2
968 elif flag < 124:
969 nBytes = 3
970 else:
971 nBytes = 4
972 assert (tripletIndex + nBytes) <= nTriplets
973 if flag < 10:
974 dx = 0
975 dy = withSign(flag, ((flag & 14) << 7) + triplets[tripletIndex])
976 elif flag < 20:
977 dx = withSign(flag, (((flag - 10) & 14) << 7) + triplets[tripletIndex])
978 dy = 0
979 elif flag < 84:
980 b0 = flag - 20
981 b1 = triplets[tripletIndex]
982 dx = withSign(flag, 1 + (b0 & 0x30) + (b1 >> 4))
983 dy = withSign(flag >> 1, 1 + ((b0 & 0x0C) << 2) + (b1 & 0x0F))
984 elif flag < 120:
985 b0 = flag - 84
986 dx = withSign(flag, 1 + ((b0 // 12) << 8) + triplets[tripletIndex])
987 dy = withSign(
988 flag >> 1, 1 + (((b0 % 12) >> 2) << 8) + triplets[tripletIndex + 1]
989 )
990 elif flag < 124:
991 b2 = triplets[tripletIndex + 1]
992 dx = withSign(flag, (triplets[tripletIndex] << 4) + (b2 >> 4))
993 dy = withSign(
994 flag >> 1, ((b2 & 0x0F) << 8) + triplets[tripletIndex + 2]
995 )
996 else:
997 dx = withSign(
998 flag, (triplets[tripletIndex] << 8) + triplets[tripletIndex + 1]
999 )
1000 dy = withSign(
1001 flag >> 1,
1002 (triplets[tripletIndex + 2] << 8) + triplets[tripletIndex + 3],
1003 )
1004 tripletIndex += nBytes
1005 x += dx
1006 y += dy
1007 glyph.coordinates[i] = (x, y)
1008 glyph.flags.append(int(onCurve))
1009 bytesConsumed = tripletIndex
1010 self.glyphStream = self.glyphStream[bytesConsumed:]
1011
1012 def _encodeGlyph(self, glyphID):
1013 glyphName = self.getGlyphName(glyphID)
1014 glyph = self[glyphName]
1015 self.nContourStream += struct.pack(">h", glyph.numberOfContours)
1016 if glyph.numberOfContours == 0:
1017 return
1018 elif glyph.isComposite():
1019 self._encodeComponents(glyph)
1020 else:
1021 self._encodeCoordinates(glyph)
1022 self._encodeOverlapSimpleFlag(glyph, glyphID)
1023 self._encodeBBox(glyphID, glyph)
1024
1025 def _encodeComponents(self, glyph):
1026 lastcomponent = len(glyph.components) - 1
1027 more = 1
1028 haveInstructions = 0
1029 for i in range(len(glyph.components)):
1030 if i == lastcomponent:
1031 haveInstructions = hasattr(glyph, "program")
1032 more = 0
1033 component = glyph.components[i]
1034 self.compositeStream += component.compile(more, haveInstructions, self)
1035 if haveInstructions:
1036 self._encodeInstructions(glyph)
1037
1038 def _encodeCoordinates(self, glyph):
1039 lastEndPoint = -1
1040 if _g_l_y_f.flagCubic in glyph.flags:
1041 raise NotImplementedError
1042 for endPoint in glyph.endPtsOfContours:
1043 ptsOfContour = endPoint - lastEndPoint
1044 self.nPointsStream += pack255UShort(ptsOfContour)
1045 lastEndPoint = endPoint
1046 self._encodeTriplets(glyph)
1047 self._encodeInstructions(glyph)
1048
1049 def _encodeOverlapSimpleFlag(self, glyph, glyphID):
1050 if glyph.numberOfContours <= 0:
1051 return
1052 if glyph.flags[0] & _g_l_y_f.flagOverlapSimple:
1053 byte = glyphID >> 3
1054 bit = glyphID & 7
1055 self.overlapSimpleBitmap[byte] |= 0x80 >> bit
1056
1057 def _encodeInstructions(self, glyph):
1058 instructions = glyph.program.getBytecode()
1059 self.glyphStream += pack255UShort(len(instructions))
1060 self.instructionStream += instructions
1061
1062 def _encodeBBox(self, glyphID, glyph):
1063 assert glyph.numberOfContours != 0, "empty glyph has no bbox"
1064 if not glyph.isComposite():
1065 # for simple glyphs, compare the encoded bounding box info with the calculated
1066 # values, and if they match omit the bounding box info
1067 currentBBox = glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax
1068 calculatedBBox = calcIntBounds(glyph.coordinates)
1069 if currentBBox == calculatedBBox:
1070 return
1071 self.bboxBitmap[glyphID >> 3] |= 0x80 >> (glyphID & 7)
1072 self.bboxStream += sstruct.pack(bboxFormat, glyph)
1073
1074 def _encodeTriplets(self, glyph):
1075 assert len(glyph.coordinates) == len(glyph.flags)
1076 coordinates = glyph.coordinates.copy()
1077 coordinates.absoluteToRelative()
1078
1079 flags = array.array("B")
1080 triplets = array.array("B")
1081 for i in range(len(coordinates)):
1082 onCurve = glyph.flags[i] & _g_l_y_f.flagOnCurve
1083 x, y = coordinates[i]
1084 absX = abs(x)
1085 absY = abs(y)
1086 onCurveBit = 0 if onCurve else 128
1087 xSignBit = 0 if (x < 0) else 1
1088 ySignBit = 0 if (y < 0) else 1
1089 xySignBits = xSignBit + 2 * ySignBit
1090
1091 if x == 0 and absY < 1280:
1092 flags.append(onCurveBit + ((absY & 0xF00) >> 7) + ySignBit)
1093 triplets.append(absY & 0xFF)
1094 elif y == 0 and absX < 1280:
1095 flags.append(onCurveBit + 10 + ((absX & 0xF00) >> 7) + xSignBit)
1096 triplets.append(absX & 0xFF)
1097 elif absX < 65 and absY < 65:
1098 flags.append(
1099 onCurveBit
1100 + 20
1101 + ((absX - 1) & 0x30)
1102 + (((absY - 1) & 0x30) >> 2)
1103 + xySignBits
1104 )
1105 triplets.append((((absX - 1) & 0xF) << 4) | ((absY - 1) & 0xF))
1106 elif absX < 769 and absY < 769:
1107 flags.append(
1108 onCurveBit
1109 + 84
1110 + 12 * (((absX - 1) & 0x300) >> 8)
1111 + (((absY - 1) & 0x300) >> 6)
1112 + xySignBits
1113 )
1114 triplets.append((absX - 1) & 0xFF)
1115 triplets.append((absY - 1) & 0xFF)
1116 elif absX < 4096 and absY < 4096:
1117 flags.append(onCurveBit + 120 + xySignBits)
1118 triplets.append(absX >> 4)
1119 triplets.append(((absX & 0xF) << 4) | (absY >> 8))
1120 triplets.append(absY & 0xFF)
1121 else:
1122 flags.append(onCurveBit + 124 + xySignBits)
1123 triplets.append(absX >> 8)
1124 triplets.append(absX & 0xFF)
1125 triplets.append(absY >> 8)
1126 triplets.append(absY & 0xFF)
1127
1128 self.flagStream += flags.tobytes()
1129 self.glyphStream += triplets.tobytes()
1130
1131
1132class WOFF2HmtxTable(getTableClass("hmtx")):
1133 def __init__(self, tag=None):
1134 self.tableTag = Tag(tag or "hmtx")
1135
1136 def reconstruct(self, data, ttFont):
1137 (flags,) = struct.unpack(">B", data[:1])
1138 data = data[1:]
1139 if flags & 0b11111100 != 0:
1140 raise TTLibError("Bits 2-7 of '%s' flags are reserved" % self.tableTag)
1141
1142 # When bit 0 is _not_ set, the lsb[] array is present
1143 hasLsbArray = flags & 1 == 0
1144 # When bit 1 is _not_ set, the leftSideBearing[] array is present
1145 hasLeftSideBearingArray = flags & 2 == 0
1146 if hasLsbArray and hasLeftSideBearingArray:
1147 raise TTLibError(
1148 "either bits 0 or 1 (or both) must set in transformed '%s' flags"
1149 % self.tableTag
1150 )
1151
1152 glyfTable = ttFont["glyf"]
1153 headerTable = ttFont["hhea"]
1154 glyphOrder = glyfTable.glyphOrder
1155 numGlyphs = len(glyphOrder)
1156 numberOfHMetrics = min(int(headerTable.numberOfHMetrics), numGlyphs)
1157
1158 assert len(data) >= 2 * numberOfHMetrics
1159 advanceWidthArray = array.array("H", data[: 2 * numberOfHMetrics])
1160 if sys.byteorder != "big":
1161 advanceWidthArray.byteswap()
1162 data = data[2 * numberOfHMetrics :]
1163
1164 if hasLsbArray:
1165 assert len(data) >= 2 * numberOfHMetrics
1166 lsbArray = array.array("h", data[: 2 * numberOfHMetrics])
1167 if sys.byteorder != "big":
1168 lsbArray.byteswap()
1169 data = data[2 * numberOfHMetrics :]
1170 else:
1171 # compute (proportional) glyphs' lsb from their xMin
1172 lsbArray = array.array("h")
1173 for i, glyphName in enumerate(glyphOrder):
1174 if i >= numberOfHMetrics:
1175 break
1176 glyph = glyfTable[glyphName]
1177 xMin = getattr(glyph, "xMin", 0)
1178 lsbArray.append(xMin)
1179
1180 numberOfSideBearings = numGlyphs - numberOfHMetrics
1181 if hasLeftSideBearingArray:
1182 assert len(data) >= 2 * numberOfSideBearings
1183 leftSideBearingArray = array.array("h", data[: 2 * numberOfSideBearings])
1184 if sys.byteorder != "big":
1185 leftSideBearingArray.byteswap()
1186 data = data[2 * numberOfSideBearings :]
1187 else:
1188 # compute (monospaced) glyphs' leftSideBearing from their xMin
1189 leftSideBearingArray = array.array("h")
1190 for i, glyphName in enumerate(glyphOrder):
1191 if i < numberOfHMetrics:
1192 continue
1193 glyph = glyfTable[glyphName]
1194 xMin = getattr(glyph, "xMin", 0)
1195 leftSideBearingArray.append(xMin)
1196
1197 if data:
1198 raise TTLibError("too much '%s' table data" % self.tableTag)
1199
1200 self.metrics = {}
1201 for i in range(numberOfHMetrics):
1202 glyphName = glyphOrder[i]
1203 advanceWidth, lsb = advanceWidthArray[i], lsbArray[i]
1204 self.metrics[glyphName] = (advanceWidth, lsb)
1205 lastAdvance = advanceWidthArray[-1]
1206 for i in range(numberOfSideBearings):
1207 glyphName = glyphOrder[i + numberOfHMetrics]
1208 self.metrics[glyphName] = (lastAdvance, leftSideBearingArray[i])
1209
1210 def transform(self, ttFont):
1211 glyphOrder = ttFont.getGlyphOrder()
1212 glyf = ttFont["glyf"]
1213 hhea = ttFont["hhea"]
1214 numberOfHMetrics = hhea.numberOfHMetrics
1215
1216 # check if any of the proportional glyphs has left sidebearings that
1217 # differ from their xMin bounding box values.
1218 hasLsbArray = False
1219 for i in range(numberOfHMetrics):
1220 glyphName = glyphOrder[i]
1221 lsb = self.metrics[glyphName][1]
1222 if lsb != getattr(glyf[glyphName], "xMin", 0):
1223 hasLsbArray = True
1224 break
1225
1226 # do the same for the monospaced glyphs (if any) at the end of hmtx table
1227 hasLeftSideBearingArray = False
1228 for i in range(numberOfHMetrics, len(glyphOrder)):
1229 glyphName = glyphOrder[i]
1230 lsb = self.metrics[glyphName][1]
1231 if lsb != getattr(glyf[glyphName], "xMin", 0):
1232 hasLeftSideBearingArray = True
1233 break
1234
1235 # if we need to encode both sidebearings arrays, then no transformation is
1236 # applicable, and we must use the untransformed hmtx data
1237 if hasLsbArray and hasLeftSideBearingArray:
1238 return
1239
1240 # set bit 0 and 1 when the respective arrays are _not_ present
1241 flags = 0
1242 if not hasLsbArray:
1243 flags |= 1 << 0
1244 if not hasLeftSideBearingArray:
1245 flags |= 1 << 1
1246
1247 data = struct.pack(">B", flags)
1248
1249 advanceWidthArray = array.array(
1250 "H",
1251 [
1252 self.metrics[glyphName][0]
1253 for i, glyphName in enumerate(glyphOrder)
1254 if i < numberOfHMetrics
1255 ],
1256 )
1257 if sys.byteorder != "big":
1258 advanceWidthArray.byteswap()
1259 data += advanceWidthArray.tobytes()
1260
1261 if hasLsbArray:
1262 lsbArray = array.array(
1263 "h",
1264 [
1265 self.metrics[glyphName][1]
1266 for i, glyphName in enumerate(glyphOrder)
1267 if i < numberOfHMetrics
1268 ],
1269 )
1270 if sys.byteorder != "big":
1271 lsbArray.byteswap()
1272 data += lsbArray.tobytes()
1273
1274 if hasLeftSideBearingArray:
1275 leftSideBearingArray = array.array(
1276 "h",
1277 [
1278 self.metrics[glyphOrder[i]][1]
1279 for i in range(numberOfHMetrics, len(glyphOrder))
1280 ],
1281 )
1282 if sys.byteorder != "big":
1283 leftSideBearingArray.byteswap()
1284 data += leftSideBearingArray.tobytes()
1285
1286 return data
1287
1288
1289class WOFF2FlavorData(WOFFFlavorData):
1290 Flavor = "woff2"
1291
1292 def __init__(self, reader=None, data=None, transformedTables=None):
1293 """Data class that holds the WOFF2 header major/minor version, any
1294 metadata or private data (as bytes strings), and the set of
1295 table tags that have transformations applied (if reader is not None),
1296 or will have once the WOFF2 font is compiled.
1297
1298 Args:
1299 reader: an SFNTReader (or subclass) object to read flavor data from.
1300 data: another WOFFFlavorData object to initialise data from.
1301 transformedTables: set of strings containing table tags to be transformed.
1302
1303 Raises:
1304 ImportError if the brotli module is not installed.
1305
1306 NOTE: The 'reader' argument, on the one hand, and the 'data' and
1307 'transformedTables' arguments, on the other hand, are mutually exclusive.
1308 """
1309 if not haveBrotli:
1310 raise ImportError("No module named brotli")
1311
1312 if reader is not None:
1313 if data is not None:
1314 raise TypeError("'reader' and 'data' arguments are mutually exclusive")
1315 if transformedTables is not None:
1316 raise TypeError(
1317 "'reader' and 'transformedTables' arguments are mutually exclusive"
1318 )
1319
1320 if transformedTables is not None and (
1321 "glyf" in transformedTables
1322 and "loca" not in transformedTables
1323 or "loca" in transformedTables
1324 and "glyf" not in transformedTables
1325 ):
1326 raise ValueError("'glyf' and 'loca' must be transformed (or not) together")
1327 super(WOFF2FlavorData, self).__init__(reader=reader)
1328 if reader:
1329 transformedTables = [
1330 tag for tag, entry in reader.tables.items() if entry.transformed
1331 ]
1332 elif data:
1333 self.majorVersion = data.majorVersion
1334 self.majorVersion = data.minorVersion
1335 self.metaData = data.metaData
1336 self.privData = data.privData
1337 if transformedTables is None and hasattr(data, "transformedTables"):
1338 transformedTables = data.transformedTables
1339
1340 if transformedTables is None:
1341 transformedTables = woff2TransformedTableTags
1342
1343 self.transformedTables = set(transformedTables)
1344
1345 def _decompress(self, rawData):
1346 return brotli.decompress(rawData)
1347
1348
1349def unpackBase128(data):
1350 r"""Read one to five bytes from UIntBase128-encoded input string, and return
1351 a tuple containing the decoded integer plus any leftover data.
1352
1353 >>> unpackBase128(b'\x3f\x00\x00') == (63, b"\x00\x00")
1354 True
1355 >>> unpackBase128(b'\x8f\xff\xff\xff\x7f')[0] == 4294967295
1356 True
1357 >>> unpackBase128(b'\x80\x80\x3f') # doctest: +IGNORE_EXCEPTION_DETAIL
1358 Traceback (most recent call last):
1359 File "<stdin>", line 1, in ?
1360 TTLibError: UIntBase128 value must not start with leading zeros
1361 >>> unpackBase128(b'\x8f\xff\xff\xff\xff\x7f')[0] # doctest: +IGNORE_EXCEPTION_DETAIL
1362 Traceback (most recent call last):
1363 File "<stdin>", line 1, in ?
1364 TTLibError: UIntBase128-encoded sequence is longer than 5 bytes
1365 >>> unpackBase128(b'\x90\x80\x80\x80\x00')[0] # doctest: +IGNORE_EXCEPTION_DETAIL
1366 Traceback (most recent call last):
1367 File "<stdin>", line 1, in ?
1368 TTLibError: UIntBase128 value exceeds 2**32-1
1369 """
1370 if len(data) == 0:
1371 raise TTLibError("not enough data to unpack UIntBase128")
1372 result = 0
1373 if byteord(data[0]) == 0x80:
1374 # font must be rejected if UIntBase128 value starts with 0x80
1375 raise TTLibError("UIntBase128 value must not start with leading zeros")
1376 for i in range(woff2Base128MaxSize):
1377 if len(data) == 0:
1378 raise TTLibError("not enough data to unpack UIntBase128")
1379 code = byteord(data[0])
1380 data = data[1:]
1381 # if any of the top seven bits are set then we're about to overflow
1382 if result & 0xFE000000:
1383 raise TTLibError("UIntBase128 value exceeds 2**32-1")
1384 # set current value = old value times 128 bitwise-or (byte bitwise-and 127)
1385 result = (result << 7) | (code & 0x7F)
1386 # repeat until the most significant bit of byte is false
1387 if (code & 0x80) == 0:
1388 # return result plus left over data
1389 return result, data
1390 # make sure not to exceed the size bound
1391 raise TTLibError("UIntBase128-encoded sequence is longer than 5 bytes")
1392
1393
1394def base128Size(n):
1395 """Return the length in bytes of a UIntBase128-encoded sequence with value n.
1396
1397 >>> base128Size(0)
1398 1
1399 >>> base128Size(24567)
1400 3
1401 >>> base128Size(2**32-1)
1402 5
1403 """
1404 assert n >= 0
1405 size = 1
1406 while n >= 128:
1407 size += 1
1408 n >>= 7
1409 return size
1410
1411
1412def packBase128(n):
1413 r"""Encode unsigned integer in range 0 to 2**32-1 (inclusive) to a string of
1414 bytes using UIntBase128 variable-length encoding. Produce the shortest possible
1415 encoding.
1416
1417 >>> packBase128(63) == b"\x3f"
1418 True
1419 >>> packBase128(2**32-1) == b'\x8f\xff\xff\xff\x7f'
1420 True
1421 """
1422 if n < 0 or n >= 2**32:
1423 raise TTLibError("UIntBase128 format requires 0 <= integer <= 2**32-1")
1424 data = b""
1425 size = base128Size(n)
1426 for i in range(size):
1427 b = (n >> (7 * (size - i - 1))) & 0x7F
1428 if i < size - 1:
1429 b |= 0x80
1430 data += struct.pack("B", b)
1431 return data
1432
1433
1434def unpack255UShort(data):
1435 """Read one to three bytes from 255UInt16-encoded input string, and return a
1436 tuple containing the decoded integer plus any leftover data.
1437
1438 >>> unpack255UShort(bytechr(252))[0]
1439 252
1440
1441 Note that some numbers (e.g. 506) can have multiple encodings:
1442 >>> unpack255UShort(struct.pack("BB", 254, 0))[0]
1443 506
1444 >>> unpack255UShort(struct.pack("BB", 255, 253))[0]
1445 506
1446 >>> unpack255UShort(struct.pack("BBB", 253, 1, 250))[0]
1447 506
1448 """
1449 code = byteord(data[:1])
1450 data = data[1:]
1451 if code == 253:
1452 # read two more bytes as an unsigned short
1453 if len(data) < 2:
1454 raise TTLibError("not enough data to unpack 255UInt16")
1455 (result,) = struct.unpack(">H", data[:2])
1456 data = data[2:]
1457 elif code == 254:
1458 # read another byte, plus 253 * 2
1459 if len(data) == 0:
1460 raise TTLibError("not enough data to unpack 255UInt16")
1461 result = byteord(data[:1])
1462 result += 506
1463 data = data[1:]
1464 elif code == 255:
1465 # read another byte, plus 253
1466 if len(data) == 0:
1467 raise TTLibError("not enough data to unpack 255UInt16")
1468 result = byteord(data[:1])
1469 result += 253
1470 data = data[1:]
1471 else:
1472 # leave as is if lower than 253
1473 result = code
1474 # return result plus left over data
1475 return result, data
1476
1477
1478def pack255UShort(value):
1479 r"""Encode unsigned integer in range 0 to 65535 (inclusive) to a bytestring
1480 using 255UInt16 variable-length encoding.
1481
1482 >>> pack255UShort(252) == b'\xfc'
1483 True
1484 >>> pack255UShort(506) == b'\xfe\x00'
1485 True
1486 >>> pack255UShort(762) == b'\xfd\x02\xfa'
1487 True
1488 """
1489 if value < 0 or value > 0xFFFF:
1490 raise TTLibError("255UInt16 format requires 0 <= integer <= 65535")
1491 if value < 253:
1492 return struct.pack(">B", value)
1493 elif value < 506:
1494 return struct.pack(">BB", 255, value - 253)
1495 elif value < 762:
1496 return struct.pack(">BB", 254, value - 506)
1497 else:
1498 return struct.pack(">BH", 253, value)
1499
1500
1501def compress(input_file, output_file, transform_tables=None):
1502 """Compress OpenType font to WOFF2.
1503
1504 Args:
1505 input_file: a file path, file or file-like object (open in binary mode)
1506 containing an OpenType font (either CFF- or TrueType-flavored).
1507 output_file: a file path, file or file-like object where to save the
1508 compressed WOFF2 font.
1509 transform_tables: Optional[Iterable[str]]: a set of table tags for which
1510 to enable preprocessing transformations. By default, only 'glyf'
1511 and 'loca' tables are transformed. An empty set means disable all
1512 transformations.
1513 """
1514 log.info("Processing %s => %s" % (input_file, output_file))
1515
1516 font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False)
1517 font.flavor = "woff2"
1518
1519 if transform_tables is not None:
1520 font.flavorData = WOFF2FlavorData(
1521 data=font.flavorData, transformedTables=transform_tables
1522 )
1523
1524 font.save(output_file, reorderTables=False)
1525
1526
1527def decompress(input_file, output_file):
1528 """Decompress WOFF2 font to OpenType font.
1529
1530 Args:
1531 input_file: a file path, file or file-like object (open in binary mode)
1532 containing a compressed WOFF2 font.
1533 output_file: a file path, file or file-like object where to save the
1534 decompressed OpenType font.
1535 """
1536 log.info("Processing %s => %s" % (input_file, output_file))
1537
1538 font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False)
1539 font.flavor = None
1540 font.flavorData = None
1541 font.save(output_file, reorderTables=True)
1542
1543
1544def main(args=None):
1545 """Compress and decompress WOFF2 fonts"""
1546 import argparse
1547 from fontTools import configLogger
1548 from fontTools.ttx import makeOutputFileName
1549
1550 class _HelpAction(argparse._HelpAction):
1551 def __call__(self, parser, namespace, values, option_string=None):
1552 subparsers_actions = [
1553 action
1554 for action in parser._actions
1555 if isinstance(action, argparse._SubParsersAction)
1556 ]
1557 for subparsers_action in subparsers_actions:
1558 for choice, subparser in subparsers_action.choices.items():
1559 print(subparser.format_help())
1560 parser.exit()
1561
1562 class _NoGlyfTransformAction(argparse.Action):
1563 def __call__(self, parser, namespace, values, option_string=None):
1564 namespace.transform_tables.difference_update({"glyf", "loca"})
1565
1566 class _HmtxTransformAction(argparse.Action):
1567 def __call__(self, parser, namespace, values, option_string=None):
1568 namespace.transform_tables.add("hmtx")
1569
1570 parser = argparse.ArgumentParser(
1571 prog="fonttools ttLib.woff2", description=main.__doc__, add_help=False
1572 )
1573
1574 parser.add_argument(
1575 "-h", "--help", action=_HelpAction, help="show this help message and exit"
1576 )
1577
1578 parser_group = parser.add_subparsers(title="sub-commands")
1579 parser_compress = parser_group.add_parser(
1580 "compress", description="Compress a TTF or OTF font to WOFF2"
1581 )
1582 parser_decompress = parser_group.add_parser(
1583 "decompress", description="Decompress a WOFF2 font to OTF"
1584 )
1585
1586 for subparser in (parser_compress, parser_decompress):
1587 group = subparser.add_mutually_exclusive_group(required=False)
1588 group.add_argument(
1589 "-v",
1590 "--verbose",
1591 action="store_true",
1592 help="print more messages to console",
1593 )
1594 group.add_argument(
1595 "-q",
1596 "--quiet",
1597 action="store_true",
1598 help="do not print messages to console",
1599 )
1600
1601 parser_compress.add_argument(
1602 "input_file",
1603 metavar="INPUT",
1604 help="the input OpenType font (.ttf or .otf)",
1605 )
1606 parser_decompress.add_argument(
1607 "input_file",
1608 metavar="INPUT",
1609 help="the input WOFF2 font",
1610 )
1611
1612 parser_compress.add_argument(
1613 "-o",
1614 "--output-file",
1615 metavar="OUTPUT",
1616 help="the output WOFF2 font",
1617 )
1618 parser_decompress.add_argument(
1619 "-o",
1620 "--output-file",
1621 metavar="OUTPUT",
1622 help="the output OpenType font",
1623 )
1624
1625 transform_group = parser_compress.add_argument_group()
1626 transform_group.add_argument(
1627 "--no-glyf-transform",
1628 dest="transform_tables",
1629 nargs=0,
1630 action=_NoGlyfTransformAction,
1631 help="Do not transform glyf (and loca) tables",
1632 )
1633 transform_group.add_argument(
1634 "--hmtx-transform",
1635 dest="transform_tables",
1636 nargs=0,
1637 action=_HmtxTransformAction,
1638 help="Enable optional transformation for 'hmtx' table",
1639 )
1640
1641 parser_compress.set_defaults(
1642 subcommand=compress,
1643 transform_tables={"glyf", "loca"},
1644 )
1645 parser_decompress.set_defaults(subcommand=decompress)
1646
1647 options = vars(parser.parse_args(args))
1648
1649 subcommand = options.pop("subcommand", None)
1650 if not subcommand:
1651 parser.print_help()
1652 return
1653
1654 quiet = options.pop("quiet")
1655 verbose = options.pop("verbose")
1656 configLogger(
1657 level=("ERROR" if quiet else "DEBUG" if verbose else "INFO"),
1658 )
1659
1660 if not options["output_file"]:
1661 if subcommand is compress:
1662 extension = ".woff2"
1663 elif subcommand is decompress:
1664 # choose .ttf/.otf file extension depending on sfntVersion
1665 with open(options["input_file"], "rb") as f:
1666 f.seek(4) # skip 'wOF2' signature
1667 sfntVersion = f.read(4)
1668 assert len(sfntVersion) == 4, "not enough data"
1669 extension = ".otf" if sfntVersion == b"OTTO" else ".ttf"
1670 else:
1671 raise AssertionError(subcommand)
1672 options["output_file"] = makeOutputFileName(
1673 options["input_file"], outputDir=None, extension=extension
1674 )
1675
1676 try:
1677 subcommand(**options)
1678 except TTLibError as e:
1679 parser.error(e)
1680
1681
1682if __name__ == "__main__":
1683 sys.exit(main())