from __future__ import annotations import bisect import collections import dataclasses import fractions import math import generator.engraving.attributes import generator.engraving.audit import generator.engraving.fonts import generator.engraving.notation import generator.engraving.notes import generator.engraving.spans import generator.engraving.tablature import generator.engraving.types import generator.harmony import generator.model def lyric_text(verse: dict) -> str: return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision')) def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]: items, anchors, fragments = [], [], [] events = index.events_at(time, include_sustained=time == start) grace_width = 0 occupied = [] for event in events: try: drawn, placed, pieces = generator.engraving.notes.draw_event(index, event, start, end, fonts, style) except ValueError as error: raise generator.engraving.types.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) items.extend(generator.engraving.tablature.column(index, event, placed, fonts, style)) 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)) measure_start, _, measure = index.measure_at(time) for owner, _, item in index.harmonies_by_onset.get(time, []): if owner is measure: items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],))) for owner, i, value in index.tempos_by_onset.get(time, []): if owner is measure: items.extend(generator.engraving.attributes.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, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',))) return items, anchors, fragments def column_metrics(index, time, start, end, fonts, style, columns): events = index.events_at(time, include_sustained=time == start) finish = min(end, max((event.end for event in events), default=time)) key = (time, start, finish) if columns is not None and key in columns: columns.move_to_end(key) return columns[key] drawn, placed, pieces = column(index, time, start, end, fonts, style) result = drawn, placed, pieces, generator.engraving.types.bounds(drawn) if columns is not None: columns[key] = result if len(columns) > 256: columns.popitem(last=False) return result def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None) -> generator.engraving.types.System: items, anchors, fragments = [], [], [] state = index.state(start) measure = index.measure_at(start)[2] header = generator.engraving.attributes.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 = [start] + index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, end)] placements = [] groups = [] for ti, time in enumerate(times): item_start, anchor_start = len(items), len(anchors) drawn, placed, pieces, box = column_metrics(index, time, start, end, fonts, style, columns) leading = [] measure_start, _, m = index.measure_at(time) if time == measure_start and time > start: previous = index.measure_at(time, before=True)[2] leading.extend(generator.engraving.attributes.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 generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields)) if time > start: for owner, ci, change in index.attributes_by_onset.get(time, []): extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['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 = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14 items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading) cursor += width 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 last_measure = index.measure_at(end, before=True)[2] tab_items = [p for p in items if p.role.startswith('tab')] items = [p for p in items if not p.role.startswith('tab')] items = generator.engraving.spans.draw_beams(index, anchors, items, start, end, content_left, right, style) items = generator.engraving.spans.draw_spans(index, anchors, items, start, end, content_left, right, fonts, style) items = generator.engraving.spans.place_text_lanes(items, fonts, style) items = generator.engraving.spans.draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts) 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 staff_lines.append(generator.engraving.types.line('staff', (), ((x1, y), (x2, y)), style.space * 0.13)) items = staff_lines + items if end in index.boundaries: items.extend(generator.engraving.attributes.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(generator.engraving.attributes.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'],))) items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style) return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items)) def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[generator.engraving.types.Page, ...]: pages = [] guitar = document['staves'][0].get('tablature', False) header_gap = 14 if guitar else 24 def page_header(number): title = document['metadata']['title'] size = (20 if number == 1 else 16) if guitar else (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 generator.engraving.types.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')) if value) if subtitle: result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + (15 if guitar else 22), 12 if guitar else 16, 'subtitle', ())) return result for first in range(0, len(systems), 2): number = len(pages) + 1 header = page_header(number) top = generator.engraving.types.bounds(header).bottom + header_gap row_height = (style.height - style.margin_y - 24 - top - style.system_gap) / 2 if row_height <= 0: raise generator.engraving.types.EngravingError('page header leaves no room for two music systems') pair = systems[first:first + 2] scale = min(1, row_height / max(s.box.bottom - s.box.top for s in pair)) placed = [] for row, system in enumerate(pair): system = system.scaled(scale) dx, dy = style.margin_x + 1, top + row * (row_height + style.system_gap) - system.box.top placed.append(dataclasses.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))) footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) pages.append(generator.engraving.types.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_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> tuple[generator.engraving.types.System, ...]: generator.model.validate_document(document) index = generator.engraving.notation.ScoreIndex(document) if not index.measures: raise generator.engraving.types.EngravingError('score has no measures') systems = [] start = fractions.Fraction() available = style.width - style.margin_x * 2 while start < index.end: columns = collections.OrderedDict() candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, 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, columns=columns) 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 index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, measure_end)]: trial = make_system(index, start, end, fonts, style, columns=columns) 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 generator.engraving.types.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=True, columns=columns) if selected.end < index.end else selected) start = selected.end return tuple(systems) def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument: style = generator.engraving.types.score_style(document, style) systems = layout_systems(document, style, fonts) layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style)) generator.engraving.audit.audit_layout(document, layout, fonts) return layout