Skip to content

Commit

Permalink
Merge pull request #44 from googlefonts/cff-varc
Browse files Browse the repository at this point in the history
Implement CFF2 export
  • Loading branch information
justvanrossum authored Jul 15, 2024
2 parents af3f533 + a9e9bcc commit 62144d2
Show file tree
Hide file tree
Showing 24 changed files with 18,198 additions and 46 deletions.
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ authors = [
]
keywords = ["font", "fonts"]
license = {text = "GNU General Public License v3"}
dependencies = ["fontra", "fontmake", "fontc"]
dependencies = ["fontra", "fontmake", "fontc", "cffsubr"]
dynamic = ["version"]
requires-python = ">=3.10"
classifiers = [
Expand Down Expand Up @@ -67,3 +67,7 @@ ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "fontmake.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "cffsubr.*"
ignore_missing_imports = true
8 changes: 6 additions & 2 deletions src/fontra_compile/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .builder import Builder


async def main_async():
async def main_async() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("source_font")
parser.add_argument("output_font")
Expand All @@ -25,7 +25,11 @@ async def main_async():
)

reader = getFileSystemBackend(sourceFontPath)
builder = Builder(reader, glyphNames)
builder = Builder(
reader=reader,
requestedGlyphNames=glyphNames,
buildCFF2=outputFontPath.suffix.lower() == ".otf",
)
await builder.setup()
ttFont = await builder.build()
ttFont.save(outputFontPath)
Expand Down
229 changes: 193 additions & 36 deletions src/fontra_compile/builder.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
from dataclasses import dataclass, field
from typing import Any

