Skip to content

Commit

Permalink
Merge pull request #1854 from googlefonts/workflow-fork
Browse files Browse the repository at this point in the history
[fontra-workflow] Implement "fork" and "fork-merge" step styles
  • Loading branch information
justvanrossum authored Dec 15, 2024
2 parents b1692c3 + 3c239c3 commit ab770ca
Show file tree
Hide file tree
Showing 13 changed files with 873 additions and 39 deletions.
3 changes: 2 additions & 1 deletion src/fontra/workflow/merger.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
class FontBackendMerger:
inputA: ReadableFontBackend
inputB: ReadableFontBackend
warnAboutDuplicates: bool = True

def __post_init__(self):
self._glyphNamesA = None
Expand Down Expand Up @@ -63,7 +64,7 @@ async def _prepareGlyphMap(self):
async def getGlyph(self, glyphName: str) -> VariableGlyph | None:
await self._prepareGlyphMap()
if glyphName in self._glyphNamesB:
if glyphName in self._glyphNamesA:
if glyphName in self._glyphNamesA and self.warnAboutDuplicates:
logger.warning(f"Merger: Glyph {glyphName!r} exists in both fonts")
return await self.inputB.getGlyph(glyphName)
elif glyphName in self._glyphNamesA:
Expand Down
137 changes: 99 additions & 38 deletions src/fontra/workflow/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from dataclasses import dataclass, field
from functools import singledispatch
from importlib.metadata import entry_points
from typing import Any, AsyncGenerator, ClassVar
from typing import Any, AsyncGenerator, Protocol

from ..backends.null import NullBackend
from ..core.protocols import ReadableFontBackend
Expand Down Expand Up @@ -56,47 +56,49 @@ class WorkflowEndPoints:
outputs: list[OutputProcessorProtocol]


@dataclass(kw_only=True)
class ActionStep:
actionName: str
arguments: dict
steps: list[ActionStep] = field(default_factory=list)
actionType: ClassVar[str]

def getAction(
self,
) -> InputActionProtocol | FilterActionProtocol | OutputActionProtocol:
actionClass = getActionClass(self.actionType, self.actionName)
action = actionClass(**self.arguments)
assert isinstance(
action, (InputActionProtocol, FilterActionProtocol, OutputActionProtocol)
)
return action

class ActionStep(Protocol):
async def setup(
self, currentInput: ReadableFontBackend, exitStack
) -> WorkflowEndPoints:
raise NotImplementedError
pass


def getAction(
actionType,
actionName,
actionArguments,
) -> InputActionProtocol | FilterActionProtocol | OutputActionProtocol:
actionClass = getActionClass(actionType, actionName)
action = actionClass(**actionArguments)
assert isinstance(
action, (InputActionProtocol, FilterActionProtocol, OutputActionProtocol)
)
return action


_actionStepClasses = {}


def registerActionStepClass(cls):
assert cls.actionType not in _actionStepClasses
_actionStepClasses[cls.actionType] = cls
return cls
def registerActionStepClass(actionType):
def register(cls):
assert actionType not in _actionStepClasses
_actionStepClasses[actionType] = cls
return cls

return register


@registerActionStepClass
@registerActionStepClass("input")
@dataclass(kw_only=True)
class InputActionStep(ActionStep):
actionType = "input"
class InputActionStep:
actionName: str
arguments: dict
steps: list[ActionStep] = field(default_factory=list)

async def setup(
self, currentInput: ReadableFontBackend, exitStack
) -> WorkflowEndPoints:
action = self.getAction()
action = getAction("input", self.actionName, self.arguments)
assert isinstance(action, InputActionProtocol)

