[cantarell-fonts/ufo-conversion] Add script for making static instances without fontmake
- From: Nikolaus Waxweiler <nwaxweiler src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [cantarell-fonts/ufo-conversion] Add script for making static instances without fontmake
- Date: Wed, 20 Mar 2019 23:48:27 +0000 (UTC)
commit 9fc0fc55d9e8692ed87f6948ec85d1993b7f8aac
Author: Nikolaus Waxweiler <madigens gmail com>
Date: Wed Mar 20 23:47:54 2019 +0000
Add script for making static instances without fontmake
scripts/LICENSE_ufoProcessor | 20 +++
scripts/instantiator.py | 393 +++++++++++++++++++++++++++++++++++++++++++
scripts/make-static-fonts.py | 75 +++++++++
3 files changed, 488 insertions(+)
---
diff --git a/scripts/LICENSE_ufoProcessor b/scripts/LICENSE_ufoProcessor
new file mode 100644
index 00000000..44838bb4
--- /dev/null
+++ b/scripts/LICENSE_ufoProcessor
@@ -0,0 +1,20 @@
+Copyright (c) 2017-2018 LettError and Erik van Blokland
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
diff --git a/scripts/instantiator.py b/scripts/instantiator.py
new file mode 100644
index 00000000..0dc65e08
--- /dev/null
+++ b/scripts/instantiator.py
@@ -0,0 +1,393 @@
+#!/bin/env python3
+#
+# This code is based on ufoProcessor code, see LICENSE_ufoProcessor.
+
+from pathlib import Path
+from typing import Any, Dict, List, Mapping, Set, Tuple, Union
+
+import attr
+import fontMath
+import fontTools.designspaceLib as designspaceLib
+import fontTools.ufoLib as ufoLib
+import fontTools.varLib as varLib
+import ufoLib2
+
+FontMathObject = Union[fontMath.MathGlyph, fontMath.MathInfo, fontMath.MathKerning]
+Location = Mapping[str, float]
+
+
+@attr.s(auto_attribs=True, frozen=True, slots=True)
+class Instantiator:
+ copy_feature_text: str
+ copy_groups: Mapping[str, List[str]]
+ copy_info: ufoLib2.objects.Info
+ copy_lib: Mapping[str, Any]
+ designspace_rules: List[designspaceLib.RuleDescriptor]
+ glyph_mutators: Mapping[str, "Variator"]
+ info_mutator: "Variator"
+ kerning_mutator: "Variator"
+ round_geometry: bool
+
+ @classmethod
+ def from_designspace(
+ cls,
+ designspace: designspaceLib.DesignSpaceDocument,
+ round_geometry: bool = True,
+ ):
+ if designspace.default is None:
+ raise ValueError(
+ "Can't generate UFOs from this designspace: no default font."
+ )
+
+ glyph_names: Set[str] = set()
+ for source in designspace.sources:
+ if not Path(source.path).exists():
+ raise ValueError(f"Source at path '{source.path}' not found.")
+ source.font = ufoLib2.Font.open(source.path, lazy=False)
+ glyph_names.update(source.font.keys())
+
+ # Construct Variators
+ axis_bounds: Dict[str, Tuple[float, float, float]] = {}
+ axis_by_name: Dict[str, designspaceLib.AxisDescriptor] = {}
+ for axis in designspace.axes:
+ axis_by_name[axis.name] = axis
+ axis_bounds[axis.name] = (axis.minimum, axis.default, axis.maximum)
+
+ masters_info = collect_info_masters(designspace)
+ info_mutator = Variator.from_masters(masters_info, axis_by_name, axis_bounds)
+
+ masters_kerning = collect_kerning_masters(designspace)
+ kerning_mutator = Variator.from_masters(
+ masters_kerning, axis_by_name, axis_bounds
+ )
+
+ glyph_mutators: Dict[str, Variator] = {}
+ for glyph_name in glyph_names:
+ items = collect_glyph_masters(designspace, glyph_name)
+ mutator = Variator.from_masters(items, axis_by_name, axis_bounds)
+ glyph_mutators[glyph_name] = mutator
+
+ # Construct defaults to copy over
+ default_source = designspace.findDefault()
+ copy_feature_text: str = next(
+ (s.font.features.text for s in designspace.sources if s.copyFeatures),
+ default_source.font.features.text,
+ )
+ copy_groups: Mapping[str, List[str]] = next(
+ (s.font.groups for s in designspace.sources if s.copyGroups),
+ default_source.font.groups,
+ )
+ copy_info: ufoLib2.objects.Info = next(
+ (s.font.info for s in designspace.sources if s.copyInfo),
+ default_source.font.info,
+ )
+ copy_lib: Mapping[str, Any] = next(
+ (s.font.lib for s in designspace.sources if s.copyLib),
+ default_source.font.lib,
+ )
+
+ return cls(
+ copy_feature_text,
+ copy_groups,
+ copy_info,
+ copy_lib,
+ designspace.rules,
+ glyph_mutators,
+ info_mutator,
+ kerning_mutator,
+ round_geometry,
+ )
+
+ def generate_instance(self, instance: designspaceLib.InstanceDescriptor):
+ """Generate a font object for this instance.
+
+ Difference to original ufoProcessor method:
+ - Removed exception eating
+ - Deleted fontParts specific code paths and workarounds(?)
+ - No kerningGroupConversionRenameMaps because ufoLib2 doesn't have that (not
+ relevant for UFO3)
+ - Removed InstanceDescriptor.glyphs handling, no muting, no instance-specific
+ masters
+ - No anisotropic locations (not currently supported by varLib)
+ """
+ font = ufoLib2.Font()
+
+ location = instance.location
+ if anisotropic(location):
+ raise ValueError(
+ f"Instance {instance.familyName}-"
+ f"{instance.styleName}: Anisotropic location "
+ f"{instance.location} not supported by varLib."
+ )
+
+ # Kerning
+ if instance.kerning:
+ kerning_instance = self.kerning_mutator.instance_at(location)
+ kerning_instance.extractKerning(font)
+
+ # Info
+ info_instance = self.info_mutator.instance_at(location)
+ if self.round_geometry:
+ info_instance = info_instance.round()
+ info_instance.extractInfo(font.info)
+
+ # Copy metadata from sources marked with `<copy info="1">` etc.
+ for attribute in ufoLib.fontInfoAttributesVersion3:
+ if hasattr(info_instance, attribute):
+ continue # Skip mutated attributes.
+ if hasattr(self.copy_info, attribute):
+ setattr(font.info, attribute, getattr(self.copy_info, attribute))
+ for key, value in self.copy_lib.items():
+ font.lib[key] = value
+ for key, value in self.copy_groups.items():
+ font.groups[key] = value
+ font.features.text = self.copy_feature_text
+
+ # TODO: multilingual names to replace possibly existing name records.
+ if instance.familyName:
+ font.info.familyName = instance.familyName
+ if instance.styleName:
+ font.info.styleName = instance.styleName
+ if instance.postScriptFontName:
+ font.info.postscriptFontName = instance.postScriptFontName
+ if instance.styleMapFamilyName:
+ font.info.styleMapFamilyName = instance.styleMapFamilyName
+ if instance.styleMapStyleName:
+ font.info.styleMapStyleName = instance.styleMapStyleName
+
+ # Glyphs
+ for glyph_name, glyph_mutator in self.glyph_mutators.items():
+ font.newGlyph(glyph_name)
+ neutral = glyph_mutator.neutral_master()
+
+ glyph_instance = glyph_mutator.instance_at(location)
+ if self.round_geometry:
+ glyph_instance = glyph_instance.round()
+ glyph_instance.extractGlyph(font[glyph_name], onlyGeometry=True)
+ font[glyph_name].width = glyph_instance.width
+ font[glyph_name].unicodes = neutral.unicodes
+
+ # Process rules
+ glyph_names_list = self.glyph_mutators.keys()
+ resultNames = designspaceLib.processRules(
+ self.designspace_rules, location, glyph_names_list
+ )
+ for oldName, newName in zip(glyph_names_list, resultNames):
+ if oldName != newName:
+ swapGlyphNames(font, oldName, newName)
+
+ font.lib["designspace.location"] = list(instance.location.items())
+
+ return font
+
+
+def anisotropic(location: Location) -> bool:
+ for v in location.values():
+ if isinstance(v, tuple):
+ return True
+ return False
+
+
+def normalize_design_location(design_location: Location, axes, axis_bounds) -> Location:
+ return varLib.models.normalizeLocation(
+ {
+ axis_name: axes[axis_name].map_backward(value)
+ for axis_name, value in design_location.items()
+ },
+ axis_bounds,
+ )
+
+
+def collect_info_masters(designspace) -> List[Tuple[Location, FontMathObject]]:
+ """Return master Info objects wrapped by MathInfo."""
+ locations_and_masters = []
+ for source in designspace.sources:
+ if source.layerName is not None:
+ continue
+ locations_and_masters.append(
+ (source.location, fontMath.MathInfo(source.font.info))
+ )
+
+ return locations_and_masters
+
+
+def collect_kerning_masters(designspace) -> List[Tuple[Location, FontMathObject]]:
+ """Return master kerning objects wrapped by MathKerning."""
+ locations_and_masters = []
+ for source in designspace.sources:
+ if source.layerName is not None:
+ continue # No kerning in source layers.
+ if not source.muteKerning:
+ # This assumes that groups of all sources are the same.
+ locations_and_masters.append(
+ (
+ source.location,
+ fontMath.MathKerning(source.font.kerning, source.font.groups),
+ )
+ )
+
+ return locations_and_masters
+
+
+def collect_glyph_masters(
+ designspace, glyph_name
+) -> List[Tuple[Location, FontMathObject]]:
+ """Return master glyph objects for glyph_name wrapped by MathGlyph."""
+ locations_and_masters = []
+ for source in designspace.sources:
+ if glyph_name in source.mutedGlyphNames:
+ continue
+
+ if source.layerName is None:
+ # Source font.
+ source_layer = source.font.layers.defaultLayer
+ else:
+ # Source layer.
+ source_layer = source.font.layers[source.layerName]
+ if glyph_name not in source_layer:
+ # Sparse source layer, skip for this glyph.
+ continue
+
+ if glyph_name not in source_layer:
+ continue
+
+ source_glyph = source_layer[glyph_name]
+ locations_and_masters.append(
+ (source.location, fontMath.MathGlyph(source_glyph))
+ )
+
+ return locations_and_masters
+
+
+def swapGlyphNames(font, oldName, newName, swapNameExtension="_______________swap"):
+ # In font swap the glyphs oldName and newName.
+ # Also swap the names in components in order to preserve appearance.
+ # Also swap the names in font groups.
+ if not oldName in font or not newName in font:
+ return
+ swapName = oldName + swapNameExtension
+ # park the old glyph
+ if not swapName in font:
+ font.newGlyph(swapName)
+ # swap the outlines
+ font[swapName].clear()
+ p = font[swapName].getPointPen()
+ font[oldName].drawPoints(p)
+ font[swapName].width = font[oldName].width
+ # lib?
+ font[oldName].clear()
+ p = font[oldName].getPointPen()
+ font[newName].drawPoints(p)
+ font[oldName].width = font[newName].width
+
+ font[newName].clear()
+ p = font[newName].getPointPen()
+ font[swapName].drawPoints(p)
+ font[newName].width = font[swapName].width
+
+ # remap the components
+ for g in font:
+ for c in g.components:
+ if c.baseGlyph == oldName:
+ c.baseGlyph = swapName
+ continue
+ for g in font:
+ for c in g.components:
+ if c.baseGlyph == newName:
+ c.baseGlyph = oldName
+ continue
+ for g in font:
+ for c in g.components:
+ if c.baseGlyph == swapName:
+ c.baseGlyph = newName
+
+ # change the names in groups
+ # the shapes will swap, that will invalidate the kerning
+ # so the names need to swap in the kerning as well.
+ newKerning = {}
+ for first, second in font.kerning.keys():
+ value = font.kerning[(first, second)]
+ if first == oldName:
+ first = newName
+ elif first == newName:
+ first = oldName
+ if second == oldName:
+ second = newName
+ elif second == newName:
+ second = oldName
+ newKerning[(first, second)] = value
+ font.kerning.clear()
+ font.kerning.update(newKerning)
+
+ for groupName, members in font.groups.items():
+ newMembers = []
+ for name in members:
+ if name == oldName:
+ newMembers.append(newName)
+ elif name == newName:
+ newMembers.append(oldName)
+ else:
+ newMembers.append(name)
+ font.groups[groupName] = newMembers
+
+ remove = []
+ for g in font:
+ if g.name.find(swapNameExtension) != -1:
+ remove.append(g.name)
+ for r in remove:
+ del font[r]
+
+
+@attr.s(auto_attribs=True, frozen=True, slots=True)
+class Variator:
+ """A middle-man class that ingests a mapping of locations to masters plus
+ axis definitions and uses varLib to spit out interpolated instances at
+ specified locations.
+
+ fontMath objects stand in for the actual master objects from the
+ UFO. Upon generating an instance, these objects have to be extracted
+ into an actual UFO object.
+ """
+
+ axes: Dict[str, designspaceLib.AxisDescriptor]
+ axis_bounds: Dict[str, Tuple[float, float, float]]
+ masters: List[FontMathObject]
+ model: varLib.models.VariationModel
+
+ @classmethod
+ def from_masters(
+ cls,
+ items: List[Tuple[Location, FontMathObject]],
+ axes: Dict[str, designspaceLib.AxisDescriptor],
+ axis_bounds: Dict[str, Tuple[float, float, float]],
+ ):
+ item_locations_normalized = []
+ masters = []
+ for design_location, master in items:
+ item_locations_normalized.append(
+ normalize_design_location(design_location, axes, axis_bounds)
+ )
+ masters.append(master)
+ model = varLib.models.VariationModel(
+ item_locations_normalized, list(axes.keys())
+ )
+
+ return cls(axes, axis_bounds, masters, model)
+
+ def get(self, key) -> FontMathObject:
+ if key in self.model.locations:
+ i = self.model.locations.index(key)
+ return self.masters[i]
+ return None
+
+ def neutral_master(self) -> FontMathObject:
+ neutral = self.get({})
+ if neutral is None:
+ raise ValueError("Can't find the neutral master.")
+ return neutral
+
+ def instance_at(self, design_location: Location) -> FontMathObject:
+ return self.model.interpolateFromMasters(
+ normalize_design_location(design_location, self.axes, self.axis_bounds),
+ self.masters,
+ )
diff --git a/scripts/make-static-fonts.py b/scripts/make-static-fonts.py
new file mode 100644
index 00000000..b77923a1
--- /dev/null
+++ b/scripts/make-static-fonts.py
@@ -0,0 +1,75 @@
+#!/bin/env python3
+
+import argparse
+import multiprocessing
+import subprocess
+from pathlib import Path
+
+import fontTools.designspaceLib
+import ufo2ft
+
+import instantiator
+
+
+def generate_and_write_instance(
+ instantiator: instantiator.Instantiator,
+ instance_descriptor: fontTools.designspaceLib.InstanceDescriptor,
+ output_dir: Path,
+ psautohint: str,
+):
+ # 3. Generate instance UFO.
+ instance = instantiator.generate_instance(instance_descriptor)
+ file_stem = f"{instance.info.familyName}-{instance.info.styleName}".replace(" ", "")
+ instance.save(output_dir / f"{file_stem}.ufo")
+
+ # 4. Compile and write instance OTF to disk.
+ instance_font = ufo2ft.compileOTF(instance)
+ output_path = output_dir / f"{file_stem}.otf"
+ instance_font.save(output_path)
+
+ # 5. Run psautohint on it.
+ subprocess.run([psautohint, str(output_path)])
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "designspace_path", type=Path, help="The path to the Designspace file."
+ )
+ parser.add_argument("psautohint", type=str, help="The path to psautohint.")
+ parser.add_argument("output_dir", type=Path, help="The output directory.")
+ args = parser.parse_args()
+
+ # 1. Load Designspace and filter out instances that are marked as non-exportable.
+ designspace = fontTools.designspaceLib.DesignSpaceDocument.fromfile(
+ args.designspace_path
+ )
+ designspace.instances = [
+ s
+ for s in designspace.instances
+ if s.lib.get("com.schriftgestaltung.export", True)
+ ]
+
+ # 2. Prepare masters.
+ # XXX: varLib rounds with fontTools.misc.fixedTools.otRound, fontMath rounds with
+ # fontTools.misc.py23.round3. This leads to off-by-one coordinates between
+ # instances and a variable font.
+ # https://github.com/robotools/fontMath/issues/148
+ generator = instantiator.Instantiator.from_designspace(
+ designspace, round_geometry=True
+ )
+
+ # (Fork one process per instance)
+ processes = []
+ pool = multiprocessing.Pool(processes=multiprocessing.cpu_count())
+ for index, instance in enumerate(designspace.instances):
+ processes.append(
+ pool.apply_async(
+ generate_and_write_instance,
+ args=(generator, instance, args.output_dir, args.psautohint),
+ )
+ )
+ pool.close()
+ pool.join()
+ for process in processes:
+ process.get() # Catch exceptions.
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]