Files
Sanctification/fonts/tools/build_medium.py

134 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Build optical-medium-v1, a modified P052 Roman outline font (2026-09-13).
This is a custom optical weight, not an interpolated or upstream P052 Medium.
Original copyright and license notices are retained. See ../URW-font-license.txt.
"""
from pathlib import Path
import argparse
import hashlib
import json
import fontTools
from fontTools.ttLib import TTFont
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.t2CharStringPen import T2CharStringPen
import pathops
FONT_DIR = Path(__file__).resolve().parents[1]
SOURCE = FONT_DIR / 'P052-Roman.otf'
RECIPE = 'optical-medium-v1'
FAMILY = 'Sanctification P052'
POSTSCRIPT = 'SanctificationP052-Medium'
EXPANSION = 4.0 # Each outline side, in the source's 1000-unit em.
def build(destination: Path):
records = json.loads((FONT_DIR / 'manifest.json').read_text())['files']
source_digest = hashlib.sha256(SOURCE.read_bytes()).hexdigest()
if source_digest != records[SOURCE.name]['sha256']:
raise ValueError('P052 Roman source does not match the shared manifest')
if fontTools.version != '4.65.0' or pathops.__version__ != '0.9.2':
raise ValueError('Use the pinned versions in tools/requirements.txt')
font = TTFont(SOURCE, recalcTimestamp=False)
if font['head'].unitsPerEm != 1000:
raise ValueError('This outline recipe requires the original 1000-unit em')
original_cmap = font.getBestCmap().copy()
original_advances = {name: metric[0] for name, metric in font['hmtx'].metrics.items()}
glyphs = font.getGlyphSet()
# Capture every original outline before changing charstrings; composite
# glyphs must not draw an already expanded component and expand it twice.
outlines = {}
for name in font.getGlyphOrder():
outline = pathops.Path()
glyphs[name].draw(outline.getPen())
outlines[name] = outline
cff = font['CFF '].cff
top = cff.topDictIndex[0]
top.Private.nominalWidthX = 0
top.Private.defaultWidthX = 0
changed = 0
for name, outline in outlines.items():
if outline:
stroke = pathops.Path(outline)
stroke.stroke(EXPANSION * 2, pathops.LineCap.BUTT_CAP,
pathops.LineJoin.MITER_JOIN, 2.0)
result = pathops.op(outline, stroke, pathops.PathOp.UNION)
changed += 1
else:
result = outline
pen = T2CharStringPen(original_advances[name], None, roundTolerance=0)
result.draw(pen)
top.CharStrings[name] = pen.getCharString(
private=top.Private, globalSubrs=cff.GlobalSubrs)
if result:
font['hmtx'].metrics[name] = (original_advances[name], round(result.bounds[0]))
top.FamilyName = FAMILY
top.FullName = f'{FAMILY} Medium'
top.Weight = 'Medium'
top.version = '1.100'
top.Notice += ' Modified 2026-09-13 for Sanctification; optical-medium-v1, +4-unit outline expansion.'
cff.fontNames = [POSTSCRIPT]
font['head'].fontRevision = 1.1
font['head'].macStyle &= ~3 # Neither bold nor italic.
font['OS/2'].usWeightClass = 500
font['OS/2'].fsSelection &= ~((1 << 0) | (1 << 5))
font['OS/2'].fsSelection |= 1 << 6 # Upright non-bold face.
names = {
1: FAMILY, 2: 'Medium', 3: f'{POSTSCRIPT};1.100;{RECIPE}',
4: f'{FAMILY} Medium', 5: f'Version 1.100; {RECIPE}',
6: POSTSCRIPT, 16: FAMILY, 17: 'Medium',
10: ('Modified P052 Roman; 2026-09-13. Custom optical weight 500. '
'Contours expanded 4 font units per side; original advances and '
'kerning retained. Not an upstream or interpolated Medium face.'),
}
for record in list(font['name'].names):
if record.nameID in names:
font['name'].names.remove(record)
for name_id, value in names.items():
font['name'].setName(value, name_id, 3, 1, 0x409)
font['name'].setName(value, name_id, 1, 0, 0)
bounds = []
for name in font.getGlyphOrder():
pen = BoundsPen(None)
top.CharStrings[name].draw(pen)
if pen.bounds:
bounds.append(pen.bounds)
ymax = max(b[3] for b in bounds)
ymin = min(b[1] for b in bounds)
font['OS/2'].usWinAscent = max(font['OS/2'].usWinAscent, int(ymax + .999))
font['OS/2'].usWinDescent = max(font['OS/2'].usWinDescent, int(-ymin + .999))
font['hhea'].minLeftSideBearing = min(m[1] for m in font['hmtx'].metrics.values())
destination.parent.mkdir(parents=True, exist_ok=True)
font.save(destination)
rebuilt = TTFont(destination, recalcTimestamp=False)
assert rebuilt.getBestCmap() == original_cmap
assert {n: m[0] for n, m in rebuilt['hmtx'].metrics.items()} == original_advances
source_font = TTFont(SOURCE, recalcTimestamp=False)
for tag in ['kern', 'GPOS', 'GSUB']:
if tag in source_font:
assert rebuilt.getTableData(tag) == source_font.getTableData(tag), tag
report = {
'recipe': RECIPE, 'source': 'P052-Roman.otf', 'sourceSHA256': source_digest,
'output': destination.name, 'outputSHA256': hashlib.sha256(destination.read_bytes()).hexdigest(),
'family': FAMILY, 'style': 'Medium', 'nominalWeight': 500,
'method': 'Uniform outline expansion; not master interpolation',
'hinting': 'Charstrings rebuilt without the original stem hint instructions',
'expansionPerSideUnits': EXPANSION, 'unitsPerEm': 1000,
'modifiedGlyphs': changed, 'glyphCount': len(rebuilt.getGlyphOrder()),
'checks': {'cmapPreserved': True, 'advanceWidthsPreserved': True, 'kerningAndShapingTablesPreserved': True},
'tools': {'fonttools': fontTools.version, 'skia-pathops': pathops.__version__},
'status': 'Typography candidate; visual approval pending',
}
return report
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=FONT_DIR / f'{POSTSCRIPT}.otf')
parser.add_argument('--report', type=Path, default=FONT_DIR / 'medium-build.json')
args = parser.parse_args()
report = build(args.output)
args.report.write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report, indent=2))