Skip to content
Open
215 changes: 183 additions & 32 deletions Lib/feaPyFoFum/feaPyFoFum.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class FeaPyFoFumError(Exception):
# External API
# ------------

def compileFeatures(text, font, verbose=False, compileReferencedFiles=False):
def compileFeatures(textOrPath, font, verbose=False, compileReferencedFiles=False, namespaceAdditions={}, parseIncludes=False):
"""
Compile the dynamic features in the given text.

Expand All @@ -31,46 +31,146 @@ def compileFeatures(text, font, verbose=False, compileReferencedFiles=False):
files will be compiled and the references will be updated.
The locations of the referenced files are assumed to be
relative to the directory containing the font.

Additions to the execution namespace can be made through namespaceAdditions.

If parseIncludes is set to True, all include statements will be replaced by the compiled contents of the referenced files recursively.
"""
if not compileReferencedFiles:
text = _compileFeatureText(
text,
font,
verbose=verbose
)[0]
# detect .fea path or text
try:
assert os.path.exists(textOrPath) and os.path.splitext(textOrPath)[1] == ".fea"
filePath = os.path.abspath(textOrPath)
with open(filePath, "r") as f:
text = f.read()
except:
filePath = None
text = textOrPath
# create namespace with additions
namespace = dict(
FEA_PATH=filePath,
**namespaceAdditions,
)
# determine the base directory for relative paths
if filePath is not None:
relativePath = os.path.dirname(filePath)
elif font.path:
relativePath = os.path.dirname(font.path)
else:
relativePath = None
if font.path:
relativePath = os.path.dirname(font.path)
text, referencedFiles = _compileFeatureText(
text,
font,
relativePath=relativePath,
verbose=verbose
)
for inPath, outPath in referencedFiles:
_compileReferencedFeatureFile(
inPath,
outPath,
relativePath,
# compile
if parseIncludes:
text = _parseIncludes(text, filePath, set(), font=font, namespace=namespace, verbose=verbose)
else:
if not compileReferencedFiles:
text = _compileFeatureText(
text,
font,
verbose=False
updateIncludes=False,
namespace=namespace,
verbose=verbose
)[0]
else:
relativePath = None
if font.path:
relativePath = os.path.dirname(font.path)
text, referencedFiles = _compileFeatureText(
text,
font,
namespace=namespace,
relativePath=relativePath,
verbose=verbose
)
for inPath, outPath in referencedFiles:
_compileReferencedFeatureFile(
inPath,
outPath,
relativePath,
font,
namespace=namespace,
verbose=False
)
return text


# ------------------
# .fea File Creation
# ------------------

def _compileFeatureText(text, font, relativePath=None, verbose=False, recursionDepth=0):
def _parseIncludes(text, filePath, processedFiles, font=None, namespace={}, verbose=False, recursionDepth=0):
"""
Recursively replace include(path); statements with the compiled contents of the referenced files.
Each include path is resolved relative to the directory of the file containing the include statement (not the entry file).
basePath: directory to resolve relative include paths for the current file
processedFiles: set of absolute paths to avoid infinite recursion
font: font object to pass to _compileFeatureText
namespace: namespace dict for code execution
verbose: verbose flag for compilation
recursionDepth: current recursion depth (must be <= 5)
The included text will be indented to match the include statement.
"""
if recursionDepth > 5:
raise FeaPyFoFumError("Maximum include recursion depth exceeded.")
# Compile the text before searching for include statements
namespace["FEA_PATH"] = filePath # update namespace
compiledText, _ = _compileFeatureText(
text,
font,
updateIncludes=False,
namespace=namespace,
verbose=verbose
)
text = compiledText
pattern = re.compile(r"^([ \t]*)include\s*\(([^)]+)\)\s*;", re.MULTILINE)
def _readFile(path):
with open(path, 'r', encoding='utf-8') as f:
return f.read()
while True:
match = pattern.search(text)
if not match:
break
indent = match.group(1)
basePath = os.path.dirname(filePath)
relPath = match.group(2).strip()
absPath = os.path.normpath(os.path.join(basePath, relPath))
if absPath in processedFiles:
raise FeaPyFoFumError(f"Recursive include detected: {absPath}")
processedFiles.add(absPath)
if not os.path.isfile(absPath):
raise FeaPyFoFumError(f"Included file not found: {absPath}")
includedText = _readFile(absPath)
# Recursively parse and compile includes in the included file
namespace["FEA_PATH"] = absPath # update namespace
includedText = _parseIncludes(
includedText,
absPath, # absPath is updated for each file
processedFiles,
font=font,
namespace=namespace,
verbose=verbose,
recursionDepth=recursionDepth + 1
)
# Indent the compiled text to match the include statement
indentedCompiled = '\n'.join(
(indent + line if line.strip() != '' else line)
for line in includedText.splitlines()
)
# Replace the include statement with the indented compiled text
text = text[:match.start()] + indentedCompiled + text[match.end():]
processedFiles.remove(absPath)
return text


def _compileFeatureText(text, font, relativePath=None, updateIncludes=True, verbose=False, namespace={}, recursionDepth=0):
"""
Compile the completed feature text.
If the relativePath is given files referenced
If updateIncludes is True and the relativePath is given files referenced
with include statements will be processed
"""
# compile
text = _executeFeatureText(text, font, namespace, verbose=verbose)
# update include statements
referencedFiles = []
if relativePath is not None:
if updateIncludes and relativePath is not None:
# find referenced files and update them to the new paths
# XXX the relative path stuff here is potentially problematic.
# XXX the .fea spec is vague about how paths should be resolved.
Expand All @@ -83,13 +183,10 @@ def _compileFeatureText(text, font, relativePath=None, verbose=False, recursionD
text = text.replace(referencedData["target"], referencedData["replacement"])
else:
raise FeaPyFoFumError("Maximum reference file recursion depth exceeded.")
# compile
namespace = {}
text = _executeFeatureText(text, font, namespace, verbose=verbose)
return text, referencedFiles


def _compileReferencedFeatureFile(inPath, outPath, relativePath, font, verbose=False, recursionDepth=0):
def _compileReferencedFeatureFile(inPath, outPath, relativePath, font, verbose=False, namespace={}, recursionDepth=0):
"""
Compile the file given in inPath and write it to outPath.
"""
Expand All @@ -103,6 +200,7 @@ def _compileReferencedFeatureFile(inPath, outPath, relativePath, font, verbose=F
text,
font,
relativePath,
namespace=namespace,
verbose=verbose,
recursionDepth=recursionDepth
)
Expand All @@ -115,6 +213,7 @@ def _compileReferencedFeatureFile(inPath, outPath, relativePath, font, verbose=F
referenceOutPath,
relativePath,
font,
namespace=namespace,
verbose=verbose,
recursionDepth=recursionDepth + 1
)
Expand Down Expand Up @@ -158,10 +257,10 @@ def _findReferenceFiles(text):
"""
text = _stripComments(text)
pattern = re.compile(
"include\s*\("
"[^\)]+"
"\s*\)"
"\s*;"
r"include\s*\("
r"[^\)]+"
r"\s*\)"
r"\s*;"
)
return pattern.findall(text)

Expand Down Expand Up @@ -1057,3 +1156,55 @@ def _stylisticSetNames(self, names):
self._indentText(text)
self._identifierStack.append("stylisticSetNames")
return text

# character variant

def formatCharacterVariantNames(self, *names):
lines = ["cvParameters {"]
orderedNames = dict(
FeatUILabelNameID=[],
FeatUITooltipTextNameID=[],
SampleTextNameID=[],
ParamUILabelNameID=[],
)
for name in names:
if (nameType := name["type"]) in orderedNames:
orderedNames[nameType].append(name)
# XXX silently fail here?
for nameType, nameDicts in orderedNames.items():
if len(nameDicts) == 0:
continue
block = [f"{self._whitespace}{nameType} {{"]
for name in nameDicts:
text = name["text"]
platform = name.get("platform")
script = name.get("script")
language = name.get("language")
line = ["name"]
if platform is not None:
line.append(str(platform))
if script is not None:
line.append(str(script))
line.append(str(language))
line.append(u'\"%s\"' % text)
line = (self._whitespace * 2) + " ".join(line) + ";"
block.append(line)
block.append((self._whitespace + "};"))
lines.extend(block)
lines.append("};")
text = "\n".join(lines)
return text

def characterVariantNames(self, *names):
d = dict(
identifier="characterVariantNames",
names=names
)
self._content.append(d)

def _characterVariantNames(self, names):
text = self._handleBreakBefore("characterVariantNames")
text.extend(self.formatCharacterVariantNames(*names).splitlines())
self._indentText(text)
self._identifierStack.append("characterVariantNames")
return text
24 changes: 21 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ If `choice` is `True` the rule will be written as a `from` rule (GSUB LookupType

The same contextual marking defined in the `substitution` method will be run for `ignoreSubstitution`.

##### writer.stylisticSetFeatureNames(name1, name2, name3, ...)
##### writer.stylisticSetNames(name1, name2, name3, ...)

This will write the given names as `featureNames` in the current feature. Names must be dicts of this form:

Expand All @@ -116,6 +116,21 @@ name = {
}
```

##### writer.characterVariantNames(name1, name2, name3, ...)

This will write the given names as `cvParameters` in the current feature. Names must be dicts of this form:

```python
name = {
"type": "type for this name",
# 'type' must be one of [FeatUILabelNameID, FeatUITooltipTextNameID, SampleTextNameID, ParamUILabelNameID]
"text" : "name for string",
"platform" : int, # optional
"script" : int, # optional
"language" : int, # optional
}
```

##### writer.write()

Return a string containing everything stored in the writer properly formatted for .fea.
Expand Down Expand Up @@ -149,6 +164,8 @@ This will only assume that the substitution rule should contain contextual marki

##### writer.formatStylisticSetNames(name1, name2, name3, ...)

##### writer.formatCharacterVariantNames(name1, name2, name3, ...)


# FeaPyFoFum

Expand All @@ -167,7 +184,8 @@ originalFeatures = font.features.text
font.features.text = compileFeatures(
originalFeatures,
font,
compileReferencedFiles=True
compileReferencedFiles=True,
namespaceAdditions=dict(someVariable=123),
)

# generate the binary
Expand All @@ -190,7 +208,7 @@ This snippet will compile the features, put them in the font, generate an OTF-CF
- probably other stuff
* In the namespace, insert all `writer.format*` methods as `format*` function lookalikes to make calling them less cumbersome.
* Clean up the output from the writer.
- http://opentypecookbook.com/style-guide.html
- http://opentypecookbook.com/style-guide/
- The identifier system seems to be going haywire and inserting unnecessary blank lines.
* Test cases.
* Add commandline tool.
Expand Down
6 changes: 3 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import re
from setuptools import setup

from setuptools import setup

_versionRE = re.compile(r'__version__\s*=\s*\"([^\"]+)\"')
# read the version number for the settings file
with open('lib/feaPyFoFum/__init__.py', "r") as settings:
with open('Lib/feaPyFoFum/__init__.py', "r") as settings:
code = settings.read()
found = _versionRE.search(code)
assert found is not None, "glyphConstruction __version__ not found"
assert found is not None, "feaPyFoFum __version__ not found"
__version__ = found.group(1)

setup(
Expand Down