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

Implement CFF2 export #44

Merged
merged 29 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9ebbd2e
Rename var/attr to be more explicit
justvanrossum Jul 12, 2024
266bec7
Prepare for charstrings
justvanrossum Jul 12, 2024
46c442c
Improve type checking, make Builder a dataclass
justvanrossum Jul 12, 2024
50725b8
Make Builder only take keyword args
justvanrossum Jul 12, 2024
46362ab
Prepare for CFF2
justvanrossum Jul 12, 2024
758e462
Parametrize output format
justvanrossum Jul 12, 2024
93f29fc
Factor out TTF glyph builder
justvanrossum Jul 12, 2024
3732758
Implement CFF2 export
justvanrossum Jul 12, 2024
bf24754
Factor out the CFF var data hard bits
justvanrossum Jul 12, 2024
90245b0
whitespace
justvanrossum Jul 12, 2024
bd3dee0
Fix bounds pen usage: for CFF2 we need tight bounds, for TTF control …
justvanrossum Jul 12, 2024
263d44b
Fiddle
justvanrossum Jul 12, 2024
27708b1
Do subroutinization
justvanrossum Jul 13, 2024
aa699e6
Adjust expected output to subroutinization
justvanrossum Jul 13, 2024
30a9b88
Ignore cffsubr for type checking
justvanrossum Jul 13, 2024
bd0fdf9
Fix type annotation
justvanrossum Jul 13, 2024
080b14b
Also test otf export via fontra-workflow
justvanrossum Jul 13, 2024
7f56124
Fold two operations into one func
justvanrossum Jul 13, 2024
48b5366
Ensure we use a model that has the default source as the first item
justvanrossum Jul 13, 2024
b87d94b
Test that our CFF2CharStringMergePen source order fix is effective
justvanrossum Jul 13, 2024
24aaaf6
Simplify by not repeating condition
justvanrossum Jul 13, 2024
3c73ea1
Also test random glyph source order for ttf output
justvanrossum Jul 13, 2024
ed34d8c
Simplify explainer comment
justvanrossum Jul 13, 2024
f08bf1e
Generalize helper func, because who knows
justvanrossum Jul 13, 2024
8def733
Work around fonttools issue with PointToSegmentPen
justvanrossum Jul 14, 2024
e06ad24
Add tiny subset of Noto Sans CJK VarCo to test edge case in otf export
justvanrossum Jul 15, 2024
8bbb973
Explain the need for this test case
justvanrossum Jul 15, 2024
a82fae4
Factor out computation of LSB
justvanrossum Jul 15, 2024
a9e9bcc
Move helper func
justvanrossum Jul 15, 2024
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
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