Skip to content

Commit

Permalink
Merge pull request #1775 from googlefonts/background-image-infra
Browse files Browse the repository at this point in the history
Background image infrastructure
  • Loading branch information
justvanrossum authored Nov 11, 2024
2 parents 2762795 + 343b399 commit 1af2432
Show file tree
Hide file tree
Showing 39 changed files with 713 additions and 55 deletions.
30 changes: 28 additions & 2 deletions src/fontra/backends/copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
import shutil
from contextlib import aclosing, asynccontextmanager

from ..core.protocols import ReadableFontBackend, WritableFontBackend
from ..core.protocols import (
ReadableFontBackend,
ReadBackgroundImage,
WritableFontBackend,
WriteBackgroundImage,
)
from . import getFileSystemBackend, newFileSystemBackend

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -81,6 +86,17 @@ async def _copyFont(
assert e is not None
raise e

if isinstance(destBackend, WriteBackgroundImage):
backgroundImageInfos = [info for t in done for info in t.result()]
if backgroundImageInfos:
assert isinstance(sourceBackend, ReadBackgroundImage), type(sourceBackend)
for glyphName, layerName, imageIdentifier in backgroundImageInfos:
imageData = await sourceBackend.getBackgroundImage(imageIdentifier)
if imageData is not None:
await destBackend.putBackgroundImage(
imageIdentifier, glyphName, layerName, imageData
)

await destBackend.putKerning(await sourceBackend.getKerning())
await destBackend.putFeatures(await sourceBackend.getFeatures())

Expand All @@ -93,7 +109,9 @@ async def copyGlyphs(
glyphNamesCopied: set[str],
progressInterval: int,
continueOnError: bool,
) -> None:
) -> list:
backgroundImageInfos = []

while glyphNamesToCopy:
if progressInterval and not (len(glyphNamesToCopy) % progressInterval):
logger.info(f"{len(glyphNamesToCopy)} glyphs left to copy")
Expand Down Expand Up @@ -122,8 +140,16 @@ async def copyGlyphs(
}
glyphNamesToCopy.extend(sorted(componentNames - glyphNamesCopied))

for layerName, layer in glyph.layers.items():
if layer.glyph.backgroundImage is not None:
backgroundImageInfos.append(
(glyphName, layerName, layer.glyph.backgroundImage.identifier)
)

await destBackend.putGlyph(glyphName, glyph, glyphMap[glyphName])

return backgroundImageInfos


async def mainAsync() -> None:
logging.basicConfig(
Expand Down
178 changes: 168 additions & 10 deletions src/fontra/backends/designspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
import pathlib
import secrets
import shutil
import uuid
from collections import defaultdict
from copy import deepcopy
from dataclasses import asdict, dataclass, replace
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime
from functools import cache, cached_property, partial, singledispatch
from os import PathLike
Expand All @@ -22,17 +23,18 @@
DiscreteAxisDescriptor,
SourceDescriptor,
)
from fontTools.misc.transform import DecomposedTransform
from fontTools.misc.transform import DecomposedTransform, Transform
from fontTools.pens.pointPen import AbstractPointPen
from fontTools.pens.recordingPen import RecordingPointPen
from fontTools.ufoLib import UFOReaderWriter
from fontTools.ufoLib import UFOLibError, UFOReaderWriter
from fontTools.ufoLib.glifLib import GlyphSet

from ..core.async_property import async_property
from ..core.classes import (
Anchor,
Axes,
AxisValueLabel,
BackgroundImage,
Component,
CrossAxisMapping,
DiscreteFontAxis,
Expand All @@ -42,10 +44,13 @@
GlyphAxis,
GlyphSource,
Guideline,
ImageData,
ImageType,
Kerning,
Layer,
LineMetric,
OpenTypeFeatures,
RGBAColor,
StaticGlyph,
VariableGlyph,
)
Expand Down Expand Up @@ -208,6 +213,7 @@ def __init__(self, dsDoc: DesignSpaceDocument) -> None:
self._glyphDependenciesTask: asyncio.Task[GlyphDependencies] | None = None
self._glyphDependencies: GlyphDependencies | None = None
self._backgroundTasksTask: asyncio.Task | None = None
self._imageMapping = DoubleDict()
# Set this to true to set "public.truetype.overlap" in each writte .glif's lib:
self.setOverlapSimpleFlag = False
self._familyName: str | None = None
Expand Down Expand Up @@ -444,6 +450,11 @@ async def getGlyph(self, glyphName: str) -> VariableGlyph | None:
GLYPH_SOURCE_CUSTOM_DATA_LIB_KEY, {}
)

