84 lines
4.2 KiB
Python
84 lines
4.2 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
|
|
import PIL.ImageFont
|
|
import fontTools.pens.boundsPen
|
|
import fontTools.pens.recordingPen
|
|
import fontTools.ttLib
|
|
|
|
import generator.engraving.types
|
|
|
|
class Fonts:
|
|
def __init__(self, root: pathlib.Path):
|
|
self.root = root
|
|
try:
|
|
self.music = fontTools.ttLib.TTFont(root / 'Petaluma.otf')
|
|
self.script = fontTools.ttLib.TTFont(root / 'PetalumaScript.otf')
|
|
self.metadata = json.loads((root / 'petaluma_metadata.json').read_text())
|
|
self.names = json.loads((root / 'smufl_glyphnames.json').read_text())
|
|
except (OSError, ValueError) as error:
|
|
raise generator.engraving.types.EngravingError(f'cannot load bundled engraving fonts: {error}') from error
|
|
self.names.update(self.metadata.get('optionalGlyphs', {}))
|
|
self.cmap = self.music.getBestCmap()
|
|
self.text_cmap = self.script.getBestCmap()
|
|
self.glyphs = self.music.getGlyphSet()
|
|
self.units = self.music['head'].unitsPerEm / 4
|
|
self.metrics = {}
|
|
self.outlines = {}
|
|
self.text_fonts = {}
|
|
def fingerprint(self, style: generator.engraving.types.Style) -> str:
|
|
digest = hashlib.sha256(json.dumps(dataclasses.asdict(style), sort_keys=True).encode())
|
|
for name in ('Petaluma.otf', 'PetalumaScript.otf', 'petaluma_metadata.json', 'smufl_glyphnames.json'):
|
|
digest.update(name.encode())
|
|
digest.update((self.root / name).read_bytes())
|
|
return digest.hexdigest()
|
|
def glyph_name(self, name: str) -> str:
|
|
if name not in self.names:
|
|
raise generator.engraving.types.EngravingError(f'unknown SMuFL glyph {name!r}')
|
|
code = int(self.names[name]['codepoint'].removeprefix('U+'), 16)
|
|
if code not in self.cmap:
|
|
raise generator.engraving.types.EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}')
|
|
return self.cmap[code]
|
|
def glyph_box(self, name: str, size: float) -> generator.engraving.types.Box:
|
|
if name not in self.metrics:
|
|
pen = fontTools.pens.boundsPen.BoundsPen(self.glyphs)
|
|
self.glyphs[self.glyph_name(name)].draw(pen)
|
|
if pen.bounds is None:
|
|
raise generator.engraving.types.EngravingError(f'empty SMuFL glyph {name!r}')
|
|
self.metrics[name] = pen.bounds
|
|
left, bottom, right, top = self.metrics[name]
|
|
scale = size / self.units
|
|
return generator.engraving.types.Box(left * scale, -top * scale, right * scale, -bottom * scale)
|
|
def outline(self, name: str):
|
|
if name not in self.outlines:
|
|
pen = fontTools.pens.recordingPen.DecomposingRecordingPen(self.glyphs)
|
|
self.glyphs[self.glyph_name(name)].draw(pen)
|
|
self.outlines[name] = pen
|
|
return self.outlines[name]
|
|
def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> generator.engraving.types.Primitive:
|
|
return generator.engraving.types.Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size)
|
|
def anchor(self, name: str, anchor: str, size: float) -> tuple[float, float]:
|
|
value = self.metadata.get('glyphsWithAnchors', {}).get(name, {}).get(anchor)
|
|
if value is None:
|
|
box = self.glyph_box(name, size)
|
|
return (box.right, 0) if 'Up' in anchor else (box.left, 0)
|
|
return value[0] * size, -value[1] * size
|
|
def text_font(self, size: float):
|
|
pixels = max(1, round(size))
|
|
if pixels not in self.text_fonts:
|
|
self.text_fonts[pixels] = PIL.ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels)
|
|
return self.text_fonts[pixels]
|
|
def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> generator.engraving.types.Primitive:
|
|
missing = [c for c in value if ord(c) not in self.text_cmap and not c.isspace()]
|
|
if missing:
|
|
raise generator.engraving.types.EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}')
|
|
font = self.text_font(size * 4)
|
|
left, top, right, bottom = font.getbbox(value, anchor='ls')
|
|
offset = (left + right) / 8 if center else 0
|
|
x -= offset
|
|
return generator.engraving.types.Primitive('text', role, sources, generator.engraving.types.Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size)
|