from __future__ import annotations from fractions import Fraction from generator.model import TYPE_LENGTHS, fraction from generator import harmony from generator.engraving.fonts import Fonts from generator.engraving.notation import ScoreIndex, head_glyph from generator.engraving.types import EngravingError, LayoutDocument 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')