from __future__ import annotations from dataclasses import asdict import hashlib import json from pathlib import Path from fontTools.pens.boundsPen import BoundsPen from fontTools.pens.recordingPen import DecomposingRecordingPen from fontTools.ttLib import TTFont from PIL import ImageFont from generator.engraving.types import Box, EngravingError, Primitive, Style class Fonts: def __init__(self, root: Path): self.root = root try: self.music = TTFont(root / 'Petaluma.otf') self.script = 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 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: Style) -> str: digest = hashlib.sha256(json.dumps(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 EngravingError(f'unknown SMuFL glyph {name!r}') code = int(self.names[name]['codepoint'].removeprefix('U+'), 16) if code not in self.cmap: raise EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}') return self.cmap[code] def glyph_box(self, name: str, size: float) -> Box: if name not in self.metrics: pen = BoundsPen(self.glyphs) self.glyphs[self.glyph_name(name)].draw(pen) if pen.bounds is None: raise EngravingError(f'empty SMuFL glyph {name!r}') self.metrics[name] = pen.bounds left, bottom, right, top = self.metrics[name] scale = size / self.units return Box(left * scale, -top * scale, right * scale, -bottom * scale) def outline(self, name: str): if name not in self.outlines: pen = 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, ...]) -> Primitive: return 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] = 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) -> Primitive: missing = [c for c in value if ord(c) not in self.text_cmap and not c.isspace()] if missing: raise 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 Primitive('text', role, sources, Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size)