diff --git a/Lib/feaPyFoFum/feaPyFoFum.py b/Lib/feaPyFoFum/feaPyFoFum.py index 1c4fb31..7f4b31c 100644 --- a/Lib/feaPyFoFum/feaPyFoFum.py +++ b/Lib/feaPyFoFum/feaPyFoFum.py @@ -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. @@ -31,31 +31,64 @@ 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 @@ -63,14 +96,81 @@ def compileFeatures(text, font, verbose=False, compileReferencedFiles=False): # .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. @@ -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. """ @@ -103,6 +200,7 @@ def _compileReferencedFeatureFile(inPath, outPath, relativePath, font, verbose=F text, font, relativePath, + namespace=namespace, verbose=verbose, recursionDepth=recursionDepth ) @@ -115,6 +213,7 @@ def _compileReferencedFeatureFile(inPath, outPath, relativePath, font, verbose=F referenceOutPath, relativePath, font, + namespace=namespace, verbose=verbose, recursionDepth=recursionDepth + 1 ) @@ -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) @@ -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 diff --git a/readme.md b/readme.md index f6b5b01..03ccb22 100644 --- a/readme.md +++ b/readme.md @@ -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: @@ -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. @@ -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 @@ -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 @@ -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. diff --git a/setup.py b/setup.py index d8c5ded..1cc1621 100644 --- a/setup.py +++ b/setup.py @@ -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(