Sizing
ci / checks (push) Successful in 47m22s

This commit is contained in:
khodowany
2026-09-30 02:49:54 -05:00
parent 923b7f14e2
commit 7cad6db980
17 changed files with 1443 additions and 1298 deletions
-19
View File
@@ -101,22 +101,3 @@ def barline(style: str, x: float, top: float, source: str) -> list[generator.eng
return [generator.engraving.types.line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))]
weights = {'regular': [1.4], 'heavy': [4], 'light-light': [1.4, 1.4], 'light-heavy': [1.4, 4], 'heavy-light': [4, 1.4], 'heavy-heavy': [4, 4]}[style]
return [generator.engraving.types.line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)]
def tempo(value: dict, fonts: generator.engraving.fonts.Fonts, x: float, y: float, space: float, source: str) -> list[generator.engraving.types.Primitive]:
written = value['written']
references = (source,)
glyph = fonts.glyph(generator.engraving.notation.head_glyph(written, None), x, y, space * 0.7, 'tempo', references)
items = [glyph]
stem_x = glyph.box.right - 0.5
if written['type'] not in ('whole', 'breve', 'long', 'maxima'):
items.append(generator.engraving.types.line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1))
if generator.engraving.notation.flags(written):
name = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
items.append(fonts.glyph(name, stem_x, y - 23, space * 0.7, 'tempo', references))
cursor = max(p.box.right for p in items) + 3
for _ in range(written.get('dots', 0)):
dot = fonts.glyph('augmentationDot', cursor, y - 3, space * 0.7, 'tempo', references)
items.append(dot)
cursor = dot.box.right + 3
items.append(fonts.text(' = ' + str(generator.model.fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references))
return items
+7 -3
View File
@@ -23,16 +23,23 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
index = generator.engraving.notation.ScoreIndex(document)
fragments, primitives = {}, {}
previous_end = fractions.Fraction()
scale = layout.pages[0].systems[0].scale
for page in layout.pages:
if len(page.systems) != 2 and (page is not layout.pages[-1] or len(page.systems) != 1):
raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system')
if len(page.systems) == 2 and page.systems[0].box.bottom + layout.style.system_gap > page.systems[1].box.top + 0.01:
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
audit_primitives(page.primitives, page.width, page.height, fonts)
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page-number'])
if page.systems[0].box.top < header.bottom + layout.style.page_clearance - 0.01 or page.systems[-1].box.bottom > footer.top - layout.style.page_clearance + 0.01:
raise generator.engraving.types.EngravingError('music overlaps the page header or footer')
for item in page.primitives:
for source in item.sources:
primitives.setdefault(source, []).append(item)
for system in page.systems:
if system.scale != scale:
raise generator.engraving.types.EngravingError('notation size must remain consistent throughout a part')
for role in ('staff', 'tab-staff'):
lines = [p for p in system.primitives if p.role == role]
if lines:
@@ -123,9 +130,6 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
label = generator.harmony.symbol(value)
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
raise generator.engraving.types.EngravingError(f'{value["id"]}: harmony was omitted')
for i, _ in enumerate(measure.get('tempos', [])):
if not any(p.role == 'tempo' for p in primitives.get(measure['id'] + f':tempo:{i}', [])):
raise generator.engraving.types.EngravingError(f'{measure["id"]}: tempo was omitted')
for span in index.staff.get('tuplets', []):
if span.get('show_number', 'actual') != 'none' or span.get('bracket', 'auto') != 'no':
if not any(p.role.startswith('tuplet-') for p in primitives.get(span['id'], [])):
+65 -38
View File
@@ -54,9 +54,6 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract
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'):
@@ -159,54 +156,84 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
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_page_systems(systems, index, row_height, fonts, style):
scale = min(1, row_height / max(s.box.bottom - s.box.top for s in systems))
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()
# Re-space columns at the final physical width; scale glyphs uniformly, not horizontally.
# 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]
height = max(s.box.bottom - s.box.top for s in fitted)
if height * scale <= row_height + 0.001:
next_scale = fitting_scale(fitted)
if scale <= next_scale + 0.000001:
return [s.scaled(scale) for s in fitted]
scale = row_height / height
raise generator.engraving.types.EngravingError(f'at musical offset {systems[0].start}: page spacing did not converge')
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)
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
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
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 = fit_page_systems(systems[first:first + 2], index, row_height, fonts, style)
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 row, system in enumerate(pair):
dx, dy = style.margin_x + 1, top + row * (row_height + style.system_gap) - system.box.top
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)))
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
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)
+1 -3
View File
@@ -29,7 +29,6 @@ class ScoreIndex:
self.events_by_onset = {}
self.attributes_by_onset = {}
self.harmonies_by_onset = {}
self.tempos_by_onset = {}
self.by_id = {}
self.tones = {}
self.boundaries = {fractions.Fraction()}
@@ -51,8 +50,7 @@ class ScoreIndex:
self.columns.add(onset)
for tone in note['pitches']:
self.tones[tone['id']] = (event, tone)
for field in ('attributes', 'harmonies', 'tempos'):
bucket = {'attributes': self.attributes_by_onset, 'harmonies': self.harmonies_by_onset, 'tempos': self.tempos_by_onset}[field]
for field, bucket in (('attributes', self.attributes_by_onset), ('harmonies', self.harmonies_by_onset)):
for index, item in enumerate(measure.get(field, [])):
onset = time + generator.model.fraction(item['onset'])
self.columns.add(onset)
+2 -2
View File
@@ -203,11 +203,11 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
return items + result
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')]
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section')]
box = generator.engraving.types.bounds(music)
result = list(music)
top = box.top - 12
for role in ('harmony', 'tempo', 'section'):
for role in ('harmony', 'section'):
lane = [p for p in items if p.role == role]
if lane:
extent = generator.engraving.types.bounds(lane)
+1
View File
@@ -14,6 +14,7 @@ class Style:
space: float = 12
margin_x: float = 28
margin_y: float = 18
page_clearance: float = 14
supersampling: int = 4
column_gap: float = 10
system_gap: float = 26