import cffsubr
from fontra.core.classes import VariableGlyph
from fontra.core.path import PackedPath
from fontra.core.path import PackedPath, Path
from fontra.core.protocols import ReadableFontBackend
from fontTools.designspaceLib import AxisDescriptor
from fontTools.fontBuilder import FontBuilder
from fontTools.misc.fixedTools import floatToFixed as fl2fi
from fontTools.misc.roundTools import noRound, otRound
from fontTools.misc.timeTools import timestampNow
from fontTools.misc.transform import DecomposedTransform
from fontTools.misc.vector import Vector
from fontTools.pens.boundsPen import BoundsPen, ControlBoundsPen
from fontTools.pens.pointPen import PointToSegmentPen
from fontTools.pens.recordingPen import RecordingPen
from fontTools.pens.t2CharStringPen import T2CharStringPen
from fontTools.pens.ttGlyphPen import TTGlyphPointPen
from fontTools.ttLib import TTFont, newTable
from fontTools.ttLib.tables import otTables as ot
Expand All @@ -18,7 +24,8 @@
from fontTools.ttLib.tables._g_v_a_r import TupleVariation
from fontTools.ttLib.tables.otTables import VAR_TRANSFORM_MAPPING, VarComponentFlags
from fontTools.varLib import HVAR_FIELDS, VVAR_FIELDS
from fontTools.varLib.builder import buildVarIdxMap
from fontTools.varLib.builder import buildVarData, buildVarIdxMap
from fontTools.varLib.cff import CFF2CharStringMergePen, addCFFVarStore
from fontTools.varLib.models import (
VariationModel,
VariationModelError,
Expand Down Expand Up @@ -53,15 +60,28 @@ class MissingBaseGlyphError(Exception):

@dataclass
class GlyphInfo:
ttGlyph: TTGlyph
hasContours: bool
xAdvance: float = 500
xAdvanceVariations: list = field(default_factory=list)
variations: list = field(default_factory=list)
xAdvance: float
xAdvanceVariations: list
leftSideBearing: int
ttGlyph: TTGlyph | None = None
gvarVariations: list | None = None
charString: Any | None = None
charStringSupports: tuple | None = None
variableComponents: list = field(default_factory=list)
localAxisTags: set = field(default_factory=set)
model: VariationModel | None = None

def __post_init__(self) -> None:
if self.ttGlyph is None:
assert self.gvarVariations is None
assert self.charString is not None
else:
assert self.charString is None
assert self.charStringSupports is None
if self.gvarVariations is None:
self.gvarVariations = []


@dataclass
class ComponentInfo:
Expand Down Expand Up @@ -125,10 +145,11 @@ def addLocationToComponent(self, compo, axisIndicesMapping, axisTags, storeBuild
compo.axisValuesVarIndex = varIdx


@dataclass(kw_only=True)
class Builder:
def __init__(self, reader, requestedGlyphNames=None):
self.reader = reader # a Fontra Backend, such as DesignspaceBackend
self.requestedGlyphNames = requestedGlyphNames
reader: ReadableFontBackend # a Fontra Backend, such as DesignspaceBackend
requestedGlyphNames: list = field(default_factory=list)
buildCFF2: bool = False

async def setup(self) -> None:
self.glyphMap = await self.reader.getGlyphMap()
Expand All @@ -149,7 +170,7 @@ async def setup(self) -> None:
self.globalAxisTags = {axis.name: axis.tag for axis in self.globalAxes}
self.defaultLocation = {k: v[1] for k, v in self.globalAxisDict.items()}

self.cachedSourceGlyphs: dict[str, VariableGlyph] = {}
self.cachedSourceGlyphs: dict[str, VariableGlyph | None] = {}
self.cachedComponentBaseInfo: dict = {}

self.glyphInfos: dict[str, GlyphInfo] = {}
Expand All @@ -165,6 +186,7 @@ async def getSourceGlyph(
sourceGlyph = self.cachedSourceGlyphs.get(glyphName)
if sourceGlyph is None:
sourceGlyph = await self.reader.getGlyph(glyphName)
assert sourceGlyph is not None
if storeInCache:
self.cachedSourceGlyphs[glyphName] = sourceGlyph
return sourceGlyph
Expand Down Expand Up @@ -195,10 +217,19 @@ async def prepareGlyphs(self) -> None:
if glyphInfo is None:
# make .notdef based on UPM
glyphInfo = GlyphInfo(
ttGlyph=TTGlyphPointPen(None).glyph(),
ttGlyph=(
TTGlyphPointPen(None).glyph() if not self.buildCFF2 else None
),
charString=(
T2CharStringPen(None, None, CFF2=True).getCharString()
if self.buildCFF2
else None
),
hasContours=False,
xAdvance=500,
leftSideBearing=0, # TODO: fix when actual notdef shape is added
xAdvanceVariations=[500],
gvarVariations=None,
)

self.glyphInfos[glyphName] = glyphInfo
Expand All @@ -225,26 +256,38 @@ async def prepareOneGlyph(self, glyphName: str) -> GlyphInfo:

xAdvanceVariations = prepareXAdvanceVariations(glyph, glyphSources)

sourceCoordinates = prepareSourceCoordinates(glyph, glyphSources)
variations = (
prepareGvarVariations(sourceCoordinates, model) if model is not None else []
)

defaultSourceIndex = model.reverseMapping[0] if model is not None else 0
defaultGlyph = glyph.layers[glyphSources[defaultSourceIndex].layerName].glyph

ttGlyphPen = TTGlyphPointPen(None)
defaultGlyph.path.drawPoints(ttGlyphPen)
ttGlyph = ttGlyphPen.glyph()
defaultLayerGlyph = glyph.layers[
glyphSources[defaultSourceIndex].layerName
].glyph

ttGlyph = None
gvarVariations = None
charString = None
charStringSupports = None

if not self.buildCFF2:
ttGlyph, gvarVariations = buildTTGlyph(
glyph, glyphSources, defaultLayerGlyph, model
)
else:
charString, charStringSupports = buildCharString(
glyph, glyphSources, defaultLayerGlyph, model
)

componentInfo = await self.collectComponentInfo(glyph, defaultSourceIndex)

leftSideBearing = computeLeftSideBearing(defaultLayerGlyph.path, self.buildCFF2)

return GlyphInfo(
ttGlyph=ttGlyph,
hasContours=not defaultGlyph.path.isEmpty(),
xAdvance=max(defaultGlyph.xAdvance or 0, 0),
gvarVariations=gvarVariations,
charString=charString,
charStringSupports=charStringSupports,
hasContours=not defaultLayerGlyph.path.isEmpty(),
xAdvance=max(defaultLayerGlyph.xAdvance or 0, 0),
xAdvanceVariations=xAdvanceVariations,
variations=variations,
leftSideBearing=leftSideBearing,
variableComponents=componentInfo,
localAxisTags=set(localAxisTags.values()),
model=model,
Expand Down Expand Up @@ -397,12 +440,15 @@ async def setupComponentBaseInfo(self, baseGlyphName: str) -> dict[str, Any]:
)

async def buildFont(self) -> TTFont:
builder = FontBuilder(await self.reader.getUnitsPerEm(), glyphDataFormat=1)
builder = FontBuilder(
await self.reader.getUnitsPerEm(),
glyphDataFormat=1,
isTTF=not self.buildCFF2,
)

builder.updateHead(created=timestampNow(), modified=timestampNow())
builder.setupGlyphOrder(self.glyphOrder)
builder.setupNameTable(dict())
builder.setupGlyf(getGlyphInfoAttributes(self.glyphInfos, "ttGlyph"))

localAxisTags = set()
for glyphInfo in self.glyphInfos.values():
Expand All @@ -417,19 +463,29 @@ async def buildFont(self) -> TTFont:
if any(axis.map for axis in dsAxes):
builder.setupAvar(dsAxes)

variations = getGlyphInfoAttributes(self.glyphInfos, "variations")
if variations:
builder.setupGvar(variations)
if not self.buildCFF2:
builder.setupGlyf(getGlyphInfoAttributes(self.glyphInfos, "ttGlyph"))
gvarVariations = getGlyphInfoAttributes(self.glyphInfos, "gvarVariations")
if gvarVariations:
builder.setupGvar(gvarVariations)
else:
charStrings = getGlyphInfoAttributes(self.glyphInfos, "charString")
charStringSupports = getGlyphInfoAttributes(
self.glyphInfos, "charStringSupports"
)
varDataList, regionList = prepareCFFVarData(charStrings, charStringSupports)
builder.setupCFF2(charStrings)
addCFFVarStore(builder.font, None, varDataList, regionList)

if any(glyphInfo.variableComponents for glyphInfo in self.glyphInfos.values()):
varcTable = self.buildVARC(axisTags)
builder.font["VARC"] = varcTable

builder.setupHorizontalHeader()
builder.setupHorizontalMetrics(
addLSB(
builder.font["glyf"],
dictZip(
getGlyphInfoAttributes(self.glyphInfos, "xAdvance"),
getGlyphInfoAttributes(self.glyphInfos, "leftSideBearing"),
)
)
hvarTable = self.buildHVAR(axisTags)
Expand All @@ -439,6 +495,9 @@ async def buildFont(self) -> TTFont:
builder.setupOS2()
builder.setupPost()

if self.buildCFF2:
cffsubr.subroutinize(builder.font)

return builder.font

def buildVARC(self, axisTags):
Expand Down Expand Up @@ -643,6 +702,24 @@ def prepareXAdvanceVariations(glyph: VariableGlyph, glyphSources):
return [glyph.layers[source.layerName].glyph.xAdvance for source in glyphSources]


def computeLeftSideBearing(path: Path | PackedPath, useTightBounds: bool) -> int:
boundsPen = (BoundsPen if useTightBounds else ControlBoundsPen)(None)
path.drawPoints(PointToSegmentPen(boundsPen))
return otRound(boundsPen.bounds[0]) if boundsPen.bounds is not None else 0


def buildTTGlyph(glyph, glyphSources, defaultLayerGlyph, model):
ttGlyphPen = TTGlyphPointPen(None)
defaultLayerGlyph.path.drawPoints(ttGlyphPen)
ttGlyph = ttGlyphPen.glyph()

sourceCoordinates = prepareSourceCoordinates(glyph, glyphSources)
gvarVariations = (
prepareGvarVariations(sourceCoordinates, model) if model is not None else []
)
return ttGlyph, gvarVariations


def prepareSourceCoordinates(glyph: VariableGlyph, glyphSources):
sourceCoordinates = []

Expand Down Expand Up @@ -681,11 +758,72 @@ def prepareGvarVariations(sourceCoordinates, model):
return [TupleVariation(s, d) for s, d in zip(supports, deltas)]


def addLSB(glyfTable, metrics: dict[str, int]) -> dict[str, tuple[int, int]]:
return {
glyphName: (xAdvance, glyfTable[glyphName].xMin)
for glyphName, xAdvance in metrics.items()
}
def buildCharString(glyph, glyphSources, defaultLayerGlyph, model):
if model is None:
pen = T2CharStringPen(None, None, CFF2=True)
defaultLayerGlyph.path.drawPoints(PointToSegmentPen(pen))
charString = pen.getCharString()
charStringSupports = None
else:
if model.reverseMapping[0] != 0:
# For some reason, CFF2CharStringMergePen requires the first source
# to be the default, so let's make it so.
glyphSources = [glyphSources[i] for i in model.reverseMapping]
model = VariationModel(model.locations, model.axisOrder)
assert model.reverseMapping[0] == 0

pen = CFF2CharStringMergePen([], glyph.name, len(glyphSources), 0)
for sourceIndex, source in enumerate(glyphSources):
if sourceIndex:
pen.restart(sourceIndex)
layerGlyph = glyph.layers[source.layerName].glyph
drawPathToSegmentPen(layerGlyph.path, pen)

charString = pen.getCharString(var_model=model)
charStringSupports = tuple(
tuple(sorted(sup.items())) for sup in model.supports[1:]
)

return charString, charStringSupports


def prepareCFFVarData(charStrings, charStringSupports):
vsindexMap = {}
for supports in charStringSupports.values():
if supports and supports not in vsindexMap:
vsindexMap[supports] = len(vsindexMap)

for glyphName, charString in charStrings.items():
supports = charStringSupports.get(glyphName)
if supports is not None:
assert "vsindex" not in charString.program
vsindex = vsindexMap[supports]
if vsindex != 0:
charString.program[:0] = [vsindex, "vsindex"]

assert list(vsindexMap.values()) == list(range(len(vsindexMap)))

regionMap = {}
for supports in vsindexMap.keys():
for region in supports:
if region not in regionMap:
regionMap[region] = len(regionMap)
assert list(regionMap.values()) == list(range(len(regionMap)))
regionList = [dict(region) for region in regionMap.keys()]

varDataList = []
for supports in vsindexMap.keys():
varTupleIndexes = [regionMap[region] for region in supports]
varDataList.append(buildVarData(varTupleIndexes, None, False))

return varDataList, regionList


def dictZip(*dicts: dict) -> dict:
keys = dicts[0].keys()
if not all(keys == d.keys() for d in dicts[1:]):
raise ValueError("all input dicts must have the same set of keys")
return {key: tuple(d[key] for d in dicts) for key in keys}


def applyAxisMapToAxisValues(axis) -> tuple[float, float, float]:
Expand Down Expand Up @@ -787,3 +925,22 @@ def getGlyphInfoAttributes(glyphInfos, attrName):
glyphName: getattr(glyphInfo, attrName)
for glyphName, glyphInfo in glyphInfos.items()
}


def drawPathToSegmentPen(path, pen):
# We ask PointToSegmentPen to output implied closing lines, then filter
# said closing lines again because we don't need them in the CharString.
# The reason is that PointToSegment pen will still output closing lines
# in some cases, based on input coordinates, even if we ask it not to.
# https://github.com/fonttools/fonttools/issues/3584
recPen = DropImpliedClosingLinePen()
pointPen = PointToSegmentPen(recPen, outputImpliedClosingLine=True)
path.drawPoints(pointPen)
recPen.replay(pen)


class DropImpliedClosingLinePen(RecordingPen):
def closePath(self):
if self.value[-1][0] == "lineTo":
del self.value[-1]
super().closePath()
Loading

0 comments on commit 62144d2

Please sign in to comment.