if staticGlyph.backgroundImage is not None:
staticGlyph.backgroundImage.identifier = self._getImageIdentifier(
ufoLayer.path, staticGlyph.backgroundImage.identifier
)

layers[ufoLayer.fontraLayerName] = Layer(glyph=staticGlyph)

# When a glyph has axes with names that also exist as global axes, we need
Expand Down Expand Up @@ -632,8 +643,25 @@ async def putGlyph(
if self.setOverlapSimpleFlag:
layerGlyph.lib["public.truetype.overlap"] = True

imageFileName = None
if layer.glyph.backgroundImage is not None:
imageInfo = self._imageMapping.reverse.get(
layer.glyph.backgroundImage.identifier
)
if imageInfo is not None:
_, imageFileName = imageInfo
else:
imageFileName = f"{layer.glyph.backgroundImage.identifier}.png"
imageInfo = (ufoLayer.path, imageFileName)
self._imageMapping[imageInfo] = (
layer.glyph.backgroundImage.identifier
)

drawPointsFunc = populateUFOLayerGlyph(
layerGlyph, layer.glyph, hasVariableComponents
layerGlyph,
layer.glyph,
hasVariableComponents,
imageFileName=imageFileName,
)
glyphSet.writeGlyph(glyphName, layerGlyph, drawPointsFunc=drawPointsFunc)
if writeGlyphSetContents:
Expand Down Expand Up @@ -1139,6 +1167,63 @@ async def putFeatures(self, features: OpenTypeFeatures) -> None:
featureText = features.text
writer.writeFeatures(featureText)

async def getBackgroundImage(self, imageIdentifier: str) -> ImageData | None:
imageInfo = self._imageMapping.reverse.get(imageIdentifier)
if imageInfo is None:
return None

ufoPath, imageFileName = imageInfo
reader = self.ufoManager.getReader(ufoPath)

try:
data = reader.readImage(imageFileName, validate=True)
except UFOLibError as e:
logger.warning(str(e))
return None

return ImageData(type=ImageType.PNG, data=data)

async def putBackgroundImage(
self, imageIdentifier: str, glyphName: str, layerName: str, data: ImageData
) -> None:
if glyphName not in self.glyphMap:
raise KeyError(glyphName)

if data.type != ImageType.PNG:
raise NotImplementedError("convert image to PNG")

defaultStaticGlyph, defaultUFOGlyph = ufoLayerToStaticGlyph(
self.defaultUFOLayer.glyphSet, glyphName
)

layerNameMapping = defaultUFOGlyph.lib.get(LAYER_NAME_MAPPING_LIB_KEY, {})
revLayerNameMapping = {v: k for k, v in layerNameMapping.items()}
layerName = revLayerNameMapping.get(layerName, layerName)
ufoLayer = self.ufoLayers.findItem(fontraLayerName=layerName)

imageFileName = f"{imageIdentifier}.{data.type.lower()}"

ufoLayer.reader.writeImage(imageFileName, data.data, validate=True)

key = (ufoLayer.path, imageFileName)
self._imageMapping[key] = imageIdentifier

def _getImageIdentifier(self, ufoPath: str, imageFileName: str) -> str:
key = (ufoPath, imageFileName)
imageIdentifier = self._imageMapping.get(key)

if imageIdentifier is None:
ufoFileName = os.path.basename(ufoPath)
imageIdentifier = str(
uuid.uuid5(
uuid.NAMESPACE_URL,
f"https://fontra.xyz/image-ids/{ufoFileName}/{imageFileName}",
)
)
self._imageMapping[key] = imageIdentifier

return imageIdentifier

async def getCustomData(self) -> dict[str, Any]:
return deepcopy(self.dsDoc.lib)

Expand Down Expand Up @@ -1471,14 +1556,16 @@ def _updateFontInfoFromDict(fontInfo: UFOFontInfo, infoDict: dict):
setattr(fontInfo, infoAttr, value)


@dataclass(kw_only=True)
class UFOGlyph:
unicodes: list = []
unicodes: list = field(default_factory=list)
width: float | None = 0
height: float | None = None
anchors: list = []
guidelines: list = []
anchors: list = field(default_factory=list)
guidelines: list = field(default_factory=list)
image: dict | None = None
note: str | None = None
lib: dict
lib: dict = field(default_factory=dict)


class UFOFontInfo:
Expand Down Expand Up @@ -1653,9 +1740,26 @@ def iterAttrs(self, attrName):
yield getattr(item, attrName)


class DoubleDict(dict):
def __init__(self):
self.reverse = {}

def __setitem__(self, key, value):
super().__setitem__(key, value)
self.reverse[value] = key

def __delitem__(self, key):
raise NotImplementedError()

def pop(self, *args, **kwargs):
raise NotImplementedError()

def setdefault(self, *args, **kwargs):
raise NotImplementedError()


def ufoLayerToStaticGlyph(glyphSet, glyphName, penClass=PackedPathPointPen):
glyph = UFOGlyph()
glyph.lib = {}
pen = penClass()
glyphSet.readGlyph(glyphName, glyph, pen, validate=False)
components = [*pen.components] + unpackVariableComponents(glyph.lib)
Expand All @@ -1670,6 +1774,7 @@ def ufoLayerToStaticGlyph(glyphSet, glyphName, penClass=PackedPathPointPen):
verticalOrigin=verticalOrigin,
anchors=unpackAnchors(glyph.anchors),
guidelines=unpackGuidelines(glyph.guidelines),
backgroundImage=unpackBackgroundImage(glyph.image),
)

