diff --git a/README.md b/README.md index 3bec738..9714924 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,17 @@ # 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: - Brace layers - Smart components (for now restricted to interpolation: axis values need to be within the minimum and maximum values) + + +### Writing is currently limited to ... + +#### Glyph Layer +- Contour (Paths, Nodes) ✅ +- (Smart) Components ✅ +- Anchors ✅ +- Guidelines ✅ diff --git a/src/fontra_glyphs/backend.py b/src/fontra_glyphs/backend.py index 05152ca..98c6bb4 100644 --- a/src/fontra_glyphs/backend.py +++ b/src/fontra_glyphs/backend.py @@ -1,5 +1,7 @@ +import io import pathlib from collections import defaultdict +from copy import deepcopy from os import PathLike from typing import Any @@ -16,6 +18,7 @@ GlyphAxis, GlyphSource, Guideline, + ImageData, Kerning, Layer, LineMetric, @@ -24,15 +27,27 @@ VariableGlyph, ) from fontra.core.path import PackedPathPointPen -from fontra.core.protocols import ReadableFontBackend +from fontra.core.protocols import WritableFontBackend +from fontra.core.varutils import makeDenseLocation, makeSparseLocation from fontTools.designspaceLib import DesignSpaceDocument from fontTools.misc.transform import DecomposedTransform +from fontTools.ufoLib.filenames import userNameToFileName from glyphsLib.builder.axes import ( + AxisDefinitionFactory, get_axis_definitions, get_regular_master, to_designspace_axes, ) from glyphsLib.builder.smart_components import Pole +from glyphsLib.types import Transform as GSTransform + +from .utils import ( + convertMatchesToTuples, + getAssociatedMasterId, + getSourceFromLayerName, + matchTreeFont, + splitLocation, +) rootInfoNames = [ "familyName", @@ -81,13 +96,14 @@ class GlyphsBackend: @classmethod - def fromPath(cls, path: PathLike) -> ReadableFontBackend: + def fromPath(cls, path: PathLike) -> WritableFontBackend: self = cls() self._setupFromPath(path) return self def _setupFromPath(self, path: PathLike) -> None: gsFont = glyphsLib.classes.GSFont() + self.gsFilePath = pathlib.Path(path) rawFontData, rawGlyphsData = self._loadFiles(path) @@ -100,6 +116,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 = { @@ -129,6 +146,7 @@ def _setupFromPath(self, path: PathLike) -> None: self.gsFont.format_version, ) + axis: FontAxis | DiscreteFontAxis axes: list[FontAxis | DiscreteFontAxis] = [] for dsAxis in dsAxes: axis = FontAxis( @@ -145,6 +163,13 @@ def _setupFromPath(self, path: PathLike) -> None: axes.append(axis) self.axes = axes + self.defaultLocation = {} + for axis in self.axes: + self.defaultLocation[axis.name] = next( + (v for k, v in axis.mapping if k == axis.defaultValue), + axis.defaultValue, + ) + @staticmethod def _loadFiles(path: PathLike) -> tuple[dict[str, Any], list[Any]]: with open(path, "r", encoding="utf-8") as fp: @@ -155,7 +180,15 @@ 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 + + async def deleteGlyph(self, glyphName): + raise NotImplementedError( + "GlyphsApp Backend: Deleting glyphs is not yet implemented." + ) async def getFontInfo(self) -> FontInfo: infoDict = {} @@ -172,15 +205,35 @@ async def getFontInfo(self) -> FontInfo: return FontInfo(**infoDict) + async def putFontInfo(self, fontInfo: FontInfo): + raise NotImplementedError( + "GlyphsApp Backend: 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: + raise NotImplementedError( + "GlyphsApp Backend: 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( + "GlyphsApp Backend: Editing Axes is not yet implemented." + ) async def getUnitsPerEm(self) -> int: return self.gsFont.upm + async def putUnitsPerEm(self, value: int) -> None: + raise NotImplementedError( + "GlyphsApp Backend: Editing UnitsPerEm is not yet implemented." + ) + async def getKerning(self) -> dict[str, Kerning]: # TODO: RTL kerning: https://docu.glyphsapp.com/#GSFont.kerningRTL kerningLTR = gsKerningToFontraKerning( @@ -200,13 +253,36 @@ async def getKerning(self) -> dict[str, Kerning]: kerning["vkrn"] = kerningVertical return kerning + async def putKerning(self, kerning: dict[str, Kerning]) -> None: + raise NotImplementedError( + "GlyphsApp Backend: Editing Kerning is not yet implemented." + ) + async def getFeatures(self) -> OpenTypeFeatures: # TODO: extract features return OpenTypeFeatures() + async def putFeatures(self, features: OpenTypeFeatures) -> None: + raise NotImplementedError( + "GlyphsApp Backend: 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: + raise NotImplementedError( + "GlyphsApp Backend: Editing BackgroundImage is not yet implemented." + ) + async def getCustomData(self) -> dict[str, Any]: return {} + async def putCustomData(self, lib): + raise NotImplementedError( + "GlyphsApp Backend: Editing CustomData is not yet implemented." + ) + async def getGlyph(self, glyphName: str) -> VariableGlyph | None: if glyphName not in self.glyphNameToIndex: return None @@ -242,14 +318,19 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None: braceLocation = self._getBraceLayerLocation(gsLayer) smartLocation = self._getSmartLocation(gsLayer, localAxesByName) masterName = self.gsFont.masters[gsLayer.associatedMasterId].name - if braceLocation or smartLocation: + if gsLayer.userData["xyz.fontra.source-name"]: + sourceName = gsLayer.userData["xyz.fontra.source-name"] + elif braceLocation or smartLocation: sourceName = f"{masterName} / {gsLayer.name}" else: sourceName = gsLayer.name or masterName - layerName = gsLayer.layerId + layerName = gsLayer.userData["xyz.fontra.layer-name"] or gsLayer.layerId location = { - **self.locationByMasterID[gsLayer.associatedMasterId], + **makeSparseLocation( + self.locationByMasterID[gsLayer.associatedMasterId], + self.defaultLocation, + ), **braceLocation, **smartLocation, } @@ -287,7 +368,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() @@ -330,6 +410,67 @@ def _getSmartLocation(self, gsLayer, localAxesByName): if value != localAxesByName[name].defaultValue } + 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 + + # Glyph does not exist: create new one. + if not self.gsFont.glyphs[glyphName]: + gsGlyph = glyphsLib.classes.GSGlyph(glyphName) + gsGlyph.unicodes = codePoints + self.gsFont.glyphs.append(gsGlyph) + self.glyphNameToIndex[glyphName] = len(self.gsFont.glyphs) - 1 + + # Convert VariableGlyph to GSGlyph + gsGlyphNew = variableGlyphToGSGlyph( + self.defaultLocation, glyph, deepcopy(self.gsFont.glyphs[glyphName]) + ) + + # 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) + + # Parse stream into "raw" object + f.seek(0) + rawGlyphData = openstep_plist.load(f, use_numbers=True) + + # Replace original "raw" object with new "raw" object + if len(self.rawGlyphsData) - 1 < self.glyphNameToIndex[glyphName]: + self.rawGlyphsData.append(rawGlyphData) + else: + self.rawGlyphsData[self.glyphNameToIndex[glyphName]] = rawGlyphData + self.rawFontData["glyphs"] = self.rawGlyphsData + + self._writeRawGlyph(glyphName, f) + + # 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): + # Write whole file with openstep_plist + result = convertMatchesToTuples(self.rawFontData, 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, + binary_spaces=False, + ) + + "\n" + ) + + self.gsFilePath.write_text(out) + async def aclose(self) -> None: pass @@ -371,6 +512,15 @@ def sortKey(glyphData): return rawFontData, rawGlyphsData + def _writeRawGlyph(self, glyphName, f): + filePath = self.getGlyphFilePath(glyphName) + filePath.write_text(f.getvalue(), encoding="utf=8") + + def getGlyphFilePath(self, glyphName): + glyphsPath = self.gsFilePath / "glyphs" + refFileName = userNameToFileName(glyphName, suffix=".glyph") + return glyphsPath / refFileName + def _readGlyphMapAndKerningGroups( rawGlyphsData: list, formatVersion: int @@ -421,6 +571,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( @@ -428,6 +581,7 @@ def gsLayerToFontraLayer(gsLayer, globalAxisNames): path=pen.getPath(), components=components, anchors=anchors, + guidelines=guidelines, ) ) @@ -441,6 +595,11 @@ def gsComponentToFontraComponent(gsComponent, gsLayer, globalAxisNames): for name, value in gsComponent.smartComponentValues.items() }, ) + if gsComponent.alignment: + # The aligment can be 0, but in that case, do not set it. + component.customData["com.glyphsapp.component.alignment"] = ( + gsComponent.alignment + ) return component @@ -640,3 +799,233 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster): # ) return lineMetricsHorizontal + + +def variableGlyphToGSGlyph(defaultLocation, variableGlyph, gsGlyph): + defaultGlyphLocation = {axis.name: axis.defaultValue for axis in variableGlyph.axes} + gsMasterAxesToIdMapping = {tuple(m.axes): m.id for m in gsGlyph.parent.masters} + gsMasterIdToNameMapping = {m.id: m.name for m in gsGlyph.parent.masters} + # Convert Fontra variableGlyph to GlyphsApp glyph + for gsLayerId in [gsLayer.layerId for gsLayer in gsGlyph.layers]: + if gsLayerId in variableGlyph.layers: + # This layer will be modified later. + continue + # Removing layer: + del gsGlyph.layers[gsLayerId] + + # prepare smart component glyph + smartComponentAxesNames = [axis.name for axis in gsGlyph.smartComponentAxes] + for axis in variableGlyph.axes: + if axis.name not in smartComponentAxesNames: + if axis.defaultValue not in [axis.minValue, axis.maxValue]: + # NOTE: GlyphsApp does not have axis.defaultValue, + # therefore it must be at MIN or MAX. + # https://docu.glyphsapp.com/#GSSmartComponentAxis + raise TypeError( + f"GlyphsApp Backend: Glyph axis '{axis.name}' defaultValue " + "must be at MIN or MAX." + ) + gsAxis = glyphsLib.classes.GSSmartComponentAxis() + gsAxis.name = axis.name + gsAxis.bottomValue = axis.minValue + gsAxis.topValue = axis.maxValue + gsGlyph.smartComponentAxes.append(gsAxis) + + axisNamesToBeRemoved = [] + for i, axisName in reversed(list(enumerate(smartComponentAxesNames))): + if axisName not in defaultGlyphLocation: + # An axis has been removed from the glyph, + # therefore delete axis + del gsGlyph.smartComponentAxes[i] + axisNamesToBeRemoved.append(axisName) + + # update values, after deleting axis + for i, axis in enumerate(variableGlyph.axes): + gsGlyph.smartComponentAxes[i].bottomValue = axis.minValue + gsGlyph.smartComponentAxes[i].topValue = axis.maxValue + + for layerName, layer in iter(variableGlyph.layers.items()): + gsLayer = gsGlyph.layers[layerName] + # layerName is equal to gsLayer.layerId if it comes from Glyphsapp, + # otherwise the layer has been newly created within Fontra. + + if gsLayer is not None: + # gsLayer exists – modify existing gsLayer: + fontraLayerToGSLayer(layer, gsLayer) + # It might be, that we added a new glyph axis within Fontra + # for an existing smart comp glyph, in that case we need to add + # the new axis to gsLayer.smartComponentPoleMapping. + for axis in variableGlyph.axes: + if axis.name in gsLayer.smartComponentPoleMapping: + continue + pole = ( + int(Pole.MIN) # convert to int for Python <= 3.10 + if axis.minValue == defaultGlyphLocation[axis.name] + else int(Pole.MAX) # convert to int for Python <= 3.10 + ) + gsLayer.smartComponentPoleMapping[axis.name] = pole + + for axisName in axisNamesToBeRemoved: + # An axis has been removed from the glyph, therefore we need + # to delete the axis from smartComponentPoleMapping as well. + del gsLayer.smartComponentPoleMapping[axisName] + else: + # gsLayer does not exist – create new layer: + gsLayer = glyphsLib.classes.GSLayer() + gsLayer.parent = gsGlyph + + glyphSource = getSourceFromLayerName(variableGlyph.sources, layerName) + fontLocation, glyphLocation = splitLocation( + glyphSource.location, variableGlyph.axes + ) + fontLocation = makeDenseLocation(fontLocation, defaultLocation) + glyphLocation = makeDenseLocation(glyphLocation, defaultGlyphLocation) + + gsFontLocation = [] + for axis in gsGlyph.parent.axes: + if fontLocation.get(axis.name): + gsFontLocation.append(fontLocation[axis.name]) + else: + # This 'else' is necessary for GlyphsApp 2 files, only. + # 'Weight' and 'Width' are always there, + # even if there is no axis specified for it. + factory = AxisDefinitionFactory() + axis_def = factory.get(axis.axisTag, axis.name) + gsFontLocation.append(axis_def.default_user_loc) + + gsGlyphLocation = [] + for axis in gsGlyph.smartComponentAxes: + gsGlyphLocation.append(glyphLocation[axis.name]) + pole = ( + int(Pole.MIN) # convert to int for Python <= 3.10 + if axis.bottomValue == glyphLocation[axis.name] + else int(Pole.MAX) # convert to int for Python <= 3.10 + ) + # Set pole, only MIN or MAX possible. + # NOTE: In GlyphsApp these are checkboxes, either: on or off. + gsLayer.smartComponentPoleMapping[axis.name] = pole + + masterId = gsMasterAxesToIdMapping.get(tuple(gsFontLocation)) + + isDefaultLayer = False + # It is not enough to check if it has a masterId, because in case of a smart component, + # the layer for each glyph axis has the same location as the master layer. + if masterId: + if not gsGlyphLocation: + isDefaultLayer = True + elif defaultGlyphLocation == glyphLocation: + isDefaultLayer = True + + gsLayer.name = ( + gsMasterIdToNameMapping.get(masterId) + if isDefaultLayer + else glyphSource.name + ) + gsLayer.layerId = masterId if isDefaultLayer else layerName + gsLayer.associatedMasterId = getAssociatedMasterId( + gsGlyph.parent, gsFontLocation + ) + + if not isDefaultLayer and not gsGlyphLocation: + # This is an intermediate layer + gsLayer.name = "{" + ",".join(str(x) for x in gsFontLocation) + "}" + gsLayer.attributes["coordinates"] = gsFontLocation + + gsLayer.userData["xyz.fontra.source-name"] = glyphSource.name + gsLayer.userData["xyz.fontra.layer-name"] = layerName + + if glyphLocation: + # We have a smart component. Check if it is an intermediate master/layer, + # because we currently do not support writing this to GlyphsApp files. + isIntermediateLayer = False + + if not masterId: + # If it has glyph axes and is not on any master location, + # it must be an intermediate master. + isIntermediateLayer = True + else: + # If it has glyph axes and is on a master location, + # but any of the glyph axes are not at min or max position, + # it must be an intermediate layer. + if any( + [ + True + for axis in variableGlyph.axes + if glyphLocation[axis.name] + not in [axis.minValue, axis.maxValue] + ] + ): + isIntermediateLayer = True + + if isIntermediateLayer: + raise NotImplementedError( + "GlyphsApp Backend: Intermediate layers " + "within smart glyphs are not yet implemented." + ) + + fontraLayerToGSLayer(layer, gsLayer) + gsGlyph.layers.append(gsLayer) + + return gsGlyph + + +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 = [fontraAnchorToGSAnchor(anchor) for anchor in layer.glyph.anchors] + gsLayer.guides = [ + fontraGuidelineToGSGuide(guideline) for guideline in layer.glyph.guidelines + ] + + +EPSILON = 1e-9 + + +def fontraComponentToGSComponent(component): + if ( + abs(component.transformation.skewX) > EPSILON + or abs(component.transformation.skewY) > EPSILON + ): + raise TypeError( + "GlyphsApp Backend: Does not support skewing of components, yet." + ) + gsComponent = glyphsLib.classes.GSComponent(component.name) + transformation = component.transformation.toTransform() + gsComponent.transform = GSTransform(*transformation) + for axisName in component.location: + gsComponent.smartComponentValues[axisName] = component.location[axisName] + gsComponent.alignment = component.customData.get( + "com.glyphsapp.component.alignment", 0 + ) + return gsComponent + + +def fontraAnchorToGSAnchor(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 + + +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/src/fontra_glyphs/utils.py b/src/fontra_glyphs/utils.py new file mode 100644 index 0000000..e0331cb --- /dev/null +++ b/src/fontra_glyphs/utils.py @@ -0,0 +1,109 @@ +def getSourceFromLayerName(sources, layerName): + for source in sources: + if source.layerName == layerName: + return source + if source.location == {}: + defaultSource = source + + # NOTE: Theoretically it's possible to have a layer with no matching source. + # In that case, fallback to default. + return defaultSource + + +def splitLocation(location, glyphAxes): + glyphAxisNames = {axis.name for axis in glyphAxes} + + fontLocation = {} + glyphLocation = {} + + for axisName, axisValue in location.items(): + if axisName in glyphAxisNames: + glyphLocation[axisName] = axisValue + else: + fontLocation[axisName] = axisValue + + return fontLocation, glyphLocation + + +def getAssociatedMasterId(gsFont, gsLocation): + # Best guess for associatedMasterId + closestMasterID = gsFont.masters[0].id # default first master. + closestDistance = float("inf") + for gsMaster in gsFont.masters: + distance = sum( + abs(gsMaster.axes[i] - gsLocation[i]) + for i in range(len(gsMaster.axes)) + if i < len(gsLocation) + ) + if distance < closestDistance: + closestDistance = distance + closestMasterID = gsMaster.id + + return closestMasterID + + +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, "background", "anchors", None, "pos"], + ["glyphs", None, "layers", None, "annotations", None, "pos"], + ["glyphs", None, "layers", None, "background", "shapes", None, "nodes", None], + ["glyphs", None, "layers", None, "background", "shapes", None, "pos"], + ["glyphs", None, "layers", None, "background", "shapes", None, "scale"], + ["glyphs", None, "layers", None, "background", "shapes", None, "slant"], + ["glyphs", None, "layers", None, "guides", None, "pos"], + ["glyphs", None, "layers", None, "hints", None, "origin"], + ["glyphs", None, "layers", None, "hints", None, "scale"], + ["glyphs", None, "layers", None, "background", "hints", None, "origin"], + ["glyphs", None, "layers", None, "background", "hints", None, "scale"], + ["glyphs", None, "layers", None, "background", "hints", None, "place"], + ["glyphs", None, "layers", None, "hints", None, "target"], + ["glyphs", None, "layers", None, "shapes", None, "nodes", None], + ["glyphs", None, "layers", None, "shapes", None, "pos"], + ["glyphs", None, "layers", None, "shapes", None, "scale"], +] + + +matchTreeFont = patternsToMatchTree(patterns) +matchTreeGlyph = matchTreeFont["glyphs"][None] diff --git a/tests/data/GlyphsUnitTestSans.glyphs b/tests/data/GlyphsUnitTestSans.glyphs index a88827f..f9e633e 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 = 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..14c46f4 --- /dev/null +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A-cy^1.json @@ -0,0 +1,55 @@ +{ +"name": "A-cy", +"sources": [ +{ +"name": "Light", +"layerName": "C4872ECA-A3A9-40AB-960A-1DB2202F16DE", +"location": { +"Weight": 17 +} +}, +{ +"name": "Regular", +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" +}, +{ +"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/data/GlyphsUnitTestSans3.fontra/glyphs/A^1.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A^1.json index 13dbe9a..d7317e7 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A^1.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/A^1.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Bold", @@ -276,6 +273,13 @@ "x": 297, "y": 700 } +], +"guidelines": [ +{ +"name": "", +"x": 45, +"angle": 71.7587 +} ] } } diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/Adieresis^1.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/Adieresis^1.json index c8db159..78cbb25 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/Adieresis^1.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/Adieresis^1.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Bold", diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/V^1.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/V^1.json index 8130455..6789b27 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/V^1.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/V^1.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Bold", diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/_part.shoulder.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/_part.shoulder.json index 83aafd6..121cc08 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/_part.shoulder.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/_part.shoulder.json @@ -40,16 +40,12 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Regular / NarrowShoulder", "layerName": "595FDB8C-ED41-486A-B76A-0FEFEF8BCDD1", "location": { -"Weight": 90, "shoulderWidth": 0 } }, @@ -57,7 +53,6 @@ "name": "Regular / LowCrotch", "layerName": "65575EEB-523C-4A39-985D-FB9ACFE951AF", "location": { -"Weight": 90, "crotchDepth": -100 } }, diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/_part.stem.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/_part.stem.json index 09b740e..fea7246 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/_part.stem.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/_part.stem.json @@ -26,16 +26,12 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Regular / TallStem", "layerName": "3E1733D9-3B83-4E6A-B1E9-6381BBE1BD3A", "location": { -"Weight": 90, "height": 100 } }, diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/a.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/a.json index cce9993..012498d 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/a.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/a.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Regular / {155, 100}", diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/a.sc.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/a.sc.json index 3dd18fd..5328312 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/a.sc.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/a.sc.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Bold", diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/adieresis.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/adieresis.json index e61514e..f4709a3 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/adieresis.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/adieresis.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Bold", diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/dieresis.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/dieresis.json index ab0115b..d1cc6e3 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/dieresis.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/dieresis.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Bold", diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/h.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/h.json index e8925e4..1e34b8b 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/h.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/h.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Bold", @@ -31,12 +28,18 @@ "name": "_part.stem", "location": { "height": 100 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } }, { "name": "_part.shoulder", "location": { "crotchDepth": -80.20097 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } } ], @@ -50,12 +53,18 @@ "name": "_part.stem", "location": { "height": 100 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } }, { "name": "_part.shoulder", "location": { "crotchDepth": -80.20097 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } } ], @@ -69,12 +78,18 @@ "name": "_part.stem", "location": { "height": 100 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } }, { "name": "_part.shoulder", "location": { "crotchDepth": -80.20097 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } } ], diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/m.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/m.json index 3cca04a..7b0e5e8 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/m.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/m.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Bold", @@ -28,12 +25,18 @@ "glyph": { "components": [ { -"name": "_part.stem" +"name": "_part.stem", +"customData": { +"com.glyphsapp.component.alignment": -1 +} }, { "name": "_part.shoulder", "location": { "shoulderWidth": 0 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } }, { @@ -43,6 +46,9 @@ }, "location": { "shoulderWidth": 0 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } } ], @@ -53,12 +59,18 @@ "glyph": { "components": [ { -"name": "_part.stem" +"name": "_part.stem", +"customData": { +"com.glyphsapp.component.alignment": -1 +} }, { "name": "_part.shoulder", "location": { "shoulderWidth": 0 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } }, { @@ -68,6 +80,9 @@ }, "location": { "shoulderWidth": 0 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } } ], @@ -78,12 +93,18 @@ "glyph": { "components": [ { -"name": "_part.stem" +"name": "_part.stem", +"customData": { +"com.glyphsapp.component.alignment": -1 +} }, { "name": "_part.shoulder", "location": { "shoulderWidth": 0 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } }, { @@ -93,6 +114,9 @@ }, "location": { "shoulderWidth": 0 +}, +"customData": { +"com.glyphsapp.component.alignment": -1 } } ], diff --git a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/n.json b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/n.json index 68b738e..0ded153 100644 --- a/tests/data/GlyphsUnitTestSans3.fontra/glyphs/n.json +++ b/tests/data/GlyphsUnitTestSans3.fontra/glyphs/n.json @@ -10,10 +10,7 @@ }, { "name": "Regular", -"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24", -"location": { -"Weight": 90 -} +"layerName": "3E7589AA-8194-470F-8E2F-13C1C581BE24" }, { "name": "Bold", @@ -28,10 +25,16 @@ "glyph": { "components": [ { -"name": "_part.shoulder" +"name": "_part.shoulder", +"customData": { +"com.glyphsapp.component.alignment": -1 +} }, { -"name": "_part.stem" +"name": "_part.stem", +"customData": { +"com.glyphsapp.component.alignment": -1 +} } ], "xAdvance": 528 @@ -41,10 +44,16 @@ "glyph": { "components": [ { -"name": "_part.shoulder" +"name": "_part.shoulder", +"customData": { +"com.glyphsapp.component.alignment": -1 +} }, { -"name": "_part.stem" +"name": "_part.stem", +"customData": { +"com.glyphsapp.component.alignment": -1 +} } ], "xAdvance": 560 @@ -54,10 +63,16 @@ "glyph": { "components": [ { -"name": "_part.shoulder" +"name": "_part.shoulder", +"customData": { +"com.glyphsapp.component.alignment": -1 +} }, { -"name": "_part.stem" +"name": "_part.stem", +"customData": { +"com.glyphsapp.component.alignment": -1 +} } ], "xAdvance": 501 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 = ( 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 9cd8f4a..b14c91e 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,8 +1,23 @@ +import os import pathlib +import shutil +import uuid +from copy import deepcopy import pytest from fontra.backends import getFileSystemBackend -from fontra.core.classes import Axes, FontInfo, structure +from fontra.core.classes import ( + Anchor, + Axes, + FontInfo, + GlyphAxis, + GlyphSource, + Guideline, + Layer, + StaticGlyph, + VariableGlyph, + structure, +) dataDir = pathlib.Path(__file__).resolve().parent / "data" @@ -12,6 +27,13 @@ referenceFontPath = dataDir / "GlyphsUnitTestSans3.fontra" +mappingMasterIDs = { + "Light": "C4872ECA-A3A9-40AB-960A-1DB2202F16DE", + "Regular": "3E7589AA-8194-470F-8E2F-13C1C581BE24", + "Bold": "BFFFD157-90D3-4B85-B99D-9A2F366F03CA", +} + + @pytest.fixture(scope="module", params=[glyphs2Path, glyphs3Path, glyphsPackagePath]) def testFont(request): return getFileSystemBackend(request.param) @@ -22,6 +44,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": [ @@ -69,6 +102,7 @@ async def test_getAxes(testFont): "m": [109], "n": [110], "V": [86], + "A-cy": [1040], } @@ -110,12 +144,282 @@ 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] + + if ( + glyphName in ["h", "m", "n"] + and "com.glyphsapp.glyph-color" not in glyph.customData + ): + # glyphsLib doesn't read the component alignment from Glyphs-2 files, + # so let's monkeypatch the data + for layerName in glyph.layers: + for component in glyph.layers[layerName].glyph.components: + if "com.glyphsapp.component.alignment" not in component.customData: + component.customData["com.glyphsapp.component.alignment"] = -1 referenceGlyph = await referenceFont.getGlyph(glyphName) assert referenceGlyph == glyph +@pytest.mark.asyncio +@pytest.mark.parametrize("glyphName", list(expectedGlyphMap)) +async def test_putGlyph(writableTestFont, glyphName): + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(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 + + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert glyph == savedGlyph + + +async def test_duplicateGlyph(writableTestFont): + glyphName = "a.ss01" + glyph = deepcopy(await writableTestFont.getGlyph("a")) + glyph.name = glyphName + await writableTestFont.putGlyph(glyphName, glyph, []) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert glyph == savedGlyph + + +async def test_createNewGlyph(writableTestFont): + glyphName = "a.ss02" + glyph = VariableGlyph(name=glyphName) + + layerName = str(uuid.uuid4()).upper() + glyph.sources.append(GlyphSource(name="Default", location={}, layerName=layerName)) + glyph.layers[layerName] = Layer(glyph=StaticGlyph(xAdvance=333)) + + await writableTestFont.putGlyph(glyphName, glyph, []) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert glyph == savedGlyph + + +async def test_createNewSmartGlyph(writableTestFont): + glyphName = "a.smart" + glyphAxis = GlyphAxis(name="Height", minValue=0, maxValue=100, defaultValue=0) + glyph = VariableGlyph(name=glyphName, axes=[glyphAxis]) + + # create a glyph with glyph axis + for sourceName, location in { + "Light": {"Weight": 17}, + "Light-Height": {"Weight": 17, "Height": 100}, + "Regular": {}, + "Regular-Height": {"Height": 100}, + "Bold": {"Weight": 220}, + "Bold-Height": {"Weight": 220, "Height": 100}, + }.items(): + layerName = mappingMasterIDs.get(sourceName) or str(uuid.uuid4()).upper() + glyph.sources.append( + GlyphSource(name=sourceName, location=location, layerName=layerName) + ) + glyph.layers[layerName] = Layer(glyph=StaticGlyph(xAdvance=100)) + + await writableTestFont.putGlyph(glyphName, glyph, []) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert glyph == savedGlyph + + +async def test_extendSmartGlyphWithIntermedaiteLayer(writableTestFont): + # This should fail, because not yet implemented. + glyphName = "_part.shoulder" + glyph = await writableTestFont.getGlyph(glyphName) + + layerName = str(uuid.uuid4()).upper() + glyph.sources.append( + GlyphSource( + name="Intermediate Layer", location={"Weight": 99}, layerName=layerName + ) + ) + glyph.layers[layerName] = Layer(glyph=StaticGlyph(xAdvance=100)) + + with pytest.raises( + NotImplementedError, + match="Intermediate layers within smart glyphs are not yet implemented", + ): + await writableTestFont.putGlyph(glyphName, glyph, []) + + +async def test_smartGlyphAddGlyphAxisWithDefaultNotMinOrMax(writableTestFont): + # This should fail, because not yet implemented. + glyphName = "_part.shoulder" + glyph = await writableTestFont.getGlyph(glyphName) + glyphAxis = GlyphAxis(name="Height", minValue=0, maxValue=100, defaultValue=50) + glyph.axes.append(glyphAxis) + + with pytest.raises( + TypeError, + match="Glyph axis 'Height' defaultValue must be at MIN or MAX.", + ): + await writableTestFont.putGlyph(glyphName, glyph, []) + + +async def test_smartGlyphAddGlyphAxisWithDefaultAtMinOrMax(writableTestFont): + glyphName = "_part.shoulder" + glyph = await writableTestFont.getGlyph(glyphName) + glyphAxis = GlyphAxis(name="Height", minValue=0, maxValue=100, defaultValue=100) + glyph.axes.append(glyphAxis) + + await writableTestFont.putGlyph(glyphName, glyph, []) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert glyph == savedGlyph + + +async def test_smartGlyphRemoveGlyphAxis(writableTestFont): + glyphName = "_part.shoulder" + glyph = await writableTestFont.getGlyph(glyphName) + del glyph.axes[0] + + # We expect we cannot roundtrip a glyph when removing a glyph axis, + # because then some layers locations are not unique anymore. + for i in [8, 5, 2]: + del glyph.layers[glyph.sources[i].layerName] + del glyph.sources[i] + + await writableTestFont.putGlyph(glyphName, glyph, []) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert glyph == savedGlyph + + +async def test_smartGlyphChangeGlyphAxisValue(writableTestFont): + glyphName = "_part.shoulder" + glyph = await writableTestFont.getGlyph(glyphName) + + glyph.axes[1].maxValue = 200 + # We expect we cannot roundtrip a glyph when changing a glyph axis min or + # max value without changing the default, because in GlyphsApp there is + # no defaultValue-concept. Therefore we need to change the defaultValue as well. + glyph.axes[1].defaultValue = 200 + await writableTestFont.putGlyph(glyphName, glyph, []) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert glyph == savedGlyph + + +async def test_deleteLayer(writableTestFont): + glyphName = "a" + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + numGlyphLayers = len(glyph.layers) + + # delete intermediate layer + del glyph.layers["1FA54028-AD2E-4209-AA7B-72DF2DF16264"] + + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + savedGlyph = await writableTestFont.getGlyph(glyphName) + assert len(savedGlyph.layers) < numGlyphLayers + + +async def test_addLayer(writableTestFont): + glyphName = "a" + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + + layerName = str(uuid.uuid4()).upper() + glyph.sources.append( + GlyphSource(name="SemiBold", location={"Weight": 166}, layerName=layerName) + ) + # Copy StaticGlyph from Bold: + 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 glyph == savedGlyph + + +async def test_addLayerWithComponent(writableTestFont): + glyphName = "n" # n is made from components + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + + layerName = str(uuid.uuid4()).upper() + glyph.sources.append( + GlyphSource(name="SemiBold", location={"Weight": 166}, layerName=layerName) + ) + # Copy StaticGlyph of Bold: + 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 glyph == savedGlyph + + +expectedSkewErrors = [ + # skewValue, expectedErrorMatch + [20, "Does not support skewing of components"], + [-0.001, "Does not support skewing of components"], +] + + +@pytest.mark.parametrize("skewValue,expectedErrorMatch", expectedSkewErrors) +async def test_skewComponent(writableTestFont, skewValue, expectedErrorMatch): + glyphName = "Adieresis" # Adieresis is made from components + glyphMap = await writableTestFont.getGlyphMap() + glyph = await writableTestFont.getGlyph(glyphName) + + glyph.layers[mappingMasterIDs.get("Light")].glyph.components[ + 0 + ].transformation.skewX = skewValue + with pytest.raises(TypeError, match=expectedErrorMatch): + await writableTestFont.putGlyph(glyphName, glyph, glyphMap[glyphName]) + + +async def test_addAnchor(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.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[layerName].glyph.anchors + ) + + +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() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..c6f11e6 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,162 @@ +import pathlib + +import openstep_plist +import pytest +from fontra.backends import getFileSystemBackend +from fontra.core.varutils import makeDenseLocation +from glyphsLib.classes import GSAxis, GSFont, GSFontMaster, GSGlyph, GSLayer + +from fontra_glyphs.utils import ( + convertMatchesToTuples, + getAssociatedMasterId, + getSourceFromLayerName, + matchTreeFont, + splitLocation, +) + +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) + + +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_getSourceFromLayerName(testFont): + glyph = await testFont.getGlyph("a") + glyphSource = getSourceFromLayerName( + glyph.sources, "1FA54028-AD2E-4209-AA7B-72DF2DF16264" + ) + assert glyphSource.location == {"Weight": 155} + + +expectedLocations = [ + # gsLayerId, expectedFontLocation, expectedGlyphLocation + [ + "C4872ECA-A3A9-40AB-960A-1DB2202F16DE", + {"Weight": 17}, + {"crotchDepth": 0, "shoulderWidth": 100}, + ], + [ + "7C8F98EE-D140-44D5-86AE-E00A730464C0", + {"Weight": 17}, + {"crotchDepth": -100, "shoulderWidth": 100}, + ], + [ + "BA4F7DF9-9552-48BB-A5B8-E2D21D8D086E", + {"Weight": 220}, + {"crotchDepth": -100, "shoulderWidth": 100}, + ], +] + + +@pytest.mark.parametrize( + "gsLayerId,expectedFontLocation,expectedGlyphLocation", expectedLocations +) +async def test_splitLocation( + testFont, gsLayerId, expectedFontLocation, expectedGlyphLocation +): + glyph = await testFont.getGlyph("_part.shoulder") + glyphSource = getSourceFromLayerName(glyph.sources, gsLayerId) + fontLocation, glyphLocation = splitLocation(glyphSource.location, glyph.axes) + glyphLocation = makeDenseLocation( + glyphLocation, {axis.name: axis.defaultValue for axis in glyph.axes} + ) + assert fontLocation == expectedFontLocation + assert glyphLocation == expectedGlyphLocation + + +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 + + +@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) + + 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, + binary_spaces=False, + ) + + "\n" + ) + + for root_line, out_line in zip(path.read_text().splitlines(), out.splitlines()): + assert root_line == out_line