275 lines
16 KiB
Python
275 lines
16 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'],)))
|
|
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, staff_width: float | None = 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 = staff_width + 1 if staff_width is not None else 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
|
|
if staff_width is not None:
|
|
if width > available + 0.01:
|
|
raise generator.engraving.types.EngravingError(f'at musical offset {start}: content exceeds the final staff width')
|
|
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 fit_part_systems(systems, index, available_height, fonts, style):
|
|
|
|
def fitting_scale(items):
|
|
scale = 1
|
|
for first in range(0, len(items), 2):
|
|
pair = items[first:first + 2]
|
|
height = sum(s.box.bottom - s.box.top for s in pair)
|
|
scale = min(scale, (available_height - style.system_gap * (len(pair) - 1)) / height)
|
|
return scale
|
|
|
|
scale = fitting_scale(systems)
|
|
columns = collections.OrderedDict()
|
|
# Fit the entire part together; only spacing, never page-specific glyph sizes, varies.
|
|
for _ in range(8):
|
|
width = (style.width - 2 * style.margin_x - 2) / scale
|
|
fitted = [make_system(index, s.start, s.end, fonts, style, justify=s.end < index.end, columns=columns, staff_width=width) for s in systems]
|
|
next_scale = fitting_scale(fitted)
|
|
if scale <= next_scale + 0.000001:
|
|
return [s.scaled(scale) for s in fitted]
|
|
scale = next_scale
|
|
raise generator.engraving.types.EngravingError('part spacing did not converge')
|
|
|
|
def part_header(document, fonts, style):
|
|
size = 16
|
|
title = document['metadata']['title']
|
|
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]
|
|
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
|
|
if not subtitle:
|
|
return result
|
|
credit = fonts.text(subtitle, 0, style.margin_y + size, 12, 'subtitle', ())
|
|
width = credit.box.right - credit.box.left
|
|
if item.box.right + 20 + width <= style.width - style.margin_x:
|
|
result.append(credit.moved(style.width - style.margin_x - credit.box.right, 0))
|
|
return result
|
|
line = ''
|
|
baseline = item.box.bottom + 15
|
|
for word in subtitle.split():
|
|
text = ' '.join(filter(None, (line, word)))
|
|
candidate = fonts.text(text, style.margin_x, baseline, 12, 'subtitle', ())
|
|
if candidate.box.right > style.width - style.margin_x and line:
|
|
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
|
|
baseline = result[-1].box.bottom + 15
|
|
text = word
|
|
candidate = fonts.text(text, style.margin_x, baseline, 12, 'subtitle', ())
|
|
if candidate.box.right > style.width - style.margin_x:
|
|
raise generator.engraving.types.EngravingError('score credits cannot fit the page without clipping')
|
|
line = text
|
|
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
|
|
return result
|
|
|
|
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[generator.engraving.types.Page, ...]:
|
|
pages = []
|
|
index = index or generator.engraving.notation.ScoreIndex(document)
|
|
header = part_header(document, fonts, style)
|
|
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
|
|
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) for number in range(1, (len(systems) + 1) // 2 + 1)]
|
|
bottom = min(p.box.top for p in footers) - style.page_clearance
|
|
if bottom - top <= style.system_gap:
|
|
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
|
|
fitted = fit_part_systems(systems, index, bottom - top, fonts, style)
|
|
for first in range(0, len(systems), 2):
|
|
number = len(pages) + 1
|
|
pair = fitted[first:first + 2]
|
|
slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
|
|
spacing = slack / 3 if len(pair) == 2 else 0
|
|
cursor = top + spacing
|
|
placed = []
|
|
for system in pair:
|
|
dx, dy = style.margin_x + 1, cursor - 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)))
|
|
cursor = placed[-1].box.bottom + style.system_gap + spacing
|
|
footer = footers[first // 2]
|
|
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, *, index=None) -> tuple[generator.engraving.types.System, ...]:
|
|
if index is None:
|
|
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 - 2
|
|
while start < index.end:
|
|
columns = collections.OrderedDict()
|
|
candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, start):]
|
|
selected = None
|
|
# Reserve title/credit and footer bands before fitting complete measures.
|
|
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 = index.measure_at(start)[2]
|
|
raise generator.engraving.types.EngravingError(f'measure {measure["number"]} ({measure["id"]}): complete measure exceeds the page width or height at readable spacing')
|
|
systems.append(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:
|
|
generator.model.validate_document(document)
|
|
style = generator.engraving.types.score_style(document, style)
|
|
index = generator.engraving.notation.ScoreIndex(document)
|
|
systems = layout_systems(document, style, fonts, index=index)
|
|
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style, index=index), style, fonts.fingerprint(style))
|
|
generator.engraving.audit.audit_layout(document, layout, fonts)
|
|
return layout
|