237 lines
14 KiB
Python
237 lines
14 KiB
Python
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
|