Files
product-vamp/generator/engraving/layout.py
T
khodowany 09656a9d60
ci / checks (push) Successful in 48m34s
Package
2026-09-30 19:46:37 -05:00

277 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.scales
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'],)))
items.extend(generator.engraving.scales.column(index, time, start, fonts))
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