Skip to content

Instantly share code, notes, and snippets.

@typesupply
Created June 8, 2018 13:36
Show Gist options
  • Save typesupply/a7416cb867332f0893c9af0d2ba50581 to your computer and use it in GitHub Desktop.
Save typesupply/a7416cb867332f0893c9af0d2ba50581 to your computer and use it in GitHub Desktop.
"""
This is some very old code that implemented a doctest-like
format for .fea files. It was useful, but I seemed to be
the only person using it so it fell off my radar. I still
think this, or something like it would be extremely useful
in the font development workflow.
The syntax is defined below. This implementation uses a
test file that is separate from the .fea file. It shouldn't
be too hard to make it work with tests that are embedded
within the .fea. I had that working for something, but I
can't find it right now.
The font object passed to the FeaTest object was a
Compositor font object.
--------------
sample.featest
--------------
# This is a comment, which shouldn't be visible!
# This is a script declaration:
^ latn
# This is a language declaration:
@ None
# This is a writing direction start point declaration:
* left
# the options are "left" for left to right
# and "right" for right to left.
! This is a note!
# Notes are visible in the output.
! These are feature state changes:
+ liga calt
- kern
! This is input:
> T h i s space i s space a space t e s t exclam
! This is input with an expected output as glyph names:
> T h i s space i s space a space t e s t exclam
< T h i s space i s space a space t e s t exclam
! This is input with an expected output as glyph records:
> T h i s space i s space a space t e s t exclam
< <T 0 0 0 0> <h 0 0 0 0> <i 0 0 0 0> <s 0 0 0 0> <space 0 0 0 0> <i 0 0 0 0> <s 0 0 0 0> <space 0 0 0 0> <a 0 0 0 0> <space 0 0 0 0> <t 0 0 0 0> <e 0 0 0 0> <s 0 0 0 0> <t 0 0 0 0> <exclam 0 0 0 0>
! This is a test with a failing expected output:
> T h i s space i s space a space t e s t exclam
< T h i s space i s space a space f a i l i n g space t e s t exclam
"""
import os
import time
import re
from compositor.glyphRecord import GlyphRecord
class FeaTestError(Exception): pass
recordRE = re.compile(
"<" # <
"\s*" #
"([\w._]+)" # glyph name
"\s+" #
"([\d.-]+)" # x placement
"\s+" #
"([\d.-]+)" # y placement
"\s+" #
"([\d.-]+)" # x advance
"\s+" #
"([\d.-]+)" # y advance
"\s*" #
">" # >
)
class FeaTest(object):
def __init__(self, font):
self.font = font
self._results = []
self._runTime = 0
self._testTotal = 0
self._testPass = 0
self._testFail = 0
# ---------------
# Test Extraction
# ---------------
def _parseTestFile(self, path):
f = open(path, "r")
text = f.read()
f.close()
parsed = []
for line in text.splitlines():
line = line.strip()
if line:
token = line[0]
content = line[1:].strip()
tag = None
if not line:
continue
elif token == "#":
continue
elif token == "!":
tag = "note"
elif token == "+":
tag = "features on"
content = self._spaceSplit(content)
elif token == "-":
tag = "features off"
content = self._spaceSplit(content)
elif token == "^":
tag = "script"
elif token == "@":
tag = "language"
elif token == "*":
tag = "text direction"
if content not in ["left", "right"]:
raise FeaTestError("Unknown text direction: %s" % content)
elif token == ">":
tag = "input glyph names"
content = self._spaceSplit(content)
elif token == "<":
tag = "expected result"
content = self._parseRecords(content)
# XXX alternates!
else:
raise FeaTestError("Unknown token: %s" % token)
parsed.append((tag, content))
return parsed
def _parseRecords(self, content):
if "<" in content:
records = []
while 1:
m = recordRE.match(content)
if m is None:
raise FeaTestError("Invalid glyph record syntax: %s" % content)
record = (m.group(1), float(m.group(2)), float(m.group(3)), float(m.group(4)), float(m.group(5)))
records.append(record)
content = content[m.end():].strip()
if not content:
break
else:
records = [(i, None, None, None, None) for i in self._spaceSplit(content)]
return records
def _spaceSplit(self, text):
return [i for i in text.split(" ") if i.strip()]
# ---------------
# Test Evaluation
# ---------------
def evaluateTests(self, path):
font = self.font
test = self._parseTestFile(path)
engineChangeTags = [
"features on",
"features off",
"script",
"language",
"text direction"
]
startTime = time.time()
currentScript = "latn"
currentLanguage = "None"
currentDirection = "left"
engineState = {
"script" : currentScript,
"language" : currentLanguage,
"text direction" : currentDirection,
"feature states" : dict()
}
haveEngineChange = True
# turn all features off
for featureName in font.getFeatureList():
font.setFeatureState(featureName, False)
engineState["feature states"][featureName] = False
# execute
results = []
_previousResult = None
testTotal = 0
testPass = 0
testFail = 0
for tag, content in test:
# as we run through, watch for engine state tags
# and as they appear, group them together.
if tag in engineChangeTags:
haveEngineChange = True
if tag == "features on":
for feature in content:
engineState["feature states"][feature] = True
font.setFeatureState(feature, True)
elif tag == "features off":
for feature in content:
engineState["feature states"][feature] = False
font.setFeatureState(feature, False)
else:
if tag == "script":
currentScript = content
elif tag == "language":
currentLanguage = content
elif tag == "text direction":
currentDirection = content
engineState[tag] = content
continue
# we're exiting a run of engine changes
# so log the changes that were made
if haveEngineChange:
results.append(("engine changes", engineState))
# start a new dict to contain engine changes
engineState = {"feature states" : dict()}
haveEngineChange = False
# input string. convert to glyph records.
if tag == "input string":
tag = "input"
_rawInput = content
content = font.stringToGlyphRecords(content)
# force record.glyphName to be the requested
# glyph name even if the font does not contain
# a glyph with that name.
for index, glyphName in enumerate(_rawInput):
content[index].glyphName = glyphName
# input names. convert to glyph records.
elif tag == "input glyph names":
tag = "input"
_rawInput = content
content = font.glyphListToGlyphRecords(content)
# force record.glyphName to be the requested
# glyph name even if the font does not contain
# a glyph with that name.
for index, glyphName in enumerate(_rawInput):
content[index].glyphName = glyphName
# expected result. convert to glyph records.
elif tag == "expected result":
records = []
for glyphName, xP, yP, xA, yA in content:
record = GlyphRecord(glyphName)
record.xPlacement = xP
record.yPlacement = yP
record.xAdvance = xA
record.yAdvance = yA
records.append(record)
passed = self._compareResultRecords(_previousResult, records)
content = (passed, records)
if passed:
testPass += 1
else:
testFail += 1
# store the tag and the content
results.append((tag, content))
# evaluate the test
if tag == "input":
if currentLanguage is not None and currentLanguage.lower() == "none":
currentLanguage = None
if currentScript:
if len(currentScript) < 4:
currentScript += " " * (4 - len(currentScript))
if currentLanguage:
if len(currentLanguage) < 4:
currentLanguage += " " * (4 - len(currentLanguage))
result = _previousResult = font.process(_rawInput, script=currentScript, langSys=currentLanguage, rightToLeft=currentDirection != "left")
results.append(("result", result))
testTotal += 1
runTime = round(time.time() - startTime, 3)
if runTime < 0:
runTime = 0
self._runTime += runTime
self._testTotal += testTotal
self._testPass += testPass
self._testFail += testFail
self._results.extend(results)
def _compareResultRecords(self, results1, results2):
if len(results1) != len (results2):
return False
comparison = []
for index, r1 in enumerate(results1):
r2 = results2[index]
if r1.glyphName != r2.glyphName:
comparison.append(False)
continue
if r2.xPlacement is not None:
if r1.xPlacement != r2.xPlacement:
comparison.append(False)
continue
if r2.yPlacement is not None:
if r1.yPlacement != r2.yPlacement:
comparison.append(False)
continue
if r2.xAdvance is not None:
if r1.xAdvance != r2.xAdvance:
comparison.append(False)
continue
if r2.yAdvance is not None:
if r1.yAdvance != r2.yAdvance:
comparison.append(False)
continue
return False not in comparison
# -------------
# Result Export
# -------------
def exportResults(self):
from cStringIO import StringIO
from xmlWriter import XMLWriter
f = StringIO()
writer = XMLWriter(f, encoding="utf-8")
writer.begintag("xml")
writer.newline()
writer.begintag("statistics")
writer.newline()
writer.simpletag("runTime", value="%s seconds" % str(round(self._runTime, 3)))
writer.newline()
writer.simpletag("totalTests", value=self._testTotal)
writer.newline()
writer.simpletag("passedTests", value=self._testPass)
writer.newline()
writer.simpletag("failedTests", value=self._testFail)
writer.newline()
writer.endtag("statistics")
writer.newline()
for tag, data in self._segregateResults():
if tag == "engine changes":
writer.begintag("engineChanges")
writer.newline()
if "script" in data:
writer.simpletag("script", value=data["script"])
writer.newline()
if "language" in data:
writer.simpletag("language", value=data["language"])
writer.newline()
if "text direction" in data:
if data["text direction"] == "left":
t = "left to right"
else:
t = "right to left"
writer.simpletag("textDirection", value=t)
writer.newline()
if "feature states" in data:
onState = []
offState = []
for featureTag, state in sorted(data["feature states"].items()):
if state:
onState.append(featureTag)
else:
offState.append(featureTag)
writer.begintag("featureStates")
writer.newline()
writer.begintag("featuresOn")
writer.newline()
for featureTag in onState:
writer.simpletag("feature", name=featureTag)
writer.newline()
writer.endtag("featuresOn")
writer.newline()
writer.begintag("featuresOff")
writer.newline()
for featureTag in offState:
writer.simpletag("feature", name=featureTag)
writer.newline()
writer.endtag("featuresOff")
writer.newline()
writer.endtag("featureStates")
writer.newline()
writer.endtag("engineChanges")
writer.newline()
elif tag == "test":
writer.begintag("test")
writer.newline()
for subTag, subData in data:
if subTag == "input":
writer.begintag("input")
writer.newline()
elif subTag == "result":
writer.begintag("result")
writer.newline()
elif subTag == "expected result":
writer.begintag("expectedResult", passed=subData[0])
writer.newline()
subData = subData[1]
for glyphRecord in subData:
xP = glyphRecord.xPlacement
if xP is not None:
xP = int(xP)
yP = glyphRecord.yPlacement
if yP is not None:
yP = int(yP)
xA = glyphRecord.xAdvance
if xA is not None:
xA = int(xA)
yA = glyphRecord.yAdvance
if yA is not None:
yA = int(yA)
writer.simpletag("glyphRecord", name=glyphRecord.glyphName,
xPlacement=xP, yPlacement=yP, xAdvance=xA, yAdvance=yA)
writer.newline()
if subTag == "input":
writer.endtag("input")
writer.newline()
elif subTag == "result":
writer.endtag("result")
writer.newline()
elif subTag == "expected result":
writer.endtag("expectedResult")
writer.newline()
writer.endtag("test")
writer.newline()
else:
writer.simpletag("note", text=data)
writer.newline()
writer.endtag("xml")
return f.getvalue()
def _segregateResults(self):
results = self._results
clumps = []
# the stats a already clumped
clumps.append(results[0])
currentClump = None
for tag, content in results[1:]:
# engine changes are already clumped
if tag == "engine changes":
clumps.append((tag, content))
currentClump = None
# notes can't exist within a test run
# so they always get appened. this
# will force them to appear at the
# end of teh test run.
elif tag == "note":
clumps.append((tag, content))
# clump the test run
else:
if tag == "input":
currentClump = []
clumps.append(("test", currentClump))
currentClump.append((tag, content))
return clumps
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment