#!/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))