backend = await exitStack.enter_async_context(action.prepare())
Expand All @@ -109,15 +111,17 @@ async def setup(
return WorkflowEndPoints(endPoint=endPoint, outputs=endPoints.outputs)


@registerActionStepClass
@registerActionStepClass("filter")
@dataclass(kw_only=True)
class FilterActionStep(ActionStep):
actionType = "filter"
class FilterActionStep:
actionName: str
arguments: dict
steps: list[ActionStep] = field(default_factory=list)

async def setup(
self, currentInput: ReadableFontBackend, exitStack
) -> WorkflowEndPoints:
action = self.getAction()
action = getAction("filter", self.actionName, self.arguments)
assert isinstance(action, FilterActionProtocol)

backend = await exitStack.enter_async_context(action.connect(currentInput))
Expand All @@ -126,16 +130,18 @@ async def setup(
return await _prepareEndPoints(backend, self.steps, exitStack)


@registerActionStepClass
@registerActionStepClass("output")
@dataclass(kw_only=True)
class OutputActionStep(ActionStep):
actionType = "output"
class OutputActionStep:
actionName: str
arguments: dict
steps: list[ActionStep] = field(default_factory=list)

async def setup(
self, currentInput: ReadableFontBackend, exitStack
) -> WorkflowEndPoints:
assert currentInput is not None
action = self.getAction()
action = getAction("output", self.actionName, self.arguments)
assert isinstance(action, OutputActionProtocol)

outputs = []
Expand All @@ -154,17 +160,72 @@ async def setup(
return WorkflowEndPoints(endPoint=currentInput, outputs=outputs)


@registerActionStepClass("fork")
@dataclass(kw_only=True)
class ForkActionStep:
actionName: str
arguments: dict
steps: list[ActionStep] = field(default_factory=list)

def __post_init__(self):
if self.actionName:
raise WorkflowError(
"fork 'value' needs to be empty; use 'fork:', "
+ "instead of 'fork: <something>'"
)
if self.arguments:
raise WorkflowError("fork does not expect arguments")

async def setup(
self, currentInput: ReadableFontBackend, exitStack
) -> WorkflowEndPoints:
# set up nested steps
endPoints = await _prepareEndPoints(currentInput, self.steps, exitStack)
return WorkflowEndPoints(endPoint=currentInput, outputs=endPoints.outputs)


@registerActionStepClass("fork-merge")
@dataclass(kw_only=True)
class ForkMergeActionStep:
actionName: str
arguments: dict
steps: list[ActionStep] = field(default_factory=list)

def __post_init__(self):
if self.actionName:
raise WorkflowError(
"fork-merge 'value' needs to be empty; use 'fork-merge:', "
+ "instead of 'fork-merge: <something>'"
)
if self.arguments:
raise WorkflowError("fork-merge does not expect arguments")

async def setup(
self, currentInput: ReadableFontBackend, exitStack
) -> WorkflowEndPoints:
# set up nested steps
endPoints = await _prepareEndPoints(currentInput, self.steps, exitStack)

endPoint = FontBackendMerger(
inputA=currentInput, inputB=endPoints.endPoint, warnAboutDuplicates=False
)
return WorkflowEndPoints(endPoint=endPoint, outputs=endPoints.outputs)


MISSING_ACTION_TYPE = object()


def _structureSteps(rawSteps) -> list[ActionStep]:
structured = []

for rawStep in rawSteps:
actionName = None
for actionType in _actionStepClasses:
actionName = rawStep.get(actionType)
if actionName is None:
actionName = rawStep.get(actionType, MISSING_ACTION_TYPE)
if actionName is MISSING_ACTION_TYPE:
continue
break
if actionName is None:
if actionName is MISSING_ACTION_TYPE:
raise WorkflowError("no action type keyword found in step")
arguments = dict(rawStep)
del arguments[actionType]
Expand Down
29 changes: 29 additions & 0 deletions test-py/data/workflow/output-fork-A.fontra/font-data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"sources": {
"5bea6334": {
"name": "LightCondensed",
"lineMetricsHorizontalLayout": {
"ascender": {
"value": 800,
"zone": 16
},
"capHeight": {
"value": 750,
"zone": 16
},
"xHeight": {
"value": 500,
"zone": 16
},
"baseline": {
"value": 0,
"zone": -16
},
"descender": {
"value": -250,
"zone": -16
}
}
}
}
}
2 changes: 2 additions & 0 deletions test-py/data/workflow/output-fork-A.fontra/glyph-info.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
glyph name;code points
A;U+0041,U+0061
109 changes: 109 additions & 0 deletions test-py/data/workflow/output-fork-A.fontra/glyphs/A^1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
{
"name": "A",
"sources": [
{
"name": "LightCondensed",
"layerName": "MutatorSansLightCondensed/foreground"
}
],
"layers": {
"MutatorSansLightCondensed/foreground": {
"glyph": {
"path": {
"contours": [
{
"points": [
{
"x": 20,
"y": 0
},
{
"x": 60,
"y": 0
},
{
"x": 200,
"y": 700
},
{
"x": 165,
"y": 700
}
],
"isClosed": true
},
{
"points": [
{
"x": 75,
"y": 164
},
{
"x": 325,
"y": 164
},
{
"x": 325,
"y": 200
},
{
"x": 75,
"y": 200
}
],
"isClosed": true
},
{
"points": [
{
"x": 332,
"y": 0
},
{
"x": 376,
"y": 0
},
{
"x": 231,
"y": 700
},
{
"x": 192,
"y": 700
}
],
"isClosed": true
},
{
"points": [
{
"x": 175,
"y": 661
},
{
"x": 222,
"y": 661
},
{
"x": 222,
"y": 700
},
{
"x": 175,
"y": 700
}
],
"isClosed": true
}
]
},
"xAdvance": 396
}
},
"MutatorSansLightCondensed/support": {
"glyph": {
"xAdvance": 930
}
}
}
}
29 changes: 29 additions & 0 deletions test-py/data/workflow/output-fork-B.fontra/font-data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"sources": {
"5bea6334": {
"name": "LightCondensed",
"lineMetricsHorizontalLayout": {
"ascender": {
"value": 800,
"zone": 16
},
"capHeight": {
"value": 750,
"zone": 16
},
"xHeight": {
"value": 500,
"zone": 16
},
"baseline": {
"value": 0,
"zone": -16
},
"descender": {
"value": -250,
"zone": -16
}
}
}
}
}
2 changes: 2 additions & 0 deletions test-py/data/workflow/output-fork-B.fontra/glyph-info.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
glyph name;code points
B;U+0042,U+0062
Loading

0 comments on commit ab770ca

Please sign in to comment.