134 lines
6.1 KiB
Python
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))
|