return staticGlyph, glyph
Expand Down Expand Up @@ -1708,6 +1813,54 @@ def unpackGuidelines(guidelines):
]


imageTransformFields = [
("xScale", 1),
("xyScale", 0),
("yxScale", 0),
("yScale", 1),
("xOffset", 0),
("yOffset", 0),
]


def unpackBackgroundImage(imageDict: dict | None) -> BackgroundImage | None:
if imageDict is None:
return None

t = Transform(*(imageDict.get(k, dv) for k, dv in imageTransformFields))
colorChannels = [float(ch.strip()) for ch in imageDict.get("color", "").split(",")]

return BackgroundImage(
identifier=imageDict["fileName"],
transformation=DecomposedTransform.fromTransform(t),
color=RGBAColor(*colorChannels) if len(colorChannels) == 4 else None,
)


def packBackgroundImage(backgroundImage, imageFileName) -> dict:
imageDict = {"fileName": imageFileName}

t = backgroundImage.transformation.toTransform()
for (fieldName, default), value in zip(imageTransformFields, t):
if value != default:
imageDict[fieldName] = value

if backgroundImage.color is not None:
c = backgroundImage.color
imageDict["color"] = ",".join(
_formatChannelValue(ch) for ch in [c.red, c.green, c.blue, c.alpha]
)

return imageDict


def _formatChannelValue(ch):
s = f"{ch:0.5f}"
s = s.rstrip("0")
s = s.rstrip(".")
return s


def packGuidelines(guidelines):
packedGuidelines = []
for g in guidelines:
Expand All @@ -1727,7 +1880,6 @@ def readGlyphOrCreate(
codePoints: list[int],
) -> UFOGlyph:
layerGlyph = UFOGlyph()
layerGlyph.lib = {}
if glyphName in glyphSet:
# We read the existing glyph so we don't lose any data that
# Fontra doesn't understand
Expand All @@ -1740,6 +1892,7 @@ def populateUFOLayerGlyph(
layerGlyph: UFOGlyph,
staticGlyph: StaticGlyph,
forceVariableComponents: bool = False,
imageFileName: str | None = None,
) -> Callable[[AbstractPointPen], None]:
pen = RecordingPointPen()

Expand All @@ -1758,6 +1911,11 @@ def populateUFOLayerGlyph(
{"name": g.name, "x": g.x, "y": g.y, "angle": g.angle}
for g in staticGlyph.guidelines
]
if staticGlyph.backgroundImage is not None and imageFileName is not None:
layerGlyph.image = packBackgroundImage(
staticGlyph.backgroundImage, imageFileName
)

for component in staticGlyph.components:
if component.location or forceVariableComponents:
# Store as a variable component
Expand Down
Loading

0 comments on commit 1af2432

Please sign in to comment.