Skip to content

Commit

Permalink
Merge pull request #1967 from anthrotype/pickle
Browse files Browse the repository at this point in the history
sfnt: add __getstate__ and __setstate__ to SFNTReader to make it pickelable
  • Loading branch information
anthrotype committed May 19, 2020
2 parents e838cd8 + cdd1037 commit 7ca42f6
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 26 deletions.
49 changes: 25 additions & 24 deletions Lib/fontTools/ttLib/sfnt.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
a table's length chages you need to rewrite the whole file anyway.
"""

from fontTools.misc.py23 import *
from io import BytesIO
from fontTools.misc.py23 import Tag

This comment has been minimized.

Copy link
@punchcutter

punchcutter May 20, 2020

Collaborator

@anthrotype Need to import SimpleNamespace because it's used below! This killed a bunch of stuff.

This comment has been minimized.

Copy link
@anthrotype

anthrotype May 20, 2020

Author Member

Oops I'm sorry. Looks like that code has no tests so we didn't catch the regression. I'll fix it immediately

This comment has been minimized.

Copy link
@anthrotype

anthrotype May 20, 2020

Author Member

Pushed a hotfix 4.10.2 release just now. Thanks again for reporting this quickly. It looks like we even have an issue open for adding unit tests for ttCollection.py.. #1210

from fontTools.misc import sstruct
from fontTools.ttLib import TTLibError
import struct
Expand Down Expand Up @@ -122,29 +123,29 @@ def __delitem__(self, tag):
def close(self):
self.file.close()

def __deepcopy__(self, memo):
"""Overrides the default deepcopy of SFNTReader object, to make it work
in the case when TTFont is loaded with lazy=True, and thus reader holds a
reference to a file object which is not pickleable.
We work around it by manually copying the data into a in-memory stream.
"""
from copy import deepcopy

cls = self.__class__
obj = cls.__new__(cls)
for k, v in self.__dict__.items():
if k == "file":
pos = v.tell()
v.seek(0)
buf = BytesIO(v.read())
v.seek(pos)
buf.seek(pos)
if hasattr(v, "name"):
buf.name = v.name
obj.file = buf
else:
obj.__dict__[k] = deepcopy(v, memo)
return obj
# We define custom __getstate__ and __setstate__ to make SFNTReader pickle-able
# and deepcopy-able. When a TTFont is loaded as lazy=True, SFNTReader holds a
# reference to an external file object which is not pickleable. So in __getstate__
# we store the file name and current position, and in __setstate__ we reopen the
# same named file after unpickling.

def __getstate__(self):
if isinstance(self.file, BytesIO):
# BytesIO is already pickleable, return the state unmodified
return self.__dict__

# remove unpickleable file attribute, and only store its name and pos
state = self.__dict__.copy()
del state["file"]
state["_filename"] = self.file.name
state["_filepos"] = self.file.tell()
return state

def __setstate__(self, state):
if "file" not in state:
self.file = open(state.pop("_filename"), "rb")
self.file.seek(state.pop("_filepos"))
self.__dict__.update(state)


# default compression level for WOFF 1.0 tables and metadata
Expand Down
56 changes: 54 additions & 2 deletions Tests/ttLib/sfnt_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,59 @@
from fontTools.misc.py23 import *
from fontTools.ttLib.sfnt import calcChecksum
import io
import copy
import pickle
from fontTools.ttLib.sfnt import calcChecksum, SFNTReader
import pytest


def test_calcChecksum():
assert calcChecksum(b"abcd") == 1633837924
assert calcChecksum(b"abcdxyz") == 3655064932


EMPTY_SFNT = b"\x00\x01\x00\x00" + b"\x00" * 8


def pickle_unpickle(obj):
return pickle.loads(pickle.dumps(obj))


class SFNTReaderTest:
@pytest.mark.parametrize("deepcopy", [copy.deepcopy, pickle_unpickle])
def test_pickle_protocol_FileIO(self, deepcopy, tmp_path):
fontfile = tmp_path / "test.ttf"
fontfile.write_bytes(EMPTY_SFNT)
reader = SFNTReader(fontfile.open("rb"))

reader2 = deepcopy(reader)

assert reader2 is not reader
assert reader2.file is not reader.file

assert isinstance(reader2.file, io.BufferedReader)
assert isinstance(reader2.file.raw, io.FileIO)
assert reader2.file.name == reader.file.name
assert reader2.file.tell() == reader.file.tell()

for k, v in reader.__dict__.items():
if k == "file":
continue
assert getattr(reader2, k) == v

@pytest.mark.parametrize("deepcopy", [copy.deepcopy, pickle_unpickle])
def test_pickle_protocol_BytesIO(self, deepcopy, tmp_path):
buf = io.BytesIO(EMPTY_SFNT)
reader = SFNTReader(buf)

reader2 = deepcopy(reader)

assert reader2 is not reader
assert reader2.file is not reader.file

assert isinstance(reader2.file, io.BytesIO)
assert reader2.file.tell() == reader.file.tell()
assert reader2.file.getvalue() == reader.file.getvalue()

for k, v in reader.__dict__.items():
if k == "file":
continue
assert getattr(reader2, k) == v
3 changes: 3 additions & 0 deletions Tests/varLib/varLib_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ def reload_font(font):
"""(De)serialize to get final binary layout."""
buf = BytesIO()
font.save(buf)
# Close the font to release filesystem resources so that on Windows the tearDown
# method can successfully remove the temporary directory created during setUp.
font.close()
buf.seek(0)
return TTFont(buf)

Expand Down

0 comments on commit 7ca42f6

Please sign in to comment.