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)