Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make GlyphsBackend writable #76

Open
wants to merge 44 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
de23e83
Add all missing 'put' methods from WritableFontBackend as stubs
ollimeier Jan 8, 2025
35015d3
GlyphsBackend work in progress: two options for discussion
ollimeier Jan 16, 2025
417d2fd
Remove option 2, use some better options for openstep_plist.dump, adj…
ollimeier Jan 17, 2025
1f25bbb
Rework putGlyph based on brainstorming together with Just
ollimeier Jan 17, 2025
e8ef42a
Add A-cy for testing
ollimeier Jan 17, 2025
e2a2626
get the right glyphspackage glyph file name
ollimeier Jan 17, 2025
6dcbf38
Add _findAndReplaceGlyph
ollimeier Jan 17, 2025
d8ac61e
openstep_plist: Fix order issue with toOrderedDict
ollimeier Jan 17, 2025
354e7fd
Remove _findAndReplaceGlyph idea
ollimeier Jan 17, 2025
bf1bfb9
Commit current stage.
ollimeier Jan 17, 2025
c7a66a4
Use pen for drawing
ollimeier Jan 19, 2025
62d0e93
Adding some comments
ollimeier Jan 19, 2025
f76ea61
Add A-cy also to Glyphs 2 and 3 file for unittests
ollimeier Jan 19, 2025
b560a89
Rework unittests and fix issue based on self.parsedGlyphNames
ollimeier Jan 19, 2025
fc5469a
Fixing the special layer issue with adding a gsLayer.layerId mapping …
ollimeier Jan 19, 2025
eaf8559
Extend unittest with test_deleteLayer
ollimeier Jan 19, 2025
53a106d
Add unittest test_addLayer (work in progress)
ollimeier Jan 19, 2025
c27e22e
Add support for 'intermediate layer'
ollimeier Jan 20, 2025
e343a3d
putGlyph including components and anchors + unittests
ollimeier Jan 21, 2025
b43907a
Adjust comments, restructure a bit + more unittests
ollimeier Jan 22, 2025
f6a6728
Fix missing comma and remove 'local hack'
ollimeier Jan 22, 2025
7afe3fa
Rework gsFormatting
ollimeier Jan 22, 2025
f6fe46c
Extend Readme file
ollimeier Jan 22, 2025
14144d3
Move getGlyphspackageGlyphFileName to utils and add unittest
ollimeier Jan 22, 2025
6d8e783
Move comment and make it a bit more clear.
ollimeier Jan 22, 2025
3a210e6
Remove seenLayerIDs. Use 'new' layerName (equal to layerId)
ollimeier Jan 22, 2025
698722f
Update test data "A-cy" + fix test_getGlyph: bug in GlyphsApp 2 file
ollimeier Jan 22, 2025
a40a429
Replace getGlyphspackageGlyphFileName with userNameToFileName (fontto…
ollimeier Jan 22, 2025
f25b1b6
Refactor and fix unittests
ollimeier Jan 22, 2025
1e0ae6a
rework _writeRawGlyph (dumps)
ollimeier Jan 22, 2025
e4e097e
Remove resolved comment
ollimeier Jan 22, 2025
f1fc7e0
Fix variable name, add and remove some comments
ollimeier Jan 22, 2025
7d56bac
Improve getAssociatedMasterId + unittests
ollimeier Jan 23, 2025
248fdf1
Raise NotImplementedError for stubs
ollimeier Jan 23, 2025
00753f9
Update self.glyphMap within putGlyph
ollimeier Jan 23, 2025
834dfe7
replace gsFormatting with convertMatchesToTuples
ollimeier Jan 23, 2025
a7fbd45
Fix customBinaryData formatting with regEx
ollimeier Jan 23, 2025
d41b0b2
Adding guideline to get- and putGlyph
ollimeier Jan 23, 2025
2161e6d
Removing toOrderedDict, not necessary anymore
ollimeier Jan 23, 2025
33840a3
Updating fontra data, because of missing guideline
ollimeier Jan 23, 2025
3568338
Raise error when trying to delete a master layer. Remove numbers from…
ollimeier Jan 23, 2025
0d558d8
Update workflow pytest.yml with Just's openstep-plist
ollimeier Jan 23, 2025
5a0b821
Return copy, as callers may mutate the result. This fixes googlefonts…
justvanrossum Jan 23, 2025
4ed0ac5
Better deepcopy the glyphMap, as clients may mutate
justvanrossum Jan 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)


## Write

### Glyph Layer
- Contour (Paths, Nodes) ✅
- Components ✅
- Anchors ✅
- Guidelines ✅
219 changes: 214 additions & 5 deletions src/fontra_glyphs/backend.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import io
import pathlib
from collections import defaultdict
from copy import deepcopy
from os import PathLike
from typing import Any

