+28
-103
@@ -2,77 +2,20 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
from generator.model import ACCIDENTALS, alteration, fraction, validate_document
|
||||
from generator.musicxml_writer import write_musicxml
|
||||
from generator.engraving.audit import audit_roundtrip
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.layout import layout_score
|
||||
from generator.engraving.raster import rasterize_page
|
||||
from generator.engraving.types import EngravingError, Style
|
||||
|
||||
ENGINE_VERSION = '4.7.4'
|
||||
ENGINE_NAME = 'Vamp Python'
|
||||
ENGINE_VERSION = '1.0.0'
|
||||
PAGE_WIDTH = 976
|
||||
PAGE_HEIGHT = 484
|
||||
STYLE = Path(__file__).with_name('petaluma.mss')
|
||||
|
||||
class EngravingError(ValueError):
|
||||
pass
|
||||
|
||||
def qualify(document: dict) -> None:
|
||||
"""Keep model support separate from the pinned engine's qualified surface."""
|
||||
validate_document(document)
|
||||
staff = document['staves'][0]
|
||||
if len({v['id'] for m in staff['measures'] for v in m['voices']} - {'voice-arranged'}) > 4:
|
||||
raise EngravingError('MuseScore 4.7.4 supports at most four voices on one staff')
|
||||
event_measures = {n['id']: m['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
|
||||
for span in staff.get('tuplets', []):
|
||||
if event_measures[span['start']] != event_measures[span['end']]:
|
||||
raise EngravingError(f'{span["id"]}: cross-bar tuplet engraving is not qualified')
|
||||
if span.get('show_type', 'none') != 'none' or span.get('line_shape', 'straight') != 'straight':
|
||||
raise EngravingError(f'{span["id"]}: this tuplet appearance is not qualified')
|
||||
for measure in staff['measures']:
|
||||
for event in (n for v in measure['voices'] for n in v['notes']):
|
||||
location = f'{measure["id"]}, voice {event.get("voice")}, {event["id"]}'
|
||||
if event['written']['type'] == 'maxima' or event['written'].get('dots', 0) > 4:
|
||||
raise EngravingError(f'{location}: written duration exceeds the qualified engraver range')
|
||||
if event.get('stem') == 'double':
|
||||
raise EngravingError(f'{location}: double-stem engraving is not qualified')
|
||||
for pitch in event['pitches']:
|
||||
if pitch['written'] != event['written'] or pitch['duration'] != event['duration']:
|
||||
raise EngravingError(f'{location}: independently sustained chord tones need separate voices for MuseScore engraving')
|
||||
if 'alter' in pitch and alteration(pitch['alter']) not in ACCIDENTALS.values():
|
||||
raise EngravingError(f'{location}: this microtonal alteration is not qualified for engraving')
|
||||
if pitch.get('accidental_display', {}).get('name') == 'other':
|
||||
raise EngravingError(f'{location}: custom accidental glyph is not qualified')
|
||||
|
||||
def isolated_environment(root: Path, fonts: Path) -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
for name, path in (('HOME', root / 'home'), ('XDG_CONFIG_HOME', root / 'config'), ('XDG_DATA_HOME', root / 'data'), ('XDG_CACHE_HOME', root / 'cache'), ('XDG_RUNTIME_DIR', root / 'runtime')):
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
env[name] = str(path)
|
||||
config = ElementTree.Element('fontconfig')
|
||||
ElementTree.SubElement(config, 'include', {'ignore_missing': 'yes'}).text = '/etc/fonts/fonts.conf'
|
||||
ElementTree.SubElement(config, 'dir').text = str(fonts.resolve())
|
||||
ElementTree.SubElement(config, 'cachedir').text = str(root / 'font-cache')
|
||||
fontconfig = root / 'fonts.conf'
|
||||
ElementTree.ElementTree(config).write(fontconfig, encoding='utf-8', xml_declaration=True)
|
||||
env.update({'QT_QPA_PLATFORM': 'offscreen', 'QT_QPA_PLATFORMTHEME': '', 'FONTCONFIG_FILE': str(fontconfig), 'QT_STYLE_OVERRIDE': 'Fusion'})
|
||||
return env
|
||||
|
||||
def run_engine(executable: str, arguments: list[str], env: dict) -> str:
|
||||
try:
|
||||
result = subprocess.run([executable, '-platform', 'offscreen', *arguments], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise EngravingError(f'MuseScore export failed: {error}') from error
|
||||
if result.returncode:
|
||||
raise EngravingError(f'MuseScore export exited {result.returncode}: {result.stdout[-4000:]}')
|
||||
return result.stdout
|
||||
|
||||
def check_page(path: Path) -> None:
|
||||
with Image.open(path) as image:
|
||||
@@ -92,43 +35,25 @@ def engrave_tree(root: Path, fonts: Path) -> None:
|
||||
paths = sorted(root.rglob('*.json'))
|
||||
if not paths:
|
||||
return
|
||||
executable = os.environ.get('VAMP_MUSESCORE') or shutil.which('mscore') or shutil.which('musescore')
|
||||
if not executable:
|
||||
raise EngravingError(f'MuseScore Studio {ENGINE_VERSION} is required at build time (set VAMP_MUSESCORE)')
|
||||
with tempfile.TemporaryDirectory(prefix='vamp-engraving-') as temporary:
|
||||
work = Path(temporary)
|
||||
env = isolated_environment(work, fonts)
|
||||
version = run_engine(executable, ['--version'], env)
|
||||
if not re.search(r'\b4\.7\.4\b', version):
|
||||
raise EngravingError(f'expected MuseScore Studio {ENGINE_VERSION}, received {version.strip()!r}')
|
||||
for index, path in enumerate(paths):
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
try:
|
||||
qualify(document)
|
||||
source = work / f'score-{index}.musicxml'
|
||||
write_musicxml(source, document, engine_voices=True)
|
||||
output = work / f'score-{index}.png'
|
||||
audit = work / f'audit-{index}.musicxml'
|
||||
job = work / 'job.json'
|
||||
job.write_text(json.dumps([{'in': str(source), 'out': [str(output), str(audit)]}]), encoding='utf-8')
|
||||
run_engine(executable, ['-S', str(STYLE.resolve()), '-r', '96', '-j', str(job)], env)
|
||||
audit_roundtrip(source, audit)
|
||||
pages = sorted(work.glob(f'score-{index}-*.png'), key=lambda p: int(p.stem.rsplit('-', 1)[1]))
|
||||
if not pages:
|
||||
raise EngravingError('engraver produced no pages')
|
||||
destination = path.with_suffix('.pages')
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
destination.mkdir()
|
||||
entries = []
|
||||
for number, page in enumerate(pages, 1):
|
||||
check_page(page)
|
||||
name = f'{number:04}.png'
|
||||
target = destination / name
|
||||
shutil.copyfile(page, target)
|
||||
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
music = {k: v for k, v in document.items() if k != 'schema_version'}
|
||||
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': 'MuseScore Studio', 'engine_version': ENGINE_VERSION, 'style_sha256': hashlib.sha256(STYLE.read_bytes()).hexdigest(), 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
|
||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
except ValueError as error:
|
||||
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
|
||||
family = Fonts(fonts)
|
||||
style = Style()
|
||||
for path in paths:
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
try:
|
||||
layout = layout_score(document, style, family)
|
||||
destination = path.with_suffix('.pages')
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
destination.mkdir()
|
||||
entries = []
|
||||
for page in layout.pages:
|
||||
name = f'{page.number:04}.png'
|
||||
target = destination / name
|
||||
rasterize_page(page, family).save(target, format='PNG')
|
||||
check_page(target)
|
||||
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
music = {k: v for k, v in document.items() if k != 'schema_version'}
|
||||
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'style_sha256': layout.fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
|
||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
except ValueError as error:
|
||||
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
from generator.model import LETTERS, TYPE_LENGTHS, alteration, fraction
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.notation import ScoreIndex, accidental
|
||||
from generator.engraving.types import Primitive, bounds, line
|
||||
|
||||
def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[Primitive]:
|
||||
result = []
|
||||
x = 0
|
||||
references = (source,)
|
||||
clef = state['clef']
|
||||
lines = state['staff_details']['lines']
|
||||
if 'clef' in fields and clef['sign'] != 'none':
|
||||
sign = clef['sign']
|
||||
name = {'G': 'gClef', 'F': 'fClef', 'C': 'cClef', 'percussion': 'unpitchedPercussionClef1', 'TAB': '6stringTabClef' if lines != 4 else '4stringTabClef'}[sign]
|
||||
y = -space * (clef.get('line', 2) - 1) if sign in ('G', 'F', 'C') else -space * (lines - 1) / 2
|
||||
glyph = fonts.glyph(name, x, y, space, 'clef', references)
|
||||
result.append(glyph)
|
||||
if octave := clef.get('octave_change', 0):
|
||||
label = str(abs(octave) * 7 + 1)
|
||||
result.append(fonts.text(label, (glyph.box.left + glyph.box.right) / 2, glyph.box.top - 2 if octave > 0 else glyph.box.bottom + 14, 14, 'clef-octave', references, True))
|
||||
x = bounds(result).right + space
|
||||
if 'key' in fields and clef['sign'] not in ('percussion', 'TAB'):
|
||||
key = state['key']
|
||||
steps = key.get('steps')
|
||||
if steps is None:
|
||||
order = 'fcgdaeb' if key.get('fifths', 0) >= 0 else 'beadgcf'
|
||||
steps = [{'letter': c, 'alter': key['alterations'][LETTERS.index(c)]} for c in order if alteration(key['alterations'][LETTERS.index(c)])]
|
||||
cancellation = key.get('cancel', 0)
|
||||
if cancellation:
|
||||
order = 'fcgdaeb' if cancellation > 0 else 'beadgcf'
|
||||
steps = [{'letter': c, 'alter': 0} for c in order[:min(7, abs(cancellation))]] + steps
|
||||
for step in steps:
|
||||
pitch = {'letter': step['letter'], 'octave': step.get('octave', 5), 'id': source}
|
||||
position = index.pitch_position(pitch, time)
|
||||
if 'octave' not in step:
|
||||
while position > 9:
|
||||
position -= 7
|
||||
while position < 2:
|
||||
position += 7
|
||||
items = accidental(fonts, alteration(step['alter']), {'name': step.get('accidental', '')}, space, references)
|
||||
result.extend(p.moved(x, -position * space / 2) for p in items)
|
||||
x += bounds(items).right - bounds(items).left + 4
|
||||
if steps:
|
||||
x += space / 2
|
||||
if 'meter' in fields:
|
||||
meter = state['meter']
|
||||
if meter.get('unmetered'):
|
||||
result.append(fonts.text('senza misura', x, -space * 2, 16, 'meter', references))
|
||||
elif meter.get('symbol') in ('common', 'cut'):
|
||||
result.append(fonts.glyph('timeSigCommon' if meter['symbol'] == 'common' else 'timeSigCutCommon', x, -space * 2, space, 'meter', references))
|
||||
else:
|
||||
for gi, group in enumerate(meter['groups']):
|
||||
if gi:
|
||||
plus = fonts.glyph('timeSigPlus', x, -space * 2, space, 'meter', references)
|
||||
result.append(plus)
|
||||
x = plus.box.right + 7
|
||||
numerator = '+'.join(map(str, group['beats']))
|
||||
denominator = str(group['unit'])
|
||||
rows = [(numerator, -space * 2)] if meter.get('symbol') == 'single-number' else [(numerator, -space * 3), (denominator, -space)]
|
||||
groups = []
|
||||
for value, y in rows:
|
||||
items = []
|
||||
cursor = 0
|
||||
if y == -space and meter.get('symbol') in ('note', 'dotted-note'):
|
||||
unit = Fraction(4, group['unit'])
|
||||
kind = next((name for name, length in TYPE_LENGTHS.items() if length == unit), None)
|
||||
glyph_name = {'whole': 'noteWhole', 'half': 'noteHalfUp', 'quarter': 'noteQuarterUp', 'eighth': 'note8thUp'}.get(kind, 'note' + str(kind) + 'Up')
|
||||
if kind and glyph_name in fonts.names:
|
||||
glyph = fonts.glyph(glyph_name, 0, y + space, space * 0.55, 'meter', references)
|
||||
items.append(glyph)
|
||||
if meter['symbol'] == 'dotted-note':
|
||||
items.append(fonts.glyph('augmentationDot', glyph.box.right + 3, y + space / 2, space * 0.55, 'meter', references))
|
||||
groups.append(items)
|
||||
continue
|
||||
for char in value:
|
||||
glyph = fonts.glyph('timeSigPlus' if char == '+' else 'timeSig' + char, cursor, y, space, 'meter', references)
|
||||
items.append(glyph)
|
||||
cursor = glyph.box.right + 1
|
||||
groups.append(items)
|
||||
width = max(bounds(items).right for items in groups)
|
||||
for items in groups:
|
||||
result.extend(p.moved(x + (width - bounds(items).right) / 2, 0) for p in items)
|
||||
x += width + 6
|
||||
if 'staff_details' in fields and state['staff_details'].get('capo'):
|
||||
result.append(fonts.text(f'Capo {state["staff_details"]["capo"]}', x, -space * (lines + 1), 16, 'staff-details', references))
|
||||
return result
|
||||
|
||||
def barline(style: str, x: float, top: float, source: str) -> list[Primitive]:
|
||||
if style == 'none':
|
||||
return []
|
||||
references = (source,)
|
||||
if style in ('dashed', 'dotted'):
|
||||
dash, gap = (5, 4) if style == 'dashed' else (1, 4)
|
||||
return [line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)]
|
||||
if style in ('tick', 'short'):
|
||||
return [line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))]
|
||||
weights = {'regular': [1.4], 'heavy': [4], 'light-light': [1.4, 1.4], 'light-heavy': [1.4, 4], 'heavy-light': [4, 1.4], 'heavy-heavy': [4, 4]}[style]
|
||||
return [line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)]
|
||||
|
||||
def tempo(value: dict, fonts: Fonts, x: float, y: float, space: float, source: str) -> list[Primitive]:
|
||||
from generator.engraving.notation import flags, head_glyph
|
||||
written = value['written']
|
||||
references = (source,)
|
||||
glyph = fonts.glyph(head_glyph(written, None), x, y, space * 0.7, 'tempo', references)
|
||||
items = [glyph]
|
||||
stem_x = glyph.box.right - 0.5
|
||||
if written['type'] not in ('whole', 'breve', 'long', 'maxima'):
|
||||
items.append(line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1))
|
||||
if flags(written):
|
||||
name = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
|
||||
items.append(fonts.glyph(name, stem_x, y - 23, space * 0.7, 'tempo', references))
|
||||
cursor = max(p.box.right for p in items) + 3
|
||||
for _ in range(written.get('dots', 0)):
|
||||
dot = fonts.glyph('augmentationDot', cursor, y - 3, space * 0.7, 'tempo', references)
|
||||
items.append(dot)
|
||||
cursor = dot.box.right + 3
|
||||
items.append(fonts.text(' = ' + str(fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references))
|
||||
return items
|
||||
+108
-87
@@ -1,92 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from fractions import Fraction
|
||||
|
||||
from generator.model import TYPE_LENGTHS, fraction
|
||||
from generator import harmony
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.notation import ScoreIndex, head_glyph
|
||||
from generator.engraving.types import EngravingError, LayoutDocument
|
||||
|
||||
def same(left, right, location: str) -> None:
|
||||
if left != right:
|
||||
raise ValueError(f'{location}: engraver changed musical content ({left!r} -> {right!r})')
|
||||
|
||||
def event_positions(staff: dict) -> dict:
|
||||
return {event['id']: (mi, vi, ni) for mi, measure in enumerate(staff['measures']) for vi, voice in enumerate(measure['voices']) for ni, event in enumerate(voice['notes'])}
|
||||
|
||||
def tuplet_members(staff: dict, identifier: str) -> list:
|
||||
positions = event_positions(staff)
|
||||
return [positions[event['id']] for measure in staff['measures'] for voice in measure['voices'] for event in voice['notes'] if identifier in event.get('tuplet_ids', [])]
|
||||
|
||||
def audit_roundtrip(source: Path, output: Path) -> None:
|
||||
before = load_musicxml(source)['staves'][0]
|
||||
after = load_musicxml(output)['staves'][0]
|
||||
before_positions, after_positions = event_positions(before), event_positions(after)
|
||||
before_instruments = {i['id']: i for i in before.get('instruments', [])}
|
||||
after_instruments = {i['id']: i for i in after.get('instruments', [])}
|
||||
same(len(before['measures']), len(after['measures']), 'measure count')
|
||||
for original, rendered in zip(before['measures'], after['measures']):
|
||||
location = original['id']
|
||||
for field in ('duration', 'key', 'meter', 'clef', 'transpose', 'attributes'):
|
||||
same(original[field], rendered[field], f'{location} {field}')
|
||||
same({'capo': 0, **original['staff_details']}, {'capo': 0, **rendered['staff_details']}, f'{location} staff details')
|
||||
for side, style in original.get('barlines', {}).items():
|
||||
same(style, rendered.get('barlines', {}).get(side, 'regular'), f'{location} {side} barline')
|
||||
same(original.get('tempos', []), rendered.get('tempos', []), f'{location} tempos')
|
||||
same(original.get('section_label', ''), rendered.get('section_label', ''), f'{location} section label')
|
||||
same(len(original['harmonies']), len(rendered['harmonies']), f'{location} harmony count')
|
||||
for a, b in zip(original['harmonies'], rendered['harmonies']):
|
||||
for field in ('root', 'bass', 'onset'):
|
||||
same(a.get(field), b.get(field), f'{location} harmony {field}')
|
||||
same(harmony.degrees(a), harmony.degrees(b), f'{location} harmony degrees')
|
||||
same(len(original['voices']), len(rendered['voices']), f'{location} voice count')
|
||||
for first, second in zip(original['voices'], rendered['voices']):
|
||||
same(len(first['notes']), len(second['notes']), f'{location} voice {first["id"]} event count')
|
||||
for a, b in zip(first['notes'], second['notes']):
|
||||
event_location = f'{location}, voice {first["id"]}, {a["id"]}'
|
||||
for field in ('kind', 'onset', 'duration', 'written', 'hidden'):
|
||||
same(a[field], b[field], f'{event_location} {field}')
|
||||
for field in ('grace', 'cue', 'measure_rest', 'rest_position', 'lyrics'):
|
||||
same(a.get(field), b.get(field), f'{event_location} {field}')
|
||||
for field in ('stem', 'beams', 'notehead'):
|
||||
if field in a:
|
||||
same(a[field], b.get(field), f'{event_location} explicit {field}')
|
||||
same(len(a['pitches']), len(b['pitches']), f'{event_location} chord tone count')
|
||||
for p, q in zip(a['pitches'], b['pitches']):
|
||||
if p.get('instrument') and 'unpitched' in p:
|
||||
first_instrument = before_instruments[p['instrument']]
|
||||
second_instrument = after_instruments.get(q.get('instrument'), {})
|
||||
same(first_instrument.get('midi', {}).get('midi-unpitched'), second_instrument.get('midi', {}).get('midi-unpitched'), f'{event_location} percussion instrument')
|
||||
for field in ('letter', 'alter', 'octave', 'duration', 'written', 'unpitched', 'tablature', 'notehead', 'cue'):
|
||||
same(p.get(field), q.get(field), f'{event_location} pitch {field}')
|
||||
for field in ('stem', 'print'):
|
||||
if field in p:
|
||||
same(p[field], q.get(field), f'{event_location} pitch {field}')
|
||||
for field in ('tie_from', 'tie_to'):
|
||||
same(bool(p.get(field)), bool(q.get(field)), f'{event_location} {field}')
|
||||
if display := p.get('accidental_display'):
|
||||
other = q.get('accidental_display', {})
|
||||
same(display.get('name'), other.get('name'), f'{event_location} accidental')
|
||||
for field in ('parentheses', 'bracket'):
|
||||
same(display.get(field, 'no'), other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||
for field in ('cautionary', 'editorial'):
|
||||
if field in display:
|
||||
same(display[field], other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||
if tremolo := a.get('tremolo'):
|
||||
other = b.get('tremolo', {})
|
||||
for field in ('type', 'marks'):
|
||||
same(tremolo[field], other.get(field), f'{event_location} tremolo {field}')
|
||||
if 'partner' in tremolo:
|
||||
same(before_positions.get(tremolo['partner']), after_positions.get(other.get('partner')), f'{event_location} tremolo partner')
|
||||
same(len(before.get('tuplets', [])), len(after.get('tuplets', [])), 'tuplet count')
|
||||
for a, b in zip(before.get('tuplets', []), after.get('tuplets', [])):
|
||||
for field in ('note_count', 'space_count', 'depth', 'show_number', 'show_type'):
|
||||
same(a.get(field), b.get(field), f'{a["id"]} {field}')
|
||||
if a['bracket'] != 'auto':
|
||||
same(a['bracket'], b['bracket'], f'{a["id"]} bracket')
|
||||
same(tuplet_members(before, a['id']), tuplet_members(after, b['id']), f'{a["id"]} members')
|
||||
for endpoint in ('start', 'end'):
|
||||
same(before_positions[a[endpoint]], after_positions[b[endpoint]], f'{a["id"]} {endpoint}')
|
||||
for field in ('tuplet_actual', 'tuplet_normal'):
|
||||
if a.get(field, {}).get('type'):
|
||||
same(a[field], b.get(field), f'{a["id"]} {field}')
|
||||
same([{k: v for k, v in s.items() if k != 'id'} for s in before.get('octave_shifts', [])],
|
||||
[{k: v for k, v in s.items() if k != 'id'} for s in after.get('octave_shifts', [])], 'octave spans')
|
||||
def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
index = ScoreIndex(document)
|
||||
fragments, primitives = {}, {}
|
||||
previous_end = Fraction()
|
||||
for page in layout.pages:
|
||||
for item in page.primitives:
|
||||
if not item.box.finite():
|
||||
raise EngravingError(f'{item.sources}: non-finite or inverted primitive geometry')
|
||||
if item.box.left < 4 or item.box.top < 4 or item.box.right > page.width - 4 or item.box.bottom > page.height - 4:
|
||||
raise EngravingError(f'{item.sources}, {item.role}: primitive outside page bounds')
|
||||
if item.kind == 'glyph':
|
||||
actual = fonts.glyph_box(item.value, item.size).moved(*item.points[0])
|
||||
if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))):
|
||||
raise EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
|
||||
for source in item.sources:
|
||||
primitives.setdefault(source, []).append(item)
|
||||
for system in page.systems:
|
||||
if system.start != previous_end or system.end <= system.start:
|
||||
raise EngravingError('layout systems do not partition the score timeline')
|
||||
previous_end = system.end
|
||||
baseline = max(p.points[0][1] for p in system.primitives if p.role == 'staff')
|
||||
for anchor in system.anchors:
|
||||
if anchor.fragment.kind == 'tone':
|
||||
expected = index.pitch_position(index.tones[anchor.source][1], anchor.time)
|
||||
if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01:
|
||||
raise EngravingError(f'{anchor.source}: displayed staff position changed pitch')
|
||||
role = 'tab' if anchor.fragment.kind == 'tone' and index.state(anchor.time)['clef']['sign'] == 'TAB' else 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind
|
||||
candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01]
|
||||
if not candidates:
|
||||
raise EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive')
|
||||
if role == 'head' and candidates[0].kind == 'glyph':
|
||||
event, tone = index.tones[anchor.source]
|
||||
expected_glyph = head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
|
||||
if candidates[0].value != expected_glyph:
|
||||
raise EngravingError(f'{anchor.source}: notehead changed written duration or shape')
|
||||
for fragment in system.fragments:
|
||||
fragments.setdefault(fragment.source, []).append(fragment)
|
||||
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
|
||||
# noteheads, accidentals and text in the same lane must remain separate.
|
||||
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric')]
|
||||
for i, item in enumerate(checked):
|
||||
for other in checked[i + 1:]:
|
||||
if set(item.sources) & set(other.sources):
|
||||
continue
|
||||
if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}:
|
||||
continue
|
||||
if item.box.overlaps(other.box, 0.1):
|
||||
raise EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
|
||||
if previous_end != index.end:
|
||||
raise EngravingError('layout omits the end of the score')
|
||||
for event in index.events:
|
||||
note = event.value
|
||||
for source in note['pitches'] or [note]:
|
||||
identifier = source['id']
|
||||
pieces = sorted(fragments.get(identifier, []), key=lambda f: f.start)
|
||||
if not pieces:
|
||||
raise EngravingError(f'{identifier}: missing notation fragments')
|
||||
cursor = event.start
|
||||
for fragment in pieces:
|
||||
expected_visible = not note.get('hidden') and note['kind'] != 'spacer' and source.get('print', {}).get('print-object') != 'no'
|
||||
if isinstance(source.get('notehead'), dict) and source['notehead'].get('name') == 'none':
|
||||
expected_visible = False
|
||||
if fragment.visible != expected_visible:
|
||||
raise EngravingError(f'{identifier}: notation visibility changed')
|
||||
if fragment.start != cursor:
|
||||
raise EngravingError(f'{identifier}: fragments overlap or omit metric time')
|
||||
cursor = fragment.end
|
||||
expected = TYPE_LENGTHS[fragment.written_type] * (2 - Fraction(1, 2 ** fragment.dots)) * fragment.ratio
|
||||
if not note.get('grace') and not note.get('measure_rest') and fragment.length() != expected:
|
||||
raise EngravingError(f'{identifier}: written fragment duration is inexact')
|
||||
if fragment.visible and not any(p.role in ('head', 'tab', 'rest', 'slash') for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: visible notation has no drawing primitive')
|
||||
if cursor != event.start + fraction(source['duration']):
|
||||
raise EngravingError(f'{identifier}: rendered duration changed')
|
||||
if any(f.visible for f in pieces) and identifier in index.tones and index.accidentals[identifier]:
|
||||
if not any(p.role == 'accidental' for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: required accidental was omitted')
|
||||
if identifier in index.tones and source.get('tie_to') and any(f.visible for f in pieces):
|
||||
if not any(p.role == 'tie' for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: tie was omitted')
|
||||
if not note.get('hidden'):
|
||||
for verse in note.get('lyrics', []):
|
||||
text = ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
|
||||
identifier = note['id'] + ':lyric:' + verse['number']
|
||||
if text and not any(p.role == 'lyric' and p.value == text for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: lyric text was omitted or changed')
|
||||
for _, _, measure in index.measures:
|
||||
for value in measure['harmonies']:
|
||||
label = harmony.symbol(value)
|
||||
if value.get('chord_optional'):
|
||||
label = '(' + label + ')'
|
||||
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
|
||||
raise EngravingError(f'{value["id"]}: harmony was omitted')
|
||||
for i, _ in enumerate(measure.get('tempos', [])):
|
||||
if not any(p.role == 'tempo' for p in primitives.get(measure['id'] + f':tempo:{i}', [])):
|
||||
raise EngravingError(f'{measure["id"]}: tempo was omitted')
|
||||
for span in index.staff.get('tuplets', []):
|
||||
if span.get('show_number', 'actual') != 'none' or span.get('bracket', 'auto') != 'no':
|
||||
if not any(p.role.startswith('tuplet-') for p in primitives.get(span['id'], [])):
|
||||
raise EngravingError(f'{span["id"]}: tuplet was omitted')
|
||||
for span in index.staff.get('octave_shifts', []):
|
||||
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
|
||||
raise EngravingError(f'{span["id"]}: octave shift was omitted')
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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)
|
||||
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from fractions import Fraction
|
||||
import math
|
||||
|
||||
from generator import harmony
|
||||
from generator.model import fraction, validate_document
|
||||
from generator.engraving.attributes import barline, staff_attributes, tempo
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
from generator.engraving.notes import draw_event
|
||||
from generator.engraving.spans import draw_beams, draw_lyric_spans, draw_spans, place_text_lanes
|
||||
from generator.engraving.types import Anchor, EngravingError, Fragment, LayoutDocument, Page, Primitive, Style, System, bounds, line
|
||||
|
||||
def lyric_text(verse: dict) -> str:
|
||||
return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
|
||||
|
||||
def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]:
|
||||
items, anchors, fragments = [], [], []
|
||||
events = [e for e in index.events if e.start == time or (time == start and e.start < start < e.end)]
|
||||
grace_width = 0
|
||||
occupied = []
|
||||
for event in events:
|
||||
try:
|
||||
drawn, placed, pieces = draw_event(index, event, start, end, fonts, style)
|
||||
except ValueError as error:
|
||||
raise EngravingError(f'measure {event.measure["number"]}, voice {event.voice}, {event.value["id"]}: {error}') from error
|
||||
x = grace_width
|
||||
collidable = [p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot')]
|
||||
while any(p.box.moved(x, 0).overlaps(q.box, 2) for p in collidable for q in occupied):
|
||||
x += style.space * 1.4
|
||||
drawn = [p.moved(x, 0) for p in drawn]
|
||||
placed = [a.moved(x, 0) for a in placed]
|
||||
if event.value.get('grace'):
|
||||
grace_width = max((p.box.right for p in drawn), default=grace_width) + 9
|
||||
occupied.extend(p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot'))
|
||||
items.extend(drawn)
|
||||
anchors.extend(placed)
|
||||
fragments.extend(pieces)
|
||||
if event.start == time and not event.value.get('hidden'):
|
||||
verses = event.value.get('lyrics', [])
|
||||
for verse in verses:
|
||||
lane = index.lyric_lanes.index((event.voice, verse['number']))
|
||||
text = lyric_text(verse)
|
||||
if text:
|
||||
items.append(fonts.text(text, x + style.space / 2, lane * 23, 18, 'lyric', (event.value['id'] + ':lyric:' + verse['number'], event.value['id']), True))
|
||||
for measure_start, measure_end, measure in index.measures:
|
||||
if not measure_start <= time < measure_end:
|
||||
continue
|
||||
for item in measure['harmonies']:
|
||||
if measure_start + fraction(item['onset']) == time:
|
||||
label = harmony.symbol(item)
|
||||
if item.get('chord_optional'):
|
||||
label = '(' + label + ')'
|
||||
items.append(fonts.text(label, 0, 0, 21, 'harmony', (item['id'],)))
|
||||
for i, value in enumerate(measure.get('tempos', [])):
|
||||
if measure_start + fraction(value['onset']) == time:
|
||||
items.extend(tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}'))
|
||||
if time == measure_start:
|
||||
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],)))
|
||||
if label := measure.get('section_label'):
|
||||
items.append(fonts.text(label, 0, 0, 19, 'section', (measure['id'] + ':section',)))
|
||||
return items, anchors, fragments
|
||||
|
||||
def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, style: Style, justify: bool = False) -> System:
|
||||
items, anchors, fragments = [], [], []
|
||||
state = index.state(start)
|
||||
measure = next(m for a, b, m in index.measures if a <= start < b)
|
||||
header = staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
|
||||
items.extend(header)
|
||||
cursor = max((p.box.right for p in header), default=0) + 22
|
||||
content_left = cursor - 9
|
||||
times = sorted({start} | {t for t in index.columns if start < t < end})
|
||||
placements = []
|
||||
groups = []
|
||||
for ti, time in enumerate(times):
|
||||
item_start, anchor_start = len(items), len(anchors)
|
||||
drawn, placed, pieces = column(index, time, start, end, fonts, style)
|
||||
leading = []
|
||||
for measure_start, measure_end, m in index.measures:
|
||||
if time == measure_start and time > start:
|
||||
previous = next(previous for a, b, previous in index.measures if b == time)
|
||||
leading.extend(barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
|
||||
old_state = index.state(time, before=True)
|
||||
fields = tuple(k for k in ('clef', 'key', 'meter', 'staff_details') if old_state[k] != index.state(time)[k])
|
||||
leading.extend(p.moved(12, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
|
||||
for ci, change in enumerate(m.get('attributes', [])):
|
||||
if measure_start + fraction(change['onset']) == time and time > start:
|
||||
extra = staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + f':attributes:{ci}', tuple(change))
|
||||
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
|
||||
if leading:
|
||||
width = bounds(leading).right - bounds(leading).left + 14
|
||||
items.extend(p.moved(cursor - bounds(leading).left, 0) for p in leading)
|
||||
cursor += width
|
||||
box = bounds(drawn)
|
||||
x = cursor - min(0, box.left)
|
||||
placements.append((time, x))
|
||||
items.extend(p.moved(x, 0) for p in drawn)
|
||||
anchors.extend(a.moved(x, 0) for a in placed)
|
||||
fragments.extend(pieces)
|
||||
next_time = times[ti + 1] if ti + 1 < len(times) else end
|
||||
spring = style.column_gap + 13 * math.sqrt(min(4, float(next_time - time)))
|
||||
cursor = x + max(style.space * 1.4, box.right) + spring
|
||||
groups.append((item_start, len(items), anchor_start, len(anchors)))
|
||||
width = cursor + 6
|
||||
available = style.width - style.margin_x * 2
|
||||
if justify and width < available:
|
||||
stretch = (available - width) / max(1, len(times))
|
||||
for i, (first_item, last_item, first_anchor, last_anchor) in enumerate(groups):
|
||||
items[first_item:last_item] = [p.moved(i * stretch, 0) for p in items[first_item:last_item]]
|
||||
anchors[first_anchor:last_anchor] = [a.moved(i * stretch, 0) for a in anchors[first_anchor:last_anchor]]
|
||||
placements = [(time, x + i * stretch) for i, (time, x) in enumerate(placements)]
|
||||
width = available
|
||||
right = width - 5
|
||||
items = draw_beams(index, anchors, items, start, end, content_left, right, style)
|
||||
items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
|
||||
items = place_text_lanes(items, fonts, style)
|
||||
items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
|
||||
# Staff lines are interrupted behind TAB numerals, not painted through them.
|
||||
changes = [(0, state['staff_details']['lines'])]
|
||||
for time, x in placements:
|
||||
lines = index.state(time)['staff_details']['lines']
|
||||
if changes[-1][1] != lines:
|
||||
changes.append((x - 8, lines))
|
||||
staff_lines = []
|
||||
for i, (x1, lines) in enumerate(changes):
|
||||
x2 = changes[i + 1][0] if i + 1 < len(changes) else width - 1
|
||||
for number in range(lines):
|
||||
y = -number * style.space
|
||||
cuts = sorted((max(x1, p.box.left - 2), min(x2, p.box.right + 2)) for p in items if p.role == 'tab' and p.box.top <= y <= p.box.bottom and p.box.left < x2 and p.box.right > x1)
|
||||
cursor = x1
|
||||
for a, b in cuts + [(x2, x2)]:
|
||||
if a > cursor:
|
||||
staff_lines.append(line('staff', (), ((cursor, y), (a, y)), style.space * 0.13))
|
||||
cursor = max(cursor, b)
|
||||
items = staff_lines + items
|
||||
last_measure = next(m for a, b, m in index.measures if a < end <= b)
|
||||
if end in index.boundaries:
|
||||
items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
|
||||
if start in index.boundaries:
|
||||
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
|
||||
else:
|
||||
items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],)))
|
||||
return System(start, end, tuple(items), tuple(anchors), tuple(fragments), bounds(items))
|
||||
|
||||
def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style) -> tuple[Page, ...]:
|
||||
pages = []
|
||||
placed = []
|
||||
header = []
|
||||
cursor = style.margin_y
|
||||
first = True
|
||||
|
||||
def page_header(number):
|
||||
title = document['metadata']['title']
|
||||
size = 26 if number == 1 else 17
|
||||
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
|
||||
while item.box.right > style.width - style.margin_x and size > 12:
|
||||
size -= 1
|
||||
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
|
||||
if item.box.right > style.width - style.margin_x:
|
||||
raise EngravingError('score title cannot fit the page without clipping')
|
||||
result = [item]
|
||||
if number == 1:
|
||||
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name'), document['metadata'].get('style')) if value)
|
||||
if subtitle:
|
||||
result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + 22, 16, 'subtitle', ()))
|
||||
return result
|
||||
|
||||
for system in systems:
|
||||
height = system.box.bottom - system.box.top
|
||||
if first:
|
||||
header = page_header(len(pages) + 1)
|
||||
cursor = bounds(header).bottom + 24
|
||||
first = False
|
||||
if cursor + height > style.height - style.margin_y - 18:
|
||||
if not placed:
|
||||
raise EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it')
|
||||
number = len(pages) + 1
|
||||
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
|
||||
pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
|
||||
placed = []
|
||||
header = page_header(len(pages) + 1)
|
||||
cursor = bounds(header).bottom + 24
|
||||
if cursor + height > style.height - style.margin_y - 18:
|
||||
raise EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page')
|
||||
dx, dy = style.margin_x + 1, cursor - system.box.top
|
||||
moved = replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))
|
||||
placed.append(moved)
|
||||
cursor += height + style.system_gap
|
||||
if placed:
|
||||
number = len(pages) + 1
|
||||
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
|
||||
pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
|
||||
return tuple(pages)
|
||||
|
||||
def layout_score(document: dict, style: Style, fonts: Fonts) -> LayoutDocument:
|
||||
validate_document(document)
|
||||
index = ScoreIndex(document)
|
||||
if not index.measures:
|
||||
raise EngravingError('score has no measures')
|
||||
systems = []
|
||||
start = Fraction()
|
||||
available = style.width - style.margin_x * 2
|
||||
while start < index.end:
|
||||
candidates = sorted(b for b in index.boundaries if b > start)
|
||||
selected = None
|
||||
# Reserve the same title/credit and footer bands when considering a split,
|
||||
# so a wide pitch range can break vertically as well as horizontally.
|
||||
height_limit = style.height - style.margin_y * 2 - 110
|
||||
for end in candidates:
|
||||
trial = make_system(index, start, end, fonts, style)
|
||||
if trial.box.right - trial.box.left > available or trial.box.bottom - trial.box.top > height_limit:
|
||||
break
|
||||
selected = trial
|
||||
if selected is None:
|
||||
measure_end = candidates[0]
|
||||
for end in sorted(t for t in index.columns if start < t < measure_end):
|
||||
trial = make_system(index, start, end, fonts, style)
|
||||
if trial.box.right - trial.box.left <= available and trial.box.bottom - trial.box.top <= height_limit:
|
||||
selected = trial
|
||||
else:
|
||||
break
|
||||
if selected is None:
|
||||
raise EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height')
|
||||
systems.append(make_system(index, start, selected.end, fonts, style, justify=selected.end < index.end))
|
||||
start = selected.end
|
||||
layout = LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style))
|
||||
from generator.engraving.audit import audit_layout
|
||||
audit_layout(document, layout, fonts)
|
||||
return layout
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from bisect import bisect_left, bisect_right
|
||||
from fractions import Fraction
|
||||
|
||||
from generator.model import LETTERS, NOTE_TYPES, alteration, fraction, written_for
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.types import EngravingError, Primitive, bounds
|
||||
|
||||
ACCIDENTAL_GLYPHS = {Fraction(0): 'accidentalNatural', Fraction(1): 'accidentalSharp', Fraction(-1): 'accidentalFlat', Fraction(2): 'accidentalDoubleSharp', Fraction(-2): 'accidentalDoubleFlat', Fraction(3): 'accidentalTripleSharp', Fraction(-3): 'accidentalTripleFlat', Fraction(1, 2): 'accidentalQuarterToneSharpStein', Fraction(-1, 2): 'accidentalQuarterToneFlatStein', Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'}
|
||||
HEADS = {'x': 'X', 'diamond': 'Diamond', 'triangle': 'TriangleUp', 'inverted triangle': 'TriangleDown', 'circle-x': 'CircleX', 'cross': 'Plus', 'slash': 'Slash', 'normal': '', 'do': 'TriangleUp', 're': 'Moon', 'mi': 'Diamond', 'fa': 'TriangleRight', 'fa up': 'TriangleLeft', 'so': 'Round', 'la': 'Square', 'ti': 'TriangleRoundDown', 'rectangle': 'Square', 'square': 'Square', 'arrow down': 'LargeArrowDown', 'arrow up': 'LargeArrowUp'}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Event:
|
||||
measure: dict
|
||||
voice: str
|
||||
lane: int
|
||||
lanes: int
|
||||
start: Fraction
|
||||
end: Fraction
|
||||
value: dict
|
||||
|
||||
class ScoreIndex:
|
||||
def __init__(self, document: dict):
|
||||
self.staff = document['staves'][0]
|
||||
self.measures = []
|
||||
self.events = []
|
||||
self.by_id = {}
|
||||
self.tones = {}
|
||||
self.boundaries = {Fraction()}
|
||||
self.columns = {Fraction()}
|
||||
self._states = {}
|
||||
self._beam_groups = None
|
||||
time = Fraction()
|
||||
for measure in self.staff['measures']:
|
||||
end = time + fraction(measure['duration'])
|
||||
self.measures.append((time, end, measure))
|
||||
self.boundaries.add(end)
|
||||
self.columns.update((time, end))
|
||||
for lane, voice in enumerate(measure['voices']):
|
||||
for note in voice['notes']:
|
||||
onset = time + fraction(note['onset'])
|
||||
event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + fraction(note['duration']), note)
|
||||
self.events.append(event)
|
||||
self.by_id[note['id']] = event
|
||||
self.columns.add(onset)
|
||||
for tone in note['pitches']:
|
||||
self.tones[tone['id']] = (event, tone)
|
||||
for field in ('attributes', 'harmonies', 'tempos'):
|
||||
self.columns.update(time + fraction(item['onset']) for item in measure.get(field, []))
|
||||
time = end
|
||||
self.end = time
|
||||
self.measure_starts = [item[0] for item in self.measures]
|
||||
self.events.sort(key=lambda e: (e.start, not bool(e.value.get('grace')), e.lane))
|
||||
self.lyric_lanes = list(dict.fromkeys((e.voice, verse['number']) for e in self.events for verse in e.value.get('lyrics', [])))
|
||||
self.accidentals = self.accidental_state()
|
||||
def state(self, time: Fraction, before: bool = False) -> dict:
|
||||
if (time, before) in self._states:
|
||||
return self._states[time, before]
|
||||
position = bisect_left(self.measure_starts, time) if before else bisect_right(self.measure_starts, time)
|
||||
start, _, measure = self.measures[max(0, position - 1)]
|
||||
state = {key: measure[key] for key in ('key', 'meter', 'clef', 'transpose', 'staff_details')}
|
||||
for change in measure.get('attributes', []):
|
||||
onset = start + fraction(change['onset'])
|
||||
if onset < time or (onset == time and not before):
|
||||
state.update({key: value for key, value in change.items() if key != 'onset'})
|
||||
self._states[time, before] = state
|
||||
return state
|
||||
def pitch_position(self, tone: dict, time: Fraction) -> float:
|
||||
state = self.state(time)
|
||||
clef = state['clef']
|
||||
if clef['sign'] == 'TAB':
|
||||
if 'tablature' not in tone:
|
||||
raise EngravingError(f'{tone["id"]}: TAB notes require explicit string and fret')
|
||||
string = tone['tablature']['string']
|
||||
if not 1 <= string <= state['staff_details']['lines']:
|
||||
raise EngravingError(f'{tone["id"]}: TAB string is outside the staff')
|
||||
return 2 * (state['staff_details']['lines'] - string)
|
||||
pitch = tone.get('unpitched', tone)
|
||||
sign = clef['sign']
|
||||
reference = {'G': 4 * 7 + 4, 'F': 3 * 7 + 3, 'C': 4 * 7}.get(sign, 4 * 7 + 4)
|
||||
clef_line = clef.get('line', 2) if sign in ('G', 'F', 'C') else 2
|
||||
octave = 0 if 'unpitched' in tone else clef.get('octave_change', 0)
|
||||
shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if fraction(s['start']) <= time < fraction(s['end'])) if 'unpitched' not in tone else 0
|
||||
return pitch['octave'] * 7 + LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1)
|
||||
def accidental_state(self) -> dict[str, bool]:
|
||||
result = {}
|
||||
for start, end, measure in self.measures:
|
||||
memory = {}
|
||||
key = None
|
||||
onsets = sorted({e.start for e in self.events if start <= e.start < end})
|
||||
for onset in onsets:
|
||||
state = self.state(onset)
|
||||
if key != state['key']:
|
||||
key, memory = state['key'], {}
|
||||
simultaneous = {}
|
||||
for event in (e for e in self.events if e.start == onset):
|
||||
for tone in event.value['pitches']:
|
||||
hidden_head = isinstance(tone.get('notehead'), dict) and tone['notehead'].get('name') == 'none'
|
||||
if event.value.get('hidden') or tone.get('print', {}).get('print-object') == 'no' or hidden_head:
|
||||
result[tone['id']] = False
|
||||
continue
|
||||
if 'unpitched' in tone or state['clef']['sign'] == 'TAB':
|
||||
result[tone['id']] = False
|
||||
continue
|
||||
identity = (tone['letter'], tone['octave'])
|
||||
value = alteration(tone['alter'])
|
||||
signature = Fraction() if key.get('custom') else alteration(key['alterations'][LETTERS.index(tone['letter'])])
|
||||
for step in key.get('steps', []):
|
||||
if step['letter'] == tone['letter'] and step.get('octave', tone['octave']) == tone['octave']:
|
||||
signature = alteration(step['alter'])
|
||||
result[tone['id']] = bool(tone.get('accidental_display')) or (not tone.get('tie_from') and memory.get(identity, signature) != value)
|
||||
simultaneous.setdefault(identity, set()).add(value)
|
||||
for identity, values in simultaneous.items():
|
||||
memory[identity] = next(iter(values)) if len(values) == 1 else None
|
||||
if any(len(values) > 1 for values in simultaneous.values()):
|
||||
for event in (e for e in self.events if e.start == onset):
|
||||
for tone in event.value['pitches']:
|
||||
if 'letter' in tone and len(simultaneous.get((tone['letter'], tone['octave']), ())) > 1:
|
||||
result[tone['id']] = True
|
||||
return result
|
||||
|
||||
def fragment_written(written: dict, original_start: Fraction, original_end: Fraction, start: Fraction, end: Fraction) -> dict:
|
||||
return written if (original_start, original_end) == (start, end) else written_for(end - start)
|
||||
|
||||
def flags(written: dict) -> int:
|
||||
return max(0, NOTE_TYPES.index(written['type']) - NOTE_TYPES.index('quarter'))
|
||||
|
||||
def head_glyph(written: dict, head: dict | str | None) -> str:
|
||||
head = {'name': head} if isinstance(head, str) else head or {'name': 'normal'}
|
||||
if head.get('smufl'):
|
||||
return head['smufl']
|
||||
name = head['name']
|
||||
if name == 'none':
|
||||
return ''
|
||||
kind = written['type']
|
||||
suffix = 'DoubleWhole' if kind in ('maxima', 'long', 'breve') else 'Whole' if kind == 'whole' else 'Half' if kind == 'half' else 'Black'
|
||||
if head.get('filled') in ('yes', 'no'):
|
||||
suffix = 'Black' if head['filled'] == 'yes' else 'Half'
|
||||
if name == 'slash':
|
||||
return {'DoubleWhole': 'noteheadSlashWhiteDoubleWhole', 'Whole': 'noteheadSlashWhiteWhole', 'Half': 'noteheadSlashWhiteHalf', 'Black': 'noteheadSlashVerticalEnds'}[suffix]
|
||||
if name in ('slashed', 'back slashed'):
|
||||
return 'noteheadSlashed' + suffix + ('1' if name == 'slashed' else '2')
|
||||
if name in ('circled', 'circle dot'):
|
||||
return 'noteheadCircled' + suffix
|
||||
if name not in HEADS:
|
||||
raise EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name')
|
||||
shape = HEADS[name]
|
||||
if shape in ('Moon', 'Round', 'Square', 'TriangleRight', 'TriangleLeft', 'TriangleRoundDown'):
|
||||
suffix = 'Black' if suffix == 'Black' else 'White'
|
||||
return 'notehead' + shape + suffix
|
||||
|
||||
def accidental(fonts: Fonts, value: Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[Primitive]:
|
||||
glyph = display.get('smufl') or ACCIDENTAL_GLYPHS.get(value)
|
||||
special = {'natural-sharp': ('accidentalNatural', 'accidentalSharp'), 'natural-flat': ('accidentalNatural', 'accidentalFlat')}
|
||||
if display.get('name') in special and not display.get('smufl'):
|
||||
items = []
|
||||
x = 0
|
||||
for name in special[display['name']]:
|
||||
part = fonts.glyph(name, x, 0, size, 'accidental', sources)
|
||||
items.append(part)
|
||||
x = part.box.right + 2
|
||||
elif glyph:
|
||||
items = [fonts.glyph(glyph, 0, 0, size, 'accidental', sources)]
|
||||
else:
|
||||
sign = '+' if value > 0 else ''
|
||||
items = [fonts.text(f'({sign}{value} st)', 0, 0, size * 1.15, 'accidental', sources)]
|
||||
if display.get('parentheses') == 'yes' or display.get('bracket') == 'yes':
|
||||
box = bounds(items)
|
||||
left, right = ('[', ']') if display.get('bracket') == 'yes' else ('(', ')')
|
||||
items.extend((fonts.text(left, box.left - 7, size / 2, size * 1.8, 'accidental', sources), fonts.text(right, box.right + 2, size / 2, size * 1.8, 'accidental', sources)))
|
||||
return items
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
from generator.model import alteration, fraction
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.notation import Event, ScoreIndex, accidental, flags, fragment_written, head_glyph
|
||||
from generator.engraving.types import Anchor, Fragment, Primitive, Style, bounds, line
|
||||
|
||||
def dots(fonts: Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[Primitive]:
|
||||
result = []
|
||||
for i in range(count):
|
||||
result.append(fonts.glyph('augmentationDot', x + i * size * 0.6, y, size, 'dot', sources))
|
||||
return result
|
||||
|
||||
def rest_glyph(kind: str) -> str:
|
||||
return {'maxima': 'restMaxima', 'long': 'restLonga', 'breve': 'restDoubleWhole', 'whole': 'restWhole', 'half': 'restHalf', 'quarter': 'restQuarter', 'eighth': 'rest8th'}.get(kind, 'rest' + kind)
|
||||
|
||||
def time_modification(written: dict, items: list[Primitive], fonts: Fonts, sources: tuple[str, ...]) -> None:
|
||||
ratio = fraction(written.get('ratio', {'numerator': 1, 'denominator': 1}))
|
||||
if ratio != 1 and items:
|
||||
box = bounds(items)
|
||||
items.append(fonts.text(f'{ratio.denominator}:{ratio.numerator}', (box.left + box.right) / 2, box.top - 6, 13, 'time-modification', sources, True))
|
||||
|
||||
def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]:
|
||||
note = event.value
|
||||
onset = max(start, event.start)
|
||||
finish = min(end, event.end)
|
||||
visible = not note.get('hidden') and note['kind'] != 'spacer'
|
||||
references = (note['id'],)
|
||||
size = style.space * (0.7 if note.get('cue') or note.get('grace') else 1)
|
||||
items, anchors, fragments = [], [], []
|
||||
state = index.state(onset)
|
||||
lines = state['staff_details']['lines']
|
||||
if not note['pitches']:
|
||||
written = fragment_written(note['written'], event.start, event.end, onset, finish)
|
||||
y = -style.space * (lines - 1) / 2
|
||||
if position := note.get('rest_position'):
|
||||
y = -index.pitch_position(position, onset) * style.space / 2
|
||||
elif event.lanes > 1:
|
||||
y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * 3
|
||||
kind = 'slash' if note.get('notehead') == 'slash' else 'rest'
|
||||
fragment = Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None)
|
||||
fragments.append(fragment)
|
||||
if visible:
|
||||
glyph = head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type'])
|
||||
if written['type'] == 'whole' and not note.get('rest_position') and event.lanes == 1:
|
||||
y -= style.space
|
||||
items.append(fonts.glyph(glyph, 0, y, size, kind, references))
|
||||
items.extend(dots(fonts, written.get('dots', 0), items[0].box.right + 4, y - size / 2, size, references))
|
||||
if kind == 'slash' and written['type'] not in ('whole', 'breve', 'long', 'maxima'):
|
||||
stem_x = items[0].box.right
|
||||
stem_y = y - 3 * size
|
||||
items.append(line('stem', references, ((stem_x, y), (stem_x, stem_y))))
|
||||
if flags(written):
|
||||
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
|
||||
items.append(fonts.glyph(flag, stem_x, stem_y, size, 'flag', references))
|
||||
anchors.append(Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment))
|
||||
if not note.get('tuplet_ids') or written != note['written']:
|
||||
time_modification(written, items, fonts, references)
|
||||
return items, anchors, fragments
|
||||
groups = {}
|
||||
for tone in note['pitches']:
|
||||
tone_end = event.start + fraction(tone['duration'])
|
||||
if tone_end <= start and not note.get('grace'):
|
||||
continue
|
||||
written = fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end))
|
||||
stem = tone.get('stem', note.get('stem', 'auto'))
|
||||
identity = (written['type'], written.get('dots', 0), str(written.get('ratio')), stem)
|
||||
groups.setdefault(identity, []).append((tone, written, tone_end))
|
||||
group_x = 0
|
||||
for members in groups.values():
|
||||
positioned = sorted(((index.pitch_position(tone, onset), tone, written, tone_end) for tone, written, tone_end in members), key=lambda item: item[0])
|
||||
stem = members[0][0].get('stem', note.get('stem', 'auto'))
|
||||
direction = -1 if stem in ('up', 'double') else 1 if stem == 'down' else (-1 if event.lane % 2 == 0 else 1) if event.lanes > 1 else (1 if sum(p[0] for p in positioned) / len(positioned) >= lines - 1 else -1)
|
||||
if direction == 1:
|
||||
positioned.reverse()
|
||||
group_items, group_anchors = [], []
|
||||
previous = None
|
||||
displaced = False
|
||||
accidental_boxes = []
|
||||
for position, tone, written, tone_end in positioned:
|
||||
sources = (note['id'], tone['id'])
|
||||
tone_size = size * (0.7 if tone.get('cue') and not note.get('cue') else 1)
|
||||
glyph = head_glyph(written, tone.get('notehead', note.get('notehead')))
|
||||
displayed = visible and tone.get('print', {}).get('print-object') != 'no' and bool(glyph)
|
||||
y = -position * style.space / 2
|
||||
if previous is not None and abs(position - previous) < 2:
|
||||
displaced = not displaced
|
||||
else:
|
||||
displaced = False
|
||||
x = group_x + (tone_size * 1.2 * -direction if displaced else 0)
|
||||
previous = position
|
||||
fragment = Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position)
|
||||
fragments.append(fragment)
|
||||
if not displayed:
|
||||
continue
|
||||
if state['clef']['sign'] == 'TAB':
|
||||
head = fonts.text(str(tone['tablature']['fret']), x, y + 5, tone_size * 1.5, 'tab', sources)
|
||||
elif written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal':
|
||||
width = tone_size * (2.8 if written['type'] == 'maxima' else 1.4)
|
||||
head = line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2)
|
||||
else:
|
||||
head = fonts.glyph(glyph, x, y, tone_size, 'head', sources)
|
||||
group_items.append(head)
|
||||
if tone.get('notehead', {}).get('parentheses') == 'yes':
|
||||
group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head-parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head-parentheses', sources)))
|
||||
if state['clef']['sign'] != 'TAB':
|
||||
ledger = -2
|
||||
while position <= ledger:
|
||||
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
|
||||
ledger -= 2
|
||||
ledger = lines * 2
|
||||
while position >= ledger:
|
||||
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
|
||||
ledger += 2
|
||||
if 'alter' in tone and index.accidentals[tone['id']] and onset == event.start:
|
||||
accidental_items = accidental(fonts, alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources)
|
||||
box = bounds(accidental_items)
|
||||
dx = min(group_x, head.box.left) - 5 - box.right
|
||||
candidate = box.moved(dx, y)
|
||||
while any(candidate.overlaps(other, 2) for other in accidental_boxes):
|
||||
dx -= box.right - box.left + 4
|
||||
candidate = box.moved(dx, y)
|
||||
accidental_boxes.append(candidate)
|
||||
group_items.extend(p.moved(dx, y) for p in accidental_items)
|
||||
if tone.get('print', {}).get('print-dot') != 'no':
|
||||
dot_y = y - style.space / 2 if position % 2 == 0 else y
|
||||
group_items.extend(dots(fonts, written.get('dots', 0), head.box.right + 5, dot_y, tone_size, sources))
|
||||
if head.kind == 'glyph':
|
||||
stem_x = x + fonts.anchor(glyph, 'stemUpSE' if direction == -1 else 'stemDownNW', tone_size)[0]
|
||||
else:
|
||||
stem_x = head.box.right - 0.6 if direction == -1 else head.box.left + 0.6
|
||||
stem_y = y + direction * max(3.5 * size, (flags(written) + 1) * size * 0.75)
|
||||
group_anchors.append(Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, flags(written), fragment))
|
||||
if group_anchors:
|
||||
stem_y = min(a.stem_y for a in group_anchors) if direction == -1 else max(a.stem_y for a in group_anchors)
|
||||
anchor = min(group_anchors, key=lambda a: a.y) if direction == 1 else max(group_anchors, key=lambda a: a.y)
|
||||
written = members[0][1]
|
||||
if stem != 'none' and written['type'] not in ('whole', 'breve'):
|
||||
group_items.append(line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12))
|
||||
if stem == 'double':
|
||||
other_x = anchor.x if direction == -1 else anchor.x + size * 1.3
|
||||
other_y = anchor.y - direction * size * 3.5
|
||||
group_items.append(line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12))
|
||||
if flags(written):
|
||||
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Down' if direction == -1 else 'Up')
|
||||
group_items.append(fonts.glyph(flag, other_x, other_y, size, 'flag', references))
|
||||
if flags(written):
|
||||
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Up' if direction == -1 else 'Down')
|
||||
group_items.append(fonts.glyph(flag, anchor.stem_x, stem_y, size, 'flag', references))
|
||||
if note.get('grace', {}).get('slash'):
|
||||
group_items.append(line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
|
||||
if note.get('tremolo', {}).get('type') == 'single':
|
||||
for mark in range(note['tremolo']['marks']):
|
||||
ty = stem_y - direction * (10 + mark * 5)
|
||||
group_items.append(line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3))
|
||||
from dataclasses import replace
|
||||
anchors.extend(replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors)
|
||||
if not note.get('tuplet_ids') or members[0][1] != members[0][0]['written']:
|
||||
time_modification(members[0][1], group_items, fonts, references)
|
||||
items.extend(group_items)
|
||||
group_x = max((p.box.right for p in items), default=group_x) + size
|
||||
return items, anchors, fragments
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<museScore version="4.70">
|
||||
<Style>
|
||||
<pageWidth>10.1666666667</pageWidth>
|
||||
<pageHeight>5.0416666667</pageHeight>
|
||||
<pagePrintableWidth>9.5833333333</pagePrintableWidth>
|
||||
<pageEvenLeftMargin>0.2916666667</pageEvenLeftMargin>
|
||||
<pageOddLeftMargin>0.2916666667</pageOddLeftMargin>
|
||||
<pageEvenTopMargin>0.1875</pageEvenTopMargin>
|
||||
<pageOddTopMargin>0.1875</pageOddTopMargin>
|
||||
<pageEvenBottomMargin>0.1875</pageEvenBottomMargin>
|
||||
<pageOddBottomMargin>0.1875</pageOddBottomMargin>
|
||||
<pageTwosided>0</pageTwosided>
|
||||
<Spatium>3.175</Spatium>
|
||||
<musicalSymbolFont>Petaluma</musicalSymbolFont>
|
||||
<musicalTextFont>Petaluma Text</musicalTextFont>
|
||||
<defaultFontFace>Petaluma Script</defaultFontFace>
|
||||
<titleFontFace>Petaluma Script</titleFontFace>
|
||||
<titleFontSize>18</titleFontSize>
|
||||
<composerFontFace>Petaluma Script</composerFontFace>
|
||||
<composerFontSize>10</composerFontSize>
|
||||
<lyricsOddFontFace>Petaluma Script</lyricsOddFontFace>
|
||||
<lyricsEvenFontFace>Petaluma Script</lyricsEvenFontFace>
|
||||
<harmonyFontFace>Petaluma Script</harmonyFontFace>
|
||||
<harmonyFontSize>10</harmonyFontSize>
|
||||
<rehearsalMarkAlign>left,baseline</rehearsalMarkAlign>
|
||||
<rehearsalMarkFrameType>0</rehearsalMarkFrameType>
|
||||
<enableVerticalSpread>0</enableVerticalSpread>
|
||||
<minSystemDistance>6</minSystemDistance>
|
||||
<maxSystemDistance>6</maxSystemDistance>
|
||||
<showPageNumber>1</showPageNumber>
|
||||
<showPageNumberOne>1</showPageNumberOne>
|
||||
</Style>
|
||||
</museScore>
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import aggdraw
|
||||
from fontTools.pens.basePen import BasePen
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.types import Page
|
||||
|
||||
class OutlinePen(BasePen):
|
||||
def __init__(self, glyphs, scale: float, x: float, y: float):
|
||||
super().__init__(glyphs)
|
||||
self.path = aggdraw.Path()
|
||||
self.scale, self.x, self.y = scale, x, y
|
||||
def point(self, point):
|
||||
return self.x + point[0] * self.scale, self.y - point[1] * self.scale
|
||||
def _moveTo(self, point):
|
||||
self.path.moveto(*self.point(point))
|
||||
def _lineTo(self, point):
|
||||
self.path.lineto(*self.point(point))
|
||||
def _curveToOne(self, first, second, last):
|
||||
self.path.curveto(*self.point(first), *self.point(second), *self.point(last))
|
||||
def _closePath(self):
|
||||
self.path.close()
|
||||
def _endPath(self):
|
||||
pass
|
||||
|
||||
def rasterize_page(page: Page, fonts: Fonts) -> Image.Image:
|
||||
scale = page.supersampling
|
||||
image = Image.new('RGB', (page.width * scale, page.height * scale), 'white')
|
||||
context = aggdraw.Draw(image)
|
||||
for item in page.primitives:
|
||||
points = [(x * scale, y * scale) for x, y in item.points]
|
||||
if item.kind == 'text':
|
||||
continue
|
||||
if item.kind == 'glyph':
|
||||
pen = OutlinePen(fonts.glyphs, item.size * scale / fonts.units, *points[0])
|
||||
fonts.outline(item.value).replay(pen)
|
||||
path = pen.path
|
||||
else:
|
||||
path = aggdraw.Path()
|
||||
path.moveto(*points[0])
|
||||
if item.kind == 'curve':
|
||||
path.curveto(*points[1], *points[2], *points[3])
|
||||
else:
|
||||
for point in points[1:]:
|
||||
path.lineto(*point)
|
||||
if item.kind == 'polygon':
|
||||
path.close()
|
||||
if item.kind in ('glyph', 'polygon'):
|
||||
context.path(path, None, aggdraw.Brush('black'))
|
||||
else:
|
||||
context.path(path, aggdraw.Pen('black', item.thickness * scale))
|
||||
context.flush()
|
||||
drawing = ImageDraw.Draw(image)
|
||||
for item in page.primitives:
|
||||
if item.kind == 'text':
|
||||
drawing.text(tuple(v * scale for v in item.points[0]), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black')
|
||||
return image.resize((page.width, page.height), Image.Resampling.LANCZOS)
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
from generator.model import beat_lengths, fraction
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.notation import ScoreIndex, flags
|
||||
from generator.engraving.types import Anchor, Primitive, Style, bounds, curve, line, polygon
|
||||
|
||||
def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]:
|
||||
if index._beam_groups is not None:
|
||||
return index._beam_groups
|
||||
groups = []
|
||||
for voice in dict.fromkeys(e.voice for e in index.events):
|
||||
current = []
|
||||
previous_key = None
|
||||
for event in (e for e in index.events if e.voice == voice):
|
||||
note = event.value
|
||||
primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None)
|
||||
measure_start = next(start for start, _, measure in index.measures if measure['id'] == event.measure['id'])
|
||||
beats = beat_lengths(index.state(event.start)['meter'])
|
||||
offset = event.start - measure_start
|
||||
beat = 0
|
||||
edge = Fraction()
|
||||
for duration in beats:
|
||||
edge += duration
|
||||
if offset < edge:
|
||||
break
|
||||
beat += 1
|
||||
key = (event.measure['id'], beat, tuple(note.get('tuplet_ids', [])), bool(note.get('grace')))
|
||||
eligible = note['kind'] == 'note' and flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden')
|
||||
if not eligible or primary == 'begin' or (primary is None and key != previous_key):
|
||||
if current:
|
||||
groups.append(current)
|
||||
current = []
|
||||
if eligible:
|
||||
current.append(note['id'])
|
||||
if primary == 'end':
|
||||
groups.append(current)
|
||||
current = []
|
||||
previous_key = key
|
||||
if current:
|
||||
groups.append(current)
|
||||
index._beam_groups = tuple(tuple(g) for g in groups if len(g) > 1 or index.by_id[g[0]].value.get('beams'))
|
||||
return index._beam_groups
|
||||
|
||||
def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, style: Style) -> list[Primitive]:
|
||||
additions = []
|
||||
removed = set()
|
||||
for group in beam_groups(index):
|
||||
selected = []
|
||||
for identifier in group:
|
||||
candidates = [a for a in anchors if a.event == identifier and a.flags]
|
||||
if candidates:
|
||||
selected.append(candidates[0])
|
||||
if not selected:
|
||||
continue
|
||||
direction = selected[0].direction
|
||||
if any(a.direction != direction for a in selected):
|
||||
continue
|
||||
sources = tuple(a.event for a in selected)
|
||||
removed.update(sources)
|
||||
beam_y = min(a.stem_y for a in selected) if direction == -1 else max(a.stem_y for a in selected)
|
||||
first, last = selected[0], selected[-1]
|
||||
x1 = left if index.by_id[group[0]].start < start else first.stem_x
|
||||
x2 = right if index.by_id[group[-1]].start >= end else last.stem_x
|
||||
for anchor in selected:
|
||||
additions.append(line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
|
||||
for level in range(1, max(a.flags for a in selected) + 1):
|
||||
y = beam_y - direction * (level - 1) * style.space * 0.75
|
||||
for i, anchor in enumerate(selected):
|
||||
if anchor.flags < level:
|
||||
continue
|
||||
event = index.by_id[anchor.event].value
|
||||
explicit = next((b['type'] for b in event.get('beams', []) if b['level'] == level), None)
|
||||
next_anchor = selected[i + 1] if i + 1 < len(selected) else None
|
||||
previous = selected[i - 1] if i else None
|
||||
a = anchor.stem_x
|
||||
if explicit in ('forward hook', 'backward hook'):
|
||||
b = a + (8 if explicit == 'forward hook' else -8)
|
||||
elif next_anchor and next_anchor.flags >= level and explicit != 'end':
|
||||
b = next_anchor.stem_x
|
||||
elif i == len(selected) - 1 and x2 > a and explicit != 'end':
|
||||
b = x2
|
||||
elif i == 0 and x1 < a and explicit != 'begin':
|
||||
b = x1
|
||||
elif not previous or previous.flags < level or explicit == 'begin':
|
||||
b = a + (8 if next_anchor else -8)
|
||||
else:
|
||||
continue
|
||||
additions.append(polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5))))
|
||||
if selected and x1 < selected[0].stem_x and selected[0].flags >= level:
|
||||
additions.append(line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5))
|
||||
return [p for p in items if not (p.role == 'flag' and any(s in removed for s in p.sources))] + additions
|
||||
|
||||
def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts, style: Style) -> list[Primitive]:
|
||||
result = []
|
||||
by_tone = {a.source: a for a in anchors}
|
||||
by_event = {a.event: a for a in anchors}
|
||||
for identifier, (event, tone) in index.tones.items():
|
||||
target = tone.get('tie_to')
|
||||
tone_end = event.start + fraction(tone['duration'])
|
||||
anchor = by_tone.get(identifier)
|
||||
if anchor and anchor.fragment.end < tone_end:
|
||||
target = identifier
|
||||
if target:
|
||||
target_event = index.tones[target][0]
|
||||
target_time = tone_end if target == identifier else target_event.start
|
||||
if event.start >= end or target_time < start:
|
||||
continue
|
||||
destination = by_tone.get(target) if target != identifier else None
|
||||
if anchor is None and destination is None:
|
||||
continue
|
||||
reference = anchor or destination
|
||||
sign = -reference.direction
|
||||
x1 = anchor.x + style.space * 1.4 if anchor else left
|
||||
x2 = destination.x - 2 if destination else right
|
||||
y1 = anchor.y + sign * 8 if anchor else reference.y + sign * 8
|
||||
y2 = destination.y + sign * 8 if destination else y1
|
||||
if x2 > x1:
|
||||
result.append(curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6))
|
||||
if anchor and anchor.fragment.start > event.start:
|
||||
y = anchor.y - anchor.direction * 8
|
||||
if anchor.x - 2 > left:
|
||||
result.append(curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
|
||||
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')), default=0))
|
||||
tuplet_lanes = []
|
||||
for span in index.staff.get('tuplets', []):
|
||||
a, b = index.by_id[span['start']], index.by_id[span['end']]
|
||||
if b.end <= start or a.start >= end:
|
||||
continue
|
||||
members = [anchor for anchor in anchors if span['id'] in index.by_id[anchor.event].value.get('tuplet_ids', [])]
|
||||
if not members:
|
||||
continue
|
||||
x1 = left if a.start < start else min(m.stem_x for m in members) - 3
|
||||
x2 = right if b.end > end else max(m.stem_x for m in members) + style.space
|
||||
y = top - 16 - span.get('depth', 0) * 23
|
||||
while any(x1 < b and a < x2 and abs(y - lane) < 22 for a, b, lane in tuplet_lanes):
|
||||
y -= 23
|
||||
tuplet_lanes.append((x1, x2, y))
|
||||
number = span.get('show_number', 'actual')
|
||||
label = '' if number == 'none' else str(span['note_count'])
|
||||
if number == 'both':
|
||||
label += ':' + str(span['space_count'])
|
||||
if span.get('show_type', 'none') != 'none':
|
||||
actual = span.get('tuplet_actual', {}).get('type', a.value['written']['type'])
|
||||
normal = span.get('tuplet_normal', {}).get('type', actual)
|
||||
label += ' ' + actual + '.' * span.get('tuplet_actual', {}).get('dots', 0)
|
||||
if span['show_type'] == 'both':
|
||||
label += ' : ' + normal + '.' * span.get('tuplet_normal', {}).get('dots', 0)
|
||||
references = (span['id'],)
|
||||
text = fonts.text(label, (x1 + x2) / 2, y + 4, 15, 'tuplet-number', references, True) if label else None
|
||||
if text:
|
||||
result.append(text)
|
||||
if span.get('bracket', 'auto') != 'no':
|
||||
intervals = [(x1, text.box.left - 4), (text.box.right + 4, x2)] if text else [(x1, x2)]
|
||||
for p, q in intervals:
|
||||
if q <= p:
|
||||
continue
|
||||
if span.get('line_shape') == 'curved':
|
||||
result.append(curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3))
|
||||
else:
|
||||
result.append(line('tuplet-bracket', references, ((p, y), (q, y)), 1.3))
|
||||
if a.start >= start:
|
||||
result.append(line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3))
|
||||
if b.end <= end:
|
||||
result.append(line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3))
|
||||
outer_top = min(top, bounds(result).top if result else top)
|
||||
for span in index.staff.get('octave_shifts', []):
|
||||
a, b = fraction(span['start']), fraction(span['end'])
|
||||
if b <= start or a >= end:
|
||||
continue
|
||||
members = [n for n in anchors if a <= n.time < b]
|
||||
x1 = min((n.x for n in members), default=left) if a >= start else left
|
||||
x2 = max((n.x + style.space * 2 for n in members), default=right) if b <= end else right
|
||||
above = span['octaves'] < 0
|
||||
y = outer_top - 18 if above else max(bounds(items + result).bottom, 0) + 24
|
||||
label = str(abs(span['octaves']) * 7 + 1) + ('va' if above else 'vb')
|
||||
text = fonts.text(label, x1, y, 16, 'octave-shift', (span['id'],))
|
||||
result.append(text)
|
||||
outer_top = min(outer_top, text.box.top - 4)
|
||||
cursor = text.box.right + 4
|
||||
while cursor < x2:
|
||||
result.append(line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1))
|
||||
cursor += 10
|
||||
if b <= end:
|
||||
result.append(line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1))
|
||||
for event in index.events:
|
||||
tremolo = event.value.get('tremolo', {})
|
||||
if tremolo.get('type') != 'start':
|
||||
continue
|
||||
partner = index.by_id[tremolo['partner']]
|
||||
if event.start >= end or partner.end <= start:
|
||||
continue
|
||||
a, b = by_event.get(event.value['id']), by_event.get(partner.value['id'])
|
||||
if not a and not b:
|
||||
continue
|
||||
anchor = a or b
|
||||
x1, x2 = a.stem_x + 4 if a else left, b.stem_x - 4 if b else right
|
||||
y = anchor.stem_y - anchor.direction * 10
|
||||
for mark in range(tremolo['marks']):
|
||||
result.append(line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5))
|
||||
return items + result
|
||||
|
||||
def place_text_lanes(items: list[Primitive], fonts: Fonts, style: Style) -> list[Primitive]:
|
||||
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')]
|
||||
box = bounds(music)
|
||||
result = list(music)
|
||||
top = box.top - 12
|
||||
for role in ('harmony', 'tempo', 'section'):
|
||||
lane = [p for p in items if p.role == role]
|
||||
if lane:
|
||||
extent = bounds(lane)
|
||||
result.extend(p.moved(0, top - extent.bottom) for p in lane)
|
||||
top -= extent.bottom - extent.top + 10
|
||||
lyrics = [p for p in items if p.role == 'lyric']
|
||||
if lyrics:
|
||||
result.extend(p.moved(0, max(box.bottom, 0) + 20) for p in lyrics)
|
||||
return result
|
||||
|
||||
def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[Anchor], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts) -> list[Primitive]:
|
||||
result = list(items)
|
||||
labels = {p.sources[0]: p for p in items if p.role == 'lyric'}
|
||||
positions = {a.event: a for a in anchors}
|
||||
lyric_baseline = None
|
||||
for event in index.events:
|
||||
for verse in event.value.get('lyrics', []):
|
||||
label = labels.get(event.value['id'] + ':lyric:' + verse['number'])
|
||||
if label:
|
||||
lyric_baseline = label.points[0][1] - index.lyric_lanes.index((event.voice, verse['number'])) * 23
|
||||
break
|
||||
if lyric_baseline is not None:
|
||||
break
|
||||
if lyric_baseline is None:
|
||||
lyric_baseline = max(0, bounds(items).bottom) + 20
|
||||
for voice, number in index.lyric_lanes:
|
||||
verses = [(e, v) for e in index.events if e.voice == voice and not e.value.get('hidden') for v in e.value.get('lyrics', []) if v['number'] == number]
|
||||
for i, (event, verse) in enumerate(verses):
|
||||
following = verses[i + 1] if i + 1 < len(verses) else None
|
||||
syllabic = next((s['text'] for s in verse['syllables'] if s['kind'] == 'syllabic'), 'single')
|
||||
extend = next((s['attributes'].get('type', 'start') for s in verse['syllables'] if s['kind'] == 'extend'), None)
|
||||
if syllabic not in ('begin', 'middle') and extend not in ('start', 'continue'):
|
||||
continue
|
||||
target = following[0].start if following else max(e.end for e in index.events if e.voice == voice)
|
||||
if target < start or event.start >= end:
|
||||
continue
|
||||
identifier = event.value['id'] + ':lyric:' + number
|
||||
next_id = following[0].value['id'] + ':lyric:' + number if following else ''
|
||||
a, b = labels.get(identifier), labels.get(next_id)
|
||||
x1 = a.box.right + 5 if a else left
|
||||
x2 = b.box.left - 5 if b else right
|
||||
if b is None and following and following[0].value['id'] in positions:
|
||||
x2 = positions[following[0].value['id']].x
|
||||
y = lyric_baseline + index.lyric_lanes.index((voice, number)) * 23
|
||||
if x2 <= x1:
|
||||
continue
|
||||
if extend in ('start', 'continue'):
|
||||
result.append(line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
|
||||
elif following:
|
||||
count = max(1, int((x2 - x1) / 90))
|
||||
for dash in range(count):
|
||||
result.append(fonts.text('-', x1 + (x2 - x1) * (dash + 1) / (count + 1), y, 18, 'lyric-hyphen', (identifier,), True))
|
||||
return result
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from fractions import Fraction
|
||||
import math
|
||||
|
||||
class EngravingError(ValueError):
|
||||
pass
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Style:
|
||||
width: int = 976
|
||||
height: int = 484
|
||||
space: float = 12
|
||||
margin_x: float = 28
|
||||
margin_y: float = 18
|
||||
supersampling: int = 4
|
||||
column_gap: float = 10
|
||||
system_gap: float = 26
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Box:
|
||||
left: float
|
||||
top: float
|
||||
right: float
|
||||
bottom: float
|
||||
def moved(self, x: float, y: float) -> Box:
|
||||
return Box(self.left + x, self.top + y, self.right + x, self.bottom + y)
|
||||
def overlaps(self, other: Box, clearance: float = 0) -> bool:
|
||||
return self.left < other.right + clearance and other.left < self.right + clearance and self.top < other.bottom + clearance and other.top < self.bottom + clearance
|
||||
def finite(self) -> bool:
|
||||
return all(math.isfinite(v) for v in (self.left, self.top, self.right, self.bottom)) and self.left <= self.right and self.top <= self.bottom
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Primitive:
|
||||
kind: str
|
||||
role: str
|
||||
sources: tuple[str, ...]
|
||||
box: Box
|
||||
points: tuple[tuple[float, float], ...]
|
||||
value: str = ''
|
||||
size: float = 0
|
||||
thickness: float = 0
|
||||
def moved(self, x: float, y: float) -> Primitive:
|
||||
return replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points))
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fragment:
|
||||
source: str
|
||||
event: str
|
||||
start: Fraction
|
||||
end: Fraction
|
||||
written_type: str
|
||||
dots: int
|
||||
ratio: Fraction
|
||||
visible: bool
|
||||
kind: str
|
||||
position: float | None
|
||||
def length(self) -> Fraction:
|
||||
return self.end - self.start
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Anchor:
|
||||
source: str
|
||||
event: str
|
||||
voice: str
|
||||
time: Fraction
|
||||
end: Fraction
|
||||
x: float
|
||||
y: float
|
||||
stem_x: float
|
||||
stem_y: float
|
||||
direction: int
|
||||
flags: int
|
||||
fragment: Fragment
|
||||
def moved(self, x: float, y: float) -> Anchor:
|
||||
return replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class System:
|
||||
start: Fraction
|
||||
end: Fraction
|
||||
primitives: tuple[Primitive, ...]
|
||||
anchors: tuple[Anchor, ...]
|
||||
fragments: tuple[Fragment, ...]
|
||||
box: Box
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Page:
|
||||
number: int
|
||||
width: int
|
||||
height: int
|
||||
supersampling: int
|
||||
systems: tuple[System, ...]
|
||||
primitives: tuple[Primitive, ...]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LayoutDocument:
|
||||
pages: tuple[Page, ...]
|
||||
style: Style
|
||||
fingerprint: str
|
||||
|
||||
def bounds(items: list[Primitive] | tuple[Primitive, ...]) -> Box:
|
||||
if not items:
|
||||
return Box(0, 0, 0, 0)
|
||||
return Box(min(p.box.left for p in items), min(p.box.top for p in items), max(p.box.right for p in items), max(p.box.bottom for p in items))
|
||||
|
||||
def line(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.4) -> Primitive:
|
||||
pad = thickness / 2
|
||||
box = Box(min(x for x, _ in points) - pad, min(y for _, y in points) - pad, max(x for x, _ in points) + pad, max(y for _, y in points) + pad)
|
||||
return Primitive('line', role, sources, box, points, thickness=thickness)
|
||||
|
||||
def curve(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.6) -> Primitive:
|
||||
return replace(line(role, sources, points, thickness), kind='curve')
|
||||
|
||||
def polygon(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...]) -> Primitive:
|
||||
return replace(line(role, sources, points, 0), kind='polygon')
|
||||
Reference in New Issue
Block a user