From de23e837feaefa9d586d331b64145ac705c08579 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 8 Jan 2025 22:08:19 +0100 Subject: [PATCH 01/48] Add all missing 'put' methods from WritableFontBackend as stubs --- README.md | 2 +- src/fontra_glyphs/backend.py | 54 ++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3bec738..c5b1181 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # fontra-glyphs -Fontra file system backend for the Glyphs app file format. **Read-only** for now. +Fontra file system backend for the Glyphs app file format. It supports the following features: diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 05152ca..d42dd79 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -16,6 +16,7 @@ GlyphAxis, GlyphSource, Guideline, + ImageData, Kerning, Layer, LineMetric, @@ -24,7 +25,7 @@ VariableGlyph, ) from fontra.core.path import PackedPathPointPen -from fontra.core.protocols import ReadableFontBackend +from fontra.core.protocols import WritableFontBackend from fontTools.designspaceLib import DesignSpaceDocument from fontTools.misc.transform import DecomposedTransform from glyphsLib.builder.axes import ( @@ -81,7 +82,7 @@ class GlyphsBackend: @classmethod - def fromPath(cls, path: PathLike) -> ReadableFontBackend: + def fromPath(cls, path: PathLike) -> WritableFontBackend: self = cls() self._setupFromPath(path) return self @@ -157,6 +158,14 @@ def _loadFiles(path: PathLike) -> tuple[dict[str, Any], list[Any]]: async def getGlyphMap(self) -> dict[str, list[int]]: return self.glyphMap + async def putGlyphMap(self, value: dict[str, list[int]]) -> None: + print("GlyphsBackend putGlyphMap: ", value) + pass + + async def deleteGlyph(self, glyphName): + print("GlyphsBackend deleteGlyph: ", glyphName) + pass + async def getFontInfo(self) -> FontInfo: infoDict = {} for name in rootInfoNames: @@ -172,15 +181,31 @@ async def getFontInfo(self) -> FontInfo: return FontInfo(**infoDict) + async def putFontInfo(self, fontInfo: FontInfo): + print("GlyphsBackend putFontInfo: ", fontInfo) + pass + async def getSources(self) -> dict[str, FontSource]: return gsMastersToFontraFontSources(self.gsFont, self.locationByMasterID) + async def putSources(self, sources: dict[str, FontSource]) -> None: + print("GlyphsBackend putSources: ", sources) + pass + async def getAxes(self) -> Axes: return Axes(axes=self.axes) + async def putAxes(self, axes: Axes) -> None: + print("GlyphsBackend putAxes: ", axes) + pass + async def getUnitsPerEm(self) -> int: return self.gsFont.upm + async def putUnitsPerEm(self, value: int) -> None: + print("GlyphsBackend putUnitsPerEm: ", value) + pass + async def getKerning(self) -> dict[str, Kerning]: # TODO: RTL kerning: https://docu.glyphsapp.com/#GSFont.kerningRTL kerningLTR = gsKerningToFontraKerning( @@ -200,13 +225,32 @@ async def getKerning(self) -> dict[str, Kerning]: kerning["vkrn"] = kerningVertical return kerning + async def putKerning(self, kerning: dict[str, Kerning]) -> None: + print("GlyphsBackend putKerning: ", kerning) + pass + async def getFeatures(self) -> OpenTypeFeatures: # TODO: extract features return OpenTypeFeatures() + async def putFeatures(self, features: OpenTypeFeatures) -> None: + print("GlyphsBackend putFeatures: ", features) + pass + + async def getBackgroundImage(self, imageIdentifier: str) -> ImageData | None: + return None + + async def putBackgroundImage(self, imageIdentifier: str, data: ImageData) -> None: + print("GlyphsBackend putBackgroundImage: ", imageIdentifier, data) + pass + async def getCustomData(self) -> dict[str, Any]: return {} + async def putCustomData(self, lib): + print("GlyphsBackend putCustomData: ", lib) + pass + async def getGlyph(self, glyphName: str) -> VariableGlyph | None: if glyphName not in self.glyphNameToIndex: return None @@ -330,6 +374,12 @@ def _getSmartLocation(self, gsLayer, localAxesByName): if value != localAxesByName[name].defaultValue } + async def putGlyph( + self, glyphName: str, glyph: VariableGlyph, codePoints: list[int] + ) -> None: + print("GlyphsBackend putGlyph: ", glyphName, glyph, codePoints) + pass + async def aclose(self) -> None: pass From 35015d3759494aaf66fe905ee69f4529d3dcb748 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 16 Jan 2025 23:53:15 +0100 Subject: [PATCH 02/48] GlyphsBackend work in progress: two options for discussion --- src/fontra_glyphs/backend.py | 108 ++++++++++++++++++++++++++++++++++- tests/test_backend.py | 30 +++++++++- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index d42dd79..a8252bd 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -1,4 +1,5 @@ import pathlib +import re from collections import defaultdict from os import PathLike from typing import Any @@ -89,6 +90,7 @@ def fromPath(cls, path: PathLike) -> WritableFontBackend: def _setupFromPath(self, path: PathLike) -> None: gsFont = glyphsLib.classes.GSFont() + self.gsFilePath = path rawFontData, rawGlyphsData = self._loadFiles(path) @@ -101,6 +103,7 @@ def _setupFromPath(self, path: PathLike) -> None: self.gsFont.glyphs = [ glyphsLib.classes.GSGlyph() for i in range(len(rawGlyphsData)) ] + self.rawFontData = rawFontData self.rawGlyphsData = rawGlyphsData self.glyphNameToIndex = { @@ -331,7 +334,7 @@ def _ensureGlyphIsParsed(self, glyphName: str) -> None: glyphIndex = self.glyphNameToIndex[glyphName] rawGlyphData = self.rawGlyphsData[glyphIndex] - self.rawGlyphsData[glyphIndex] = None + # self.rawGlyphsData[glyphIndex] = None self.parsedGlyphNames.add(glyphName) gsGlyph = glyphsLib.classes.GSGlyph() @@ -377,8 +380,67 @@ def _getSmartLocation(self, gsLayer, localAxesByName): async def putGlyph( self, glyphName: str, glyph: VariableGlyph, codePoints: list[int] ) -> None: - print("GlyphsBackend putGlyph: ", glyphName, glyph, codePoints) - pass + # NOTE: + # Just said: Compare with the fontra backend and the designspace backend, + # see how they implement writing glyphs. + # 1. option: similar to fontra backend + # 2. option: similar to designspace backend + + # 1. option: + # Convert the fontra glyph and replace the glyph in the rawGlyphsData and + # save it via openstep_plist.dump(). + # 1. issue: How to convert the fontra glyph without losing too much data? + # 2. issue: How do we handle GlyphsApp packages? + # 3. issue: The formatting of openstep_plist.dump() is totally different to how a + # .glyphs file usually looks like. + # 4. issue: Glyphs 3 and 2 may look different. Do we have to support that? And if so, + # how would it look like? + + layerMap = {} + for layerIndex, (layerName, layer) in enumerate(iter(glyph.layers.items())): + # For now only do glyph width and assume the layer order is the same. + # (better would be based on layerId not on index). + layerMap[layerIndex] = { + "anchors": [], + "shapes": [], + "width": layer.glyph.xAdvance, + } + + # # TODO: associatedMasterId, attr, background, layerId, name, width + # for i, contour in enumerate(layer.glyph.path.unpackedContours()): + # # make a rawShape based on our fontra glyph + # shape = {"closed": 1 if contour["isClosed"] else 0, "nodes": []} + + # for j, point in enumerate(contour["points"]): + # pointType = "l" # TODO: fontraPointTypeToGsNodeType(point.get("type")) + # shape["nodes"].append([point["x"], point["y"], pointType]) + + # layerMap[layerIndex]["shapes"].append(shape) + + glyphIndex = self.glyphNameToIndex[glyphName] + rawGlyphData = self.rawGlyphsData[glyphIndex] + + for i, rawLayer in enumerate(rawGlyphData["layers"]): + rawLayer["width"] = layerMap[i]["width"] + + self.rawGlyphsData[glyphIndex] = rawGlyphData + self.rawFontData["glyphs"] = self.rawGlyphsData + + with open(self.gsFilePath, "w", encoding="utf-8") as fp: + openstep_plist.dump(self.rawFontData, fp) + + saveFileWithGsFormatting(self.gsFilePath) + + # # 2. option: + # # Similar to designspace backend: Instead of using fonttools for writing UFO + # # use glyphsLib for writing to a GlyphsApp file + + # gsGlyph = self.gsFont.glyphs[glyphName] + # for layerIndex, (layerName, layer) in enumerate(iter(glyph.layers.items())): + # gsLayer = gsGlyph.layers[layerIndex] + # gsLayer.width = layer.glyph.xAdvance + + # self.gsFont.save(self.gsFilePath) async def aclose(self) -> None: pass @@ -690,3 +752,43 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster): # ) return lineMetricsHorizontal + + +def saveFileWithGsFormatting(gsFilePath): + # openstep_plist.dump changes the whole formatting, therefore + # it's very diffucute to see what has changed. + # This function is a very bad try to get close to how the formatting + # looks like for a .glyphs file. + # There must be a better solution, but this is better than nothing. + with open(gsFilePath, "r", encoding="utf-8") as file: + content = file.read() + + content = content.replace(", ", ",") + content = content.replace("{", "{\n") + content = content.replace(";", ";\n") + content = content.replace("(", "(\n") + content = content.replace(",", ",\n") + content = content.replace(")", "\n)") + + content = re.sub(r"\(\s*(\d+),\s*(\d+),\s*([a-zA-Z])\s*\)", r"(\1,\2,\3)", content) + + content = re.sub( + r"\(\s*(\d+),\s*(-?\d+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)", content + ) + content = re.sub( + r"\{\s*(\d+),\s*(-?\d+),\s*([a-zA-Z]+)\s*\}", r"{\1, \2, \3}", content + ) + + content = re.sub(r"\(\s*([\d.]+),\s*([\d.]+)\s*\)", r"(\1,\2)", content) + content = re.sub(r"\{\s*([\d.]+),\s*([\d.]+)\s*\}", r"{\1,\2}", content) + + content = re.sub( + r"\{\s*([\d.]+),\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)\s*\}", + r"{\1, \2, \3, \4}", + content, + ) + + content = "\n".join(line.strip() for line in content.splitlines()) + + with open(gsFilePath, "w", encoding="utf-8") as file: + file.write(content) diff --git a/tests/test_backend.py b/tests/test_backend.py index 9cd8f4a..4228d1c 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -8,7 +8,9 @@ glyphs2Path = dataDir / "GlyphsUnitTestSans.glyphs" glyphs3Path = dataDir / "GlyphsUnitTestSans3.glyphs" -glyphsPackagePath = dataDir / "GlyphsUnitTestSans3.glyphspackage" +glyphsPackagePath = ( + dataDir / "GlyphsUnitTestSans3.glyphs" +) # "GlyphsUnitTestSans3.glyphspackage" # ignore package for now. referenceFontPath = dataDir / "GlyphsUnitTestSans3.fontra" @@ -116,6 +118,32 @@ async def test_getGlyph(testFont, referenceFont, glyphName): assert referenceGlyph == glyph +@pytest.mark.asyncio +@pytest.mark.parametrize("glyphName", list(expectedGlyphMap)) +async def test_putGlyph(testFont, referenceFont, glyphName): + glyphMap = await testFont.getGlyphMap() + glyph = await testFont.getGlyph(glyphName) + if glyphName == "A" and "com.glyphsapp.glyph-color" not in glyph.customData: + # glyphsLib doesn't read the color attr from Glyphs-2 files, + # so let's monkeypatch the data + glyph.customData = {"com.glyphsapp.glyph-color": [120, 220, 20, 4]} + + # # for testing change every coordinate by 10 units + # for (layerName, layer) in iter(glyph.layers.items()): + # for i, coordinate in enumerate(layer.glyph.path.coordinates): + # layer.glyph.path.coordinates[i] = coordinate + 10 + + # for testing change xAdvance + for layerName, layer in iter(glyph.layers.items()): + for i, coordinate in enumerate(layer.glyph.path.coordinates): + layer.glyph.xAdvance = 500 + + await testFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + referenceGlyph = await referenceFont.getGlyph(glyphName) + assert referenceGlyph == glyph + + async def test_getKerning(testFont, referenceFont): assert await testFont.getKerning() == await referenceFont.getKerning() From 417d2fd17a8acbb0c88e869821b1df775fe606cd Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Fri, 17 Jan 2025 16:28:46 +0100 Subject: [PATCH 03/48] Remove option 2, use some better options for openstep_plist.dump, adjust saveFileWithGsFormatting --- src/fontra_glyphs/backend.py | 78 ++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index a8252bd..5d33d02 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -381,20 +381,21 @@ async def putGlyph( self, glyphName: str, glyph: VariableGlyph, codePoints: list[int] ) -> None: # NOTE: - # Just said: Compare with the fontra backend and the designspace backend, - # see how they implement writing glyphs. - # 1. option: similar to fontra backend - # 2. option: similar to designspace backend - - # 1. option: - # Convert the fontra glyph and replace the glyph in the rawGlyphsData and - # save it via openstep_plist.dump(). - # 1. issue: How to convert the fontra glyph without losing too much data? - # 2. issue: How do we handle GlyphsApp packages? - # 3. issue: The formatting of openstep_plist.dump() is totally different to how a - # .glyphs file usually looks like. - # 4. issue: Glyphs 3 and 2 may look different. Do we have to support that? And if so, - # how would it look like? + # Reading a glyph from .glyphs(package) + + # 1. parse the source text into a "raw" object + # 2. turn the "raw" object into a GSGlyph instance + # 3. convert GSGlyph to VariableGlyph + + # Writing a glyph to .glyphs(package) + + # 1. convert VariableGlyph to GSGlyph (but start with a copy of the original) + # 2. serialize to text with glyphsLib.writer.Writer(), using io.StringIO or io.BytesIO + # 3. parse stream into "raw" object + # 4. replace original "raw" object with new "raw" object + # 5. write whole file with openstep_plist + + # _writeRawGlyph(glyphName, rawGlyph) layerMap = {} for layerIndex, (layerName, layer) in enumerate(iter(glyph.layers.items())): @@ -427,21 +428,17 @@ async def putGlyph( self.rawFontData["glyphs"] = self.rawGlyphsData with open(self.gsFilePath, "w", encoding="utf-8") as fp: - openstep_plist.dump(self.rawFontData, fp) + openstep_plist.dump( + self.rawFontData, + fp, + unicode_escape=False, + indent=0, + single_line_tuples=True, + escape_newlines=False, + ) saveFileWithGsFormatting(self.gsFilePath) - # # 2. option: - # # Similar to designspace backend: Instead of using fonttools for writing UFO - # # use glyphsLib for writing to a GlyphsApp file - - # gsGlyph = self.gsFont.glyphs[glyphName] - # for layerIndex, (layerName, layer) in enumerate(iter(glyph.layers.items())): - # gsLayer = gsGlyph.layers[layerIndex] - # gsLayer.width = layer.glyph.xAdvance - - # self.gsFont.save(self.gsFilePath) - async def aclose(self) -> None: pass @@ -763,32 +760,33 @@ def saveFileWithGsFormatting(gsFilePath): with open(gsFilePath, "r", encoding="utf-8") as file: content = file.read() - content = content.replace(", ", ",") - content = content.replace("{", "{\n") - content = content.replace(";", ";\n") - content = content.replace("(", "(\n") - content = content.replace(",", ",\n") - content = content.replace(")", "\n)") + content = re.sub(r"pos = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"pos = (\1,\2);", content) - content = re.sub(r"\(\s*(\d+),\s*(\d+),\s*([a-zA-Z])\s*\)", r"(\1,\2,\3)", content) + content = re.sub( + r"pos = \(\s*([\d.]+),\s*([\d.]+)\s*\);", r"pos = (\1,\2);", content + ) content = re.sub( - r"\(\s*(\d+),\s*(-?\d+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)", content + r"\(\s*([\d.]+),\s*(-?\d+),\s*([a-zA-Z])\s*\)", r"(\1,\2,\3)", content ) + content = re.sub( - r"\{\s*(\d+),\s*(-?\d+),\s*([a-zA-Z]+)\s*\}", r"{\1, \2, \3}", content + r"origin = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"origin = (\1,\2);", content ) - content = re.sub(r"\(\s*([\d.]+),\s*([\d.]+)\s*\)", r"(\1,\2)", content) - content = re.sub(r"\{\s*([\d.]+),\s*([\d.]+)\s*\}", r"{\1,\2}", content) + content = re.sub( + r"target = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"target = (\1,\2);", content + ) content = re.sub( - r"\{\s*([\d.]+),\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)\s*\}", - r"{\1, \2, \3, \4}", + r"color = \(\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)\s*\);", + r"color = (\1,\2,\3,\4);", content, ) - content = "\n".join(line.strip() for line in content.splitlines()) + content = re.sub( + r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)", content + ) with open(gsFilePath, "w", encoding="utf-8") as file: file.write(content) From 1f25bbb2fb58a3df230071244fc1e2b1a4aa26de Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Fri, 17 Jan 2025 17:21:00 +0100 Subject: [PATCH 04/48] Rework putGlyph based on brainstorming together with Just # 1. convert VariableGlyph to GSGlyph (but start with a copy of the original) # 2. serialize to text with glyphsLib.writer.Writer(), using io.StringIO or io.BytesIO # 3. parse stream into "raw" object # 4. replace original "raw" object with new "raw" object # 5. write whole file with openstep_plist --- src/fontra_glyphs/backend.py | 72 +++++++++++++++++------------------- tests/test_backend.py | 7 +--- 2 files changed, 36 insertions(+), 43 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 5d33d02..dd0eb33 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -1,6 +1,8 @@ +import io import pathlib import re from collections import defaultdict +from copy import deepcopy from os import PathLike from typing import Any @@ -380,53 +382,28 @@ def _getSmartLocation(self, gsLayer, localAxesByName): async def putGlyph( self, glyphName: str, glyph: VariableGlyph, codePoints: list[int] ) -> None: - # NOTE: - # Reading a glyph from .glyphs(package) - - # 1. parse the source text into a "raw" object - # 2. turn the "raw" object into a GSGlyph instance - # 3. convert GSGlyph to VariableGlyph - - # Writing a glyph to .glyphs(package) - # 1. convert VariableGlyph to GSGlyph (but start with a copy of the original) - # 2. serialize to text with glyphsLib.writer.Writer(), using io.StringIO or io.BytesIO - # 3. parse stream into "raw" object - # 4. replace original "raw" object with new "raw" object - # 5. write whole file with openstep_plist + gsGlyphNew = deepcopy(self.gsFont.glyphs[glyphName]) - # _writeRawGlyph(glyphName, rawGlyph) - - layerMap = {} - for layerIndex, (layerName, layer) in enumerate(iter(glyph.layers.items())): - # For now only do glyph width and assume the layer order is the same. - # (better would be based on layerId not on index). - layerMap[layerIndex] = { - "anchors": [], - "shapes": [], - "width": layer.glyph.xAdvance, - } - - # # TODO: associatedMasterId, attr, background, layerId, name, width - # for i, contour in enumerate(layer.glyph.path.unpackedContours()): - # # make a rawShape based on our fontra glyph - # shape = {"closed": 1 if contour["isClosed"] else 0, "nodes": []} + # 2. serialize to text with glyphsLib.writer.Writer(), using io.StringIO or io.BytesIO + f = io.StringIO() + writer = glyphsLib.writer.Writer(f) + writer.format_version = self.gsFont.format_version + writer.write(gsGlyphNew) - # for j, point in enumerate(contour["points"]): - # pointType = "l" # TODO: fontraPointTypeToGsNodeType(point.get("type")) - # shape["nodes"].append([point["x"], point["y"], pointType]) + # 3. parse stream into "raw" object + f.seek(0) + rawGlyphData = openstep_plist.load(f, use_numbers=True) - # layerMap[layerIndex]["shapes"].append(shape) + self._writeRawGlyph(glyphName, rawGlyphData) + def _writeRawGlyph(self, glyphName, rawGlyphData): + # 4. replace original "raw" object with new "raw" object glyphIndex = self.glyphNameToIndex[glyphName] - rawGlyphData = self.rawGlyphsData[glyphIndex] - - for i, rawLayer in enumerate(rawGlyphData["layers"]): - rawLayer["width"] = layerMap[i]["width"] - self.rawGlyphsData[glyphIndex] = rawGlyphData self.rawFontData["glyphs"] = self.rawGlyphsData + # 5. write whole file with openstep_plist with open(self.gsFilePath, "w", encoding="utf-8") as fp: openstep_plist.dump( self.rawFontData, @@ -437,6 +414,7 @@ async def putGlyph( escape_newlines=False, ) + # 6. fix formatting saveFileWithGsFormatting(self.gsFilePath) async def aclose(self) -> None: @@ -480,6 +458,24 @@ def sortKey(glyphData): return rawFontData, rawGlyphsData + def _writeRawGlyph(self, glyphName, rawGlyphData): + glyphsPath = pathlib.Path(self.gsFilePath) / "glyphs" + + # 5. write glyh specific file with openstep_plist + glyphPath = f"{glyphsPath}/{glyphName}.glyph" + with open(glyphPath, "w", encoding="utf-8") as fp: + openstep_plist.dump( + rawGlyphData, + fp, + unicode_escape=False, + indent=0, + single_line_tuples=True, + escape_newlines=False, + ) + + # 6. fix formatting + saveFileWithGsFormatting(glyphPath) + def _readGlyphMapAndKerningGroups( rawGlyphsData: list, formatVersion: int diff --git a/tests/test_backend.py b/tests/test_backend.py index 4228d1c..67fd3fc 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -8,9 +8,7 @@ glyphs2Path = dataDir / "GlyphsUnitTestSans.glyphs" glyphs3Path = dataDir / "GlyphsUnitTestSans3.glyphs" -glyphsPackagePath = ( - dataDir / "GlyphsUnitTestSans3.glyphs" -) # "GlyphsUnitTestSans3.glyphspackage" # ignore package for now. +glyphsPackagePath = dataDir / "GlyphsUnitTestSans3.glyphspackage" referenceFontPath = dataDir / "GlyphsUnitTestSans3.fontra" @@ -135,8 +133,7 @@ async def test_putGlyph(testFont, referenceFont, glyphName): # for testing change xAdvance for layerName, layer in iter(glyph.layers.items()): - for i, coordinate in enumerate(layer.glyph.path.coordinates): - layer.glyph.xAdvance = 500 + layer.glyph.xAdvance = 500 await testFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) From e8ef42ae31dcb34cbbfdefd4dc99c8ecbce3d662 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Fri, 17 Jan 2025 17:46:12 +0100 Subject: [PATCH 05/48] Add A-cy for testing --- .../glyphs/A_-cy.glyph | 33 +++++++++++++++++++ .../order.plist | 3 +- tests/test_backend.py | 1 + 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/data/GlyphsUnitTestSans3.glyphspackage/glyphs/A_-cy.glyph diff --git a/tests/data/GlyphsUnitTestSans3.glyphspackage/glyphs/A_-cy.glyph b/tests/data/GlyphsUnitTestSans3.glyphspackage/glyphs/A_-cy.glyph new file mode 100644 index 0000000..8da36a6 --- /dev/null +++ b/tests/data/GlyphsUnitTestSans3.glyphspackage/glyphs/A_-cy.glyph @@ -0,0 +1,33 @@ +{ +glyphname = "A-cy"; +layers = ( +{ +layerId = "C4872ECA-A3A9-40AB-960A-1DB2202F16DE"; +shapes = ( +{ +ref = A; +} +); +width = 593; +}, +{ +layerId = "3E7589AA-8194-470F-8E2F-13C1C581BE24"; +shapes = ( +{ +ref = A; +} +); +width = 657; +}, +{ +layerId = "BFFFD157-90D3-4B85-B99D-9A2F366F03CA"; +shapes = ( +{ +ref = A; +} +); +width = 753; +} +); +unicode = 1040; +} diff --git a/tests/data/GlyphsUnitTestSans3.glyphspackage/order.plist b/tests/data/GlyphsUnitTestSans3.glyphspackage/order.plist index 35b0584..5134c32 100644 --- a/tests/data/GlyphsUnitTestSans3.glyphspackage/order.plist +++ b/tests/data/GlyphsUnitTestSans3.glyphspackage/order.plist @@ -10,5 +10,6 @@ a.sc, dieresis, _part.shoulder, _part.stem, -V +V, +"A-cy" ) diff --git a/tests/test_backend.py b/tests/test_backend.py index 67fd3fc..5260044 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -69,6 +69,7 @@ async def test_getAxes(testFont): "m": [109], "n": [110], "V": [86], + "A-cy": [0x0410], } From e2a2626049d8c9f4fa5a6868359302778d631c6b Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Fri, 17 Jan 2025 18:04:22 +0100 Subject: [PATCH 06/48] get the right glyphspackage glyph file name --- src/fontra_glyphs/backend.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index dd0eb33..8baf117 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -462,7 +462,10 @@ def _writeRawGlyph(self, glyphName, rawGlyphData): glyphsPath = pathlib.Path(self.gsFilePath) / "glyphs" # 5. write glyh specific file with openstep_plist - glyphPath = f"{glyphsPath}/{glyphName}.glyph" + # TODO: Get the right glyph file name will be challenging, + # because for example the glyph A-cy is stored in the package as A_-cy.glyph + realGlyphName = getGlyphspackageGlyphFileName(glyphName) + glyphPath = f"{glyphsPath}/{realGlyphName}.glyph" with open(glyphPath, "w", encoding="utf-8") as fp: openstep_plist.dump( rawGlyphData, @@ -477,6 +480,18 @@ def _writeRawGlyph(self, glyphName, rawGlyphData): saveFileWithGsFormatting(glyphPath) +def getGlyphspackageGlyphFileName(glyphName): + nameParts = glyphName.split("-") + firstPart = ( + f"{nameParts[0]}_" + if len(nameParts[0]) == 1 and nameParts[0].isupper() + else nameParts[0] + ) + nameParts[0] = firstPart + + return "-".join(nameParts) + + def _readGlyphMapAndKerningGroups( rawGlyphsData: list, formatVersion: int ) -> tuple[dict[str, list[int]], dict[str, tuple[str, str]]]: @@ -784,5 +799,17 @@ def saveFileWithGsFormatting(gsFilePath): r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)", content ) + content = re.sub( + r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]),\s*\{", r"(\1,\2,\3,{", content + ) + + content = re.sub( + r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]+),\s*\{", r"(\1,\2,\3,{", content + ) + + content = re.sub(r"\}\s*\),", r"}),", content) + + content += "\n" # add blank break at the end of the file. + with open(gsFilePath, "w", encoding="utf-8") as file: file.write(content) From 6dcbf389b373f62a9e01f7480b4f2ce79f59494f Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Fri, 17 Jan 2025 21:01:33 +0100 Subject: [PATCH 07/48] Add _findAndReplaceGlyph --- src/fontra_glyphs/backend.py | 85 ++++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 8baf117..156426c 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -383,7 +383,9 @@ async def putGlyph( self, glyphName: str, glyph: VariableGlyph, codePoints: list[int] ) -> None: # 1. convert VariableGlyph to GSGlyph (but start with a copy of the original) - gsGlyphNew = deepcopy(self.gsFont.glyphs[glyphName]) + gsGlyphNew = variableGlyphToGsGlyph( + glyph, deepcopy(self.gsFont.glyphs[glyphName]) + ) # 2. serialize to text with glyphsLib.writer.Writer(), using io.StringIO or io.BytesIO f = io.StringIO() @@ -391,11 +393,52 @@ async def putGlyph( writer.format_version = self.gsFont.format_version writer.write(gsGlyphNew) - # 3. parse stream into "raw" object - f.seek(0) - rawGlyphData = openstep_plist.load(f, use_numbers=True) - - self._writeRawGlyph(glyphName, rawGlyphData) + # # 3. parse stream into "raw" object + # f.seek(0) + # rawGlyphData = openstep_plist.load(f, use_numbers=True) + + # self._writeRawGlyph(glyphName, rawGlyphData) + + self._findAndReplaceGlyph(glyphName, f) + + def _findAndReplaceGlyph(self, glyphName, f): + glyphChunkIndicator = f"glyphname = {glyphName};" + + # find glyph chunk + glyphChunkStart = None + glyphChunkEnd = None + + with open(self.gsFilePath, "r", encoding="utf-8") as fp: + lines = fp.readlines() + for i, line in enumerate(lines): + if glyphChunkIndicator in line: + for j in range(i, 0, -1): + if "{" in lines[j]: + glyphChunkStart = j + break + + braceLeftCount = 1 + for k in range(i, len(lines)): + if "{" in lines[k]: + braceLeftCount += 1 + if "}" in lines[k]: + braceLeftCount -= 1 + if "}" in lines[k] and braceLeftCount == 0: + glyphChunkEnd = k + break + break + + if glyphChunkStart is None or glyphChunkEnd is None: + # If not found, maybe add as a new glyph at the end of the glyphs list. + print("ERROR: Could not find glyph: ", glyphChunkIndicator) + return + + rawGlyphAsText = f.getvalue() + newLines = ( + lines[:glyphChunkStart] + [rawGlyphAsText[:-2]] + lines[glyphChunkEnd:] + ) + with open(self.gsFilePath, "w", encoding="utf-8") as fp: + fp.writelines(newLines) def _writeRawGlyph(self, glyphName, rawGlyphData): # 4. replace original "raw" object with new "raw" object @@ -459,14 +502,9 @@ def sortKey(glyphData): return rawFontData, rawGlyphsData def _writeRawGlyph(self, glyphName, rawGlyphData): - glyphsPath = pathlib.Path(self.gsFilePath) / "glyphs" - # 5. write glyh specific file with openstep_plist - # TODO: Get the right glyph file name will be challenging, - # because for example the glyph A-cy is stored in the package as A_-cy.glyph - realGlyphName = getGlyphspackageGlyphFileName(glyphName) - glyphPath = f"{glyphsPath}/{realGlyphName}.glyph" - with open(glyphPath, "w", encoding="utf-8") as fp: + filePath = self.getGlyphFilePath(glyphName) + with open(filePath, "w", encoding="utf-8") as fp: openstep_plist.dump( rawGlyphData, fp, @@ -477,7 +515,18 @@ def _writeRawGlyph(self, glyphName, rawGlyphData): ) # 6. fix formatting - saveFileWithGsFormatting(glyphPath) + saveFileWithGsFormatting(filePath) + + def _findAndReplaceGlyph(self, glyphName, f): + filePath = self.getGlyphFilePath(glyphName) + filePath.write_text(f.getvalue(), encoding="utf=8") + + def getGlyphFilePath(self, glyphName): + glyphsPath = pathlib.Path(self.gsFilePath) / "glyphs" + # TODO: Get the right glyph file name might be challenging, + # because for example the glyph A-cy is stored in the package as A_-cy.glyph + realGlyphName = getGlyphspackageGlyphFileName(glyphName) + return glyphsPath / (realGlyphName + ".glyph") def getGlyphspackageGlyphFileName(glyphName): @@ -762,6 +811,7 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster): return lineMetricsHorizontal +# The following should be obsolte with _findAndReplaceGlyph def saveFileWithGsFormatting(gsFilePath): # openstep_plist.dump changes the whole formatting, therefore # it's very diffucute to see what has changed. @@ -813,3 +863,10 @@ def saveFileWithGsFormatting(gsFilePath): with open(gsFilePath, "w", encoding="utf-8") as file: file.write(content) + + +def variableGlyphToGsGlyph(variableGlyph, gsGlyph): + # TODO: convert fontra variableGlyph to GlyphsApp glyph + gsGlyph.name = f"{variableGlyph.name}.changed" + + return gsGlyph From d8ac61e4edba260b4c03f69ea042e65bffe96dc6 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Fri, 17 Jan 2025 21:54:38 +0100 Subject: [PATCH 08/48] openstep_plist: Fix order issue with toOrderedDict --- src/fontra_glyphs/backend.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 156426c..b3ba591 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -1,7 +1,7 @@ import io import pathlib import re -from collections import defaultdict +from collections import OrderedDict, defaultdict from copy import deepcopy from os import PathLike from typing import Any @@ -105,8 +105,8 @@ def _setupFromPath(self, path: PathLike) -> None: self.gsFont.glyphs = [ glyphsLib.classes.GSGlyph() for i in range(len(rawGlyphsData)) ] - self.rawFontData = rawFontData - self.rawGlyphsData = rawGlyphsData + self.rawFontData = toOrderedDict(rawFontData) + self.rawGlyphsData = toOrderedDict(rawGlyphsData) self.glyphNameToIndex = { glyphData["glyphname"]: i for i, glyphData in enumerate(rawGlyphsData) @@ -393,13 +393,13 @@ async def putGlyph( writer.format_version = self.gsFont.format_version writer.write(gsGlyphNew) - # # 3. parse stream into "raw" object - # f.seek(0) - # rawGlyphData = openstep_plist.load(f, use_numbers=True) + # 3. parse stream into "raw" object + f.seek(0) + rawGlyphData = openstep_plist.load(f, use_numbers=True) - # self._writeRawGlyph(glyphName, rawGlyphData) + self._writeRawGlyph(glyphName, rawGlyphData) - self._findAndReplaceGlyph(glyphName, f) + # self._findAndReplaceGlyph(glyphName, f) def _findAndReplaceGlyph(self, glyphName, f): glyphChunkIndicator = f"glyphname = {glyphName};" @@ -811,7 +811,7 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster): return lineMetricsHorizontal -# The following should be obsolte with _findAndReplaceGlyph +# The following should be obsolete with _findAndReplaceGlyph def saveFileWithGsFormatting(gsFilePath): # openstep_plist.dump changes the whole formatting, therefore # it's very diffucute to see what has changed. @@ -870,3 +870,12 @@ def variableGlyphToGsGlyph(variableGlyph, gsGlyph): gsGlyph.name = f"{variableGlyph.name}.changed" return gsGlyph + + +def toOrderedDict(obj): + if isinstance(obj, dict): + return OrderedDict({k: toOrderedDict(v) for k, v in obj.items()}) + elif isinstance(obj, list): + return [toOrderedDict(item) for item in obj] + else: + return obj From 354e7fd5b18a4cb6ac48fef322c6eb102fffda26 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Fri, 17 Jan 2025 21:59:04 +0100 Subject: [PATCH 09/48] Remove _findAndReplaceGlyph idea --- src/fontra_glyphs/backend.py | 66 +++--------------------------------- 1 file changed, 4 insertions(+), 62 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index b3ba591..81aaff8 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -393,54 +393,13 @@ async def putGlyph( writer.format_version = self.gsFont.format_version writer.write(gsGlyphNew) + self._writeRawGlyph(glyphName, f) + + def _writeRawGlyph(self, glyphName, f): # 3. parse stream into "raw" object f.seek(0) rawGlyphData = openstep_plist.load(f, use_numbers=True) - self._writeRawGlyph(glyphName, rawGlyphData) - - # self._findAndReplaceGlyph(glyphName, f) - - def _findAndReplaceGlyph(self, glyphName, f): - glyphChunkIndicator = f"glyphname = {glyphName};" - - # find glyph chunk - glyphChunkStart = None - glyphChunkEnd = None - - with open(self.gsFilePath, "r", encoding="utf-8") as fp: - lines = fp.readlines() - for i, line in enumerate(lines): - if glyphChunkIndicator in line: - for j in range(i, 0, -1): - if "{" in lines[j]: - glyphChunkStart = j - break - - braceLeftCount = 1 - for k in range(i, len(lines)): - if "{" in lines[k]: - braceLeftCount += 1 - if "}" in lines[k]: - braceLeftCount -= 1 - if "}" in lines[k] and braceLeftCount == 0: - glyphChunkEnd = k - break - break - - if glyphChunkStart is None or glyphChunkEnd is None: - # If not found, maybe add as a new glyph at the end of the glyphs list. - print("ERROR: Could not find glyph: ", glyphChunkIndicator) - return - - rawGlyphAsText = f.getvalue() - newLines = ( - lines[:glyphChunkStart] + [rawGlyphAsText[:-2]] + lines[glyphChunkEnd:] - ) - with open(self.gsFilePath, "w", encoding="utf-8") as fp: - fp.writelines(newLines) - - def _writeRawGlyph(self, glyphName, rawGlyphData): # 4. replace original "raw" object with new "raw" object glyphIndex = self.glyphNameToIndex[glyphName] self.rawGlyphsData[glyphIndex] = rawGlyphData @@ -501,23 +460,7 @@ def sortKey(glyphData): return rawFontData, rawGlyphsData - def _writeRawGlyph(self, glyphName, rawGlyphData): - # 5. write glyh specific file with openstep_plist - filePath = self.getGlyphFilePath(glyphName) - with open(filePath, "w", encoding="utf-8") as fp: - openstep_plist.dump( - rawGlyphData, - fp, - unicode_escape=False, - indent=0, - single_line_tuples=True, - escape_newlines=False, - ) - - # 6. fix formatting - saveFileWithGsFormatting(filePath) - - def _findAndReplaceGlyph(self, glyphName, f): + def _writeRawGlyph(self, glyphName, f): filePath = self.getGlyphFilePath(glyphName) filePath.write_text(f.getvalue(), encoding="utf=8") @@ -811,7 +754,6 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster): return lineMetricsHorizontal -# The following should be obsolete with _findAndReplaceGlyph def saveFileWithGsFormatting(gsFilePath): # openstep_plist.dump changes the whole formatting, therefore # it's very diffucute to see what has changed. From bf1bfb9123e308526cd8151fd692918a2e30ec2a Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Fri, 17 Jan 2025 23:48:30 +0100 Subject: [PATCH 10/48] Commit current stage. --- src/fontra_glyphs/backend.py | 55 +++++++++++++++++++++++++++++++++++- tests/test_backend.py | 8 +++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 81aaff8..93d038e 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -807,9 +807,62 @@ def saveFileWithGsFormatting(gsFilePath): file.write(content) +def fontraLayerToGSLayer(layer, gsLayer=None): + if gsLayer is None: + gsLayer = glyphsLib.classes.GSLayer() + + gsLayer.width = layer.glyph.xAdvance + + return gsLayer + + +def fontraLayerToGSPaths(layer, gsLayer=None): + if gsLayer is None: + gsLayer = glyphsLib.classes.GSLayer() + + gsPaths = [] + # I have the feeling the following goes into the wrong direction. + # It might should work with a pen and with the help of glyphsLib. + for i, contour in enumerate(layer.glyph.path.unpackedContours()): + gsPath = glyphsLib.classes.GSPath() + gsPath.closed = contour["isClosed"] + gsPath.nodes = [] + + for j, point in enumerate(contour["points"]): + gsNode = glyphsLib.classes.GSNode() + gsNode.position = ( + point["x"], + point["y"], + ) # glyphsLib.classes.Point(point["x"], point["y"]) + gsNode.smooth = point.get("smooth", False) + # gsNode.type = fontraPointTypeToGsNodeType(point.get("type")) + gsPath.nodes.append(gsNode) + gsPaths.append(gsPath) + + return gsPaths + + +def fontraPointTypeToGsNodeType(pointType): + # The type of the node, LINE, CURVE or OFFCURVE + # https://docu.glyphsapp.com/#GSNode.type + if pointType is None: + # Can either be LINE or CURVE, I am currently not sure how to figure this out. + return "LINE" # "CURVE" + elif pointType == "cubic": + return "OFFCURVE" + elif pointType == "quad": + return "QCURVE" + + def variableGlyphToGsGlyph(variableGlyph, gsGlyph): # TODO: convert fontra variableGlyph to GlyphsApp glyph - gsGlyph.name = f"{variableGlyph.name}.changed" + + for i, (layerName, layer) in enumerate(iter(variableGlyph.layers.items())): + gsLayerCopy = deepcopy(gsGlyph.layers[i]) + gsGlyph.layers[i].width = layer.glyph.xAdvance + gsGlyph.layers[i].paths = fontraLayerToGSPaths(layer, gsLayerCopy) + + # gsGlyph.layers[i] = fontraLayerToGSLayer(layer, gsLayerCopy) return gsGlyph diff --git a/tests/test_backend.py b/tests/test_backend.py index 5260044..982f6ad 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -127,10 +127,10 @@ async def test_putGlyph(testFont, referenceFont, glyphName): # so let's monkeypatch the data glyph.customData = {"com.glyphsapp.glyph-color": [120, 220, 20, 4]} - # # for testing change every coordinate by 10 units - # for (layerName, layer) in iter(glyph.layers.items()): - # for i, coordinate in enumerate(layer.glyph.path.coordinates): - # layer.glyph.path.coordinates[i] = coordinate + 10 + # for testing change every coordinate by 10 units + for layerName, layer in iter(glyph.layers.items()): + for i, coordinate in enumerate(layer.glyph.path.coordinates): + layer.glyph.path.coordinates[i] = coordinate + 10 # for testing change xAdvance for layerName, layer in iter(glyph.layers.items()): From c7a66a4969f9d5799cb9f0f09a8d48ef26617a62 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Sun, 19 Jan 2025 02:43:34 +0100 Subject: [PATCH 11/48] Use pen for drawing --- src/fontra_glyphs/backend.py | 54 ++++++------------------------------ 1 file changed, 8 insertions(+), 46 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 93d038e..9f07135 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -807,62 +807,24 @@ def saveFileWithGsFormatting(gsFilePath): file.write(content) -def fontraLayerToGSLayer(layer, gsLayer=None): - if gsLayer is None: - gsLayer = glyphsLib.classes.GSLayer() - +def fontraLayerToGSLayer(layer, gsLayer): gsLayer.width = layer.glyph.xAdvance - return gsLayer - - -def fontraLayerToGSPaths(layer, gsLayer=None): - if gsLayer is None: - gsLayer = glyphsLib.classes.GSLayer() - - gsPaths = [] - # I have the feeling the following goes into the wrong direction. - # It might should work with a pen and with the help of glyphsLib. - for i, contour in enumerate(layer.glyph.path.unpackedContours()): - gsPath = glyphsLib.classes.GSPath() - gsPath.closed = contour["isClosed"] - gsPath.nodes = [] - - for j, point in enumerate(contour["points"]): - gsNode = glyphsLib.classes.GSNode() - gsNode.position = ( - point["x"], - point["y"], - ) # glyphsLib.classes.Point(point["x"], point["y"]) - gsNode.smooth = point.get("smooth", False) - # gsNode.type = fontraPointTypeToGsNodeType(point.get("type")) - gsPath.nodes.append(gsNode) - gsPaths.append(gsPath) - - return gsPaths + # Draw new paths with pen + gsLayer.paths = [] # first: remove all paths + pen = gsLayer.getPointPen() + layer.glyph.path.drawPoints(pen) + gsLayer.drawPoints(pen) -def fontraPointTypeToGsNodeType(pointType): - # The type of the node, LINE, CURVE or OFFCURVE - # https://docu.glyphsapp.com/#GSNode.type - if pointType is None: - # Can either be LINE or CURVE, I am currently not sure how to figure this out. - return "LINE" # "CURVE" - elif pointType == "cubic": - return "OFFCURVE" - elif pointType == "quad": - return "QCURVE" + # TODO: anchors, components, etc. def variableGlyphToGsGlyph(variableGlyph, gsGlyph): # TODO: convert fontra variableGlyph to GlyphsApp glyph for i, (layerName, layer) in enumerate(iter(variableGlyph.layers.items())): - gsLayerCopy = deepcopy(gsGlyph.layers[i]) - gsGlyph.layers[i].width = layer.glyph.xAdvance - gsGlyph.layers[i].paths = fontraLayerToGSPaths(layer, gsLayerCopy) - - # gsGlyph.layers[i] = fontraLayerToGSLayer(layer, gsLayerCopy) + fontraLayerToGSLayer(layer, gsGlyph.layers[i]) return gsGlyph From 62d0e93b6882ed8efffcd7273ad38647d63f9bff Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Sun, 19 Jan 2025 05:01:40 +0100 Subject: [PATCH 12/48] Adding some comments --- src/fontra_glyphs/backend.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 9f07135..b015cda 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -383,7 +383,7 @@ async def putGlyph( self, glyphName: str, glyph: VariableGlyph, codePoints: list[int] ) -> None: # 1. convert VariableGlyph to GSGlyph (but start with a copy of the original) - gsGlyphNew = variableGlyphToGsGlyph( + gsGlyphNew = variableGlyphToGSGlyph( glyph, deepcopy(self.gsFont.glyphs[glyphName]) ) @@ -808,27 +808,35 @@ def saveFileWithGsFormatting(gsFilePath): def fontraLayerToGSLayer(layer, gsLayer): - gsLayer.width = layer.glyph.xAdvance - # Draw new paths with pen gsLayer.paths = [] # first: remove all paths pen = gsLayer.getPointPen() layer.glyph.path.drawPoints(pen) gsLayer.drawPoints(pen) - - # TODO: anchors, components, etc. + gsLayer.width = layer.glyph.xAdvance + # gsLayer.components = components # https://docu.glyphsapp.com/#GSLayer.components + # gsLayer.anchors = anchors # https://docu.glyphsapp.com/#GSLayer.anchors -def variableGlyphToGsGlyph(variableGlyph, gsGlyph): +def variableGlyphToGSGlyph(variableGlyph, gsGlyph): # TODO: convert fontra variableGlyph to GlyphsApp glyph for i, (layerName, layer) in enumerate(iter(variableGlyph.layers.items())): fontraLayerToGSLayer(layer, gsGlyph.layers[i]) + # What happens, if the number of layers differ from the original number? + # How do we handle intermediate layers? + # https://docu.glyphsapp.com/#GSGlyph.layers font.glyphs['a'].layers.append(newLayer) + # How do we handle missing masters? + # It might be that someone deletes a not needed layer, which works for fontra, + # but is required for Glyphs. We would need to get the intermediate contours. + return gsGlyph +# The following is obsolete once this is merged: +# https://github.com/fonttools/openstep-plist/pull/35 def toOrderedDict(obj): if isinstance(obj, dict): return OrderedDict({k: toOrderedDict(v) for k, v in obj.items()}) From f76ea61f973b4fb70456342cb470f3eaf95675f2 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Sun, 19 Jan 2025 17:32:43 +0100 Subject: [PATCH 13/48] Add A-cy also to Glyphs 2 and 3 file for unittests --- tests/data/GlyphsUnitTestSans.glyphs | 33 +++++++++++++++++++++++++++ tests/data/GlyphsUnitTestSans3.glyphs | 33 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tests/data/GlyphsUnitTestSans.glyphs b/tests/data/GlyphsUnitTestSans.glyphs index a88827f..042467a 100644 --- a/tests/data/GlyphsUnitTestSans.glyphs +++ b/tests/data/GlyphsUnitTestSans.glyphs @@ -2348,6 +2348,39 @@ rightMetricsKey = "=|V"; topKerningGroup = VTop; bottomKerningGroup = VBottom; unicode = 0056; +}, +{ +glyphname = A-cy; +layers = ( +{ +components = ( +{ +name = A; +} +); +layerId = "C4872ECA-A3A9-40AB-960A-1DB2202F16DE"; +width = 593; +}, +{ +components = ( +{ +name = A; +} +); +layerId = "3E7589AA-8194-470F-8E2F-13C1C581BE24"; +width = 657; +}, +{ +components = ( +{ +name = A; +} +); +layerId = "BFFFD157-90D3-4B85-B99D-9A2F366F03CA"; +width = 753; +} +); +unicode = 1040; } ); instances = ( diff --git a/tests/data/GlyphsUnitTestSans3.glyphs b/tests/data/GlyphsUnitTestSans3.glyphs index 60ca9a0..92810ea 100644 --- a/tests/data/GlyphsUnitTestSans3.glyphs +++ b/tests/data/GlyphsUnitTestSans3.glyphs @@ -2408,6 +2408,39 @@ width = 753; ); metricRight = "=|V"; unicode = 86; +}, +{ +glyphname = "A-cy"; +layers = ( +{ +layerId = "C4872ECA-A3A9-40AB-960A-1DB2202F16DE"; +shapes = ( +{ +ref = A; +} +); +width = 593; +}, +{ +layerId = "3E7589AA-8194-470F-8E2F-13C1C581BE24"; +shapes = ( +{ +ref = A; +} +); +width = 657; +}, +{ +layerId = "BFFFD157-90D3-4B85-B99D-9A2F366F03CA"; +shapes = ( +{ +ref = A; +} +); +width = 753; +} +); +unicode = 1040; } ); instances = ( From b560a8944e4975dc38847e55f8365fa4cfb3a9b4 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Sun, 19 Jan 2025 18:29:03 +0100 Subject: [PATCH 14/48] Rework unittests and fix issue based on self.parsedGlyphNames --- src/fontra_glyphs/backend.py | 10 ++++++--- tests/test_backend.py | 42 ++++++++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index b015cda..9eee687 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -393,9 +393,6 @@ async def putGlyph( writer.format_version = self.gsFont.format_version writer.write(gsGlyphNew) - self._writeRawGlyph(glyphName, f) - - def _writeRawGlyph(self, glyphName, f): # 3. parse stream into "raw" object f.seek(0) rawGlyphData = openstep_plist.load(f, use_numbers=True) @@ -405,6 +402,13 @@ def _writeRawGlyph(self, glyphName, f): self.rawGlyphsData[glyphIndex] = rawGlyphData self.rawFontData["glyphs"] = self.rawGlyphsData + self._writeRawGlyph(glyphName, f) + + # 7. Remove glyph from parsed glyph names, because we changed it. + # Next time it needs to be parsed again. + self.parsedGlyphNames.discard(glyphName) + + def _writeRawGlyph(self, glyphName, f): # 5. write whole file with openstep_plist with open(self.gsFilePath, "w", encoding="utf-8") as fp: openstep_plist.dump( diff --git a/tests/test_backend.py b/tests/test_backend.py index 982f6ad..619f624 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,4 +1,6 @@ +import os import pathlib +import shutil import pytest from fontra.backends import getFileSystemBackend @@ -22,6 +24,17 @@ def referenceFont(request): return getFileSystemBackend(referenceFontPath) +@pytest.fixture(params=[glyphs2Path, glyphs3Path, glyphsPackagePath]) +def writableTestFont(tmpdir, request): + srcPath = request.param + dstPath = tmpdir / os.path.basename(srcPath) + if os.path.isdir(srcPath): + shutil.copytree(srcPath, dstPath) + else: + shutil.copy(srcPath, dstPath) + return getFileSystemBackend(dstPath) + + expectedAxes = structure( { "axes": [ @@ -119,9 +132,9 @@ async def test_getGlyph(testFont, referenceFont, glyphName): @pytest.mark.asyncio @pytest.mark.parametrize("glyphName", list(expectedGlyphMap)) -async def test_putGlyph(testFont, referenceFont, glyphName): - glyphMap = await testFont.getGlyphMap() - glyph = await testFont.getGlyph(glyphName) +async def test_putGlyph(writableTestFont, testFont, glyphName): + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) if glyphName == "A" and "com.glyphsapp.glyph-color" not in glyph.customData: # glyphsLib doesn't read the color attr from Glyphs-2 files, # so let's monkeypatch the data @@ -129,17 +142,28 @@ async def test_putGlyph(testFont, referenceFont, glyphName): # for testing change every coordinate by 10 units for layerName, layer in iter(glyph.layers.items()): + layer.glyph.xAdvance = 500 # for testing change xAdvance for i, coordinate in enumerate(layer.glyph.path.coordinates): layer.glyph.path.coordinates[i] = coordinate + 10 - # for testing change xAdvance - for layerName, layer in iter(glyph.layers.items()): - layer.glyph.xAdvance = 500 + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) - await testFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + savedGlyph = await writableTestFont.getGlyph(glyphName) + referenceGlyph = await testFont.getGlyph(glyphName) - referenceGlyph = await referenceFont.getGlyph(glyphName) - assert referenceGlyph == glyph + for layerName, layer in iter(referenceGlyph.layers.items()): + assert savedGlyph.layers[layerName].glyph.xAdvance == 500 + + for i, coordinate in enumerate(layer.glyph.path.coordinates): + expectedResult = coordinate + 10 + # The follwing fails currently with: _part.shoulder, _part.stem and a + # I expect this is due to special layers. + # TODO: Fix issue with special layers. + assert ( + savedGlyph.layers[layerName].glyph.path.coordinates[i] == expectedResult + ) + + assert savedGlyph != glyph async def test_getKerning(testFont, referenceFont): From fc5469a7ca7e500e56f4f2941113b550e4e2e115 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Sun, 19 Jan 2025 19:23:46 +0100 Subject: [PATCH 15/48] Fixing the special layer issue with adding a gsLayer.layerId mapping to customData --- src/fontra_glyphs/backend.py | 9 +++++---- tests/test_backend.py | 17 +++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 9eee687..acc2e4e 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -285,7 +285,7 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None: gsLayers = sorted( gsLayers, key=lambda i_gsLayer: masterOrder[i_gsLayer[1].associatedMasterId] ) - + layerIdsMapping = {} seenLocations = [] for i, gsLayer in gsLayers: braceLocation = self._getBraceLayerLocation(gsLayer) @@ -319,6 +319,7 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None: ) layers[layerName] = gsLayerToFontraLayer(gsLayer, self.axisNames) + customData["com.glyphsapp.layerIdsMapping"] = layerIdsMapping fixSourceLocations(sources, set(smartLocation)) glyph = VariableGlyph( @@ -825,9 +826,9 @@ def fontraLayerToGSLayer(layer, gsLayer): def variableGlyphToGSGlyph(variableGlyph, gsGlyph): # TODO: convert fontra variableGlyph to GlyphsApp glyph - - for i, (layerName, layer) in enumerate(iter(variableGlyph.layers.items())): - fontraLayerToGSLayer(layer, gsGlyph.layers[i]) + layerIdsMapping = variableGlyph.customData["com.glyphsapp.layerIdsMapping"] + for layerName, gsLayerId in layerIdsMapping.items(): + fontraLayerToGSLayer(variableGlyph.layers[layerName], gsGlyph.layers[gsLayerId]) # What happens, if the number of layers differ from the original number? # How do we handle intermediate layers? diff --git a/tests/test_backend.py b/tests/test_backend.py index 619f624..7080843 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -124,9 +124,13 @@ async def test_getGlyph(testFont, referenceFont, glyphName): if glyphName == "A" and "com.glyphsapp.glyph-color" not in glyph.customData: # glyphsLib doesn't read the color attr from Glyphs-2 files, # so let's monkeypatch the data - glyph.customData = {"com.glyphsapp.glyph-color": [120, 220, 20, 4]} + glyph.customData["com.glyphsapp.glyph-color"] = [120, 220, 20, 4] referenceGlyph = await referenceFont.getGlyph(glyphName) + # TODO: This unit test fails currently, because the fontra referenceFont + # does not contain the customData "com.glyphsapp.layerIdsMapping". + # Before I update the fontra file, I would like to discuss with Just, + # if this is the right approach. test_putGlyph works now. assert referenceGlyph == glyph @@ -135,10 +139,6 @@ async def test_getGlyph(testFont, referenceFont, glyphName): async def test_putGlyph(writableTestFont, testFont, glyphName): glyphMap = await writableTestFont.getGlyphMap() glyph = await writableTestFont.getGlyph(glyphName) - if glyphName == "A" and "com.glyphsapp.glyph-color" not in glyph.customData: - # glyphsLib doesn't read the color attr from Glyphs-2 files, - # so let's monkeypatch the data - glyph.customData = {"com.glyphsapp.glyph-color": [120, 220, 20, 4]} # for testing change every coordinate by 10 units for layerName, layer in iter(glyph.layers.items()): @@ -155,12 +155,9 @@ async def test_putGlyph(writableTestFont, testFont, glyphName): assert savedGlyph.layers[layerName].glyph.xAdvance == 500 for i, coordinate in enumerate(layer.glyph.path.coordinates): - expectedResult = coordinate + 10 - # The follwing fails currently with: _part.shoulder, _part.stem and a - # I expect this is due to special layers. - # TODO: Fix issue with special layers. assert ( - savedGlyph.layers[layerName].glyph.path.coordinates[i] == expectedResult + savedGlyph.layers[layerName].glyph.path.coordinates[i] + == coordinate + 10 ) assert savedGlyph != glyph From eaf855931db06d6336c3388e2e1247bb238eff63 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Sun, 19 Jan 2025 19:43:11 +0100 Subject: [PATCH 16/48] Extend unittest with test_deleteLayer --- src/fontra_glyphs/backend.py | 9 ++++++++- tests/test_backend.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index acc2e4e..a7f021d 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -828,7 +828,14 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): # TODO: convert fontra variableGlyph to GlyphsApp glyph layerIdsMapping = variableGlyph.customData["com.glyphsapp.layerIdsMapping"] for layerName, gsLayerId in layerIdsMapping.items(): - fontraLayerToGSLayer(variableGlyph.layers[layerName], gsGlyph.layers[gsLayerId]) + if layerName in variableGlyph.layers: + fontraLayerToGSLayer( + variableGlyph.layers[layerName], gsGlyph.layers[gsLayerId] + ) + else: + # Someone removed a layer, for example a special layer. + # Therefore need to be removed from gsGlyph as well. + del gsGlyph.layers[gsLayerId] # What happens, if the number of layers differ from the original number? # How do we handle intermediate layers? diff --git a/tests/test_backend.py b/tests/test_backend.py index 7080843..fdd9612 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -163,6 +163,21 @@ async def test_putGlyph(writableTestFont, testFont, glyphName): assert savedGlyph != glyph +async def test_deleteLayer(writableTestFont): + glyphName = "A" + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + + layerName = "Regular (layer #1)" + del glyph.layers[layerName] + + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + + assert layerName not in savedGlyph.layers + + async def test_getKerning(testFont, referenceFont): assert await testFont.getKerning() == await referenceFont.getKerning() From 53a106dcf4cb8abc76010980b6bafb36f29bb716 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Sun, 19 Jan 2025 21:05:33 +0100 Subject: [PATCH 17/48] Add unittest test_addLayer (work in progress) --- src/fontra_glyphs/backend.py | 21 ++++++++++++++++----- tests/test_backend.py | 27 ++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index a7f021d..53a7630 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -828,15 +828,26 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): # TODO: convert fontra variableGlyph to GlyphsApp glyph layerIdsMapping = variableGlyph.customData["com.glyphsapp.layerIdsMapping"] for layerName, gsLayerId in layerIdsMapping.items(): - if layerName in variableGlyph.layers: - fontraLayerToGSLayer( - variableGlyph.layers[layerName], gsGlyph.layers[gsLayerId] - ) - else: + if layerName not in variableGlyph.layers: # Someone removed a layer, for example a special layer. # Therefore need to be removed from gsGlyph as well. del gsGlyph.layers[gsLayerId] + for i, (layerName, layer) in enumerate(iter(variableGlyph.layers.items())): + gsLayerId = layerIdsMapping.get(layerName) + if gsLayerId is not None: + # gsLayerExists + fontraLayerToGSLayer(layer, gsGlyph.layers[gsLayerId]) + else: + # gsLayer does not exists, therefore add new layer: + newLayer = glyphsLib.classes.GSLayer() + newLayer.name = layerName + # TODO: the name need probably further modifications + # + best guess for associatedMasterId + # newLayer.associatedMasterId = gsGlyph.layers[0].associatedMasterId + # newLayer.isSpecialLayer = True + gsGlyph.layers.append(newLayer) + # What happens, if the number of layers differ from the original number? # How do we handle intermediate layers? # https://docu.glyphsapp.com/#GSGlyph.layers font.glyphs['a'].layers.append(newLayer) diff --git a/tests/test_backend.py b/tests/test_backend.py index fdd9612..4c300eb 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -4,7 +4,14 @@ import pytest from fontra.backends import getFileSystemBackend -from fontra.core.classes import Axes, FontInfo, structure +from fontra.core.classes import ( + Axes, + FontInfo, + GlyphSource, + Layer, + StaticGlyph, + structure, +) dataDir = pathlib.Path(__file__).resolve().parent / "data" @@ -178,6 +185,24 @@ async def test_deleteLayer(writableTestFont): assert layerName not in savedGlyph.layers +async def test_addLayer(writableTestFont): + glyphName = "a" + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + numGlyphLayers = len(glyph.layers) + + glyph.sources.append( + GlyphSource(name="{166, 100}", location={"weight": 166}, layerName="{166, 100}") + ) + glyph.layers["{166, 100}"] = Layer(glyph=StaticGlyph()) + + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + # TODO: we don't have a associated master concept with fontra. + assert len(savedGlyph.layers.keys()) > numGlyphLayers + + async def test_getKerning(testFont, referenceFont): assert await testFont.getKerning() == await referenceFont.getKerning() From c27e22ebdfd646e58d90439aa281ce7acb627a7e Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Mon, 20 Jan 2025 03:21:53 +0100 Subject: [PATCH 18/48] Add support for 'intermediate layer' --- src/fontra_glyphs/backend.py | 22 ++++++++++++++-------- tests/test_backend.py | 21 ++++++++++----------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 53a7630..037aa73 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -836,23 +836,29 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): for i, (layerName, layer) in enumerate(iter(variableGlyph.layers.items())): gsLayerId = layerIdsMapping.get(layerName) if gsLayerId is not None: - # gsLayerExists + # gsLayer exists, modify existing gsLayer fontraLayerToGSLayer(layer, gsGlyph.layers[gsLayerId]) else: - # gsLayer does not exists, therefore add new layer: + # gsLayer does not exists, therefore must be 'isSpecialLayer' + # and need to be added as a new layer: newLayer = glyphsLib.classes.GSLayer() newLayer.name = layerName + newLayer.isSpecialLayer = True + + source = variableGlyph.sources[i] + newLayer.attributes["coordinates"] = [ + source.location[axis.name.lower()] + for axis in gsGlyph.parent.axes + if source.location.get(axis.name.lower()) + ] + # TODO: the name need probably further modifications # + best guess for associatedMasterId # newLayer.associatedMasterId = gsGlyph.layers[0].associatedMasterId - # newLayer.isSpecialLayer = True + fontraLayerToGSLayer(layer, newLayer) gsGlyph.layers.append(newLayer) - # What happens, if the number of layers differ from the original number? - # How do we handle intermediate layers? - # https://docu.glyphsapp.com/#GSGlyph.layers font.glyphs['a'].layers.append(newLayer) - # How do we handle missing masters? - # It might be that someone deletes a not needed layer, which works for fontra, + # It might be that someone deletes a not needed master-layer, which works for fontra, # but is required for Glyphs. We would need to get the intermediate contours. return gsGlyph diff --git a/tests/test_backend.py b/tests/test_backend.py index 4c300eb..9953b22 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,17 +1,11 @@ import os import pathlib import shutil +from copy import deepcopy import pytest from fontra.backends import getFileSystemBackend -from fontra.core.classes import ( - Axes, - FontInfo, - GlyphSource, - Layer, - StaticGlyph, - structure, -) +from fontra.core.classes import Axes, FontInfo, GlyphSource, Layer, structure dataDir = pathlib.Path(__file__).resolve().parent / "data" @@ -194,13 +188,18 @@ async def test_addLayer(writableTestFont): glyph.sources.append( GlyphSource(name="{166, 100}", location={"weight": 166}, layerName="{166, 100}") ) - glyph.layers["{166, 100}"] = Layer(glyph=StaticGlyph()) + # Copy StaticGlyph of Bold: + glyph.layers["{166, 100}"] = Layer( + glyph=deepcopy(glyph.layers["Bold (layer #2)"].glyph) + ) await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) savedGlyph = await writableTestFont.getGlyph(glyphName) - # TODO: we don't have a associated master concept with fontra. - assert len(savedGlyph.layers.keys()) > numGlyphLayers + assert len(savedGlyph.layers) > numGlyphLayers + # TODO: We don't have a associated master concept with fontra, + # therefore the name contains 'Light' (The first master) + assert "Light / {166, 100} (layer #4)" in savedGlyph.layers.keys() async def test_getKerning(testFont, referenceFont): From e343a3de5af80d5cc17eb47fe0af2621285e32bd Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Tue, 21 Jan 2025 21:39:15 +0100 Subject: [PATCH 19/48] putGlyph including components and anchors + unittests --- src/fontra_glyphs/backend.py | 169 +++++++++++++++-------------------- src/fontra_glyphs/utils.py | 115 ++++++++++++++++++++++++ tests/test_backend.py | 118 ++++++++++++++++++++++-- 3 files changed, 300 insertions(+), 102 deletions(-) create mode 100644 src/fontra_glyphs/utils.py diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 037aa73..5725d54 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -1,7 +1,6 @@ import io import pathlib -import re -from collections import OrderedDict, defaultdict +from collections import defaultdict from copy import deepcopy from os import PathLike from typing import Any @@ -37,6 +36,14 @@ to_designspace_axes, ) from glyphsLib.builder.smart_components import Pole +from glyphsLib.types import Transform + +from .utils import ( + getAssociatedMasterId, + getLocation, + saveFileWithGSFormatting, + toOrderedDict, +) rootInfoNames = [ "familyName", @@ -285,7 +292,7 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None: gsLayers = sorted( gsLayers, key=lambda i_gsLayer: masterOrder[i_gsLayer[1].associatedMasterId] ) - layerIdsMapping = {} + seenLayerIDs = {} seenLocations = [] for i, gsLayer in gsLayers: braceLocation = self._getBraceLayerLocation(gsLayer) @@ -319,7 +326,7 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None: ) layers[layerName] = gsLayerToFontraLayer(gsLayer, self.axisNames) - customData["com.glyphsapp.layerIdsMapping"] = layerIdsMapping + customData["com.glyphsapp.seenLayerIDs"] = seenLayerIDs fixSourceLocations(sources, set(smartLocation)) glyph = VariableGlyph( @@ -422,7 +429,7 @@ def _writeRawGlyph(self, glyphName, f): ) # 6. fix formatting - saveFileWithGsFormatting(self.gsFilePath) + saveFileWithGSFormatting(self.gsFilePath) async def aclose(self) -> None: pass @@ -759,102 +766,47 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster): return lineMetricsHorizontal -def saveFileWithGsFormatting(gsFilePath): - # openstep_plist.dump changes the whole formatting, therefore - # it's very diffucute to see what has changed. - # This function is a very bad try to get close to how the formatting - # looks like for a .glyphs file. - # There must be a better solution, but this is better than nothing. - with open(gsFilePath, "r", encoding="utf-8") as file: - content = file.read() - - content = re.sub(r"pos = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"pos = (\1,\2);", content) - - content = re.sub( - r"pos = \(\s*([\d.]+),\s*([\d.]+)\s*\);", r"pos = (\1,\2);", content - ) - - content = re.sub( - r"\(\s*([\d.]+),\s*(-?\d+),\s*([a-zA-Z])\s*\)", r"(\1,\2,\3)", content - ) - - content = re.sub( - r"origin = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"origin = (\1,\2);", content - ) - - content = re.sub( - r"target = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"target = (\1,\2);", content - ) - - content = re.sub( - r"color = \(\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)\s*\);", - r"color = (\1,\2,\3,\4);", - content, - ) - - content = re.sub( - r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)", content - ) - - content = re.sub( - r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]),\s*\{", r"(\1,\2,\3,{", content - ) - - content = re.sub( - r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]+),\s*\{", r"(\1,\2,\3,{", content - ) - - content = re.sub(r"\}\s*\),", r"}),", content) - - content += "\n" # add blank break at the end of the file. - - with open(gsFilePath, "w", encoding="utf-8") as file: - file.write(content) - - -def fontraLayerToGSLayer(layer, gsLayer): - # Draw new paths with pen - gsLayer.paths = [] # first: remove all paths - pen = gsLayer.getPointPen() - layer.glyph.path.drawPoints(pen) - - gsLayer.drawPoints(pen) - gsLayer.width = layer.glyph.xAdvance - # gsLayer.components = components # https://docu.glyphsapp.com/#GSLayer.components - # gsLayer.anchors = anchors # https://docu.glyphsapp.com/#GSLayer.anchors - - def variableGlyphToGSGlyph(variableGlyph, gsGlyph): - # TODO: convert fontra variableGlyph to GlyphsApp glyph - layerIdsMapping = variableGlyph.customData["com.glyphsapp.layerIdsMapping"] - for layerName, gsLayerId in layerIdsMapping.items(): + # Convert fontra variableGlyph to GlyphsApp glyph + masterIds = [m.id for m in gsGlyph.parent.masters] + seenLayerIDs = variableGlyph.customData["com.glyphsapp.seenLayerIDs"] + for layerName, gsLayerId in seenLayerIDs.items(): if layerName not in variableGlyph.layers: - # Someone removed a layer, for example a special layer. - # Therefore need to be removed from gsGlyph as well. + gsLayerId = seenLayerIDs.get(layerName) + if gsLayerId in masterIds: + # Someone deleted a master, this breaks the compatibility in a .glyphs file. + # There skip deleting master layer. + continue + # Removing non-master-layer: del gsGlyph.layers[gsLayerId] for i, (layerName, layer) in enumerate(iter(variableGlyph.layers.items())): - gsLayerId = layerIdsMapping.get(layerName) + gsLayerId = seenLayerIDs.get(layerName) if gsLayerId is not None: - # gsLayer exists, modify existing gsLayer + # gsLayer exists: modify existing gsLayer fontraLayerToGSLayer(layer, gsGlyph.layers[gsLayerId]) else: - # gsLayer does not exists, therefore must be 'isSpecialLayer' - # and need to be added as a new layer: + # gsLayer does not exist: therefore must be 'isSpecialLayer' + # and need to be created as a new layer: newLayer = glyphsLib.classes.GSLayer() newLayer.name = layerName newLayer.isSpecialLayer = True - source = variableGlyph.sources[i] - newLayer.attributes["coordinates"] = [ - source.location[axis.name.lower()] + location = getLocation(variableGlyph, layerName, gsGlyph.parent.axes) + if location is None: + return + + gsLocation = [ + location[axis.name.lower()] for axis in gsGlyph.parent.axes - if source.location.get(axis.name.lower()) + if location.get(axis.name.lower()) ] + newLayer.attributes["coordinates"] = gsLocation + + associatedMasterId = getAssociatedMasterId(gsGlyph, gsLocation) + if associatedMasterId: + newLayer.associatedMasterId = associatedMasterId - # TODO: the name need probably further modifications - # + best guess for associatedMasterId - # newLayer.associatedMasterId = gsGlyph.layers[0].associatedMasterId fontraLayerToGSLayer(layer, newLayer) gsGlyph.layers.append(newLayer) @@ -864,12 +816,39 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): return gsGlyph -# The following is obsolete once this is merged: -# https://github.com/fonttools/openstep-plist/pull/35 -def toOrderedDict(obj): - if isinstance(obj, dict): - return OrderedDict({k: toOrderedDict(v) for k, v in obj.items()}) - elif isinstance(obj, list): - return [toOrderedDict(item) for item in obj] - else: - return obj +def fontraLayerToGSLayer(layer, gsLayer): + gsLayer.paths = [] + + # Draw new paths with pen + pen = gsLayer.getPointPen() + layer.glyph.path.drawPoints(pen) + + gsLayer.width = layer.glyph.xAdvance + gsLayer.components = [ + fontraComponentToGSComponent(component) for component in layer.glyph.components + ] + gsLayer.anchors = [ + fontraAnchorsToGSAnchor(anchor) for anchor in layer.glyph.anchors + ] + + +def fontraComponentToGSComponent(component): + gsComponent = glyphsLib.classes.GSComponent(component.name) + transformation = component.transformation.toTransform() + if not isinstance(transformation, Transform): + gsComponent.transform = Transform(*transformation) + # gsComponent.smartComponentValues = # TODO: see location={ disambiguateLocalAxisName + return gsComponent + + +def fontraAnchorsToGSAnchor(anchor): + gsAnchor = glyphsLib.classes.GSAnchor() + gsAnchor.name = anchor.name + gsAnchor.position.x = anchor.x + gsAnchor.position.y = anchor.y + if anchor.customData: + gsAnchor.userData = anchor.customData + # TODO: gsAnchor.orientation – If the position of the anchor + # is relative to the LSB (0), center (2) or RSB (1). + # Details: https://docu.glyphsapp.com/#GSAnchor.orientation + return gsAnchor diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py new file mode 100644 index 0000000..80964ae --- /dev/null +++ b/src/fontra_glyphs/utils.py @@ -0,0 +1,115 @@ +import re +from collections import OrderedDict + + +# The following is obsolete once this is merged: +# https://github.com/fonttools/openstep-plist/pull/35 +def toOrderedDict(obj): + if isinstance(obj, dict): + return OrderedDict({k: toOrderedDict(v) for k, v in obj.items()}) + elif isinstance(obj, list): + return [toOrderedDict(item) for item in obj] + else: + return obj + + +def getLocationFromLayerName(layerName, gsAxes): + # try to get it from name, eg. Light / {166, 100} (layer #4) + match = re.search(r"\{([^}]+)\}", layerName) + if not match: + return None + listLocation = match.group(1).replace(" ", "").split(",") + listLocationValues = [float(v) for v in listLocation] + return { + gsAxes[i].name.lower(): value + for i, value in enumerate(listLocationValues) + if i < len(gsAxes) + } + + +def getLocationFromSources(sources, layerName): + s = None + for source in sources: + if source.layerName == layerName: + s = source + break + if s is not None: + return {k.lower(): v for k, v in s.location.items()} + + +def getLocation(glyph, layerName, gsAxes): + location = getLocationFromSources(glyph.sources, layerName) + if location: + return location + # This layer is not used by any source: + return getLocationFromLayerName(layerName, gsAxes) + + +def getAssociatedMasterId(gsGlyph, gsLocation): + # Make a best guess of a associatedMasterId + closestMaster = None + closestDistance = float("inf") + for gsLayer in gsGlyph.layers: + gsMaster = gsLayer.master + distance = sum( + abs(gsMaster.axes[i] - gsLocation[i]) + for i in range(len(gsMaster.axes)) + if i < len(gsLocation) + ) + if distance < closestDistance: + closestDistance = distance + closestMaster = gsMaster + return closestMaster.id if closestMaster else None + + +def saveFileWithGSFormatting(gsFilePath): + # openstep_plist.dump changes the whole formatting, therefore + # it's very diffucute to see what has changed. + # This function is a very bad try to get close to how the formatting + # looks like for a .glyphs file. + # There must be a better solution, but this is better than nothing. + with open(gsFilePath, "r", encoding="utf-8") as file: + content = file.read() + + content = re.sub(r"pos = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"pos = (\1,\2);", content) + + content = re.sub( + r"pos = \(\s*([\d.]+),\s*([\d.]+)\s*\);", r"pos = (\1,\2);", content + ) + + content = re.sub( + r"\(\s*([\d.]+),\s*(-?\d+),\s*([a-zA-Z])\s*\)", r"(\1,\2,\3)", content + ) + + content = re.sub( + r"origin = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"origin = (\1,\2);", content + ) + + content = re.sub( + r"target = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"target = (\1,\2);", content + ) + + content = re.sub( + r"color = \(\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)\s*\);", + r"color = (\1,\2,\3,\4);", + content, + ) + + content = re.sub( + r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)", content + ) + + content = re.sub( + r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]),\s*\{", r"(\1,\2,\3,{", content + ) + + content = re.sub( + r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]+),\s*\{", r"(\1,\2,\3,{", content + ) + + content = re.sub(r"\}\s*\),", r"}),", content) + + content += "\n" # add blank break at the end of the file. + + with open(gsFilePath, "w", encoding="utf-8") as file: + file.write(content) diff --git a/tests/test_backend.py b/tests/test_backend.py index 9953b22..1775bda 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -3,9 +3,24 @@ import shutil from copy import deepcopy +import glyphsLib import pytest from fontra.backends import getFileSystemBackend -from fontra.core.classes import Axes, FontInfo, GlyphSource, Layer, structure +from fontra.core.classes import ( + Anchor, + Axes, + FontInfo, + GlyphSource, + Layer, + StaticGlyph, + structure, +) + +from fontra_glyphs.utils import ( + getAssociatedMasterId, + getLocationFromLayerName, + getLocationFromSources, +) dataDir = pathlib.Path(__file__).resolve().parent / "data" @@ -20,6 +35,11 @@ def testFont(request): return getFileSystemBackend(request.param) +@pytest.fixture(scope="module", params=[glyphs2Path, glyphs3Path, glyphsPackagePath]) +def testGSFont(request): + return glyphsLib.GSFont(request.param) + + @pytest.fixture(scope="module") def referenceFont(request): return getFileSystemBackend(referenceFontPath) @@ -129,7 +149,7 @@ async def test_getGlyph(testFont, referenceFont, glyphName): referenceGlyph = await referenceFont.getGlyph(glyphName) # TODO: This unit test fails currently, because the fontra referenceFont - # does not contain the customData "com.glyphsapp.layerIdsMapping". + # does not contain the customData "com.glyphsapp.seenLayerIDs". # Before I update the fontra file, I would like to discuss with Just, # if this is the right approach. test_putGlyph works now. assert referenceGlyph == glyph @@ -161,7 +181,9 @@ async def test_putGlyph(writableTestFont, testFont, glyphName): == coordinate + 10 ) - assert savedGlyph != glyph + assert len(layer.glyph.path.coordinates) == len( + savedGlyph.layers[layerName].glyph.path.coordinates + ) async def test_deleteLayer(writableTestFont): @@ -176,7 +198,7 @@ async def test_deleteLayer(writableTestFont): savedGlyph = await writableTestFont.getGlyph(glyphName) - assert layerName not in savedGlyph.layers + assert layerName in savedGlyph.layers async def test_addLayer(writableTestFont): @@ -197,9 +219,62 @@ async def test_addLayer(writableTestFont): savedGlyph = await writableTestFont.getGlyph(glyphName) assert len(savedGlyph.layers) > numGlyphLayers - # TODO: We don't have a associated master concept with fontra, - # therefore the name contains 'Light' (The first master) - assert "Light / {166, 100} (layer #4)" in savedGlyph.layers.keys() + assert "Bold / {166, 100} (layer #4)" in savedGlyph.layers.keys() + + +async def test_addLayerWithComponent(writableTestFont): + glyphName = "n" # n is made from components + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + numGlyphLayers = len(glyph.layers) + + glyph.sources.append( + GlyphSource(name="{166, 100}", location={"weight": 166}, layerName="{166, 100}") + ) + # Copy StaticGlyph of Bold: + glyph.layers["{166, 100}"] = Layer( + glyph=deepcopy(glyph.layers["Bold (layer #2)"].glyph) + ) + + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert len(savedGlyph.layers) > numGlyphLayers + assert "Bold / {166, 100} (layer #3)" in savedGlyph.layers.keys() + + +async def test_removeMasterLayer(writableTestFont): + glyphName = "a" + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + numGlyphLayers = len(glyph.layers) + + # Removing a "master" layer breaks compatibility within a .glyphs file. + del glyph.layers["Bold (layer #2)"] + + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert len(savedGlyph.layers) == numGlyphLayers + + +async def test_addAnchor(writableTestFont): + glyphName = "a" + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + + layerName = "{ 166, 100 }" + glyph.layers[layerName] = Layer(glyph=StaticGlyph(xAdvance=0)) + glyph.layers[layerName].glyph.anchors.append(Anchor(name="top", x=207, y=746)) + + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + + assert ( + glyph.layers[layerName].glyph.anchors + == savedGlyph.layers["Bold / { 166, 100 } (layer #4)"].glyph.anchors + ) async def test_getKerning(testFont, referenceFont): @@ -208,3 +283,32 @@ async def test_getKerning(testFont, referenceFont): async def test_getSources(testFont, referenceFont): assert await testFont.getSources() == await referenceFont.getSources() + + +layerNamesToLocation = [ + ["Light / {166, 100} (layer #4)", {"weight": 166}], + ["{ 166 } (layer #3)", {"weight": 166}], + ["Light / (layer #4)", None], +] + + +@pytest.mark.parametrize("layerName,expected", layerNamesToLocation) +def test_getLocationFromLayerName(layerName, expected): + gsFont = glyphsLib.classes.GSFont() + gsFont.axes = [glyphsLib.classes.GSAxis(name="Weight", tag="wght")] + location = getLocationFromLayerName(layerName, gsFont.axes) + assert location == expected + + +async def test_getLocationFromSources(testFont): + glyphName = "a" + glyph = await testFont.getGlyph(glyphName) + location = getLocationFromSources(glyph.sources, "Regular / {155, 100} (layer #3)") + assert location == {"weight": 155} + + +async def test_getAssociatedMasterId(testGSFont): + gsGlyph = testGSFont.glyphs["a"] + associatedMasterId = getAssociatedMasterId(gsGlyph, [155]) + associatedMaster = gsGlyph.layers[associatedMasterId] + assert associatedMaster.name == "Regular" From b43907a01c92a235344172e03583d7bf12c3bead Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 10:05:34 +0100 Subject: [PATCH 20/48] Adjust comments, restructure a bit + more unittests --- src/fontra_glyphs/backend.py | 39 +++----- src/fontra_glyphs/utils.py | 14 ++- tests/test_backend.py | 47 +-------- tests/test_utils.py | 183 +++++++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 74 deletions(-) create mode 100644 tests/test_utils.py diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 5725d54..b9061da 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -38,12 +38,7 @@ from glyphsLib.builder.smart_components import Pole from glyphsLib.types import Transform -from .utils import ( - getAssociatedMasterId, - getLocation, - saveFileWithGSFormatting, - toOrderedDict, -) +from .utils import getAssociatedMasterId, getLocation, gsFormatting, toOrderedDict rootInfoNames = [ "familyName", @@ -326,6 +321,7 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None: ) layers[layerName] = gsLayerToFontraLayer(gsLayer, self.axisNames) + # NOTE: Remember layerIds like in the following line or add 'identifier' to fontra Layer? customData["com.glyphsapp.seenLayerIDs"] = seenLayerIDs fixSourceLocations(sources, set(smartLocation)) @@ -344,7 +340,6 @@ def _ensureGlyphIsParsed(self, glyphName: str) -> None: glyphIndex = self.glyphNameToIndex[glyphName] rawGlyphData = self.rawGlyphsData[glyphIndex] - # self.rawGlyphsData[glyphIndex] = None self.parsedGlyphNames.add(glyphName) gsGlyph = glyphsLib.classes.GSGlyph() @@ -390,7 +385,7 @@ def _getSmartLocation(self, gsLayer, localAxesByName): async def putGlyph( self, glyphName: str, glyph: VariableGlyph, codePoints: list[int] ) -> None: - # 1. convert VariableGlyph to GSGlyph (but start with a copy of the original) + # 1. convert VariableGlyph to GSGlyph gsGlyphNew = variableGlyphToGSGlyph( glyph, deepcopy(self.gsFont.glyphs[glyphName]) ) @@ -406,8 +401,7 @@ async def putGlyph( rawGlyphData = openstep_plist.load(f, use_numbers=True) # 4. replace original "raw" object with new "raw" object - glyphIndex = self.glyphNameToIndex[glyphName] - self.rawGlyphsData[glyphIndex] = rawGlyphData + self.rawGlyphsData[self.glyphNameToIndex[glyphName]] = rawGlyphData self.rawFontData["glyphs"] = self.rawGlyphsData self._writeRawGlyph(glyphName, f) @@ -418,7 +412,9 @@ async def putGlyph( def _writeRawGlyph(self, glyphName, f): # 5. write whole file with openstep_plist - with open(self.gsFilePath, "w", encoding="utf-8") as fp: + with open(self.gsFilePath, "r+", encoding="utf-8") as fp: + content = gsFormatting(fp.read()) # 6. fix formatting + fp.write(content) openstep_plist.dump( self.rawFontData, fp, @@ -428,9 +424,6 @@ def _writeRawGlyph(self, glyphName, f): escape_newlines=False, ) - # 6. fix formatting - saveFileWithGSFormatting(self.gsFilePath) - async def aclose(self) -> None: pass @@ -767,7 +760,7 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster): def variableGlyphToGSGlyph(variableGlyph, gsGlyph): - # Convert fontra variableGlyph to GlyphsApp glyph + # Convert Fontra variableGlyph to GlyphsApp glyph masterIds = [m.id for m in gsGlyph.parent.masters] seenLayerIDs = variableGlyph.customData["com.glyphsapp.seenLayerIDs"] for layerName, gsLayerId in seenLayerIDs.items(): @@ -775,7 +768,7 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): gsLayerId = seenLayerIDs.get(layerName) if gsLayerId in masterIds: # Someone deleted a master, this breaks the compatibility in a .glyphs file. - # There skip deleting master layer. + # Skip deleting master layer. continue # Removing non-master-layer: del gsGlyph.layers[gsLayerId] @@ -788,9 +781,9 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): else: # gsLayer does not exist: therefore must be 'isSpecialLayer' # and need to be created as a new layer: - newLayer = glyphsLib.classes.GSLayer() - newLayer.name = layerName - newLayer.isSpecialLayer = True + gsLayer = glyphsLib.classes.GSLayer() + gsLayer.name = layerName + gsLayer.isSpecialLayer = True location = getLocation(variableGlyph, layerName, gsGlyph.parent.axes) if location is None: @@ -801,14 +794,14 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): for axis in gsGlyph.parent.axes if location.get(axis.name.lower()) ] - newLayer.attributes["coordinates"] = gsLocation + gsLayer.attributes["coordinates"] = gsLocation associatedMasterId = getAssociatedMasterId(gsGlyph, gsLocation) if associatedMasterId: - newLayer.associatedMasterId = associatedMasterId + gsLayer.associatedMasterId = associatedMasterId - fontraLayerToGSLayer(layer, newLayer) - gsGlyph.layers.append(newLayer) + fontraLayerToGSLayer(layer, gsLayer) + gsGlyph.layers.append(gsLayer) # It might be that someone deletes a not needed master-layer, which works for fontra, # but is required for Glyphs. We would need to get the intermediate contours. diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index 80964ae..211ef78 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -14,7 +14,8 @@ def toOrderedDict(obj): def getLocationFromLayerName(layerName, gsAxes): - # try to get it from name, eg. Light / {166, 100} (layer #4) + # Get the location based on name, + # for example: Light / {166, 100} (layer #4) match = re.search(r"\{([^}]+)\}", layerName) if not match: return None @@ -41,12 +42,12 @@ def getLocation(glyph, layerName, gsAxes): location = getLocationFromSources(glyph.sources, layerName) if location: return location - # This layer is not used by any source: + # This layerName is not used by any source: return getLocationFromLayerName(layerName, gsAxes) def getAssociatedMasterId(gsGlyph, gsLocation): - # Make a best guess of a associatedMasterId + # Best guess for associatedMasterId closestMaster = None closestDistance = float("inf") for gsLayer in gsGlyph.layers: @@ -62,14 +63,12 @@ def getAssociatedMasterId(gsGlyph, gsLocation): return closestMaster.id if closestMaster else None -def saveFileWithGSFormatting(gsFilePath): +def gsFormatting(content): # openstep_plist.dump changes the whole formatting, therefore # it's very diffucute to see what has changed. # This function is a very bad try to get close to how the formatting # looks like for a .glyphs file. # There must be a better solution, but this is better than nothing. - with open(gsFilePath, "r", encoding="utf-8") as file: - content = file.read() content = re.sub(r"pos = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"pos = (\1,\2);", content) @@ -111,5 +110,4 @@ def saveFileWithGSFormatting(gsFilePath): content += "\n" # add blank break at the end of the file. - with open(gsFilePath, "w", encoding="utf-8") as file: - file.write(content) + return content diff --git a/tests/test_backend.py b/tests/test_backend.py index 1775bda..2b5f6fc 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -3,7 +3,6 @@ import shutil from copy import deepcopy -import glyphsLib import pytest from fontra.backends import getFileSystemBackend from fontra.core.classes import ( @@ -16,12 +15,6 @@ structure, ) -from fontra_glyphs.utils import ( - getAssociatedMasterId, - getLocationFromLayerName, - getLocationFromSources, -) - dataDir = pathlib.Path(__file__).resolve().parent / "data" glyphs2Path = dataDir / "GlyphsUnitTestSans.glyphs" @@ -35,11 +28,6 @@ def testFont(request): return getFileSystemBackend(request.param) -@pytest.fixture(scope="module", params=[glyphs2Path, glyphs3Path, glyphsPackagePath]) -def testGSFont(request): - return glyphsLib.GSFont(request.param) - - @pytest.fixture(scope="module") def referenceFont(request): return getFileSystemBackend(referenceFontPath) @@ -158,6 +146,7 @@ async def test_getGlyph(testFont, referenceFont, glyphName): @pytest.mark.asyncio @pytest.mark.parametrize("glyphName", list(expectedGlyphMap)) async def test_putGlyph(writableTestFont, testFont, glyphName): + writableTestFont = testFont glyphMap = await writableTestFont.getGlyphMap() glyph = await writableTestFont.getGlyph(glyphName) @@ -243,13 +232,14 @@ async def test_addLayerWithComponent(writableTestFont): assert "Bold / {166, 100} (layer #3)" in savedGlyph.layers.keys() -async def test_removeMasterLayer(writableTestFont): +async def test_deleteMasterLayer(writableTestFont): + # Removing a "master" layer breaks compatibility within a .glyphs file. + # Therefore we need to make sure, that it will be added afterwords. glyphName = "a" glyphMap = await writableTestFont.getGlyphMap() glyph = await writableTestFont.getGlyph(glyphName) numGlyphLayers = len(glyph.layers) - # Removing a "master" layer breaks compatibility within a .glyphs file. del glyph.layers["Bold (layer #2)"] await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) @@ -283,32 +273,3 @@ async def test_getKerning(testFont, referenceFont): async def test_getSources(testFont, referenceFont): assert await testFont.getSources() == await referenceFont.getSources() - - -layerNamesToLocation = [ - ["Light / {166, 100} (layer #4)", {"weight": 166}], - ["{ 166 } (layer #3)", {"weight": 166}], - ["Light / (layer #4)", None], -] - - -@pytest.mark.parametrize("layerName,expected", layerNamesToLocation) -def test_getLocationFromLayerName(layerName, expected): - gsFont = glyphsLib.classes.GSFont() - gsFont.axes = [glyphsLib.classes.GSAxis(name="Weight", tag="wght")] - location = getLocationFromLayerName(layerName, gsFont.axes) - assert location == expected - - -async def test_getLocationFromSources(testFont): - glyphName = "a" - glyph = await testFont.getGlyph(glyphName) - location = getLocationFromSources(glyph.sources, "Regular / {155, 100} (layer #3)") - assert location == {"weight": 155} - - -async def test_getAssociatedMasterId(testGSFont): - gsGlyph = testGSFont.glyphs["a"] - associatedMasterId = getAssociatedMasterId(gsGlyph, [155]) - associatedMaster = gsGlyph.layers[associatedMasterId] - assert associatedMaster.name == "Regular" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..20099d7 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,183 @@ +import pathlib + +import glyphsLib +import pytest +from fontra.backends import getFileSystemBackend + +from fontra_glyphs.utils import ( + getAssociatedMasterId, + getLocationFromLayerName, + getLocationFromSources, + gsFormatting, +) + +dataDir = pathlib.Path(__file__).resolve().parent / "data" + +glyphs2Path = dataDir / "GlyphsUnitTestSans.glyphs" +glyphs3Path = dataDir / "GlyphsUnitTestSans3.glyphs" +glyphsPackagePath = dataDir / "GlyphsUnitTestSans3.glyphspackage" + + +@pytest.fixture(scope="module", params=[glyphs2Path, glyphs3Path, glyphsPackagePath]) +def testFont(request): + return getFileSystemBackend(request.param) + + +@pytest.fixture(scope="module", params=[glyphs2Path, glyphs3Path, glyphsPackagePath]) +def testGSFont(request): + return glyphsLib.GSFont(request.param) + + +layerNamesToLocation = [ + ["Light / {166, 100} (layer #4)", {"weight": 166}], + ["{ 166 } (layer #3)", {"weight": 166}], + ["Light / (layer #4)", None], +] + + +@pytest.mark.parametrize("layerName,expected", layerNamesToLocation) +def test_getLocationFromLayerName(layerName, expected): + gsFont = glyphsLib.classes.GSFont() + gsFont.axes = [glyphsLib.classes.GSAxis(name="Weight", tag="wght")] + location = getLocationFromLayerName(layerName, gsFont.axes) + assert location == expected + + +async def test_getLocationFromSources(testFont): + glyphName = "a" + glyph = await testFont.getGlyph(glyphName) + location = getLocationFromSources(glyph.sources, "Regular / {155, 100} (layer #3)") + assert location == {"weight": 155} + + +def test_getAssociatedMasterId(testGSFont): + gsGlyph = testGSFont.glyphs["a"] + associatedMasterId = getAssociatedMasterId(gsGlyph, [155]) + associatedMaster = gsGlyph.layers[associatedMasterId] + assert associatedMaster.name == "Regular" + + +contentSnippets = [ + [ + """pos = ( +524, +141 +);""", + "pos = (524,141);", + ], + [ + """pos = ( +-113, +765 +);""", + "pos = (-113,765);", + ], + [ + "customBinaryData = <74686520 62797465 73>;", + "customBinaryData = <746865206279746573>;", + ], + [ + """color = ( +120, +220, +20, +4 +);""", + "color = (120,220,20,4);", + ], + [ + """( +566.99, +700, +l +),""", + "(566.99,700,l),", + ], + [ + """( +191, +700, +l +),""", + "(191,700,l),", + ], + [ + """origin = ( +1, +1 +);""", + "origin = (1,1);", + ], + [ + """target = ( +1, +0 +);""", + "target = (1,0);", + ], + [ + """pos = ( +45, +0 +);""", + "pos = (45,0);", + ], + [ + """pos = ( +-45, +0 +);""", + "pos = (-45,0);", + ], + [ + """( +341, +720, +l, +{""", + "(321,700,l,{", + ][ + """( +268, +153, +ls +),""", + "(268,153,ls),", + ], + [ + """( +268, +153, +o +),""", + "(268,153,o),", + ], + [ + """( +268, +153, +cs +),""", + "(268,153,cs),", + ], + [ + """( +184, +-8, +c +),""", + "(184,-8,c),", + ], + [ + """pos = ( +334.937, +407.08 +);""", + "pos = (334.937,407.08);", + ], +] + + +@pytest.mark.parametrize("content,expected", contentSnippets) +def test_gsFormatting(content, expected): + assert gsFormatting(content) != expected From f6a6728d1e24c5b3f6fcaeeb94554f70610345ad Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 10:08:24 +0100 Subject: [PATCH 21/48] Fix missing comma and remove 'local hack' --- tests/test_backend.py | 1 - tests/test_utils.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_backend.py b/tests/test_backend.py index 2b5f6fc..f809bdf 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -146,7 +146,6 @@ async def test_getGlyph(testFont, referenceFont, glyphName): @pytest.mark.asyncio @pytest.mark.parametrize("glyphName", list(expectedGlyphMap)) async def test_putGlyph(writableTestFont, testFont, glyphName): - writableTestFont = testFont glyphMap = await writableTestFont.getGlyphMap() glyph = await writableTestFont.getGlyph(glyphName) diff --git a/tests/test_utils.py b/tests/test_utils.py index 20099d7..5cfefba 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -136,7 +136,8 @@ def test_getAssociatedMasterId(testGSFont): l, {""", "(321,700,l,{", - ][ + ], + [ """( 268, 153, From 7afe3fa92e4ff9ffaaf398f3015c1ba5b9960e43 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 11:54:42 +0100 Subject: [PATCH 22/48] Rework gsFormatting --- src/fontra_glyphs/backend.py | 16 +++++++-- src/fontra_glyphs/utils.py | 66 +++++++++++++++--------------------- tests/test_utils.py | 43 +++++++++++++++++++++-- 3 files changed, 81 insertions(+), 44 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index b9061da..59e456e 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -413,8 +413,6 @@ async def putGlyph( def _writeRawGlyph(self, glyphName, f): # 5. write whole file with openstep_plist with open(self.gsFilePath, "r+", encoding="utf-8") as fp: - content = gsFormatting(fp.read()) # 6. fix formatting - fp.write(content) openstep_plist.dump( self.rawFontData, fp, @@ -424,6 +422,20 @@ def _writeRawGlyph(self, glyphName, f): escape_newlines=False, ) + # 6. fix formatting + with open(self.gsFilePath, "r", encoding="utf-8") as fp: + content = gsFormatting(fp.read()) + if self.gsFont.format_version >= 3: + # add blank break at the end of the file. + content += "\n" + with open(self.gsFilePath, "w", encoding="utf-8") as fp: + fp.write(content) + + # This following does not wrok, correctly, there the code above. + # with open(self.gsFilePath, "r+", encoding="utf-8") as fp: + # content = gsFormatting(fp.read()) + # fp.write(content) + async def aclose(self) -> None: pass diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index 211ef78..f8e44f6 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -70,44 +70,32 @@ def gsFormatting(content): # looks like for a .glyphs file. # There must be a better solution, but this is better than nothing. - content = re.sub(r"pos = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"pos = (\1,\2);", content) - - content = re.sub( - r"pos = \(\s*([\d.]+),\s*([\d.]+)\s*\);", r"pos = (\1,\2);", content - ) - - content = re.sub( - r"\(\s*([\d.]+),\s*(-?\d+),\s*([a-zA-Z])\s*\)", r"(\1,\2,\3)", content - ) - - content = re.sub( - r"origin = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"origin = (\1,\2);", content - ) - - content = re.sub( - r"target = \(\s*(-?\d+),\s*(-?\d+)\s*\);", r"target = (\1,\2);", content - ) - - content = re.sub( - r"color = \(\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)\s*\);", - r"color = (\1,\2,\3,\4);", - content, - ) - - content = re.sub( - r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)", content - ) - - content = re.sub( - r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]),\s*\{", r"(\1,\2,\3,{", content - ) - - content = re.sub( - r"\(\s*(-?\d+),\s*(-?\d+),\s*([a-zA-Z]+),\s*\{", r"(\1,\2,\3,{", content - ) - - content = re.sub(r"\}\s*\),", r"}),", content) - - content += "\n" # add blank break at the end of the file. + patterns = [ + ( + r"customBinaryData = <\s*([0-9a-fA-F\s]+)\s*>;", + lambda m: f"customBinaryData = <{m.group(1).replace(' ', '')}>;", + ), + (r"\(\s*(-?[\d.]+),\s*(-?[\d.]+)\s*\);", r"(\1,\2);"), + (r"\(\s*(-?[\d.]+),\s*(-?[\d.]+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)"), + (r"origin = \(\s*(-?[\d.]+),\s*(-?[\d.]+)\s*\);", r"origin = (\1,\2);"), + (r"target = \(\s*(-?[\d.]+),\s*(-?[\d.]+)\s*\);", r"target = (\1,\2);"), + ( + r"color = \(\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)\s*\);", + r"color = (\1,\2,\3,\4);", + ), + (r"\(\s*(-?[\d.]+),\s*(-?[\d.]+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)"), + (r"\(\s*(-?[\d.]+),\s*(-?[\d.]+),\s*([a-zA-Z]+),\s*\{", r"(\1,\2,\3,{"), + (r"\}\s*\),", r"}),"), + (r"anchors = \(\);", r"anchors = (\n);"), + (r"unicode = \(\);", r"unicode = (\n);"), + (r"lib = \{\};", r"lib = {\n};"), + ( + r"verticalStems = \(\s*(-?[\d.]+),(-?[\d.]+)\);", + r"verticalStems = (\n\1,\n\2\n);", + ), + ] + + for pattern, replacement in patterns: + content = re.sub(pattern, replacement, content) return content diff --git a/tests/test_utils.py b/tests/test_utils.py index 5cfefba..2a52750 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -131,8 +131,8 @@ def test_getAssociatedMasterId(testGSFont): ], [ """( -341, -720, +321, +700, l, {""", "(321,700,l,{", @@ -176,9 +176,46 @@ def test_getAssociatedMasterId(testGSFont): );""", "pos = (334.937,407.08);", ], + [ + """pos = ( +-113, +574 +);""", + "pos = (-113,574);", + ], + ["pos = (524,-122);", "pos = (524,-122);"], + [ + "anchors = ();", + """anchors = ( +);""", + ], + [ + "unicode = ();", + """unicode = ( +);""", + ], + [ + "lib = {};", + """lib = { +};""", + ], + [ + "verticalStems = (17,19);", + """verticalStems = ( +17, +19 +);""", + ], + # TODO: The following does not fail in the unittest: diff \n vs \012 + [ + """code = "feature c2sc; +feature smcp; +";""", + """code = "feature c2sc;\012feature smcp;\012";""", + ], ] @pytest.mark.parametrize("content,expected", contentSnippets) def test_gsFormatting(content, expected): - assert gsFormatting(content) != expected + assert gsFormatting(content) == expected From f6fe46c7eabaeea011f0c2411b35641dda75fd7a Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 12:05:49 +0100 Subject: [PATCH 23/48] Extend Readme file --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index c5b1181..b8b8a2e 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,12 @@ It supports the following features: - Brace layers - Smart components (for now restricted to interpolation: axis values need to be within the minimum and maximum values) + + +## Write + +### Glyph Layer +- Contour (Paths, Nodes) ✅ +- Components ✅ +- Anchors ✅ +- Guidelines From 14144d304bc67e1325b22f477fcf0bdb5359c1bf Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 12:21:20 +0100 Subject: [PATCH 24/48] Move getGlyphspackageGlyphFileName to utils and add unittest --- src/fontra_glyphs/backend.py | 20 +++++++------------- src/fontra_glyphs/utils.py | 12 ++++++++++++ tests/test_utils.py | 12 ++++++++++++ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 59e456e..ef9174c 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -38,7 +38,13 @@ from glyphsLib.builder.smart_components import Pole from glyphsLib.types import Transform -from .utils import getAssociatedMasterId, getLocation, gsFormatting, toOrderedDict +from .utils import ( + getAssociatedMasterId, + getGlyphspackageGlyphFileName, + getLocation, + gsFormatting, + toOrderedDict, +) rootInfoNames = [ "familyName", @@ -489,18 +495,6 @@ def getGlyphFilePath(self, glyphName): return glyphsPath / (realGlyphName + ".glyph") -def getGlyphspackageGlyphFileName(glyphName): - nameParts = glyphName.split("-") - firstPart = ( - f"{nameParts[0]}_" - if len(nameParts[0]) == 1 and nameParts[0].isupper() - else nameParts[0] - ) - nameParts[0] = firstPart - - return "-".join(nameParts) - - def _readGlyphMapAndKerningGroups( rawGlyphsData: list, formatVersion: int ) -> tuple[dict[str, list[int]], dict[str, tuple[str, str]]]: diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index f8e44f6..5cef78f 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -2,6 +2,18 @@ from collections import OrderedDict +def getGlyphspackageGlyphFileName(glyphName): + nameParts = glyphName.split("-") + firstPart = ( + f"{nameParts[0]}_" + if len(nameParts[0]) == 1 and nameParts[0].isupper() + else nameParts[0] + ) + nameParts[0] = firstPart + + return "-".join(nameParts) + + # The following is obsolete once this is merged: # https://github.com/fonttools/openstep-plist/pull/35 def toOrderedDict(obj): diff --git a/tests/test_utils.py b/tests/test_utils.py index 2a52750..9210a7a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,6 +6,7 @@ from fontra_glyphs.utils import ( getAssociatedMasterId, + getGlyphspackageGlyphFileName, getLocationFromLayerName, getLocationFromSources, gsFormatting, @@ -57,6 +58,17 @@ def test_getAssociatedMasterId(testGSFont): assert associatedMaster.name == "Regular" +glyphNameToGlyphspackageGlyphFileName = [ + ["A-cy", "A_-cy"], + # This list may extend... +] + + +@pytest.mark.parametrize("glyphName,expected", glyphNameToGlyphspackageGlyphFileName) +def test_getGlyphspackageGlyphFileName(glyphName, expected): + assert getGlyphspackageGlyphFileName(glyphName) == expected + + contentSnippets = [ [ """pos = ( From 6d8e7835cd4eda2a3a7e31990fd360d7d14bfefc Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 12:28:32 +0100 Subject: [PATCH 25/48] Move comment and make it a bit more clear. --- src/fontra_glyphs/backend.py | 2 -- src/fontra_glyphs/utils.py | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index ef9174c..5b6c2c1 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -489,8 +489,6 @@ def _writeRawGlyph(self, glyphName, f): def getGlyphFilePath(self, glyphName): glyphsPath = pathlib.Path(self.gsFilePath) / "glyphs" - # TODO: Get the right glyph file name might be challenging, - # because for example the glyph A-cy is stored in the package as A_-cy.glyph realGlyphName = getGlyphspackageGlyphFileName(glyphName) return glyphsPath / (realGlyphName + ".glyph") diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index 5cef78f..12ba18b 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -3,6 +3,10 @@ def getGlyphspackageGlyphFileName(glyphName): + # Get the right glyph file name might be challenging, because for example + # the glyph "A-cy" is stored in the package as A_-cy.glyph. + # I could not find any documentation about this, yet. We may need to figure + # this out over time and extend the unittest. nameParts = glyphName.split("-") firstPart = ( f"{nameParts[0]}_" From 3a210e678a0612a744bd88b75ee5abf13469ef26 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 16:35:23 +0100 Subject: [PATCH 26/48] Remove seenLayerIDs. Use 'new' layerName (equal to layerId) --- src/fontra_glyphs/backend.py | 38 ++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 5b6c2c1..97c7fe4 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -293,7 +293,6 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None: gsLayers = sorted( gsLayers, key=lambda i_gsLayer: masterOrder[i_gsLayer[1].associatedMasterId] ) - seenLayerIDs = {} seenLocations = [] for i, gsLayer in gsLayers: braceLocation = self._getBraceLayerLocation(gsLayer) @@ -327,8 +326,6 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None: ) layers[layerName] = gsLayerToFontraLayer(gsLayer, self.axisNames) - # NOTE: Remember layerIds like in the following line or add 'identifier' to fontra Layer? - customData["com.glyphsapp.seenLayerIDs"] = seenLayerIDs fixSourceLocations(sources, set(smartLocation)) glyph = VariableGlyph( @@ -766,22 +763,24 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster): def variableGlyphToGSGlyph(variableGlyph, gsGlyph): # Convert Fontra variableGlyph to GlyphsApp glyph masterIds = [m.id for m in gsGlyph.parent.masters] - seenLayerIDs = variableGlyph.customData["com.glyphsapp.seenLayerIDs"] - for layerName, gsLayerId in seenLayerIDs.items(): - if layerName not in variableGlyph.layers: - gsLayerId = seenLayerIDs.get(layerName) - if gsLayerId in masterIds: - # Someone deleted a master, this breaks the compatibility in a .glyphs file. - # Skip deleting master layer. - continue - # Removing non-master-layer: - del gsGlyph.layers[gsLayerId] - - for i, (layerName, layer) in enumerate(iter(variableGlyph.layers.items())): - gsLayerId = seenLayerIDs.get(layerName) - if gsLayerId is not None: + for gsLayerId in [gsLayer.layerId for gsLayer in gsGlyph.layers]: + if gsLayerId in variableGlyph.layers: + # This layer will be modified later. + continue + if gsLayerId in masterIds: + # We don't delete master layers. + continue + # Removing non-master-layer: + del gsGlyph.layers[gsLayerId] + + for layerName, layer in iter(variableGlyph.layers.items()): + gsLayer = gsGlyph.layers.get(layerName) + # layerName is equal to gsLayer.layerId if it comes from Glyphsapp, + # otherwise the layer has been newly created within Fontrta. + + if gsLayer is not None: # gsLayer exists: modify existing gsLayer - fontraLayerToGSLayer(layer, gsGlyph.layers[gsLayerId]) + fontraLayerToGSLayer(layer, gsLayer) else: # gsLayer does not exist: therefore must be 'isSpecialLayer' # and need to be created as a new layer: @@ -807,9 +806,6 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): fontraLayerToGSLayer(layer, gsLayer) gsGlyph.layers.append(gsLayer) - # It might be that someone deletes a not needed master-layer, which works for fontra, - # but is required for Glyphs. We would need to get the intermediate contours. - return gsGlyph From 698722fa640a233447b444639cba39f166500174 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 16:45:23 +0100 Subject: [PATCH 27/48] Update test data "A-cy" + fix test_getGlyph: bug in GlyphsApp 2 file --- tests/data/GlyphsUnitTestSans.glyphs | 2 +- .../GlyphsUnitTestSans3.fontra/glyph-info.csv | 1 + .../glyphs/A-cy^1.json | 58 +++++++++++++++++++ tests/test_backend.py | 2 +- 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tests/data/GlyphsUnitTestSans3.fontra/glyphs/A-cy^1.json diff --git a/tests/data/GlyphsUnitTestSans.glyphs b/tests/data/GlyphsUnitTestSans.glyphs index 042467a..f9e633e 100644 --- a/tests/data/GlyphsUnitTestSans.glyphs +++ b/tests/data/GlyphsUnitTestSans.glyphs @@ -2380,7 +2380,7 @@ layerId = "BFFFD157-90D3-4B85-B99D-9A2F366F03CA"; width = 753; } ); -unicode = 1040; +unicode = 0410; } ); instances = ( diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyph-info.csv b/tests/data/GlyphsUnitTestSans3.fontra/glyph-info.csv index 3bfd775..676f7b8 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyph-info.csv +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyph-info.csv @@ -1,5 +1,6 @@ glyph name;code points A;U+0041 +A-cy;U+0410 Adieresis;U+00C4 V;U+0056 _part.shoulder; diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A-cy^1.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A-cy^1.json new file mode 100644 index 0000000..227f883 --- /dev/null +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A-cy^1.json @@ -0,0 +1,58 @@ +{ +"name": "A-cy", +"sources": [ +{ +"name": "Light", +"layerName": "C4872ECA-A3A9-40AB-960A-1DB2202F16DE", +"location": { +"Weight": 17 +} +}, +{ +"name": "Regular", +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", +"location": { +"Weight": 90 +} +}, +{ +"name": "Bold", +"layerName": "BFFFD157-90D3-4B85-B99D-9A2F366F03CA", +"location": { +"Weight": 220 +} +} +], +"layers": { +"3E7589AA-8194-470F-8E2F-13C1C581BE24": { +"glyph": { +"components": [ +{ +"name": "A" +} +], +"xAdvance": 657 +} +}, +"BFFFD157-90D3-4B85-B99D-9A2F366F03CA": { +"glyph": { +"components": [ +{ +"name": "A" +} +], +"xAdvance": 753 +} +}, +"C4872ECA-A3A9-40AB-960A-1DB2202F16DE": { +"glyph": { +"components": [ +{ +"name": "A" +} +], +"xAdvance": 593 +} +} +} +} diff --git a/tests/test_backend.py b/tests/test_backend.py index f809bdf..9bb6788 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -91,7 +91,7 @@ async def test_getAxes(testFont): "m": [109], "n": [110], "V": [86], - "A-cy": [0x0410], + "A-cy": [1040], } From a40a4297c909e39963daaa60e21b8ea8be463ddd Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 16:50:21 +0100 Subject: [PATCH 28/48] Replace getGlyphspackageGlyphFileName with userNameToFileName (fonttools) --- src/fontra_glyphs/backend.py | 13 ++++--------- src/fontra_glyphs/utils.py | 16 ---------------- tests/test_utils.py | 12 ------------ 3 files changed, 4 insertions(+), 37 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 97c7fe4..1b8cb4a 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -30,6 +30,7 @@ from fontra.core.protocols import WritableFontBackend from fontTools.designspaceLib import DesignSpaceDocument from fontTools.misc.transform import DecomposedTransform +from fontTools.ufoLib.filenames import userNameToFileName from glyphsLib.builder.axes import ( get_axis_definitions, get_regular_master, @@ -38,13 +39,7 @@ from glyphsLib.builder.smart_components import Pole from glyphsLib.types import Transform -from .utils import ( - getAssociatedMasterId, - getGlyphspackageGlyphFileName, - getLocation, - gsFormatting, - toOrderedDict, -) +from .utils import getAssociatedMasterId, getLocation, gsFormatting, toOrderedDict rootInfoNames = [ "familyName", @@ -486,8 +481,8 @@ def _writeRawGlyph(self, glyphName, f): def getGlyphFilePath(self, glyphName): glyphsPath = pathlib.Path(self.gsFilePath) / "glyphs" - realGlyphName = getGlyphspackageGlyphFileName(glyphName) - return glyphsPath / (realGlyphName + ".glyph") + refFileName = userNameToFileName(glyphName, suffix=".glyph") + return glyphsPath / refFileName def _readGlyphMapAndKerningGroups( diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index 12ba18b..f8e44f6 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -2,22 +2,6 @@ from collections import OrderedDict -def getGlyphspackageGlyphFileName(glyphName): - # Get the right glyph file name might be challenging, because for example - # the glyph "A-cy" is stored in the package as A_-cy.glyph. - # I could not find any documentation about this, yet. We may need to figure - # this out over time and extend the unittest. - nameParts = glyphName.split("-") - firstPart = ( - f"{nameParts[0]}_" - if len(nameParts[0]) == 1 and nameParts[0].isupper() - else nameParts[0] - ) - nameParts[0] = firstPart - - return "-".join(nameParts) - - # The following is obsolete once this is merged: # https://github.com/fonttools/openstep-plist/pull/35 def toOrderedDict(obj): diff --git a/tests/test_utils.py b/tests/test_utils.py index 9210a7a..2a52750 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,7 +6,6 @@ from fontra_glyphs.utils import ( getAssociatedMasterId, - getGlyphspackageGlyphFileName, getLocationFromLayerName, getLocationFromSources, gsFormatting, @@ -58,17 +57,6 @@ def test_getAssociatedMasterId(testGSFont): assert associatedMaster.name == "Regular" -glyphNameToGlyphspackageGlyphFileName = [ - ["A-cy", "A_-cy"], - # This list may extend... -] - - -@pytest.mark.parametrize("glyphName,expected", glyphNameToGlyphspackageGlyphFileName) -def test_getGlyphspackageGlyphFileName(glyphName, expected): - assert getGlyphspackageGlyphFileName(glyphName) == expected - - contentSnippets = [ [ """pos = ( From f25b1b6e949072d4233a459ba7f31f2768325f25 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 17:27:31 +0100 Subject: [PATCH 29/48] Refactor and fix unittests --- src/fontra_glyphs/backend.py | 15 +++++++++------ src/fontra_glyphs/utils.py | 28 ++-------------------------- tests/test_backend.py | 27 +++++++++++++++------------ tests/test_utils.py | 23 ++++------------------- 4 files changed, 30 insertions(+), 63 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 1b8cb4a..e3fd2fb 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -39,7 +39,12 @@ from glyphsLib.builder.smart_components import Pole from glyphsLib.types import Transform -from .utils import getAssociatedMasterId, getLocation, gsFormatting, toOrderedDict +from .utils import ( + getAssociatedMasterId, + getLocationFromSources, + gsFormatting, + toOrderedDict, +) rootInfoNames = [ "familyName", @@ -769,7 +774,7 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): del gsGlyph.layers[gsLayerId] for layerName, layer in iter(variableGlyph.layers.items()): - gsLayer = gsGlyph.layers.get(layerName) + gsLayer = gsGlyph.layers[layerName] # layerName is equal to gsLayer.layerId if it comes from Glyphsapp, # otherwise the layer has been newly created within Fontrta. @@ -781,12 +786,10 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): # and need to be created as a new layer: gsLayer = glyphsLib.classes.GSLayer() gsLayer.name = layerName + gsLayer.layerId = layerName gsLayer.isSpecialLayer = True - location = getLocation(variableGlyph, layerName, gsGlyph.parent.axes) - if location is None: - return - + location = getLocationFromSources(variableGlyph.sources, layerName) gsLocation = [ location[axis.name.lower()] for axis in gsGlyph.parent.axes diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index f8e44f6..f156393 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -13,37 +13,13 @@ def toOrderedDict(obj): return obj -def getLocationFromLayerName(layerName, gsAxes): - # Get the location based on name, - # for example: Light / {166, 100} (layer #4) - match = re.search(r"\{([^}]+)\}", layerName) - if not match: - return None - listLocation = match.group(1).replace(" ", "").split(",") - listLocationValues = [float(v) for v in listLocation] - return { - gsAxes[i].name.lower(): value - for i, value in enumerate(listLocationValues) - if i < len(gsAxes) - } - - def getLocationFromSources(sources, layerName): - s = None + s = sources[0] for source in sources: if source.layerName == layerName: s = source break - if s is not None: - return {k.lower(): v for k, v in s.location.items()} - - -def getLocation(glyph, layerName, gsAxes): - location = getLocationFromSources(glyph.sources, layerName) - if location: - return location - # This layerName is not used by any source: - return getLocationFromLayerName(layerName, gsAxes) + return {k.lower(): v for k, v in s.location.items()} def getAssociatedMasterId(gsGlyph, gsLocation): diff --git a/tests/test_backend.py b/tests/test_backend.py index 9bb6788..ebe81c5 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,6 +1,7 @@ import os import pathlib import shutil +import uuid from copy import deepcopy import pytest @@ -179,7 +180,7 @@ async def test_deleteLayer(writableTestFont): glyphMap = await writableTestFont.getGlyphMap() glyph = await writableTestFont.getGlyph(glyphName) - layerName = "Regular (layer #1)" + layerName = "3E7589AA-8194-470F-8E2F-13C1C581BE24" del glyph.layers[layerName] await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) @@ -195,19 +196,20 @@ async def test_addLayer(writableTestFont): glyph = await writableTestFont.getGlyph(glyphName) numGlyphLayers = len(glyph.layers) + layerName = str(uuid.uuid4()).upper() glyph.sources.append( - GlyphSource(name="{166, 100}", location={"weight": 166}, layerName="{166, 100}") + GlyphSource(name="SemiBold", location={"weight": 166}, layerName=layerName) ) # Copy StaticGlyph of Bold: - glyph.layers["{166, 100}"] = Layer( - glyph=deepcopy(glyph.layers["Bold (layer #2)"].glyph) + glyph.layers[layerName] = Layer( + glyph=deepcopy(glyph.layers["BFFFD157-90D3-4B85-B99D-9A2F366F03CA"].glyph) ) await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) savedGlyph = await writableTestFont.getGlyph(glyphName) assert len(savedGlyph.layers) > numGlyphLayers - assert "Bold / {166, 100} (layer #4)" in savedGlyph.layers.keys() + assert layerName in savedGlyph.layers.keys() async def test_addLayerWithComponent(writableTestFont): @@ -216,19 +218,20 @@ async def test_addLayerWithComponent(writableTestFont): glyph = await writableTestFont.getGlyph(glyphName) numGlyphLayers = len(glyph.layers) + layerName = str(uuid.uuid4()).upper() glyph.sources.append( - GlyphSource(name="{166, 100}", location={"weight": 166}, layerName="{166, 100}") + GlyphSource(name="SemiBold", location={"weight": 166}, layerName=layerName) ) # Copy StaticGlyph of Bold: - glyph.layers["{166, 100}"] = Layer( - glyph=deepcopy(glyph.layers["Bold (layer #2)"].glyph) + glyph.layers[layerName] = Layer( + glyph=deepcopy(glyph.layers["BFFFD157-90D3-4B85-B99D-9A2F366F03CA"].glyph) ) await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) savedGlyph = await writableTestFont.getGlyph(glyphName) assert len(savedGlyph.layers) > numGlyphLayers - assert "Bold / {166, 100} (layer #3)" in savedGlyph.layers.keys() + assert layerName in savedGlyph.layers.keys() async def test_deleteMasterLayer(writableTestFont): @@ -239,7 +242,7 @@ async def test_deleteMasterLayer(writableTestFont): glyph = await writableTestFont.getGlyph(glyphName) numGlyphLayers = len(glyph.layers) - del glyph.layers["Bold (layer #2)"] + del glyph.layers["BFFFD157-90D3-4B85-B99D-9A2F366F03CA"] await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) @@ -252,7 +255,7 @@ async def test_addAnchor(writableTestFont): glyphMap = await writableTestFont.getGlyphMap() glyph = await writableTestFont.getGlyph(glyphName) - layerName = "{ 166, 100 }" + layerName = str(uuid.uuid4()).upper() glyph.layers[layerName] = Layer(glyph=StaticGlyph(xAdvance=0)) glyph.layers[layerName].glyph.anchors.append(Anchor(name="top", x=207, y=746)) @@ -262,7 +265,7 @@ async def test_addAnchor(writableTestFont): assert ( glyph.layers[layerName].glyph.anchors - == savedGlyph.layers["Bold / { 166, 100 } (layer #4)"].glyph.anchors + == savedGlyph.layers[layerName].glyph.anchors ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2a52750..37a0c61 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,7 +6,6 @@ from fontra_glyphs.utils import ( getAssociatedMasterId, - getLocationFromLayerName, getLocationFromSources, gsFormatting, ) @@ -28,25 +27,11 @@ def testGSFont(request): return glyphsLib.GSFont(request.param) -layerNamesToLocation = [ - ["Light / {166, 100} (layer #4)", {"weight": 166}], - ["{ 166 } (layer #3)", {"weight": 166}], - ["Light / (layer #4)", None], -] - - -@pytest.mark.parametrize("layerName,expected", layerNamesToLocation) -def test_getLocationFromLayerName(layerName, expected): - gsFont = glyphsLib.classes.GSFont() - gsFont.axes = [glyphsLib.classes.GSAxis(name="Weight", tag="wght")] - location = getLocationFromLayerName(layerName, gsFont.axes) - assert location == expected - - async def test_getLocationFromSources(testFont): - glyphName = "a" - glyph = await testFont.getGlyph(glyphName) - location = getLocationFromSources(glyph.sources, "Regular / {155, 100} (layer #3)") + glyph = await testFont.getGlyph("a") + location = getLocationFromSources( + glyph.sources, "1FA54028-AD2E-4209-AA7B-72DF2DF16264" + ) assert location == {"weight": 155} From 1e0ae6aa39d75917389434bcd0ea6a7ff1b5c9d4 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 17:36:00 +0100 Subject: [PATCH 30/48] rework _writeRawGlyph (dumps) --- src/fontra_glyphs/backend.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index e3fd2fb..e730b03 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -416,29 +416,21 @@ async def putGlyph( def _writeRawGlyph(self, glyphName, f): # 5. write whole file with openstep_plist with open(self.gsFilePath, "r+", encoding="utf-8") as fp: - openstep_plist.dump( + content = openstep_plist.dumps( self.rawFontData, - fp, unicode_escape=False, indent=0, single_line_tuples=True, escape_newlines=False, ) - # 6. fix formatting - with open(self.gsFilePath, "r", encoding="utf-8") as fp: - content = gsFormatting(fp.read()) + # 6. fix formatting + content = gsFormatting(content) if self.gsFont.format_version >= 3: - # add blank break at the end of the file. + # add break at the end of the file. content += "\n" - with open(self.gsFilePath, "w", encoding="utf-8") as fp: fp.write(content) - # This following does not wrok, correctly, there the code above. - # with open(self.gsFilePath, "r+", encoding="utf-8") as fp: - # content = gsFormatting(fp.read()) - # fp.write(content) - async def aclose(self) -> None: pass From e4e097e1a52dbee93f80468f8a6ca80de1ce2d14 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 17:36:16 +0100 Subject: [PATCH 31/48] Remove resolved comment --- tests/test_backend.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_backend.py b/tests/test_backend.py index ebe81c5..e505ac4 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -137,10 +137,6 @@ async def test_getGlyph(testFont, referenceFont, glyphName): glyph.customData["com.glyphsapp.glyph-color"] = [120, 220, 20, 4] referenceGlyph = await referenceFont.getGlyph(glyphName) - # TODO: This unit test fails currently, because the fontra referenceFont - # does not contain the customData "com.glyphsapp.seenLayerIDs". - # Before I update the fontra file, I would like to discuss with Just, - # if this is the right approach. test_putGlyph works now. assert referenceGlyph == glyph From f1fc7e0c386817428a5fde1418c84a78390d4e57 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Wed, 22 Jan 2025 18:03:56 +0100 Subject: [PATCH 32/48] Fix variable name, add and remove some comments --- src/fontra_glyphs/backend.py | 7 +++---- src/fontra_glyphs/utils.py | 9 ++++----- tests/test_utils.py | 2 ++ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index e730b03..361be45 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -293,6 +293,7 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None: gsLayers = sorted( gsLayers, key=lambda i_gsLayer: masterOrder[i_gsLayer[1].associatedMasterId] ) + seenLocations = [] for i, gsLayer in gsLayers: braceLocation = self._getBraceLayerLocation(gsLayer) @@ -810,9 +811,7 @@ def fontraLayerToGSLayer(layer, gsLayer): gsLayer.components = [ fontraComponentToGSComponent(component) for component in layer.glyph.components ] - gsLayer.anchors = [ - fontraAnchorsToGSAnchor(anchor) for anchor in layer.glyph.anchors - ] + gsLayer.anchors = [fontraAnchorToGSAnchor(anchor) for anchor in layer.glyph.anchors] def fontraComponentToGSComponent(component): @@ -824,7 +823,7 @@ def fontraComponentToGSComponent(component): return gsComponent -def fontraAnchorsToGSAnchor(anchor): +def fontraAnchorToGSAnchor(anchor): gsAnchor = glyphsLib.classes.GSAnchor() gsAnchor.name = anchor.name gsAnchor.position.x = anchor.x diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index f156393..1ad45c2 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -40,11 +40,10 @@ def getAssociatedMasterId(gsGlyph, gsLocation): def gsFormatting(content): - # openstep_plist.dump changes the whole formatting, therefore - # it's very diffucute to see what has changed. - # This function is a very bad try to get close to how the formatting - # looks like for a .glyphs file. - # There must be a better solution, but this is better than nothing. + # TODO: This need a different solution. + # Should be solved in the raw data not via regular expressions. + # The raw data is made out of list. We need to convert some part into tuples. + # For more please see: https://github.com/fonttools/openstep-plist/issues/33 patterns = [ ( diff --git a/tests/test_utils.py b/tests/test_utils.py index 37a0c61..339a8d8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -36,6 +36,8 @@ async def test_getLocationFromSources(testFont): def test_getAssociatedMasterId(testGSFont): + # TODO: need more complex test with at least two axes, + # then improvement getAssociatedMasterId gsGlyph = testGSFont.glyphs["a"] associatedMasterId = getAssociatedMasterId(gsGlyph, [155]) associatedMaster = gsGlyph.layers[associatedMasterId] From 7d56bac0c52e767cfabe7d71dba8be4a5cc0e9e4 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 11:17:00 +0100 Subject: [PATCH 33/48] Improve getAssociatedMasterId + unittests --- src/fontra_glyphs/backend.py | 2 +- src/fontra_glyphs/utils.py | 12 +++--- tests/test_utils.py | 77 ++++++++++++++++++++++++++++++------ 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 361be45..a94794f 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -790,7 +790,7 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): ] gsLayer.attributes["coordinates"] = gsLocation - associatedMasterId = getAssociatedMasterId(gsGlyph, gsLocation) + associatedMasterId = getAssociatedMasterId(gsGlyph.parent, gsLocation) if associatedMasterId: gsLayer.associatedMasterId = associatedMasterId diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index 1ad45c2..16f0bd4 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -22,12 +22,11 @@ def getLocationFromSources(sources, layerName): return {k.lower(): v for k, v in s.location.items()} -def getAssociatedMasterId(gsGlyph, gsLocation): +def getAssociatedMasterId(gsFont, gsLocation): # Best guess for associatedMasterId - closestMaster = None + closestMasterID = gsFont.masters[0].id # default first master. closestDistance = float("inf") - for gsLayer in gsGlyph.layers: - gsMaster = gsLayer.master + for gsMaster in gsFont.masters: distance = sum( abs(gsMaster.axes[i] - gsLocation[i]) for i in range(len(gsMaster.axes)) @@ -35,8 +34,9 @@ def getAssociatedMasterId(gsGlyph, gsLocation): ) if distance < closestDistance: closestDistance = distance - closestMaster = gsMaster - return closestMaster.id if closestMaster else None + closestMasterID = gsMaster.id + + return closestMasterID def gsFormatting(content): diff --git a/tests/test_utils.py b/tests/test_utils.py index 339a8d8..aa0bcbd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,8 @@ import pathlib -import glyphsLib import pytest from fontra.backends import getFileSystemBackend +from glyphsLib.classes import GSAxis, GSFont, GSFontMaster, GSGlyph, GSLayer from fontra_glyphs.utils import ( getAssociatedMasterId, @@ -22,9 +22,54 @@ def testFont(request): return getFileSystemBackend(request.param) -@pytest.fixture(scope="module", params=[glyphs2Path, glyphs3Path, glyphsPackagePath]) -def testGSFont(request): - return glyphsLib.GSFont(request.param) +def createGSFontMaster(axes=[100, 100], id="DUMMY-MASTER-ID"): + master = GSFontMaster() + master.axes = axes + master.id = id + return master + + +def createGSGlyph(name="GlyphName", unicodes=[], layers=[]): + glyph = GSGlyph() + glyph.name = name + glyph.unicodes = unicodes + glyph.layers = layers + return glyph + + +@pytest.fixture(scope="module") +def testGSFontWW(): + gsFont = GSFont() + gsFont.format_version = 3 + gsFont.axes = [ + GSAxis(name="Optical Size", tag="opsz"), + GSAxis(name="Weight", tag="wght"), + GSAxis(name="Width", tag="wdth"), + ] + gsFont.masters = [ + createGSFontMaster(axes=[12, 50, 100], id="MasterID-TextCondLight"), + createGSFontMaster(axes=[12, 50, 400], id="MasterID-TextCondRegular"), + createGSFontMaster(axes=[12, 50, 900], id="MasterID-TextCondBold"), + createGSFontMaster(axes=[12, 200, 100], id="MasterID-TextWideLight"), + createGSFontMaster(axes=[12, 200, 400], id="MasterID-TextWideRegular"), + createGSFontMaster(axes=[12, 200, 900], id="MasterID-TextWideBold"), + createGSFontMaster(axes=[60, 50, 100], id="MasterID-PosterCondLight"), + createGSFontMaster(axes=[60, 50, 400], id="MasterID-PosterCondRegular"), + createGSFontMaster(axes=[60, 50, 900], id="MasterID-PosterCondBold"), + createGSFontMaster(axes=[60, 200, 100], id="MasterID-PosterWideLight"), + createGSFontMaster(axes=[60, 200, 400], id="MasterID-PosterWideRegular"), + createGSFontMaster(axes=[60, 200, 900], id="MasterID-PosterWideBold"), + ] + gsFont.glyphs.append( + createGSGlyph( + name="A", + unicodes=[ + 0x0041, + ], + layers=[GSLayer()], + ) + ) + return gsFont async def test_getLocationFromSources(testFont): @@ -35,13 +80,23 @@ async def test_getLocationFromSources(testFont): assert location == {"weight": 155} -def test_getAssociatedMasterId(testGSFont): - # TODO: need more complex test with at least two axes, - # then improvement getAssociatedMasterId - gsGlyph = testGSFont.glyphs["a"] - associatedMasterId = getAssociatedMasterId(gsGlyph, [155]) - associatedMaster = gsGlyph.layers[associatedMasterId] - assert associatedMaster.name == "Regular" +expectedAssociatedMasterId = [ + # gsLocation, associatedMasterId + [[14, 155, 900], "MasterID-TextWideBold"], + [[14, 155, 100], "MasterID-TextWideLight"], + [[14, 55, 900], "MasterID-TextCondBold"], + [[14, 55, 110], "MasterID-TextCondLight"], + [[55, 155, 900], "MasterID-PosterWideBold"], + [[55, 155, 100], "MasterID-PosterWideLight"], + [[55, 55, 900], "MasterID-PosterCondBold"], + [[55, 55, 110], "MasterID-PosterCondLight"], + [[30, 100, 399], "MasterID-TextCondRegular"], +] + + +@pytest.mark.parametrize("gsLocation,expected", expectedAssociatedMasterId) +def test_getAssociatedMasterId(testGSFontWW, gsLocation, expected): + assert getAssociatedMasterId(testGSFontWW, gsLocation) == expected contentSnippets = [ From 248fdf1b587e862905d3bc9b54d25a1b2783df05 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 11:48:24 +0100 Subject: [PATCH 34/48] Raise NotImplementedError for stubs --- src/fontra_glyphs/backend.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index a94794f..f6b1b18 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -172,12 +172,10 @@ async def getGlyphMap(self) -> dict[str, list[int]]: return self.glyphMap async def putGlyphMap(self, value: dict[str, list[int]]) -> None: - print("GlyphsBackend putGlyphMap: ", value) pass async def deleteGlyph(self, glyphName): - print("GlyphsBackend deleteGlyph: ", glyphName) - pass + raise NotImplementedError("deleting glyphs is not yet implemented") async def getFontInfo(self) -> FontInfo: infoDict = {} @@ -195,29 +193,25 @@ async def getFontInfo(self) -> FontInfo: return FontInfo(**infoDict) async def putFontInfo(self, fontInfo: FontInfo): - print("GlyphsBackend putFontInfo: ", fontInfo) - pass + raise NotImplementedError("editing FontInfo is not yet implemented") async def getSources(self) -> dict[str, FontSource]: return gsMastersToFontraFontSources(self.gsFont, self.locationByMasterID) async def putSources(self, sources: dict[str, FontSource]) -> None: - print("GlyphsBackend putSources: ", sources) - pass + raise NotImplementedError("editing FontSources is not yet implemented") async def getAxes(self) -> Axes: return Axes(axes=self.axes) async def putAxes(self, axes: Axes) -> None: - print("GlyphsBackend putAxes: ", axes) - pass + raise NotImplementedError("editing Axes is not yet implemented") async def getUnitsPerEm(self) -> int: return self.gsFont.upm async def putUnitsPerEm(self, value: int) -> None: - print("GlyphsBackend putUnitsPerEm: ", value) - pass + raise NotImplementedError("editing UnitsPerEm is not yet implemented") async def getKerning(self) -> dict[str, Kerning]: # TODO: RTL kerning: https://docu.glyphsapp.com/#GSFont.kerningRTL @@ -239,30 +233,26 @@ async def getKerning(self) -> dict[str, Kerning]: return kerning async def putKerning(self, kerning: dict[str, Kerning]) -> None: - print("GlyphsBackend putKerning: ", kerning) - pass + raise NotImplementedError("editing Kerning is not yet implemented") async def getFeatures(self) -> OpenTypeFeatures: # TODO: extract features return OpenTypeFeatures() async def putFeatures(self, features: OpenTypeFeatures) -> None: - print("GlyphsBackend putFeatures: ", features) - pass + raise NotImplementedError("editing OpenTypeFeatures is not yet implemented") async def getBackgroundImage(self, imageIdentifier: str) -> ImageData | None: return None async def putBackgroundImage(self, imageIdentifier: str, data: ImageData) -> None: - print("GlyphsBackend putBackgroundImage: ", imageIdentifier, data) - pass + raise NotImplementedError("editing BackgroundImage is not yet implemented") async def getCustomData(self) -> dict[str, Any]: return {} async def putCustomData(self, lib): - print("GlyphsBackend putCustomData: ", lib) - pass + raise NotImplementedError("editing CustomData is not yet implemented") async def getGlyph(self, glyphName: str) -> VariableGlyph | None: if glyphName not in self.glyphNameToIndex: From 00753f903d48cff58c27aaf6f0373859cdf5d6a5 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 11:52:42 +0100 Subject: [PATCH 35/48] Update self.glyphMap within putGlyph --- src/fontra_glyphs/backend.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index f6b1b18..06341e6 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -379,6 +379,10 @@ def _getSmartLocation(self, gsLayer, localAxesByName): async def putGlyph( self, glyphName: str, glyph: VariableGlyph, codePoints: list[int] ) -> None: + assert isinstance(codePoints, list) + assert all(isinstance(cp, int) for cp in codePoints) + self.glyphMap[glyphName] = codePoints + # 1. convert VariableGlyph to GSGlyph gsGlyphNew = variableGlyphToGSGlyph( glyph, deepcopy(self.gsFont.glyphs[glyphName]) From 834dfe7f3d6735c427a6b6159c8218a0df2c293f Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 14:40:59 +0100 Subject: [PATCH 36/48] replace gsFormatting with convertMatchesToTuples --- src/fontra_glyphs/backend.py | 21 ++-- src/fontra_glyphs/utils.py | 92 +++++++++++------- tests/test_utils.py | 184 +++++------------------------------ 3 files changed, 88 insertions(+), 209 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 06341e6..7362b14 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -40,9 +40,10 @@ from glyphsLib.types import Transform from .utils import ( + convertMatchesToTuples, getAssociatedMasterId, getLocationFromSources, - gsFormatting, + matchTreeFont, toOrderedDict, ) @@ -410,21 +411,19 @@ async def putGlyph( def _writeRawGlyph(self, glyphName, f): # 5. write whole file with openstep_plist - with open(self.gsFilePath, "r+", encoding="utf-8") as fp: - content = openstep_plist.dumps( - self.rawFontData, + result = convertMatchesToTuples(self.rawFontData, matchTreeFont) + self.gsFilePath.write_text( + openstep_plist.dumps( + result, unicode_escape=False, indent=0, single_line_tuples=True, escape_newlines=False, + sort_keys=False, + single_line_empty_objects=False, ) - - # 6. fix formatting - content = gsFormatting(content) - if self.gsFont.format_version >= 3: - # add break at the end of the file. - content += "\n" - fp.write(content) + + "\n" + ) async def aclose(self) -> None: pass diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index 16f0bd4..cf33cf4 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -1,4 +1,3 @@ -import re from collections import OrderedDict @@ -39,38 +38,59 @@ def getAssociatedMasterId(gsFont, gsLocation): return closestMasterID -def gsFormatting(content): - # TODO: This need a different solution. - # Should be solved in the raw data not via regular expressions. - # The raw data is made out of list. We need to convert some part into tuples. - # For more please see: https://github.com/fonttools/openstep-plist/issues/33 - - patterns = [ - ( - r"customBinaryData = <\s*([0-9a-fA-F\s]+)\s*>;", - lambda m: f"customBinaryData = <{m.group(1).replace(' ', '')}>;", - ), - (r"\(\s*(-?[\d.]+),\s*(-?[\d.]+)\s*\);", r"(\1,\2);"), - (r"\(\s*(-?[\d.]+),\s*(-?[\d.]+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)"), - (r"origin = \(\s*(-?[\d.]+),\s*(-?[\d.]+)\s*\);", r"origin = (\1,\2);"), - (r"target = \(\s*(-?[\d.]+),\s*(-?[\d.]+)\s*\);", r"target = (\1,\2);"), - ( - r"color = \(\s*(\d+),\s*(\d+),\s*(\d+),\s*(\d+)\s*\);", - r"color = (\1,\2,\3,\4);", - ), - (r"\(\s*(-?[\d.]+),\s*(-?[\d.]+),\s*([a-zA-Z]+)\s*\)", r"(\1,\2,\3)"), - (r"\(\s*(-?[\d.]+),\s*(-?[\d.]+),\s*([a-zA-Z]+),\s*\{", r"(\1,\2,\3,{"), - (r"\}\s*\),", r"}),"), - (r"anchors = \(\);", r"anchors = (\n);"), - (r"unicode = \(\);", r"unicode = (\n);"), - (r"lib = \{\};", r"lib = {\n};"), - ( - r"verticalStems = \(\s*(-?[\d.]+),(-?[\d.]+)\);", - r"verticalStems = (\n\1,\n\2\n);", - ), - ] - - for pattern, replacement in patterns: - content = re.sub(pattern, replacement, content) - - return content +LEAF = object() + + +def patternsToMatchTree(patterns): + tree = {} + for pattern in patterns: + subtree = tree + for item in pattern[:-1]: + if item not in subtree: + subtree[item] = {} + subtree = subtree[item] + subtree[pattern[-1]] = LEAF + return tree + + +def convertMatchesToTuples(obj, matchTree, path=()): + if isinstance(obj, dict): + assert matchTree is not LEAF, path + return { + k: convertMatchesToTuples( + v, matchTree.get(k, matchTree.get(None, {})), path + (k,) + ) + for k, v in obj.items() + } + elif isinstance(obj, list): + convertToTuple = False + if matchTree is LEAF: + convertToTuple = True + matchTree = {} + seq = [ + convertMatchesToTuples(item, matchTree.get(None, {}), path + (i,)) + for i, item in enumerate(obj) + ] + if convertToTuple: + seq = tuple(seq) + return seq + else: + return obj + + +patterns = [ + ["fontMaster", None, "guides", None, "pos"], + ["glyphs", None, "color"], + ["glyphs", None, "layers", None, "anchors", None, "pos"], + ["glyphs", None, "layers", None, "annotations", None, "pos"], + ["glyphs", None, "layers", None, "background", "shapes", None, "nodes", None], + ["glyphs", None, "layers", None, "guides", None, "pos"], + ["glyphs", None, "layers", None, "hints", None, "origin"], + ["glyphs", None, "layers", None, "hints", None, "target"], + ["glyphs", None, "layers", None, "shapes", None, "nodes", None], + ["glyphs", None, "layers", None, "shapes", None, "pos"], +] + + +matchTreeFont = patternsToMatchTree(patterns) +matchTreeGlyph = matchTreeFont["glyphs"][None] diff --git a/tests/test_utils.py b/tests/test_utils.py index aa0bcbd..3337504 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,13 +1,15 @@ import pathlib +import openstep_plist import pytest from fontra.backends import getFileSystemBackend from glyphsLib.classes import GSAxis, GSFont, GSFontMaster, GSGlyph, GSLayer from fontra_glyphs.utils import ( + convertMatchesToTuples, getAssociatedMasterId, getLocationFromSources, - gsFormatting, + matchTreeFont, ) dataDir = pathlib.Path(__file__).resolve().parent / "data" @@ -99,165 +101,23 @@ def test_getAssociatedMasterId(testGSFontWW, gsLocation, expected): assert getAssociatedMasterId(testGSFontWW, gsLocation) == expected -contentSnippets = [ - [ - """pos = ( -524, -141 -);""", - "pos = (524,141);", - ], - [ - """pos = ( --113, -765 -);""", - "pos = (-113,765);", - ], - [ - "customBinaryData = <74686520 62797465 73>;", - "customBinaryData = <746865206279746573>;", - ], - [ - """color = ( -120, -220, -20, -4 -);""", - "color = (120,220,20,4);", - ], - [ - """( -566.99, -700, -l -),""", - "(566.99,700,l),", - ], - [ - """( -191, -700, -l -),""", - "(191,700,l),", - ], - [ - """origin = ( -1, -1 -);""", - "origin = (1,1);", - ], - [ - """target = ( -1, -0 -);""", - "target = (1,0);", - ], - [ - """pos = ( -45, -0 -);""", - "pos = (45,0);", - ], - [ - """pos = ( --45, -0 -);""", - "pos = (-45,0);", - ], - [ - """( -321, -700, -l, -{""", - "(321,700,l,{", - ], - [ - """( -268, -153, -ls -),""", - "(268,153,ls),", - ], - [ - """( -268, -153, -o -),""", - "(268,153,o),", - ], - [ - """( -268, -153, -cs -),""", - "(268,153,cs),", - ], - [ - """( -184, --8, -c -),""", - "(184,-8,c),", - ], - [ - """pos = ( -334.937, -407.08 -);""", - "pos = (334.937,407.08);", - ], - [ - """pos = ( --113, -574 -);""", - "pos = (-113,574);", - ], - ["pos = (524,-122);", "pos = (524,-122);"], - [ - "anchors = ();", - """anchors = ( -);""", - ], - [ - "unicode = ();", - """unicode = ( -);""", - ], - [ - "lib = {};", - """lib = { -};""", - ], - [ - "verticalStems = (17,19);", - """verticalStems = ( -17, -19 -);""", - ], - # TODO: The following does not fail in the unittest: diff \n vs \012 - [ - """code = "feature c2sc; -feature smcp; -";""", - """code = "feature c2sc;\012feature smcp;\012";""", - ], -] - +@pytest.mark.parametrize("path", [glyphs2Path, glyphs3Path]) +def test_roundtrip_glyphs_file_dumps(path): + root = openstep_plist.loads(path.read_text(), use_numbers=True) + result = convertMatchesToTuples(root, matchTreeFont) + + out = ( + openstep_plist.dumps( + result, + unicode_escape=False, + indent=0, + single_line_tuples=True, + escape_newlines=False, + sort_keys=False, + single_line_empty_objects=False, + ) + + "\n" + ) -@pytest.mark.parametrize("content,expected", contentSnippets) -def test_gsFormatting(content, expected): - assert gsFormatting(content) == expected + for root_line, out_line in zip(path.read_text().splitlines(), out.splitlines()): + assert root_line == out_line From a7fbd4526419bf6e0632850c675c76aa73addcd4 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 15:27:14 +0100 Subject: [PATCH 37/48] Fix customBinaryData formatting with regEx --- src/fontra_glyphs/backend.py | 6 +++++- src/fontra_glyphs/utils.py | 9 +++++++++ tests/test_utils.py | 5 ++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 7362b14..a5e3929 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -41,6 +41,7 @@ from .utils import ( convertMatchesToTuples, + fixCustomBinaryDataFormatting, getAssociatedMasterId, getLocationFromSources, matchTreeFont, @@ -412,7 +413,7 @@ async def putGlyph( def _writeRawGlyph(self, glyphName, f): # 5. write whole file with openstep_plist result = convertMatchesToTuples(self.rawFontData, matchTreeFont) - self.gsFilePath.write_text( + out = ( openstep_plist.dumps( result, unicode_escape=False, @@ -425,6 +426,9 @@ def _writeRawGlyph(self, glyphName, f): + "\n" ) + out = fixCustomBinaryDataFormatting(out) + self.gsFilePath.write_text(out) + async def aclose(self) -> None: pass diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index cf33cf4..5da0011 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -1,3 +1,4 @@ +import re from collections import OrderedDict @@ -94,3 +95,11 @@ def convertMatchesToTuples(obj, matchTree, path=()): matchTreeFont = patternsToMatchTree(patterns) matchTreeGlyph = matchTreeFont["glyphs"][None] + + +def fixCustomBinaryDataFormatting(content): + return re.sub( + r"customBinaryData = <\s*([0-9a-fA-F\s]+)\s*>;", + lambda m: f"customBinaryData = <{m.group(1).replace(' ', '')}>;", + content, + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 3337504..4980fca 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,6 +7,7 @@ from fontra_glyphs.utils import ( convertMatchesToTuples, + fixCustomBinaryDataFormatting, getAssociatedMasterId, getLocationFromSources, matchTreeFont, @@ -101,7 +102,7 @@ def test_getAssociatedMasterId(testGSFontWW, gsLocation, expected): assert getAssociatedMasterId(testGSFontWW, gsLocation) == expected -@pytest.mark.parametrize("path", [glyphs2Path, glyphs3Path]) +@pytest.mark.parametrize("path", [glyphs3Path]) def test_roundtrip_glyphs_file_dumps(path): root = openstep_plist.loads(path.read_text(), use_numbers=True) result = convertMatchesToTuples(root, matchTreeFont) @@ -119,5 +120,7 @@ def test_roundtrip_glyphs_file_dumps(path): + "\n" ) + out = fixCustomBinaryDataFormatting(out) + for root_line, out_line in zip(path.read_text().splitlines(), out.splitlines()): assert root_line == out_line From d41b0b202364297404626b593fcaf30e560f2e67 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 15:45:50 +0100 Subject: [PATCH 38/48] Adding guideline to get- and putGlyph --- README.md | 2 +- src/fontra_glyphs/backend.py | 17 +++++++++++++++++ tests/test_backend.py | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b8b8a2e..04cad85 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,4 @@ It supports the following features: - Contour (Paths, Nodes) ✅ - Components ✅ - Anchors ✅ -- Guidelines +- Guidelines ✅ diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index a5e3929..208a998 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -529,6 +529,9 @@ def gsLayerToFontraLayer(gsLayer, globalAxisNames): ] anchors = [gsAnchorToFontraAnchor(gsAnchor) for gsAnchor in gsLayer.anchors] + guidelines = [ + gsGuidelineToFontraGuideline(gsGuideline) for gsGuideline in gsLayer.guides + ] return Layer( glyph=StaticGlyph( @@ -536,6 +539,7 @@ def gsLayerToFontraLayer(gsLayer, globalAxisNames): path=pen.getPath(), components=components, anchors=anchors, + guidelines=guidelines, ) ) @@ -809,6 +813,9 @@ def fontraLayerToGSLayer(layer, gsLayer): fontraComponentToGSComponent(component) for component in layer.glyph.components ] gsLayer.anchors = [fontraAnchorToGSAnchor(anchor) for anchor in layer.glyph.anchors] + gsLayer.guides = [ + fontraGuidelineToGSGuide(guideline) for guideline in layer.glyph.guidelines + ] def fontraComponentToGSComponent(component): @@ -831,3 +838,13 @@ def fontraAnchorToGSAnchor(anchor): # is relative to the LSB (0), center (2) or RSB (1). # Details: https://docu.glyphsapp.com/#GSAnchor.orientation return gsAnchor + + +def fontraGuidelineToGSGuide(guideline): + gsGuide = glyphsLib.classes.GSGuide() + gsGuide.name = guideline.name + gsGuide.position.x = guideline.x + gsGuide.position.y = guideline.y + gsGuide.angle = guideline.angle + gsGuide.locked = guideline.locked + return gsGuide diff --git a/tests/test_backend.py b/tests/test_backend.py index e505ac4..259175b 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -11,6 +11,7 @@ Axes, FontInfo, GlyphSource, + Guideline, Layer, StaticGlyph, structure, @@ -265,6 +266,25 @@ async def test_addAnchor(writableTestFont): ) +async def test_addGuideline(writableTestFont): + glyphName = "a" + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + + layerName = str(uuid.uuid4()).upper() + glyph.layers[layerName] = Layer(glyph=StaticGlyph(xAdvance=0)) + glyph.layers[layerName].glyph.guidelines.append(Guideline(name="top", x=207, y=746)) + + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + + assert ( + glyph.layers[layerName].glyph.guidelines + == savedGlyph.layers[layerName].glyph.guidelines + ) + + async def test_getKerning(testFont, referenceFont): assert await testFont.getKerning() == await referenceFont.getKerning() From 2161e6df3d2cf3b4523c4dede9631e61871e7616 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 17:19:12 +0100 Subject: [PATCH 39/48] Removing toOrderedDict, not necessary anymore because it's done by Just's openstep-plist --- src/fontra_glyphs/backend.py | 5 ++--- src/fontra_glyphs/utils.py | 12 ------------ 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 208a998..74b209f 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -45,7 +45,6 @@ getAssociatedMasterId, getLocationFromSources, matchTreeFont, - toOrderedDict, ) rootInfoNames = [ @@ -115,8 +114,8 @@ def _setupFromPath(self, path: PathLike) -> None: self.gsFont.glyphs = [ glyphsLib.classes.GSGlyph() for i in range(len(rawGlyphsData)) ] - self.rawFontData = toOrderedDict(rawFontData) - self.rawGlyphsData = toOrderedDict(rawGlyphsData) + self.rawFontData = rawFontData + self.rawGlyphsData = rawGlyphsData self.glyphNameToIndex = { glyphData["glyphname"]: i for i, glyphData in enumerate(rawGlyphsData) diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index 5da0011..10513a5 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -1,16 +1,4 @@ import re -from collections import OrderedDict - - -# The following is obsolete once this is merged: -# https://github.com/fonttools/openstep-plist/pull/35 -def toOrderedDict(obj): - if isinstance(obj, dict): - return OrderedDict({k: toOrderedDict(v) for k, v in obj.items()}) - elif isinstance(obj, list): - return [toOrderedDict(item) for item in obj] - else: - return obj def getLocationFromSources(sources, layerName): From 33840a32993102d9600db3ded3d858728c4d1695 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 17:21:30 +0100 Subject: [PATCH 40/48] Updating fontra data, because of missing guideline --- tests/data/GlyphsUnitTestSans3.fontra/glyphs/A^1.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A^1.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A^1.json index 13dbe9a..d8d9bc3 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A^1.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A^1.json @@ -276,6 +276,13 @@ "x": 297, "y": 700 } +], +"guidelines": [ +{ +"name": "", +"x": 45, +"angle": 71.7587 +} ] } } From 35683384a9232f8ca540767c14f16bf56ad2c6e9 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 17:37:18 +0100 Subject: [PATCH 41/48] Raise error when trying to delete a master layer. Remove numbers from comments. --- src/fontra_glyphs/backend.py | 16 +++++++++------- tests/test_backend.py | 11 ++++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 74b209f..e052593 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -384,33 +384,33 @@ async def putGlyph( assert all(isinstance(cp, int) for cp in codePoints) self.glyphMap[glyphName] = codePoints - # 1. convert VariableGlyph to GSGlyph + # Convert VariableGlyph to GSGlyph gsGlyphNew = variableGlyphToGSGlyph( glyph, deepcopy(self.gsFont.glyphs[glyphName]) ) - # 2. serialize to text with glyphsLib.writer.Writer(), using io.StringIO or io.BytesIO + # Serialize to text with glyphsLib.writer.Writer(), using io.StringIO f = io.StringIO() writer = glyphsLib.writer.Writer(f) writer.format_version = self.gsFont.format_version writer.write(gsGlyphNew) - # 3. parse stream into "raw" object + # Parse stream into "raw" object f.seek(0) rawGlyphData = openstep_plist.load(f, use_numbers=True) - # 4. replace original "raw" object with new "raw" object + # Replace original "raw" object with new "raw" object self.rawGlyphsData[self.glyphNameToIndex[glyphName]] = rawGlyphData self.rawFontData["glyphs"] = self.rawGlyphsData self._writeRawGlyph(glyphName, f) - # 7. Remove glyph from parsed glyph names, because we changed it. + # Remove glyph from parsed glyph names, because we changed it. # Next time it needs to be parsed again. self.parsedGlyphNames.discard(glyphName) def _writeRawGlyph(self, glyphName, f): - # 5. write whole file with openstep_plist + # Write whole file with openstep_plist result = convertMatchesToTuples(self.rawFontData, matchTreeFont) out = ( openstep_plist.dumps( @@ -762,7 +762,9 @@ def variableGlyphToGSGlyph(variableGlyph, gsGlyph): continue if gsLayerId in masterIds: # We don't delete master layers. - continue + raise NotImplementedError( + "Deleting a master layer will cause compatibility issues in GlyphsApp." + ) # Removing non-master-layer: del gsGlyph.layers[gsLayerId] diff --git a/tests/test_backend.py b/tests/test_backend.py index 259175b..7685847 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -180,11 +180,12 @@ async def test_deleteLayer(writableTestFont): layerName = "3E7589AA-8194-470F-8E2F-13C1C581BE24" del glyph.layers[layerName] - await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) - - savedGlyph = await writableTestFont.getGlyph(glyphName) - - assert layerName in savedGlyph.layers + with pytest.raises(NotImplementedError) as excinfo: + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + assert ( + str(excinfo.value) + == "Deleting a master layer will cause compatibility issues in GlyphsApp." + ) async def test_addLayer(writableTestFont): From 0d558d82a001fafa04ee66136b51b90fcae73e3a Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Thu, 23 Jan 2025 18:14:55 +0100 Subject: [PATCH 42/48] Update workflow pytest.yml with Just's openstep-plist --- .github/workflows/pytest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 91004d4..9b1719b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -30,6 +30,7 @@ jobs: python -m pip install . --no-deps python -m pip install -r requirements.txt python -m pip install -r requirements-dev.txt + pip install --no-deps git+https://github.com/justvanrossum/openstep-plist.git@glyphsapp-compat - name: Run pre-commit uses: pre-commit/action@v3.0.1 From 5a0b8212f1eb0203914536979ee604078c453ebc Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 23 Jan 2025 20:51:07 +0100 Subject: [PATCH 43/48] Return copy, as callers may mutate the result. This fixes googlefonts/fontra#1979 --- src/fontra_glyphs/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index e052593..39d3445 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -203,7 +203,7 @@ async def putSources(self, sources: dict[str, FontSource]) -> None: raise NotImplementedError("editing FontSources is not yet implemented") async def getAxes(self) -> Axes: - return Axes(axes=self.axes) + return Axes(axes=deepcopy(self.axes)) async def putAxes(self, axes: Axes) -> None: raise NotImplementedError("editing Axes is not yet implemented") From 4ed0ac533d17cc95364bddcce44c1ebc2f279397 Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 23 Jan 2025 22:03:53 +0100 Subject: [PATCH 44/48] Better deepcopy the glyphMap, as clients may mutate --- src/fontra_glyphs/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 39d3445..c67af6b 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -170,7 +170,7 @@ def _loadFiles(path: PathLike) -> tuple[dict[str, Any], list[Any]]: return rawFontData, rawGlyphsData async def getGlyphMap(self) -> dict[str, list[int]]: - return self.glyphMap + return deepcopy(self.glyphMap) async def putGlyphMap(self, value: dict[str, list[int]]) -> None: pass From eb1a70c9d090bc91364ce3e9a60ce1c89de40656 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Tue, 28 Jan 2025 08:41:08 +0100 Subject: [PATCH 45/48] Remove fixCustomBinaryDataFormatting and fix some unittests Justs openstep-plist takes care of binary_spaces, so need of extra function. --- src/fontra_glyphs/backend.py | 3 +-- tests/test_backend.py | 28 ++++++++++++++-------------- tests/test_utils.py | 4 +--- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index c67af6b..04cf8e5 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -41,7 +41,6 @@ from .utils import ( convertMatchesToTuples, - fixCustomBinaryDataFormatting, getAssociatedMasterId, getLocationFromSources, matchTreeFont, @@ -421,11 +420,11 @@ def _writeRawGlyph(self, glyphName, f): escape_newlines=False, sort_keys=False, single_line_empty_objects=False, + binary_spaces=False, ) + "\n" ) - out = fixCustomBinaryDataFormatting(out) self.gsFilePath.write_text(out) async def aclose(self) -> None: diff --git a/tests/test_backend.py b/tests/test_backend.py index 7685847..a96b916 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -173,19 +173,18 @@ async def test_putGlyph(writableTestFont, testFont, glyphName): async def test_deleteLayer(writableTestFont): - glyphName = "A" + glyphName = "a" glyphMap = await writableTestFont.getGlyphMap() glyph = await writableTestFont.getGlyph(glyphName) + numGlyphLayers = len(glyph.layers) - layerName = "3E7589AA-8194-470F-8E2F-13C1C581BE24" - del glyph.layers[layerName] + # delete intermediate layer + del glyph.layers["1FA54028-AD2E-4209-AA7B-72DF2DF16264"] - with pytest.raises(NotImplementedError) as excinfo: - await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) - assert ( - str(excinfo.value) - == "Deleting a master layer will cause compatibility issues in GlyphsApp." - ) + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert len(savedGlyph.layers) < numGlyphLayers async def test_addLayer(writableTestFont): @@ -238,14 +237,15 @@ async def test_deleteMasterLayer(writableTestFont): glyphName = "a" glyphMap = await writableTestFont.getGlyphMap() glyph = await writableTestFont.getGlyph(glyphName) - numGlyphLayers = len(glyph.layers) del glyph.layers["BFFFD157-90D3-4B85-B99D-9A2F366F03CA"] - await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) - - savedGlyph = await writableTestFont.getGlyph(glyphName) - assert len(savedGlyph.layers) == numGlyphLayers + with pytest.raises(NotImplementedError) as excinfo: + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + assert ( + str(excinfo.value) + == "Deleting a master layer will cause compatibility issues in GlyphsApp." + ) async def test_addAnchor(writableTestFont): diff --git a/tests/test_utils.py b/tests/test_utils.py index 4980fca..4c39242 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,7 +7,6 @@ from fontra_glyphs.utils import ( convertMatchesToTuples, - fixCustomBinaryDataFormatting, getAssociatedMasterId, getLocationFromSources, matchTreeFont, @@ -116,11 +115,10 @@ def test_roundtrip_glyphs_file_dumps(path): escape_newlines=False, sort_keys=False, single_line_empty_objects=False, + binary_spaces=False, ) + "\n" ) - out = fixCustomBinaryDataFormatting(out) - for root_line, out_line in zip(path.read_text().splitlines(), out.splitlines()): assert root_line == out_line From e1452c1c7bf057c9bb6740faa28bba0fc1c19171 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Tue, 28 Jan 2025 09:44:32 +0100 Subject: [PATCH 46/48] Don't raise error if some deletes master layer GlyphsApp will add an empty layer if one is missing. --- src/fontra_glyphs/backend.py | 8 +------- tests/test_backend.py | 17 ----------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 04cf8e5..63ed96c 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -754,17 +754,11 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster): def variableGlyphToGSGlyph(variableGlyph, gsGlyph): # Convert Fontra variableGlyph to GlyphsApp glyph - masterIds = [m.id for m in gsGlyph.parent.masters] for gsLayerId in [gsLayer.layerId for gsLayer in gsGlyph.layers]: if gsLayerId in variableGlyph.layers: # This layer will be modified later. continue - if gsLayerId in masterIds: - # We don't delete master layers. - raise NotImplementedError( - "Deleting a master layer will cause compatibility issues in GlyphsApp." - ) - # Removing non-master-layer: + # Removing layer: del gsGlyph.layers[gsLayerId] for layerName, layer in iter(variableGlyph.layers.items()): diff --git a/tests/test_backend.py b/tests/test_backend.py index a96b916..7fe87d9 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -231,23 +231,6 @@ async def test_addLayerWithComponent(writableTestFont): assert layerName in savedGlyph.layers.keys() -async def test_deleteMasterLayer(writableTestFont): - # Removing a "master" layer breaks compatibility within a .glyphs file. - # Therefore we need to make sure, that it will be added afterwords. - glyphName = "a" - glyphMap = await writableTestFont.getGlyphMap() - glyph = await writableTestFont.getGlyph(glyphName) - - del glyph.layers["BFFFD157-90D3-4B85-B99D-9A2F366F03CA"] - - with pytest.raises(NotImplementedError) as excinfo: - await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) - assert ( - str(excinfo.value) - == "Deleting a master layer will cause compatibility issues in GlyphsApp." - ) - - async def test_addAnchor(writableTestFont): glyphName = "a" glyphMap = await writableTestFont.getGlyphMap() From 1ec4a25b9dda15665828df18d817d1a477e2fcab Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Tue, 28 Jan 2025 09:46:54 +0100 Subject: [PATCH 47/48] Removing fixCustomBinaryDataFormatting --- src/fontra_glyphs/utils.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py index 10513a5..60a6397 100644 --- a/src/fontra_glyphs/utils.py +++ b/src/fontra_glyphs/utils.py @@ -1,6 +1,3 @@ -import re - - def getLocationFromSources(sources, layerName): s = sources[0] for source in sources: @@ -83,11 +80,3 @@ def convertMatchesToTuples(obj, matchTree, path=()): matchTreeFont = patternsToMatchTree(patterns) matchTreeGlyph = matchTreeFont["glyphs"][None] - - -def fixCustomBinaryDataFormatting(content): - return re.sub( - r"customBinaryData = <\s*([0-9a-fA-F\s]+)\s*>;", - lambda m: f"customBinaryData = <{m.group(1).replace(' ', '')}>;", - content, - ) From b00dd7603ffdf2418594708e7ae4ead9ee864160 Mon Sep 17 00:00:00 2001 From: Olli Meier Date: Fri, 31 Jan 2025 08:54:44 +0100 Subject: [PATCH 48/48] Removing Just's version of openstep-plist because it's part of the official now --- .github/workflows/pytest.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 9b1719b..91004d4 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -30,7 +30,6 @@ jobs: python -m pip install . --no-deps python -m pip install -r requirements.txt python -m pip install -r requirements-dev.txt - pip install --no-deps git+https://github.com/justvanrossum/openstep-plist.git@glyphsapp-compat - name: Run pre-commit uses: pre-commit/action@v3.0.1