Expand All @@ -16,6 +18,7 @@
GlyphAxis,
GlyphSource,
Guideline,
ImageData,
Kerning,
Layer,
LineMetric,
Expand All @@ -24,15 +27,25 @@
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 fontTools.ufoLib.filenames import userNameToFileName
from glyphsLib.builder.axes import (
get_axis_definitions,
get_regular_master,
to_designspace_axes,
)
from glyphsLib.builder.smart_components import Pole
from glyphsLib.types import Transform

from .utils import (
convertMatchesToTuples,
fixCustomBinaryDataFormatting,
getAssociatedMasterId,
getLocationFromSources,
matchTreeFont,
)

rootInfoNames = [
"familyName",
Expand Down Expand Up @@ -81,13 +94,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 = path

rawFontData, rawGlyphsData = self._loadFiles(path)

Expand All @@ -100,6 +114,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 = {
Expand Down Expand Up @@ -155,7 +170,13 @@ 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("deleting glyphs is not yet implemented")

async def getFontInfo(self) -> FontInfo:
infoDict = {}
Expand All @@ -172,15 +193,27 @@ async def getFontInfo(self) -> FontInfo:

return FontInfo(**infoDict)

async def putFontInfo(self, fontInfo: FontInfo):
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:
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")

async def getUnitsPerEm(self) -> int:
return self.gsFont.upm

async def putUnitsPerEm(self, value: int) -> None:
raise NotImplementedError("editing UnitsPerEm is not yet implemented")

async def getKerning(self) -> dict[str, Kerning]:
# TODO: RTL kerning: https://docu.glyphsapp.com/#GSFont.kerningRTL
kerningLTR = gsKerningToFontraKerning(
Expand All @@ -200,13 +233,28 @@ async def getKerning(self) -> dict[str, Kerning]:
kerning["vkrn"] = kerningVertical
return kerning

async def putKerning(self, kerning: dict[str, Kerning]) -> None:
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:
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:
raise NotImplementedError("editing BackgroundImage is not yet implemented")

async def getCustomData(self) -> dict[str, Any]:
return {}

async def putCustomData(self, lib):
raise NotImplementedError("editing CustomData is not yet implemented")

async def getGlyph(self, glyphName: str) -> VariableGlyph | None:
if glyphName not in self.glyphNameToIndex:
return None
Expand Down Expand Up @@ -287,7 +335,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()
Expand Down Expand Up @@ -330,6 +377,57 @@ 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

# Convert VariableGlyph to GSGlyph
gsGlyphNew = variableGlyphToGSGlyph(
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
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,
)
+ "\n"
)

out = fixCustomBinaryDataFormatting(out)
self.gsFilePath.write_text(out)

async def aclose(self) -> None:
pass

Expand Down Expand Up @@ -371,6 +469,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 = pathlib.Path(self.gsFilePath) / "glyphs"
refFileName = userNameToFileName(glyphName, suffix=".glyph")
return glyphsPath / refFileName


def _readGlyphMapAndKerningGroups(
rawGlyphsData: list, formatVersion: int
Expand Down Expand Up @@ -421,13 +528,17 @@ def gsLayerToFontraLayer(gsLayer, globalAxisNames):
]

anchors = [gsAnchorToFontraAnchor(gsAnchor) for gsAnchor in gsLayer.anchors]
guidelines = [
gsGuidelineToFontraGuideline(gsGuideline) for gsGuideline in gsLayer.guides
]

return Layer(
glyph=StaticGlyph(
xAdvance=gsLayer.width,
path=pen.getPath(),
components=components,
anchors=anchors,
guidelines=guidelines,
)
)

Expand Down Expand Up @@ -640,3 +751,101 @@ def gsVerticalMetricsToFontraLineMetricsHorizontal(gsFont, gsMaster):
# )

return lineMetricsHorizontal


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:
del gsGlyph.layers[gsLayerId]

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 Fontrta.

if gsLayer is not None:
# gsLayer exists: modify existing gsLayer
fontraLayerToGSLayer(layer, gsLayer)
else:
# gsLayer does not exist: therefore must be 'isSpecialLayer'
# and need to be created as a new layer:
gsLayer = glyphsLib.classes.GSLayer()
gsLayer.name = layerName
gsLayer.layerId = layerName
gsLayer.isSpecialLayer = True

location = getLocationFromSources(variableGlyph.sources, layerName)
gsLocation = [
location[axis.name.lower()]
for axis in gsGlyph.parent.axes
if location.get(axis.name.lower())
]
gsLayer.attributes["coordinates"] = gsLocation

associatedMasterId = getAssociatedMasterId(gsGlyph.parent, gsLocation)
if associatedMasterId:
gsLayer.associatedMasterId = associatedMasterId

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
]


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 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
Loading
Loading