232 lines
13 KiB
Python
232 lines
13 KiB
Python
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
|