@@ -90,11 +90,6 @@ jobs:
|
|||||||
python3 -m venv build/build_common/ci_python
|
python3 -m venv build/build_common/ci_python
|
||||||
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 -r tools/requirements.txt
|
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 -r tools/requirements.txt
|
||||||
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
|
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
|
||||||
- name: Install pinned build-time engraver
|
|
||||||
run: |
|
|
||||||
apt-get install -y --no-install-recommends libasound2t64 libdbus-1-3 libfontconfig1 libglib2.0-0t64 libgpg-error0 libnss3 libopengl0 libxkbcommon0 libxkbcommon-x11-0 libxcb-cursor0 libxcomposite1 libxrandr2 libxtst6
|
|
||||||
python3 tools/install_engraver.py build/build_common/musescore
|
|
||||||
echo "VAMP_MUSESCORE=$PWD/build/build_common/musescore/squashfs-root/AppRun" >> "$GITHUB_ENV"
|
|
||||||
- name: Restore Conan cache
|
- name: Restore Conan cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Code style is managed by Modulus. Run `./modulus.py format` to format C++ and Py
|
|||||||
|
|
||||||
See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicXML notation, Vamp properties, schema details, and diagnostics. There is no compact-JSON source format or legacy runtime adapter. Unsupported notation fails generation before existing assets are replaced.
|
See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicXML notation, Vamp properties, schema details, and diagnostics. There is no compact-JSON source format or legacy runtime adapter. Unsupported notation fails generation before existing assets are replaced.
|
||||||
|
|
||||||
Build-time engraving uses pinned MuseScore Studio 4.7.4 and the OTF Petaluma fonts in `package/assets/fonts/petaluma`. The managed Docker image and CI install the checksum-verified engraver automatically. For a direct host build, run `python3 tools/install_engraver.py build/build_common/musescore` and set `VAMP_MUSESCORE` to the printed executable path. Unsupported or unfaithfully exported notation fails the build rather than being approximated.
|
Build-time engraving is implemented in Python in `generator/engraving`, using the bundled Petaluma OTF fonts and SMuFL metadata. MuseScore is an optional authoring tool, not a build dependency or rendering backend. The managed image and CI install the pinned Python requirements automatically; direct host builds use `pip install -r tools/requirements.txt`. Exact musical timing is retained through layout, and dense measures can continue across systems without shrinking the notation. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets.
|
||||||
|
|
||||||
Petaluma is from Steinberg under the SIL Open Font License. Runtime packages include PNG score pages, `PetalumaScript.otf` for UI text, and `OFL.txt`; the music font and engraver stay build-only.
|
Petaluma is from Steinberg under the SIL Open Font License. Runtime packages include PNG score pages, `PetalumaScript.otf` for UI text, and `OFL.txt`; the music font and engraver stay build-only.
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -87,13 +87,17 @@ Automatic performance arrangements require a pitched, non-TAB lead. Percussion a
|
|||||||
|
|
||||||
### Engraving Boundary
|
### Engraving Boundary
|
||||||
|
|
||||||
`generator/musicxml_writer.py` emits MusicXML from the transformed model. `generator/engraving/` runs pinned MuseScore Studio 4.7.4 with the checked-in Petaluma house style, isolated home/config/font caches, and the repository OTF fonts. The checksum-verified AppImage is installed only on the build host; it is never packaged for the Pi.
|
`generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `engrave_tree(root, fonts)` retains the asset-pipeline entry point. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving.
|
||||||
|
|
||||||
Model support is deliberately broader than engine qualification. More than four source voice identities, maxima, more than four dots, independently sustained tones within one chord, cross-bar tuplets, double stems, custom accidental glyphs, and unqualified microtonal alterations produce explicit engraving errors. Unequal chord durations can be authored as separate voices for this engine. No model limitation or silent approximation is introduced to conceal these failures.
|
`notation.py` interprets staff state, exact pitch positions and shared accidental state; `notes.py` and `attributes.py` construct symbols; `spans.py` handles beams, ties, tuplets, octave shifts and lyric continuations; `layout.py` handles spacing and pagination. `fonts.py` reads bundled OTF outlines and SMuFL metadata, caching metrics and outlines for one generation run. `raster.py` uses FontTools, aggdraw and Pillow only as low-level font and drawing libraries. All dependencies are pinned in `tools/requirements.txt` and remain build-only. Standard glyph names and their upstream notice are vendored alongside the fonts; no system music fonts are consulted.
|
||||||
|
|
||||||
Each export is read back as MusicXML and audited for pitch spelling, exact timing, voice/event counts, written values, ties, explicit beams/stems, lyrics, harmony degrees, staff changes, tempos, and spans. Engraver changes fail generation. This is not a claim that MuseScore can engrave every MusicXML construct. Newly supported constructs must pass importer/writer tests, engine auditing, and visual qualification.
|
The former backend-specific four-voice, duration, unequal-tone and tuplet restrictions are removed. Voices retain separate stems and collision offsets; chord tones retain their own written durations and per-tone ties. Pitched, percussion and explicitly fingered TAB material use their own staff positions. Unknown exact microtonal alterations use a numeric rational semitone annotation relative to the natural pitch, not a rounded accidental. An explicit unavailable SMuFL glyph is an error. The single-part/single-staff importer contract and exclusions for dynamics, accents, expressive ornaments and navigation remain unchanged.
|
||||||
|
|
||||||
Pages are 976x484 pixels with 12-pixel staff spacing. Pagination is done by the engraver, not by shrinking the page or clipping notation. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics.
|
`audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite.
|
||||||
|
|
||||||
|
Bundles keep schema version 3 and an unchanged `music` object. Presentation identifies `Vamp Python` version `1.0.0`; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Runtime remains engine-agnostic, so valid existing schema-v3 bundles need no database migration.
|
||||||
|
|
||||||
|
Pages are 976x484 pixels with 12-pixel staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. Dense measures continue across systems/pages; sustained notes become exact, tied presentation fragments and rests become exact rest fragments. These fragments never change the semantic JSON. Irreducible simultaneous content that cannot fit a fixed page fails contextually rather than being clipped or shrunk. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics.
|
||||||
|
|
||||||
### Validation And Migration
|
### Validation And Migration
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json
|
${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
|
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
|
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/generator/engraving/*.mss
|
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/tools/requirements.txt
|
${CMAKE_CURRENT_SOURCE_DIR}/tools/requirements.txt
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/*
|
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/*
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/**/*
|
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/**/*
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class product_vamp_conan(conan.ConanFile):
|
|||||||
"tests/fixtures/**/*",
|
"tests/fixtures/**/*",
|
||||||
"generator/*.py",
|
"generator/*.py",
|
||||||
"generator/**/*.py",
|
"generator/**/*.py",
|
||||||
"generator/engraving/*.mss",
|
|
||||||
"include/*.hpp",
|
"include/*.hpp",
|
||||||
"include/vamp_style/*.hpp",
|
"include/vamp_style/*.hpp",
|
||||||
"include/vamp_pico/*.h",
|
"include/vamp_pico/*.h",
|
||||||
|
|||||||
@@ -2,77 +2,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
from xml.etree import ElementTree
|
|
||||||
|
|
||||||
from PIL import Image, ImageChops
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
from generator.model import ACCIDENTALS, alteration, fraction, validate_document
|
from generator.engraving.fonts import Fonts
|
||||||
from generator.musicxml_writer import write_musicxml
|
from generator.engraving.layout import layout_score
|
||||||
from generator.engraving.audit import audit_roundtrip
|
from generator.engraving.raster import rasterize_page
|
||||||
|
from generator.engraving.types import EngravingError, Style
|
||||||
|
|
||||||
ENGINE_VERSION = '4.7.4'
|
ENGINE_NAME = 'Vamp Python'
|
||||||
|
ENGINE_VERSION = '1.0.0'
|
||||||
PAGE_WIDTH = 976
|
PAGE_WIDTH = 976
|
||||||
PAGE_HEIGHT = 484
|
PAGE_HEIGHT = 484
|
||||||
STYLE = Path(__file__).with_name('petaluma.mss')
|
|
||||||
|
|
||||||
class EngravingError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def qualify(document: dict) -> None:
|
|
||||||
"""Keep model support separate from the pinned engine's qualified surface."""
|
|
||||||
validate_document(document)
|
|
||||||
staff = document['staves'][0]
|
|
||||||
if len({v['id'] for m in staff['measures'] for v in m['voices']} - {'voice-arranged'}) > 4:
|
|
||||||
raise EngravingError('MuseScore 4.7.4 supports at most four voices on one staff')
|
|
||||||
event_measures = {n['id']: m['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
|
|
||||||
for span in staff.get('tuplets', []):
|
|
||||||
if event_measures[span['start']] != event_measures[span['end']]:
|
|
||||||
raise EngravingError(f'{span["id"]}: cross-bar tuplet engraving is not qualified')
|
|
||||||
if span.get('show_type', 'none') != 'none' or span.get('line_shape', 'straight') != 'straight':
|
|
||||||
raise EngravingError(f'{span["id"]}: this tuplet appearance is not qualified')
|
|
||||||
for measure in staff['measures']:
|
|
||||||
for event in (n for v in measure['voices'] for n in v['notes']):
|
|
||||||
location = f'{measure["id"]}, voice {event.get("voice")}, {event["id"]}'
|
|
||||||
if event['written']['type'] == 'maxima' or event['written'].get('dots', 0) > 4:
|
|
||||||
raise EngravingError(f'{location}: written duration exceeds the qualified engraver range')
|
|
||||||
if event.get('stem') == 'double':
|
|
||||||
raise EngravingError(f'{location}: double-stem engraving is not qualified')
|
|
||||||
for pitch in event['pitches']:
|
|
||||||
if pitch['written'] != event['written'] or pitch['duration'] != event['duration']:
|
|
||||||
raise EngravingError(f'{location}: independently sustained chord tones need separate voices for MuseScore engraving')
|
|
||||||
if 'alter' in pitch and alteration(pitch['alter']) not in ACCIDENTALS.values():
|
|
||||||
raise EngravingError(f'{location}: this microtonal alteration is not qualified for engraving')
|
|
||||||
if pitch.get('accidental_display', {}).get('name') == 'other':
|
|
||||||
raise EngravingError(f'{location}: custom accidental glyph is not qualified')
|
|
||||||
|
|
||||||
def isolated_environment(root: Path, fonts: Path) -> dict[str, str]:
|
|
||||||
env = dict(os.environ)
|
|
||||||
for name, path in (('HOME', root / 'home'), ('XDG_CONFIG_HOME', root / 'config'), ('XDG_DATA_HOME', root / 'data'), ('XDG_CACHE_HOME', root / 'cache'), ('XDG_RUNTIME_DIR', root / 'runtime')):
|
|
||||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
env[name] = str(path)
|
|
||||||
config = ElementTree.Element('fontconfig')
|
|
||||||
ElementTree.SubElement(config, 'include', {'ignore_missing': 'yes'}).text = '/etc/fonts/fonts.conf'
|
|
||||||
ElementTree.SubElement(config, 'dir').text = str(fonts.resolve())
|
|
||||||
ElementTree.SubElement(config, 'cachedir').text = str(root / 'font-cache')
|
|
||||||
fontconfig = root / 'fonts.conf'
|
|
||||||
ElementTree.ElementTree(config).write(fontconfig, encoding='utf-8', xml_declaration=True)
|
|
||||||
env.update({'QT_QPA_PLATFORM': 'offscreen', 'QT_QPA_PLATFORMTHEME': '', 'FONTCONFIG_FILE': str(fontconfig), 'QT_STYLE_OVERRIDE': 'Fusion'})
|
|
||||||
return env
|
|
||||||
|
|
||||||
def run_engine(executable: str, arguments: list[str], env: dict) -> str:
|
|
||||||
try:
|
|
||||||
result = subprocess.run([executable, '-platform', 'offscreen', *arguments], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180, check=False)
|
|
||||||
except (OSError, subprocess.TimeoutExpired) as error:
|
|
||||||
raise EngravingError(f'MuseScore export failed: {error}') from error
|
|
||||||
if result.returncode:
|
|
||||||
raise EngravingError(f'MuseScore export exited {result.returncode}: {result.stdout[-4000:]}')
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
def check_page(path: Path) -> None:
|
def check_page(path: Path) -> None:
|
||||||
with Image.open(path) as image:
|
with Image.open(path) as image:
|
||||||
@@ -92,43 +35,25 @@ def engrave_tree(root: Path, fonts: Path) -> None:
|
|||||||
paths = sorted(root.rglob('*.json'))
|
paths = sorted(root.rglob('*.json'))
|
||||||
if not paths:
|
if not paths:
|
||||||
return
|
return
|
||||||
executable = os.environ.get('VAMP_MUSESCORE') or shutil.which('mscore') or shutil.which('musescore')
|
family = Fonts(fonts)
|
||||||
if not executable:
|
style = Style()
|
||||||
raise EngravingError(f'MuseScore Studio {ENGINE_VERSION} is required at build time (set VAMP_MUSESCORE)')
|
for path in paths:
|
||||||
with tempfile.TemporaryDirectory(prefix='vamp-engraving-') as temporary:
|
|
||||||
work = Path(temporary)
|
|
||||||
env = isolated_environment(work, fonts)
|
|
||||||
version = run_engine(executable, ['--version'], env)
|
|
||||||
if not re.search(r'\b4\.7\.4\b', version):
|
|
||||||
raise EngravingError(f'expected MuseScore Studio {ENGINE_VERSION}, received {version.strip()!r}')
|
|
||||||
for index, path in enumerate(paths):
|
|
||||||
document = json.loads(path.read_text(encoding='utf-8'))
|
document = json.loads(path.read_text(encoding='utf-8'))
|
||||||
try:
|
try:
|
||||||
qualify(document)
|
layout = layout_score(document, style, family)
|
||||||
source = work / f'score-{index}.musicxml'
|
|
||||||
write_musicxml(source, document, engine_voices=True)
|
|
||||||
output = work / f'score-{index}.png'
|
|
||||||
audit = work / f'audit-{index}.musicxml'
|
|
||||||
job = work / 'job.json'
|
|
||||||
job.write_text(json.dumps([{'in': str(source), 'out': [str(output), str(audit)]}]), encoding='utf-8')
|
|
||||||
run_engine(executable, ['-S', str(STYLE.resolve()), '-r', '96', '-j', str(job)], env)
|
|
||||||
audit_roundtrip(source, audit)
|
|
||||||
pages = sorted(work.glob(f'score-{index}-*.png'), key=lambda p: int(p.stem.rsplit('-', 1)[1]))
|
|
||||||
if not pages:
|
|
||||||
raise EngravingError('engraver produced no pages')
|
|
||||||
destination = path.with_suffix('.pages')
|
destination = path.with_suffix('.pages')
|
||||||
if destination.exists():
|
if destination.exists():
|
||||||
shutil.rmtree(destination)
|
shutil.rmtree(destination)
|
||||||
destination.mkdir()
|
destination.mkdir()
|
||||||
entries = []
|
entries = []
|
||||||
for number, page in enumerate(pages, 1):
|
for page in layout.pages:
|
||||||
check_page(page)
|
name = f'{page.number:04}.png'
|
||||||
name = f'{number:04}.png'
|
|
||||||
target = destination / name
|
target = destination / name
|
||||||
shutil.copyfile(page, target)
|
rasterize_page(page, family).save(target, format='PNG')
|
||||||
|
check_page(target)
|
||||||
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||||
music = {k: v for k, v in document.items() if k != 'schema_version'}
|
music = {k: v for k, v in document.items() if k != 'schema_version'}
|
||||||
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': 'MuseScore Studio', 'engine_version': ENGINE_VERSION, 'style_sha256': hashlib.sha256(STYLE.read_bytes()).hexdigest(), 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
|
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'style_sha256': layout.fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
|
||||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
|
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
from generator.model import LETTERS, TYPE_LENGTHS, alteration, fraction
|
||||||
|
from generator.engraving.fonts import Fonts
|
||||||
|
from generator.engraving.notation import ScoreIndex, accidental
|
||||||
|
from generator.engraving.types import Primitive, bounds, line
|
||||||
|
|
||||||
|
def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[Primitive]:
|
||||||
|
result = []
|
||||||
|
x = 0
|
||||||
|
references = (source,)
|
||||||
|
clef = state['clef']
|
||||||
|
lines = state['staff_details']['lines']
|
||||||
|
if 'clef' in fields and clef['sign'] != 'none':
|
||||||
|
sign = clef['sign']
|
||||||
|
name = {'G': 'gClef', 'F': 'fClef', 'C': 'cClef', 'percussion': 'unpitchedPercussionClef1', 'TAB': '6stringTabClef' if lines != 4 else '4stringTabClef'}[sign]
|
||||||
|
y = -space * (clef.get('line', 2) - 1) if sign in ('G', 'F', 'C') else -space * (lines - 1) / 2
|
||||||
|
glyph = fonts.glyph(name, x, y, space, 'clef', references)
|
||||||
|
result.append(glyph)
|
||||||
|
if octave := clef.get('octave_change', 0):
|
||||||
|
label = str(abs(octave) * 7 + 1)
|
||||||
|
result.append(fonts.text(label, (glyph.box.left + glyph.box.right) / 2, glyph.box.top - 2 if octave > 0 else glyph.box.bottom + 14, 14, 'clef-octave', references, True))
|
||||||
|
x = bounds(result).right + space
|
||||||
|
if 'key' in fields and clef['sign'] not in ('percussion', 'TAB'):
|
||||||
|
key = state['key']
|
||||||
|
steps = key.get('steps')
|
||||||
|
if steps is None:
|
||||||
|
order = 'fcgdaeb' if key.get('fifths', 0) >= 0 else 'beadgcf'
|
||||||
|
steps = [{'letter': c, 'alter': key['alterations'][LETTERS.index(c)]} for c in order if alteration(key['alterations'][LETTERS.index(c)])]
|
||||||
|
cancellation = key.get('cancel', 0)
|
||||||
|
if cancellation:
|
||||||
|
order = 'fcgdaeb' if cancellation > 0 else 'beadgcf'
|
||||||
|
steps = [{'letter': c, 'alter': 0} for c in order[:min(7, abs(cancellation))]] + steps
|
||||||
|
for step in steps:
|
||||||
|
pitch = {'letter': step['letter'], 'octave': step.get('octave', 5), 'id': source}
|
||||||
|
position = index.pitch_position(pitch, time)
|
||||||
|
if 'octave' not in step:
|
||||||
|
while position > 9:
|
||||||
|
position -= 7
|
||||||
|
while position < 2:
|
||||||
|
position += 7
|
||||||
|
items = accidental(fonts, alteration(step['alter']), {'name': step.get('accidental', '')}, space, references)
|
||||||
|
result.extend(p.moved(x, -position * space / 2) for p in items)
|
||||||
|
x += bounds(items).right - bounds(items).left + 4
|
||||||
|
if steps:
|
||||||
|
x += space / 2
|
||||||
|
if 'meter' in fields:
|
||||||
|
meter = state['meter']
|
||||||
|
if meter.get('unmetered'):
|
||||||
|
result.append(fonts.text('senza misura', x, -space * 2, 16, 'meter', references))
|
||||||
|
elif meter.get('symbol') in ('common', 'cut'):
|
||||||
|
result.append(fonts.glyph('timeSigCommon' if meter['symbol'] == 'common' else 'timeSigCutCommon', x, -space * 2, space, 'meter', references))
|
||||||
|
else:
|
||||||
|
for gi, group in enumerate(meter['groups']):
|
||||||
|
if gi:
|
||||||
|
plus = fonts.glyph('timeSigPlus', x, -space * 2, space, 'meter', references)
|
||||||
|
result.append(plus)
|
||||||
|
x = plus.box.right + 7
|
||||||
|
numerator = '+'.join(map(str, group['beats']))
|
||||||
|
denominator = str(group['unit'])
|
||||||
|
rows = [(numerator, -space * 2)] if meter.get('symbol') == 'single-number' else [(numerator, -space * 3), (denominator, -space)]
|
||||||
|
groups = []
|
||||||
|
for value, y in rows:
|
||||||
|
items = []
|
||||||
|
cursor = 0
|
||||||
|
if y == -space and meter.get('symbol') in ('note', 'dotted-note'):
|
||||||
|
unit = Fraction(4, group['unit'])
|
||||||
|
kind = next((name for name, length in TYPE_LENGTHS.items() if length == unit), None)
|
||||||
|
glyph_name = {'whole': 'noteWhole', 'half': 'noteHalfUp', 'quarter': 'noteQuarterUp', 'eighth': 'note8thUp'}.get(kind, 'note' + str(kind) + 'Up')
|
||||||
|
if kind and glyph_name in fonts.names:
|
||||||
|
glyph = fonts.glyph(glyph_name, 0, y + space, space * 0.55, 'meter', references)
|
||||||
|
items.append(glyph)
|
||||||
|
if meter['symbol'] == 'dotted-note':
|
||||||
|
items.append(fonts.glyph('augmentationDot', glyph.box.right + 3, y + space / 2, space * 0.55, 'meter', references))
|
||||||
|
groups.append(items)
|
||||||
|
continue
|
||||||
|
for char in value:
|
||||||
|
glyph = fonts.glyph('timeSigPlus' if char == '+' else 'timeSig' + char, cursor, y, space, 'meter', references)
|
||||||
|
items.append(glyph)
|
||||||
|
cursor = glyph.box.right + 1
|
||||||
|
groups.append(items)
|
||||||
|
width = max(bounds(items).right for items in groups)
|
||||||
|
for items in groups:
|
||||||
|
result.extend(p.moved(x + (width - bounds(items).right) / 2, 0) for p in items)
|
||||||
|
x += width + 6
|
||||||
|
if 'staff_details' in fields and state['staff_details'].get('capo'):
|
||||||
|
result.append(fonts.text(f'Capo {state["staff_details"]["capo"]}', x, -space * (lines + 1), 16, 'staff-details', references))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def barline(style: str, x: float, top: float, source: str) -> list[Primitive]:
|
||||||
|
if style == 'none':
|
||||||
|
return []
|
||||||
|
references = (source,)
|
||||||
|
if style in ('dashed', 'dotted'):
|
||||||
|
dash, gap = (5, 4) if style == 'dashed' else (1, 4)
|
||||||
|
return [line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)]
|
||||||
|
if style in ('tick', 'short'):
|
||||||
|
return [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 [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: Fonts, x: float, y: float, space: float, source: str) -> list[Primitive]:
|
||||||
|
from generator.engraving.notation import flags, head_glyph
|
||||||
|
written = value['written']
|
||||||
|
references = (source,)
|
||||||
|
glyph = fonts.glyph(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(line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1))
|
||||||
|
if 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(fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references))
|
||||||
|
return items
|
||||||
+108
-87
@@ -1,92 +1,113 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from fractions import Fraction
|
||||||
|
|
||||||
|
from generator.model import TYPE_LENGTHS, fraction
|
||||||
from generator import harmony
|
from generator import harmony
|
||||||
from generator.musicxml import load_musicxml
|
from generator.engraving.fonts import Fonts
|
||||||
|
from generator.engraving.notation import ScoreIndex, head_glyph
|
||||||
|
from generator.engraving.types import EngravingError, LayoutDocument
|
||||||
|
|
||||||
def same(left, right, location: str) -> None:
|
def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||||
if left != right:
|
index = ScoreIndex(document)
|
||||||
raise ValueError(f'{location}: engraver changed musical content ({left!r} -> {right!r})')
|
fragments, primitives = {}, {}
|
||||||
|
previous_end = Fraction()
|
||||||
def event_positions(staff: dict) -> dict:
|
for page in layout.pages:
|
||||||
return {event['id']: (mi, vi, ni) for mi, measure in enumerate(staff['measures']) for vi, voice in enumerate(measure['voices']) for ni, event in enumerate(voice['notes'])}
|
for item in page.primitives:
|
||||||
|
if not item.box.finite():
|
||||||
def tuplet_members(staff: dict, identifier: str) -> list:
|
raise EngravingError(f'{item.sources}: non-finite or inverted primitive geometry')
|
||||||
positions = event_positions(staff)
|
if item.box.left < 4 or item.box.top < 4 or item.box.right > page.width - 4 or item.box.bottom > page.height - 4:
|
||||||
return [positions[event['id']] for measure in staff['measures'] for voice in measure['voices'] for event in voice['notes'] if identifier in event.get('tuplet_ids', [])]
|
raise EngravingError(f'{item.sources}, {item.role}: primitive outside page bounds')
|
||||||
|
if item.kind == 'glyph':
|
||||||
def audit_roundtrip(source: Path, output: Path) -> None:
|
actual = fonts.glyph_box(item.value, item.size).moved(*item.points[0])
|
||||||
before = load_musicxml(source)['staves'][0]
|
if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))):
|
||||||
after = load_musicxml(output)['staves'][0]
|
raise EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
|
||||||
before_positions, after_positions = event_positions(before), event_positions(after)
|
for source in item.sources:
|
||||||
before_instruments = {i['id']: i for i in before.get('instruments', [])}
|
primitives.setdefault(source, []).append(item)
|
||||||
after_instruments = {i['id']: i for i in after.get('instruments', [])}
|
for system in page.systems:
|
||||||
same(len(before['measures']), len(after['measures']), 'measure count')
|
if system.start != previous_end or system.end <= system.start:
|
||||||
for original, rendered in zip(before['measures'], after['measures']):
|
raise EngravingError('layout systems do not partition the score timeline')
|
||||||
location = original['id']
|
previous_end = system.end
|
||||||
for field in ('duration', 'key', 'meter', 'clef', 'transpose', 'attributes'):
|
baseline = max(p.points[0][1] for p in system.primitives if p.role == 'staff')
|
||||||
same(original[field], rendered[field], f'{location} {field}')
|
for anchor in system.anchors:
|
||||||
same({'capo': 0, **original['staff_details']}, {'capo': 0, **rendered['staff_details']}, f'{location} staff details')
|
if anchor.fragment.kind == 'tone':
|
||||||
for side, style in original.get('barlines', {}).items():
|
expected = index.pitch_position(index.tones[anchor.source][1], anchor.time)
|
||||||
same(style, rendered.get('barlines', {}).get(side, 'regular'), f'{location} {side} barline')
|
if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01:
|
||||||
same(original.get('tempos', []), rendered.get('tempos', []), f'{location} tempos')
|
raise EngravingError(f'{anchor.source}: displayed staff position changed pitch')
|
||||||
same(original.get('section_label', ''), rendered.get('section_label', ''), f'{location} section label')
|
role = 'tab' if anchor.fragment.kind == 'tone' and index.state(anchor.time)['clef']['sign'] == 'TAB' else 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind
|
||||||
same(len(original['harmonies']), len(rendered['harmonies']), f'{location} harmony count')
|
candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01]
|
||||||
for a, b in zip(original['harmonies'], rendered['harmonies']):
|
if not candidates:
|
||||||
for field in ('root', 'bass', 'onset'):
|
raise EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive')
|
||||||
same(a.get(field), b.get(field), f'{location} harmony {field}')
|
if role == 'head' and candidates[0].kind == 'glyph':
|
||||||
same(harmony.degrees(a), harmony.degrees(b), f'{location} harmony degrees')
|
event, tone = index.tones[anchor.source]
|
||||||
same(len(original['voices']), len(rendered['voices']), f'{location} voice count')
|
expected_glyph = head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
|
||||||
for first, second in zip(original['voices'], rendered['voices']):
|
if candidates[0].value != expected_glyph:
|
||||||
same(len(first['notes']), len(second['notes']), f'{location} voice {first["id"]} event count')
|
raise EngravingError(f'{anchor.source}: notehead changed written duration or shape')
|
||||||
for a, b in zip(first['notes'], second['notes']):
|
for fragment in system.fragments:
|
||||||
event_location = f'{location}, voice {first["id"]}, {a["id"]}'
|
fragments.setdefault(fragment.source, []).append(fragment)
|
||||||
for field in ('kind', 'onset', 'duration', 'written', 'hidden'):
|
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
|
||||||
same(a[field], b[field], f'{event_location} {field}')
|
# noteheads, accidentals and text in the same lane must remain separate.
|
||||||
for field in ('grace', 'cue', 'measure_rest', 'rest_position', 'lyrics'):
|
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric')]
|
||||||
same(a.get(field), b.get(field), f'{event_location} {field}')
|
for i, item in enumerate(checked):
|
||||||
for field in ('stem', 'beams', 'notehead'):
|
for other in checked[i + 1:]:
|
||||||
if field in a:
|
if set(item.sources) & set(other.sources):
|
||||||
same(a[field], b.get(field), f'{event_location} explicit {field}')
|
continue
|
||||||
same(len(a['pitches']), len(b['pitches']), f'{event_location} chord tone count')
|
if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}:
|
||||||
for p, q in zip(a['pitches'], b['pitches']):
|
continue
|
||||||
if p.get('instrument') and 'unpitched' in p:
|
if item.box.overlaps(other.box, 0.1):
|
||||||
first_instrument = before_instruments[p['instrument']]
|
raise EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
|
||||||
second_instrument = after_instruments.get(q.get('instrument'), {})
|
if previous_end != index.end:
|
||||||
same(first_instrument.get('midi', {}).get('midi-unpitched'), second_instrument.get('midi', {}).get('midi-unpitched'), f'{event_location} percussion instrument')
|
raise EngravingError('layout omits the end of the score')
|
||||||
for field in ('letter', 'alter', 'octave', 'duration', 'written', 'unpitched', 'tablature', 'notehead', 'cue'):
|
for event in index.events:
|
||||||
same(p.get(field), q.get(field), f'{event_location} pitch {field}')
|
note = event.value
|
||||||
for field in ('stem', 'print'):
|
for source in note['pitches'] or [note]:
|
||||||
if field in p:
|
identifier = source['id']
|
||||||
same(p[field], q.get(field), f'{event_location} pitch {field}')
|
pieces = sorted(fragments.get(identifier, []), key=lambda f: f.start)
|
||||||
for field in ('tie_from', 'tie_to'):
|
if not pieces:
|
||||||
same(bool(p.get(field)), bool(q.get(field)), f'{event_location} {field}')
|
raise EngravingError(f'{identifier}: missing notation fragments')
|
||||||
if display := p.get('accidental_display'):
|
cursor = event.start
|
||||||
other = q.get('accidental_display', {})
|
for fragment in pieces:
|
||||||
same(display.get('name'), other.get('name'), f'{event_location} accidental')
|
expected_visible = not note.get('hidden') and note['kind'] != 'spacer' and source.get('print', {}).get('print-object') != 'no'
|
||||||
for field in ('parentheses', 'bracket'):
|
if isinstance(source.get('notehead'), dict) and source['notehead'].get('name') == 'none':
|
||||||
same(display.get(field, 'no'), other.get(field, 'no'), f'{event_location} accidental {field}')
|
expected_visible = False
|
||||||
for field in ('cautionary', 'editorial'):
|
if fragment.visible != expected_visible:
|
||||||
if field in display:
|
raise EngravingError(f'{identifier}: notation visibility changed')
|
||||||
same(display[field], other.get(field, 'no'), f'{event_location} accidental {field}')
|
if fragment.start != cursor:
|
||||||
if tremolo := a.get('tremolo'):
|
raise EngravingError(f'{identifier}: fragments overlap or omit metric time')
|
||||||
other = b.get('tremolo', {})
|
cursor = fragment.end
|
||||||
for field in ('type', 'marks'):
|
expected = TYPE_LENGTHS[fragment.written_type] * (2 - Fraction(1, 2 ** fragment.dots)) * fragment.ratio
|
||||||
same(tremolo[field], other.get(field), f'{event_location} tremolo {field}')
|
if not note.get('grace') and not note.get('measure_rest') and fragment.length() != expected:
|
||||||
if 'partner' in tremolo:
|
raise EngravingError(f'{identifier}: written fragment duration is inexact')
|
||||||
same(before_positions.get(tremolo['partner']), after_positions.get(other.get('partner')), f'{event_location} tremolo partner')
|
if fragment.visible and not any(p.role in ('head', 'tab', 'rest', 'slash') for p in primitives.get(identifier, [])):
|
||||||
same(len(before.get('tuplets', [])), len(after.get('tuplets', [])), 'tuplet count')
|
raise EngravingError(f'{identifier}: visible notation has no drawing primitive')
|
||||||
for a, b in zip(before.get('tuplets', []), after.get('tuplets', [])):
|
if cursor != event.start + fraction(source['duration']):
|
||||||
for field in ('note_count', 'space_count', 'depth', 'show_number', 'show_type'):
|
raise EngravingError(f'{identifier}: rendered duration changed')
|
||||||
same(a.get(field), b.get(field), f'{a["id"]} {field}')
|
if any(f.visible for f in pieces) and identifier in index.tones and index.accidentals[identifier]:
|
||||||
if a['bracket'] != 'auto':
|
if not any(p.role == 'accidental' for p in primitives.get(identifier, [])):
|
||||||
same(a['bracket'], b['bracket'], f'{a["id"]} bracket')
|
raise EngravingError(f'{identifier}: required accidental was omitted')
|
||||||
same(tuplet_members(before, a['id']), tuplet_members(after, b['id']), f'{a["id"]} members')
|
if identifier in index.tones and source.get('tie_to') and any(f.visible for f in pieces):
|
||||||
for endpoint in ('start', 'end'):
|
if not any(p.role == 'tie' for p in primitives.get(identifier, [])):
|
||||||
same(before_positions[a[endpoint]], after_positions[b[endpoint]], f'{a["id"]} {endpoint}')
|
raise EngravingError(f'{identifier}: tie was omitted')
|
||||||
for field in ('tuplet_actual', 'tuplet_normal'):
|
if not note.get('hidden'):
|
||||||
if a.get(field, {}).get('type'):
|
for verse in note.get('lyrics', []):
|
||||||
same(a[field], b.get(field), f'{a["id"]} {field}')
|
text = ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
|
||||||
same([{k: v for k, v in s.items() if k != 'id'} for s in before.get('octave_shifts', [])],
|
identifier = note['id'] + ':lyric:' + verse['number']
|
||||||
[{k: v for k, v in s.items() if k != 'id'} for s in after.get('octave_shifts', [])], 'octave spans')
|
if text and not any(p.role == 'lyric' and p.value == text for p in primitives.get(identifier, [])):
|
||||||
|
raise EngravingError(f'{identifier}: lyric text was omitted or changed')
|
||||||
|
for _, _, measure in index.measures:
|
||||||
|
for value in measure['harmonies']:
|
||||||
|
label = harmony.symbol(value)
|
||||||
|
if value.get('chord_optional'):
|
||||||
|
label = '(' + label + ')'
|
||||||
|
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
|
||||||
|
raise 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 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'], [])):
|
||||||
|
raise EngravingError(f'{span["id"]}: tuplet was omitted')
|
||||||
|
for span in index.staff.get('octave_shifts', []):
|
||||||
|
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
|
||||||
|
raise EngravingError(f'{span["id"]}: octave shift was omitted')
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fontTools.pens.boundsPen import BoundsPen
|
||||||
|
from fontTools.pens.recordingPen import DecomposingRecordingPen
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
from generator.engraving.types import Box, EngravingError, Primitive, Style
|
||||||
|
|
||||||
|
class Fonts:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.root = root
|
||||||
|
try:
|
||||||
|
self.music = TTFont(root / 'Petaluma.otf')
|
||||||
|
self.script = TTFont(root / 'PetalumaScript.otf')
|
||||||
|
self.metadata = json.loads((root / 'petaluma_metadata.json').read_text())
|
||||||
|
self.names = json.loads((root / 'smufl_glyphnames.json').read_text())
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
raise EngravingError(f'cannot load bundled engraving fonts: {error}') from error
|
||||||
|
self.names.update(self.metadata.get('optionalGlyphs', {}))
|
||||||
|
self.cmap = self.music.getBestCmap()
|
||||||
|
self.text_cmap = self.script.getBestCmap()
|
||||||
|
self.glyphs = self.music.getGlyphSet()
|
||||||
|
self.units = self.music['head'].unitsPerEm / 4
|
||||||
|
self.metrics = {}
|
||||||
|
self.outlines = {}
|
||||||
|
self.text_fonts = {}
|
||||||
|
def fingerprint(self, style: Style) -> str:
|
||||||
|
digest = hashlib.sha256(json.dumps(asdict(style), sort_keys=True).encode())
|
||||||
|
for name in ('Petaluma.otf', 'PetalumaScript.otf', 'petaluma_metadata.json', 'smufl_glyphnames.json'):
|
||||||
|
digest.update(name.encode())
|
||||||
|
digest.update((self.root / name).read_bytes())
|
||||||
|
return digest.hexdigest()
|
||||||
|
def glyph_name(self, name: str) -> str:
|
||||||
|
if name not in self.names:
|
||||||
|
raise EngravingError(f'unknown SMuFL glyph {name!r}')
|
||||||
|
code = int(self.names[name]['codepoint'].removeprefix('U+'), 16)
|
||||||
|
if code not in self.cmap:
|
||||||
|
raise EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}')
|
||||||
|
return self.cmap[code]
|
||||||
|
def glyph_box(self, name: str, size: float) -> Box:
|
||||||
|
if name not in self.metrics:
|
||||||
|
pen = BoundsPen(self.glyphs)
|
||||||
|
self.glyphs[self.glyph_name(name)].draw(pen)
|
||||||
|
if pen.bounds is None:
|
||||||
|
raise EngravingError(f'empty SMuFL glyph {name!r}')
|
||||||
|
self.metrics[name] = pen.bounds
|
||||||
|
left, bottom, right, top = self.metrics[name]
|
||||||
|
scale = size / self.units
|
||||||
|
return Box(left * scale, -top * scale, right * scale, -bottom * scale)
|
||||||
|
def outline(self, name: str):
|
||||||
|
if name not in self.outlines:
|
||||||
|
pen = DecomposingRecordingPen(self.glyphs)
|
||||||
|
self.glyphs[self.glyph_name(name)].draw(pen)
|
||||||
|
self.outlines[name] = pen
|
||||||
|
return self.outlines[name]
|
||||||
|
def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> Primitive:
|
||||||
|
return Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size)
|
||||||
|
def anchor(self, name: str, anchor: str, size: float) -> tuple[float, float]:
|
||||||
|
value = self.metadata.get('glyphsWithAnchors', {}).get(name, {}).get(anchor)
|
||||||
|
if value is None:
|
||||||
|
box = self.glyph_box(name, size)
|
||||||
|
return (box.right, 0) if 'Up' in anchor else (box.left, 0)
|
||||||
|
return value[0] * size, -value[1] * size
|
||||||
|
def text_font(self, size: float):
|
||||||
|
pixels = max(1, round(size))
|
||||||
|
if pixels not in self.text_fonts:
|
||||||
|
self.text_fonts[pixels] = ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels)
|
||||||
|
return self.text_fonts[pixels]
|
||||||
|
def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> Primitive:
|
||||||
|
missing = [c for c in value if ord(c) not in self.text_cmap and not c.isspace()]
|
||||||
|
if missing:
|
||||||
|
raise EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}')
|
||||||
|
font = self.text_font(size * 4)
|
||||||
|
left, top, right, bottom = font.getbbox(value, anchor='ls')
|
||||||
|
offset = (left + right) / 8 if center else 0
|
||||||
|
x -= offset
|
||||||
|
return Primitive('text', role, sources, Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size)
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from bisect import bisect_left, bisect_right
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
from generator.model import LETTERS, NOTE_TYPES, alteration, fraction, written_for
|
||||||
|
from generator.engraving.fonts import Fonts
|
||||||
|
from generator.engraving.types import EngravingError, Primitive, bounds
|
||||||
|
|
||||||
|
ACCIDENTAL_GLYPHS = {Fraction(0): 'accidentalNatural', Fraction(1): 'accidentalSharp', Fraction(-1): 'accidentalFlat', Fraction(2): 'accidentalDoubleSharp', Fraction(-2): 'accidentalDoubleFlat', Fraction(3): 'accidentalTripleSharp', Fraction(-3): 'accidentalTripleFlat', Fraction(1, 2): 'accidentalQuarterToneSharpStein', Fraction(-1, 2): 'accidentalQuarterToneFlatStein', Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'}
|
||||||
|
HEADS = {'x': 'X', 'diamond': 'Diamond', 'triangle': 'TriangleUp', 'inverted triangle': 'TriangleDown', 'circle-x': 'CircleX', 'cross': 'Plus', 'slash': 'Slash', 'normal': '', 'do': 'TriangleUp', 're': 'Moon', 'mi': 'Diamond', 'fa': 'TriangleRight', 'fa up': 'TriangleLeft', 'so': 'Round', 'la': 'Square', 'ti': 'TriangleRoundDown', 'rectangle': 'Square', 'square': 'Square', 'arrow down': 'LargeArrowDown', 'arrow up': 'LargeArrowUp'}
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Event:
|
||||||
|
measure: dict
|
||||||
|
voice: str
|
||||||
|
lane: int
|
||||||
|
lanes: int
|
||||||
|
start: Fraction
|
||||||
|
end: Fraction
|
||||||
|
value: dict
|
||||||
|
|
||||||
|
class ScoreIndex:
|
||||||
|
def __init__(self, document: dict):
|
||||||
|
self.staff = document['staves'][0]
|
||||||
|
self.measures = []
|
||||||
|
self.events = []
|
||||||
|
self.by_id = {}
|
||||||
|
self.tones = {}
|
||||||
|
self.boundaries = {Fraction()}
|
||||||
|
self.columns = {Fraction()}
|
||||||
|
self._states = {}
|
||||||
|
self._beam_groups = None
|
||||||
|
time = Fraction()
|
||||||
|
for measure in self.staff['measures']:
|
||||||
|
end = time + fraction(measure['duration'])
|
||||||
|
self.measures.append((time, end, measure))
|
||||||
|
self.boundaries.add(end)
|
||||||
|
self.columns.update((time, end))
|
||||||
|
for lane, voice in enumerate(measure['voices']):
|
||||||
|
for note in voice['notes']:
|
||||||
|
onset = time + fraction(note['onset'])
|
||||||
|
event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + fraction(note['duration']), note)
|
||||||
|
self.events.append(event)
|
||||||
|
self.by_id[note['id']] = event
|
||||||
|
self.columns.add(onset)
|
||||||
|
for tone in note['pitches']:
|
||||||
|
self.tones[tone['id']] = (event, tone)
|
||||||
|
for field in ('attributes', 'harmonies', 'tempos'):
|
||||||
|
self.columns.update(time + fraction(item['onset']) for item in measure.get(field, []))
|
||||||
|
time = end
|
||||||
|
self.end = time
|
||||||
|
self.measure_starts = [item[0] for item in self.measures]
|
||||||
|
self.events.sort(key=lambda e: (e.start, not bool(e.value.get('grace')), e.lane))
|
||||||
|
self.lyric_lanes = list(dict.fromkeys((e.voice, verse['number']) for e in self.events for verse in e.value.get('lyrics', [])))
|
||||||
|
self.accidentals = self.accidental_state()
|
||||||
|
def state(self, time: Fraction, before: bool = False) -> dict:
|
||||||
|
if (time, before) in self._states:
|
||||||
|
return self._states[time, before]
|
||||||
|
position = bisect_left(self.measure_starts, time) if before else bisect_right(self.measure_starts, time)
|
||||||
|
start, _, measure = self.measures[max(0, position - 1)]
|
||||||
|
state = {key: measure[key] for key in ('key', 'meter', 'clef', 'transpose', 'staff_details')}
|
||||||
|
for change in measure.get('attributes', []):
|
||||||
|
onset = start + fraction(change['onset'])
|
||||||
|
if onset < time or (onset == time and not before):
|
||||||
|
state.update({key: value for key, value in change.items() if key != 'onset'})
|
||||||
|
self._states[time, before] = state
|
||||||
|
return state
|
||||||
|
def pitch_position(self, tone: dict, time: Fraction) -> float:
|
||||||
|
state = self.state(time)
|
||||||
|
clef = state['clef']
|
||||||
|
if clef['sign'] == 'TAB':
|
||||||
|
if 'tablature' not in tone:
|
||||||
|
raise EngravingError(f'{tone["id"]}: TAB notes require explicit string and fret')
|
||||||
|
string = tone['tablature']['string']
|
||||||
|
if not 1 <= string <= state['staff_details']['lines']:
|
||||||
|
raise EngravingError(f'{tone["id"]}: TAB string is outside the staff')
|
||||||
|
return 2 * (state['staff_details']['lines'] - string)
|
||||||
|
pitch = tone.get('unpitched', tone)
|
||||||
|
sign = clef['sign']
|
||||||
|
reference = {'G': 4 * 7 + 4, 'F': 3 * 7 + 3, 'C': 4 * 7}.get(sign, 4 * 7 + 4)
|
||||||
|
clef_line = clef.get('line', 2) if sign in ('G', 'F', 'C') else 2
|
||||||
|
octave = 0 if 'unpitched' in tone else clef.get('octave_change', 0)
|
||||||
|
shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if fraction(s['start']) <= time < fraction(s['end'])) if 'unpitched' not in tone else 0
|
||||||
|
return pitch['octave'] * 7 + LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1)
|
||||||
|
def accidental_state(self) -> dict[str, bool]:
|
||||||
|
result = {}
|
||||||
|
for start, end, measure in self.measures:
|
||||||
|
memory = {}
|
||||||
|
key = None
|
||||||
|
onsets = sorted({e.start for e in self.events if start <= e.start < end})
|
||||||
|
for onset in onsets:
|
||||||
|
state = self.state(onset)
|
||||||
|
if key != state['key']:
|
||||||
|
key, memory = state['key'], {}
|
||||||
|
simultaneous = {}
|
||||||
|
for event in (e for e in self.events if e.start == onset):
|
||||||
|
for tone in event.value['pitches']:
|
||||||
|
hidden_head = isinstance(tone.get('notehead'), dict) and tone['notehead'].get('name') == 'none'
|
||||||
|
if event.value.get('hidden') or tone.get('print', {}).get('print-object') == 'no' or hidden_head:
|
||||||
|
result[tone['id']] = False
|
||||||
|
continue
|
||||||
|
if 'unpitched' in tone or state['clef']['sign'] == 'TAB':
|
||||||
|
result[tone['id']] = False
|
||||||
|
continue
|
||||||
|
identity = (tone['letter'], tone['octave'])
|
||||||
|
value = alteration(tone['alter'])
|
||||||
|
signature = Fraction() if key.get('custom') else alteration(key['alterations'][LETTERS.index(tone['letter'])])
|
||||||
|
for step in key.get('steps', []):
|
||||||
|
if step['letter'] == tone['letter'] and step.get('octave', tone['octave']) == tone['octave']:
|
||||||
|
signature = alteration(step['alter'])
|
||||||
|
result[tone['id']] = bool(tone.get('accidental_display')) or (not tone.get('tie_from') and memory.get(identity, signature) != value)
|
||||||
|
simultaneous.setdefault(identity, set()).add(value)
|
||||||
|
for identity, values in simultaneous.items():
|
||||||
|
memory[identity] = next(iter(values)) if len(values) == 1 else None
|
||||||
|
if any(len(values) > 1 for values in simultaneous.values()):
|
||||||
|
for event in (e for e in self.events if e.start == onset):
|
||||||
|
for tone in event.value['pitches']:
|
||||||
|
if 'letter' in tone and len(simultaneous.get((tone['letter'], tone['octave']), ())) > 1:
|
||||||
|
result[tone['id']] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
def fragment_written(written: dict, original_start: Fraction, original_end: Fraction, start: Fraction, end: Fraction) -> dict:
|
||||||
|
return written if (original_start, original_end) == (start, end) else written_for(end - start)
|
||||||
|
|
||||||
|
def flags(written: dict) -> int:
|
||||||
|
return max(0, NOTE_TYPES.index(written['type']) - NOTE_TYPES.index('quarter'))
|
||||||
|
|
||||||
|
def head_glyph(written: dict, head: dict | str | None) -> str:
|
||||||
|
head = {'name': head} if isinstance(head, str) else head or {'name': 'normal'}
|
||||||
|
if head.get('smufl'):
|
||||||
|
return head['smufl']
|
||||||
|
name = head['name']
|
||||||
|
if name == 'none':
|
||||||
|
return ''
|
||||||
|
kind = written['type']
|
||||||
|
suffix = 'DoubleWhole' if kind in ('maxima', 'long', 'breve') else 'Whole' if kind == 'whole' else 'Half' if kind == 'half' else 'Black'
|
||||||
|
if head.get('filled') in ('yes', 'no'):
|
||||||
|
suffix = 'Black' if head['filled'] == 'yes' else 'Half'
|
||||||
|
if name == 'slash':
|
||||||
|
return {'DoubleWhole': 'noteheadSlashWhiteDoubleWhole', 'Whole': 'noteheadSlashWhiteWhole', 'Half': 'noteheadSlashWhiteHalf', 'Black': 'noteheadSlashVerticalEnds'}[suffix]
|
||||||
|
if name in ('slashed', 'back slashed'):
|
||||||
|
return 'noteheadSlashed' + suffix + ('1' if name == 'slashed' else '2')
|
||||||
|
if name in ('circled', 'circle dot'):
|
||||||
|
return 'noteheadCircled' + suffix
|
||||||
|
if name not in HEADS:
|
||||||
|
raise EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name')
|
||||||
|
shape = HEADS[name]
|
||||||
|
if shape in ('Moon', 'Round', 'Square', 'TriangleRight', 'TriangleLeft', 'TriangleRoundDown'):
|
||||||
|
suffix = 'Black' if suffix == 'Black' else 'White'
|
||||||
|
return 'notehead' + shape + suffix
|
||||||
|
|
||||||
|
def accidental(fonts: Fonts, value: Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[Primitive]:
|
||||||
|
glyph = display.get('smufl') or ACCIDENTAL_GLYPHS.get(value)
|
||||||
|
special = {'natural-sharp': ('accidentalNatural', 'accidentalSharp'), 'natural-flat': ('accidentalNatural', 'accidentalFlat')}
|
||||||
|
if display.get('name') in special and not display.get('smufl'):
|
||||||
|
items = []
|
||||||
|
x = 0
|
||||||
|
for name in special[display['name']]:
|
||||||
|
part = fonts.glyph(name, x, 0, size, 'accidental', sources)
|
||||||
|
items.append(part)
|
||||||
|
x = part.box.right + 2
|
||||||
|
elif glyph:
|
||||||
|
items = [fonts.glyph(glyph, 0, 0, size, 'accidental', sources)]
|
||||||
|
else:
|
||||||
|
sign = '+' if value > 0 else ''
|
||||||
|
items = [fonts.text(f'({sign}{value} st)', 0, 0, size * 1.15, 'accidental', sources)]
|
||||||
|
if display.get('parentheses') == 'yes' or display.get('bracket') == 'yes':
|
||||||
|
box = bounds(items)
|
||||||
|
left, right = ('[', ']') if display.get('bracket') == 'yes' else ('(', ')')
|
||||||
|
items.extend((fonts.text(left, box.left - 7, size / 2, size * 1.8, 'accidental', sources), fonts.text(right, box.right + 2, size / 2, size * 1.8, 'accidental', sources)))
|
||||||
|
return items
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
from generator.model import alteration, fraction
|
||||||
|
from generator.engraving.fonts import Fonts
|
||||||
|
from generator.engraving.notation import Event, ScoreIndex, accidental, flags, fragment_written, head_glyph
|
||||||
|
from generator.engraving.types import Anchor, Fragment, Primitive, Style, bounds, line
|
||||||
|
|
||||||
|
def dots(fonts: Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[Primitive]:
|
||||||
|
result = []
|
||||||
|
for i in range(count):
|
||||||
|
result.append(fonts.glyph('augmentationDot', x + i * size * 0.6, y, size, 'dot', sources))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def rest_glyph(kind: str) -> str:
|
||||||
|
return {'maxima': 'restMaxima', 'long': 'restLonga', 'breve': 'restDoubleWhole', 'whole': 'restWhole', 'half': 'restHalf', 'quarter': 'restQuarter', 'eighth': 'rest8th'}.get(kind, 'rest' + kind)
|
||||||
|
|
||||||
|
def time_modification(written: dict, items: list[Primitive], fonts: Fonts, sources: tuple[str, ...]) -> None:
|
||||||
|
ratio = fraction(written.get('ratio', {'numerator': 1, 'denominator': 1}))
|
||||||
|
if ratio != 1 and items:
|
||||||
|
box = bounds(items)
|
||||||
|
items.append(fonts.text(f'{ratio.denominator}:{ratio.numerator}', (box.left + box.right) / 2, box.top - 6, 13, 'time-modification', sources, True))
|
||||||
|
|
||||||
|
def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]:
|
||||||
|
note = event.value
|
||||||
|
onset = max(start, event.start)
|
||||||
|
finish = min(end, event.end)
|
||||||
|
visible = not note.get('hidden') and note['kind'] != 'spacer'
|
||||||
|
references = (note['id'],)
|
||||||
|
size = style.space * (0.7 if note.get('cue') or note.get('grace') else 1)
|
||||||
|
items, anchors, fragments = [], [], []
|
||||||
|
state = index.state(onset)
|
||||||
|
lines = state['staff_details']['lines']
|
||||||
|
if not note['pitches']:
|
||||||
|
written = fragment_written(note['written'], event.start, event.end, onset, finish)
|
||||||
|
y = -style.space * (lines - 1) / 2
|
||||||
|
if position := note.get('rest_position'):
|
||||||
|
y = -index.pitch_position(position, onset) * style.space / 2
|
||||||
|
elif event.lanes > 1:
|
||||||
|
y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * 3
|
||||||
|
kind = 'slash' if note.get('notehead') == 'slash' else 'rest'
|
||||||
|
fragment = Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None)
|
||||||
|
fragments.append(fragment)
|
||||||
|
if visible:
|
||||||
|
glyph = head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type'])
|
||||||
|
if written['type'] == 'whole' and not note.get('rest_position') and event.lanes == 1:
|
||||||
|
y -= style.space
|
||||||
|
items.append(fonts.glyph(glyph, 0, y, size, kind, references))
|
||||||
|
items.extend(dots(fonts, written.get('dots', 0), items[0].box.right + 4, y - size / 2, size, references))
|
||||||
|
if kind == 'slash' and written['type'] not in ('whole', 'breve', 'long', 'maxima'):
|
||||||
|
stem_x = items[0].box.right
|
||||||
|
stem_y = y - 3 * size
|
||||||
|
items.append(line('stem', references, ((stem_x, y), (stem_x, stem_y))))
|
||||||
|
if flags(written):
|
||||||
|
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
|
||||||
|
items.append(fonts.glyph(flag, stem_x, stem_y, size, 'flag', references))
|
||||||
|
anchors.append(Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment))
|
||||||
|
if not note.get('tuplet_ids') or written != note['written']:
|
||||||
|
time_modification(written, items, fonts, references)
|
||||||
|
return items, anchors, fragments
|
||||||
|
groups = {}
|
||||||
|
for tone in note['pitches']:
|
||||||
|
tone_end = event.start + fraction(tone['duration'])
|
||||||
|
if tone_end <= start and not note.get('grace'):
|
||||||
|
continue
|
||||||
|
written = fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end))
|
||||||
|
stem = tone.get('stem', note.get('stem', 'auto'))
|
||||||
|
identity = (written['type'], written.get('dots', 0), str(written.get('ratio')), stem)
|
||||||
|
groups.setdefault(identity, []).append((tone, written, tone_end))
|
||||||
|
group_x = 0
|
||||||
|
for members in groups.values():
|
||||||
|
positioned = sorted(((index.pitch_position(tone, onset), tone, written, tone_end) for tone, written, tone_end in members), key=lambda item: item[0])
|
||||||
|
stem = members[0][0].get('stem', note.get('stem', 'auto'))
|
||||||
|
direction = -1 if stem in ('up', 'double') else 1 if stem == 'down' else (-1 if event.lane % 2 == 0 else 1) if event.lanes > 1 else (1 if sum(p[0] for p in positioned) / len(positioned) >= lines - 1 else -1)
|
||||||
|
if direction == 1:
|
||||||
|
positioned.reverse()
|
||||||
|
group_items, group_anchors = [], []
|
||||||
|
previous = None
|
||||||
|
displaced = False
|
||||||
|
accidental_boxes = []
|
||||||
|
for position, tone, written, tone_end in positioned:
|
||||||
|
sources = (note['id'], tone['id'])
|
||||||
|
tone_size = size * (0.7 if tone.get('cue') and not note.get('cue') else 1)
|
||||||
|
glyph = head_glyph(written, tone.get('notehead', note.get('notehead')))
|
||||||
|
displayed = visible and tone.get('print', {}).get('print-object') != 'no' and bool(glyph)
|
||||||
|
y = -position * style.space / 2
|
||||||
|
if previous is not None and abs(position - previous) < 2:
|
||||||
|
displaced = not displaced
|
||||||
|
else:
|
||||||
|
displaced = False
|
||||||
|
x = group_x + (tone_size * 1.2 * -direction if displaced else 0)
|
||||||
|
previous = position
|
||||||
|
fragment = Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position)
|
||||||
|
fragments.append(fragment)
|
||||||
|
if not displayed:
|
||||||
|
continue
|
||||||
|
if state['clef']['sign'] == 'TAB':
|
||||||
|
head = fonts.text(str(tone['tablature']['fret']), x, y + 5, tone_size * 1.5, 'tab', sources)
|
||||||
|
elif written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal':
|
||||||
|
width = tone_size * (2.8 if written['type'] == 'maxima' else 1.4)
|
||||||
|
head = line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2)
|
||||||
|
else:
|
||||||
|
head = fonts.glyph(glyph, x, y, tone_size, 'head', sources)
|
||||||
|
group_items.append(head)
|
||||||
|
if tone.get('notehead', {}).get('parentheses') == 'yes':
|
||||||
|
group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head-parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head-parentheses', sources)))
|
||||||
|
if state['clef']['sign'] != 'TAB':
|
||||||
|
ledger = -2
|
||||||
|
while position <= ledger:
|
||||||
|
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
|
||||||
|
ledger -= 2
|
||||||
|
ledger = lines * 2
|
||||||
|
while position >= ledger:
|
||||||
|
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
|
||||||
|
ledger += 2
|
||||||
|
if 'alter' in tone and index.accidentals[tone['id']] and onset == event.start:
|
||||||
|
accidental_items = accidental(fonts, alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources)
|
||||||
|
box = bounds(accidental_items)
|
||||||
|
dx = min(group_x, head.box.left) - 5 - box.right
|
||||||
|
candidate = box.moved(dx, y)
|
||||||
|
while any(candidate.overlaps(other, 2) for other in accidental_boxes):
|
||||||
|
dx -= box.right - box.left + 4
|
||||||
|
candidate = box.moved(dx, y)
|
||||||
|
accidental_boxes.append(candidate)
|
||||||
|
group_items.extend(p.moved(dx, y) for p in accidental_items)
|
||||||
|
if tone.get('print', {}).get('print-dot') != 'no':
|
||||||
|
dot_y = y - style.space / 2 if position % 2 == 0 else y
|
||||||
|
group_items.extend(dots(fonts, written.get('dots', 0), head.box.right + 5, dot_y, tone_size, sources))
|
||||||
|
if head.kind == 'glyph':
|
||||||
|
stem_x = x + fonts.anchor(glyph, 'stemUpSE' if direction == -1 else 'stemDownNW', tone_size)[0]
|
||||||
|
else:
|
||||||
|
stem_x = head.box.right - 0.6 if direction == -1 else head.box.left + 0.6
|
||||||
|
stem_y = y + direction * max(3.5 * size, (flags(written) + 1) * size * 0.75)
|
||||||
|
group_anchors.append(Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, flags(written), fragment))
|
||||||
|
if group_anchors:
|
||||||
|
stem_y = min(a.stem_y for a in group_anchors) if direction == -1 else max(a.stem_y for a in group_anchors)
|
||||||
|
anchor = min(group_anchors, key=lambda a: a.y) if direction == 1 else max(group_anchors, key=lambda a: a.y)
|
||||||
|
written = members[0][1]
|
||||||
|
if stem != 'none' and written['type'] not in ('whole', 'breve'):
|
||||||
|
group_items.append(line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12))
|
||||||
|
if stem == 'double':
|
||||||
|
other_x = anchor.x if direction == -1 else anchor.x + size * 1.3
|
||||||
|
other_y = anchor.y - direction * size * 3.5
|
||||||
|
group_items.append(line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12))
|
||||||
|
if flags(written):
|
||||||
|
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Down' if direction == -1 else 'Up')
|
||||||
|
group_items.append(fonts.glyph(flag, other_x, other_y, size, 'flag', references))
|
||||||
|
if flags(written):
|
||||||
|
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Up' if direction == -1 else 'Down')
|
||||||
|
group_items.append(fonts.glyph(flag, anchor.stem_x, stem_y, size, 'flag', references))
|
||||||
|
if note.get('grace', {}).get('slash'):
|
||||||
|
group_items.append(line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
|
||||||
|
if note.get('tremolo', {}).get('type') == 'single':
|
||||||
|
for mark in range(note['tremolo']['marks']):
|
||||||
|
ty = stem_y - direction * (10 + mark * 5)
|
||||||
|
group_items.append(line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3))
|
||||||
|
from dataclasses import replace
|
||||||
|
anchors.extend(replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors)
|
||||||
|
if not note.get('tuplet_ids') or members[0][1] != members[0][0]['written']:
|
||||||
|
time_modification(members[0][1], group_items, fonts, references)
|
||||||
|
items.extend(group_items)
|
||||||
|
group_x = max((p.box.right for p in items), default=group_x) + size
|
||||||
|
return items, anchors, fragments
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<museScore version="4.70">
|
|
||||||
<Style>
|
|
||||||
<pageWidth>10.1666666667</pageWidth>
|
|
||||||
<pageHeight>5.0416666667</pageHeight>
|
|
||||||
<pagePrintableWidth>9.5833333333</pagePrintableWidth>
|
|
||||||
<pageEvenLeftMargin>0.2916666667</pageEvenLeftMargin>
|
|
||||||
<pageOddLeftMargin>0.2916666667</pageOddLeftMargin>
|
|
||||||
<pageEvenTopMargin>0.1875</pageEvenTopMargin>
|
|
||||||
<pageOddTopMargin>0.1875</pageOddTopMargin>
|
|
||||||
<pageEvenBottomMargin>0.1875</pageEvenBottomMargin>
|
|
||||||
<pageOddBottomMargin>0.1875</pageOddBottomMargin>
|
|
||||||
<pageTwosided>0</pageTwosided>
|
|
||||||
<Spatium>3.175</Spatium>
|
|
||||||
<musicalSymbolFont>Petaluma</musicalSymbolFont>
|
|
||||||
<musicalTextFont>Petaluma Text</musicalTextFont>
|
|
||||||
<defaultFontFace>Petaluma Script</defaultFontFace>
|
|
||||||
<titleFontFace>Petaluma Script</titleFontFace>
|
|
||||||
<titleFontSize>18</titleFontSize>
|
|
||||||
<composerFontFace>Petaluma Script</composerFontFace>
|
|
||||||
<composerFontSize>10</composerFontSize>
|
|
||||||
<lyricsOddFontFace>Petaluma Script</lyricsOddFontFace>
|
|
||||||
<lyricsEvenFontFace>Petaluma Script</lyricsEvenFontFace>
|
|
||||||
<harmonyFontFace>Petaluma Script</harmonyFontFace>
|
|
||||||
<harmonyFontSize>10</harmonyFontSize>
|
|
||||||
<rehearsalMarkAlign>left,baseline</rehearsalMarkAlign>
|
|
||||||
<rehearsalMarkFrameType>0</rehearsalMarkFrameType>
|
|
||||||
<enableVerticalSpread>0</enableVerticalSpread>
|
|
||||||
<minSystemDistance>6</minSystemDistance>
|
|
||||||
<maxSystemDistance>6</maxSystemDistance>
|
|
||||||
<showPageNumber>1</showPageNumber>
|
|
||||||
<showPageNumberOne>1</showPageNumberOne>
|
|
||||||
</Style>
|
|
||||||
</museScore>
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import aggdraw
|
||||||
|
from fontTools.pens.basePen import BasePen
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from generator.engraving.fonts import Fonts
|
||||||
|
from generator.engraving.types import Page
|
||||||
|
|
||||||
|
class OutlinePen(BasePen):
|
||||||
|
def __init__(self, glyphs, scale: float, x: float, y: float):
|
||||||
|
super().__init__(glyphs)
|
||||||
|
self.path = aggdraw.Path()
|
||||||
|
self.scale, self.x, self.y = scale, x, y
|
||||||
|
def point(self, point):
|
||||||
|
return self.x + point[0] * self.scale, self.y - point[1] * self.scale
|
||||||
|
def _moveTo(self, point):
|
||||||
|
self.path.moveto(*self.point(point))
|
||||||
|
def _lineTo(self, point):
|
||||||
|
self.path.lineto(*self.point(point))
|
||||||
|
def _curveToOne(self, first, second, last):
|
||||||
|
self.path.curveto(*self.point(first), *self.point(second), *self.point(last))
|
||||||
|
def _closePath(self):
|
||||||
|
self.path.close()
|
||||||
|
def _endPath(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def rasterize_page(page: Page, fonts: Fonts) -> Image.Image:
|
||||||
|
scale = page.supersampling
|
||||||
|
image = Image.new('RGB', (page.width * scale, page.height * scale), 'white')
|
||||||
|
context = aggdraw.Draw(image)
|
||||||
|
for item in page.primitives:
|
||||||
|
points = [(x * scale, y * scale) for x, y in item.points]
|
||||||
|
if item.kind == 'text':
|
||||||
|
continue
|
||||||
|
if item.kind == 'glyph':
|
||||||
|
pen = OutlinePen(fonts.glyphs, item.size * scale / fonts.units, *points[0])
|
||||||
|
fonts.outline(item.value).replay(pen)
|
||||||
|
path = pen.path
|
||||||
|
else:
|
||||||
|
path = aggdraw.Path()
|
||||||
|
path.moveto(*points[0])
|
||||||
|
if item.kind == 'curve':
|
||||||
|
path.curveto(*points[1], *points[2], *points[3])
|
||||||
|
else:
|
||||||
|
for point in points[1:]:
|
||||||
|
path.lineto(*point)
|
||||||
|
if item.kind == 'polygon':
|
||||||
|
path.close()
|
||||||
|
if item.kind in ('glyph', 'polygon'):
|
||||||
|
context.path(path, None, aggdraw.Brush('black'))
|
||||||
|
else:
|
||||||
|
context.path(path, aggdraw.Pen('black', item.thickness * scale))
|
||||||
|
context.flush()
|
||||||
|
drawing = ImageDraw.Draw(image)
|
||||||
|
for item in page.primitives:
|
||||||
|
if item.kind == 'text':
|
||||||
|
drawing.text(tuple(v * scale for v in item.points[0]), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black')
|
||||||
|
return image.resize((page.width, page.height), Image.Resampling.LANCZOS)
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
|
from generator.model import beat_lengths, fraction
|
||||||
|
from generator.engraving.fonts import Fonts
|
||||||
|
from generator.engraving.notation import ScoreIndex, flags
|
||||||
|
from generator.engraving.types import Anchor, Primitive, Style, bounds, curve, line, polygon
|
||||||
|
|
||||||
|
def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]:
|
||||||
|
if index._beam_groups is not None:
|
||||||
|
return index._beam_groups
|
||||||
|
groups = []
|
||||||
|
for voice in dict.fromkeys(e.voice for e in index.events):
|
||||||
|
current = []
|
||||||
|
previous_key = None
|
||||||
|
for event in (e for e in index.events if e.voice == voice):
|
||||||
|
note = event.value
|
||||||
|
primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None)
|
||||||
|
measure_start = next(start for start, _, measure in index.measures if measure['id'] == event.measure['id'])
|
||||||
|
beats = beat_lengths(index.state(event.start)['meter'])
|
||||||
|
offset = event.start - measure_start
|
||||||
|
beat = 0
|
||||||
|
edge = Fraction()
|
||||||
|
for duration in beats:
|
||||||
|
edge += duration
|
||||||
|
if offset < edge:
|
||||||
|
break
|
||||||
|
beat += 1
|
||||||
|
key = (event.measure['id'], beat, tuple(note.get('tuplet_ids', [])), bool(note.get('grace')))
|
||||||
|
eligible = note['kind'] == 'note' and flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden')
|
||||||
|
if not eligible or primary == 'begin' or (primary is None and key != previous_key):
|
||||||
|
if current:
|
||||||
|
groups.append(current)
|
||||||
|
current = []
|
||||||
|
if eligible:
|
||||||
|
current.append(note['id'])
|
||||||
|
if primary == 'end':
|
||||||
|
groups.append(current)
|
||||||
|
current = []
|
||||||
|
previous_key = key
|
||||||
|
if current:
|
||||||
|
groups.append(current)
|
||||||
|
index._beam_groups = tuple(tuple(g) for g in groups if len(g) > 1 or index.by_id[g[0]].value.get('beams'))
|
||||||
|
return index._beam_groups
|
||||||
|
|
||||||
|
def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, style: Style) -> list[Primitive]:
|
||||||
|
additions = []
|
||||||
|
removed = set()
|
||||||
|
for group in beam_groups(index):
|
||||||
|
selected = []
|
||||||
|
for identifier in group:
|
||||||
|
candidates = [a for a in anchors if a.event == identifier and a.flags]
|
||||||
|
if candidates:
|
||||||
|
selected.append(candidates[0])
|
||||||
|
if not selected:
|
||||||
|
continue
|
||||||
|
direction = selected[0].direction
|
||||||
|
if any(a.direction != direction for a in selected):
|
||||||
|
continue
|
||||||
|
sources = tuple(a.event for a in selected)
|
||||||
|
removed.update(sources)
|
||||||
|
beam_y = min(a.stem_y for a in selected) if direction == -1 else max(a.stem_y for a in selected)
|
||||||
|
first, last = selected[0], selected[-1]
|
||||||
|
x1 = left if index.by_id[group[0]].start < start else first.stem_x
|
||||||
|
x2 = right if index.by_id[group[-1]].start >= end else last.stem_x
|
||||||
|
for anchor in selected:
|
||||||
|
additions.append(line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
|
||||||
|
for level in range(1, max(a.flags for a in selected) + 1):
|
||||||
|
y = beam_y - direction * (level - 1) * style.space * 0.75
|
||||||
|
for i, anchor in enumerate(selected):
|
||||||
|
if anchor.flags < level:
|
||||||
|
continue
|
||||||
|
event = index.by_id[anchor.event].value
|
||||||
|
explicit = next((b['type'] for b in event.get('beams', []) if b['level'] == level), None)
|
||||||
|
next_anchor = selected[i + 1] if i + 1 < len(selected) else None
|
||||||
|
previous = selected[i - 1] if i else None
|
||||||
|
a = anchor.stem_x
|
||||||
|
if explicit in ('forward hook', 'backward hook'):
|
||||||
|
b = a + (8 if explicit == 'forward hook' else -8)
|
||||||
|
elif next_anchor and next_anchor.flags >= level and explicit != 'end':
|
||||||
|
b = next_anchor.stem_x
|
||||||
|
elif i == len(selected) - 1 and x2 > a and explicit != 'end':
|
||||||
|
b = x2
|
||||||
|
elif i == 0 and x1 < a and explicit != 'begin':
|
||||||
|
b = x1
|
||||||
|
elif not previous or previous.flags < level or explicit == 'begin':
|
||||||
|
b = a + (8 if next_anchor else -8)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
additions.append(polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5))))
|
||||||
|
if selected and x1 < selected[0].stem_x and selected[0].flags >= level:
|
||||||
|
additions.append(line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5))
|
||||||
|
return [p for p in items if not (p.role == 'flag' and any(s in removed for s in p.sources))] + additions
|
||||||
|
|
||||||
|
def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts, style: Style) -> list[Primitive]:
|
||||||
|
result = []
|
||||||
|
by_tone = {a.source: a for a in anchors}
|
||||||
|
by_event = {a.event: a for a in anchors}
|
||||||
|
for identifier, (event, tone) in index.tones.items():
|
||||||
|
target = tone.get('tie_to')
|
||||||
|
tone_end = event.start + fraction(tone['duration'])
|
||||||
|
anchor = by_tone.get(identifier)
|
||||||
|
if anchor and anchor.fragment.end < tone_end:
|
||||||
|
target = identifier
|
||||||
|
if target:
|
||||||
|
target_event = index.tones[target][0]
|
||||||
|
target_time = tone_end if target == identifier else target_event.start
|
||||||
|
if event.start >= end or target_time < start:
|
||||||
|
continue
|
||||||
|
destination = by_tone.get(target) if target != identifier else None
|
||||||
|
if anchor is None and destination is None:
|
||||||
|
continue
|
||||||
|
reference = anchor or destination
|
||||||
|
sign = -reference.direction
|
||||||
|
x1 = anchor.x + style.space * 1.4 if anchor else left
|
||||||
|
x2 = destination.x - 2 if destination else right
|
||||||
|
y1 = anchor.y + sign * 8 if anchor else reference.y + sign * 8
|
||||||
|
y2 = destination.y + sign * 8 if destination else y1
|
||||||
|
if x2 > x1:
|
||||||
|
result.append(curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6))
|
||||||
|
if anchor and anchor.fragment.start > event.start:
|
||||||
|
y = anchor.y - anchor.direction * 8
|
||||||
|
if anchor.x - 2 > left:
|
||||||
|
result.append(curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
|
||||||
|
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')), default=0))
|
||||||
|
tuplet_lanes = []
|
||||||
|
for span in index.staff.get('tuplets', []):
|
||||||
|
a, b = index.by_id[span['start']], index.by_id[span['end']]
|
||||||
|
if b.end <= start or a.start >= end:
|
||||||
|
continue
|
||||||
|
members = [anchor for anchor in anchors if span['id'] in index.by_id[anchor.event].value.get('tuplet_ids', [])]
|
||||||
|
if not members:
|
||||||
|
continue
|
||||||
|
x1 = left if a.start < start else min(m.stem_x for m in members) - 3
|
||||||
|
x2 = right if b.end > end else max(m.stem_x for m in members) + style.space
|
||||||
|
y = top - 16 - span.get('depth', 0) * 23
|
||||||
|
while any(x1 < b and a < x2 and abs(y - lane) < 22 for a, b, lane in tuplet_lanes):
|
||||||
|
y -= 23
|
||||||
|
tuplet_lanes.append((x1, x2, y))
|
||||||
|
number = span.get('show_number', 'actual')
|
||||||
|
label = '' if number == 'none' else str(span['note_count'])
|
||||||
|
if number == 'both':
|
||||||
|
label += ':' + str(span['space_count'])
|
||||||
|
if span.get('show_type', 'none') != 'none':
|
||||||
|
actual = span.get('tuplet_actual', {}).get('type', a.value['written']['type'])
|
||||||
|
normal = span.get('tuplet_normal', {}).get('type', actual)
|
||||||
|
label += ' ' + actual + '.' * span.get('tuplet_actual', {}).get('dots', 0)
|
||||||
|
if span['show_type'] == 'both':
|
||||||
|
label += ' : ' + normal + '.' * span.get('tuplet_normal', {}).get('dots', 0)
|
||||||
|
references = (span['id'],)
|
||||||
|
text = fonts.text(label, (x1 + x2) / 2, y + 4, 15, 'tuplet-number', references, True) if label else None
|
||||||
|
if text:
|
||||||
|
result.append(text)
|
||||||
|
if span.get('bracket', 'auto') != 'no':
|
||||||
|
intervals = [(x1, text.box.left - 4), (text.box.right + 4, x2)] if text else [(x1, x2)]
|
||||||
|
for p, q in intervals:
|
||||||
|
if q <= p:
|
||||||
|
continue
|
||||||
|
if span.get('line_shape') == 'curved':
|
||||||
|
result.append(curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3))
|
||||||
|
else:
|
||||||
|
result.append(line('tuplet-bracket', references, ((p, y), (q, y)), 1.3))
|
||||||
|
if a.start >= start:
|
||||||
|
result.append(line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3))
|
||||||
|
if b.end <= end:
|
||||||
|
result.append(line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3))
|
||||||
|
outer_top = min(top, bounds(result).top if result else top)
|
||||||
|
for span in index.staff.get('octave_shifts', []):
|
||||||
|
a, b = fraction(span['start']), fraction(span['end'])
|
||||||
|
if b <= start or a >= end:
|
||||||
|
continue
|
||||||
|
members = [n for n in anchors if a <= n.time < b]
|
||||||
|
x1 = min((n.x for n in members), default=left) if a >= start else left
|
||||||
|
x2 = max((n.x + style.space * 2 for n in members), default=right) if b <= end else right
|
||||||
|
above = span['octaves'] < 0
|
||||||
|
y = outer_top - 18 if above else max(bounds(items + result).bottom, 0) + 24
|
||||||
|
label = str(abs(span['octaves']) * 7 + 1) + ('va' if above else 'vb')
|
||||||
|
text = fonts.text(label, x1, y, 16, 'octave-shift', (span['id'],))
|
||||||
|
result.append(text)
|
||||||
|
outer_top = min(outer_top, text.box.top - 4)
|
||||||
|
cursor = text.box.right + 4
|
||||||
|
while cursor < x2:
|
||||||
|
result.append(line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1))
|
||||||
|
cursor += 10
|
||||||
|
if b <= end:
|
||||||
|
result.append(line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1))
|
||||||
|
for event in index.events:
|
||||||
|
tremolo = event.value.get('tremolo', {})
|
||||||
|
if tremolo.get('type') != 'start':
|
||||||
|
continue
|
||||||
|
partner = index.by_id[tremolo['partner']]
|
||||||
|
if event.start >= end or partner.end <= start:
|
||||||
|
continue
|
||||||
|
a, b = by_event.get(event.value['id']), by_event.get(partner.value['id'])
|
||||||
|
if not a and not b:
|
||||||
|
continue
|
||||||
|
anchor = a or b
|
||||||
|
x1, x2 = a.stem_x + 4 if a else left, b.stem_x - 4 if b else right
|
||||||
|
y = anchor.stem_y - anchor.direction * 10
|
||||||
|
for mark in range(tremolo['marks']):
|
||||||
|
result.append(line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5))
|
||||||
|
return items + result
|
||||||
|
|
||||||
|
def place_text_lanes(items: list[Primitive], fonts: Fonts, style: Style) -> list[Primitive]:
|
||||||
|
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')]
|
||||||
|
box = bounds(music)
|
||||||
|
result = list(music)
|
||||||
|
top = box.top - 12
|
||||||
|
for role in ('harmony', 'tempo', 'section'):
|
||||||
|
lane = [p for p in items if p.role == role]
|
||||||
|
if lane:
|
||||||
|
extent = bounds(lane)
|
||||||
|
result.extend(p.moved(0, top - extent.bottom) for p in lane)
|
||||||
|
top -= extent.bottom - extent.top + 10
|
||||||
|
lyrics = [p for p in items if p.role == 'lyric']
|
||||||
|
if lyrics:
|
||||||
|
result.extend(p.moved(0, max(box.bottom, 0) + 20) for p in lyrics)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[Anchor], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts) -> list[Primitive]:
|
||||||
|
result = list(items)
|
||||||
|
labels = {p.sources[0]: p for p in items if p.role == 'lyric'}
|
||||||
|
positions = {a.event: a for a in anchors}
|
||||||
|
lyric_baseline = None
|
||||||
|
for event in index.events:
|
||||||
|
for verse in event.value.get('lyrics', []):
|
||||||
|
label = labels.get(event.value['id'] + ':lyric:' + verse['number'])
|
||||||
|
if label:
|
||||||
|
lyric_baseline = label.points[0][1] - index.lyric_lanes.index((event.voice, verse['number'])) * 23
|
||||||
|
break
|
||||||
|
if lyric_baseline is not None:
|
||||||
|
break
|
||||||
|
if lyric_baseline is None:
|
||||||
|
lyric_baseline = max(0, bounds(items).bottom) + 20
|
||||||
|
for voice, number in index.lyric_lanes:
|
||||||
|
verses = [(e, v) for e in index.events if e.voice == voice and not e.value.get('hidden') for v in e.value.get('lyrics', []) if v['number'] == number]
|
||||||
|
for i, (event, verse) in enumerate(verses):
|
||||||
|
following = verses[i + 1] if i + 1 < len(verses) else None
|
||||||
|
syllabic = next((s['text'] for s in verse['syllables'] if s['kind'] == 'syllabic'), 'single')
|
||||||
|
extend = next((s['attributes'].get('type', 'start') for s in verse['syllables'] if s['kind'] == 'extend'), None)
|
||||||
|
if syllabic not in ('begin', 'middle') and extend not in ('start', 'continue'):
|
||||||
|
continue
|
||||||
|
target = following[0].start if following else max(e.end for e in index.events if e.voice == voice)
|
||||||
|
if target < start or event.start >= end:
|
||||||
|
continue
|
||||||
|
identifier = event.value['id'] + ':lyric:' + number
|
||||||
|
next_id = following[0].value['id'] + ':lyric:' + number if following else ''
|
||||||
|
a, b = labels.get(identifier), labels.get(next_id)
|
||||||
|
x1 = a.box.right + 5 if a else left
|
||||||
|
x2 = b.box.left - 5 if b else right
|
||||||
|
if b is None and following and following[0].value['id'] in positions:
|
||||||
|
x2 = positions[following[0].value['id']].x
|
||||||
|
y = lyric_baseline + index.lyric_lanes.index((voice, number)) * 23
|
||||||
|
if x2 <= x1:
|
||||||
|
continue
|
||||||
|
if extend in ('start', 'continue'):
|
||||||
|
result.append(line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
|
||||||
|
elif following:
|
||||||
|
count = max(1, int((x2 - x1) / 90))
|
||||||
|
for dash in range(count):
|
||||||
|
result.append(fonts.text('-', x1 + (x2 - x1) * (dash + 1) / (count + 1), y, 18, 'lyric-hyphen', (identifier,), True))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from fractions import Fraction
|
||||||
|
import math
|
||||||
|
|
||||||
|
class EngravingError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Style:
|
||||||
|
width: int = 976
|
||||||
|
height: int = 484
|
||||||
|
space: float = 12
|
||||||
|
margin_x: float = 28
|
||||||
|
margin_y: float = 18
|
||||||
|
supersampling: int = 4
|
||||||
|
column_gap: float = 10
|
||||||
|
system_gap: float = 26
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Box:
|
||||||
|
left: float
|
||||||
|
top: float
|
||||||
|
right: float
|
||||||
|
bottom: float
|
||||||
|
def moved(self, x: float, y: float) -> Box:
|
||||||
|
return Box(self.left + x, self.top + y, self.right + x, self.bottom + y)
|
||||||
|
def overlaps(self, other: Box, clearance: float = 0) -> bool:
|
||||||
|
return self.left < other.right + clearance and other.left < self.right + clearance and self.top < other.bottom + clearance and other.top < self.bottom + clearance
|
||||||
|
def finite(self) -> bool:
|
||||||
|
return all(math.isfinite(v) for v in (self.left, self.top, self.right, self.bottom)) and self.left <= self.right and self.top <= self.bottom
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Primitive:
|
||||||
|
kind: str
|
||||||
|
role: str
|
||||||
|
sources: tuple[str, ...]
|
||||||
|
box: Box
|
||||||
|
points: tuple[tuple[float, float], ...]
|
||||||
|
value: str = ''
|
||||||
|
size: float = 0
|
||||||
|
thickness: float = 0
|
||||||
|
def moved(self, x: float, y: float) -> Primitive:
|
||||||
|
return replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points))
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Fragment:
|
||||||
|
source: str
|
||||||
|
event: str
|
||||||
|
start: Fraction
|
||||||
|
end: Fraction
|
||||||
|
written_type: str
|
||||||
|
dots: int
|
||||||
|
ratio: Fraction
|
||||||
|
visible: bool
|
||||||
|
kind: str
|
||||||
|
position: float | None
|
||||||
|
def length(self) -> Fraction:
|
||||||
|
return self.end - self.start
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Anchor:
|
||||||
|
source: str
|
||||||
|
event: str
|
||||||
|
voice: str
|
||||||
|
time: Fraction
|
||||||
|
end: Fraction
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
stem_x: float
|
||||||
|
stem_y: float
|
||||||
|
direction: int
|
||||||
|
flags: int
|
||||||
|
fragment: Fragment
|
||||||
|
def moved(self, x: float, y: float) -> Anchor:
|
||||||
|
return replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y)
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class System:
|
||||||
|
start: Fraction
|
||||||
|
end: Fraction
|
||||||
|
primitives: tuple[Primitive, ...]
|
||||||
|
anchors: tuple[Anchor, ...]
|
||||||
|
fragments: tuple[Fragment, ...]
|
||||||
|
box: Box
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Page:
|
||||||
|
number: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
supersampling: int
|
||||||
|
systems: tuple[System, ...]
|
||||||
|
primitives: tuple[Primitive, ...]
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LayoutDocument:
|
||||||
|
pages: tuple[Page, ...]
|
||||||
|
style: Style
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
def bounds(items: list[Primitive] | tuple[Primitive, ...]) -> Box:
|
||||||
|
if not items:
|
||||||
|
return Box(0, 0, 0, 0)
|
||||||
|
return Box(min(p.box.left for p in items), min(p.box.top for p in items), max(p.box.right for p in items), max(p.box.bottom for p in items))
|
||||||
|
|
||||||
|
def line(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.4) -> Primitive:
|
||||||
|
pad = thickness / 2
|
||||||
|
box = Box(min(x for x, _ in points) - pad, min(y for _, y in points) - pad, max(x for x, _ in points) + pad, max(y for _, y in points) + pad)
|
||||||
|
return Primitive('line', role, sources, box, points, thickness=thickness)
|
||||||
|
|
||||||
|
def curve(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.6) -> Primitive:
|
||||||
|
return replace(line(role, sources, points, thickness), kind='curve')
|
||||||
|
|
||||||
|
def polygon(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...]) -> Primitive:
|
||||||
|
return replace(line(role, sources, points, 0), kind='polygon')
|
||||||
@@ -201,13 +201,10 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
|
|||||||
for syllable in verse['syllables']:
|
for syllable in verse['syllables']:
|
||||||
element(lyric, syllable['kind'], syllable['text'], **syllable['attributes'])
|
element(lyric, syllable['kind'], syllable['text'], **syllable['attributes'])
|
||||||
|
|
||||||
def musicxml_tree(document: dict, *, engine_voices: bool = False) -> ElementTree.ElementTree:
|
def musicxml_tree(document: dict) -> ElementTree.ElementTree:
|
||||||
validate_document(document)
|
validate_document(document)
|
||||||
root = ElementTree.Element('score-partwise', {'version': '4.0'})
|
root = ElementTree.Element('score-partwise', {'version': '4.0'})
|
||||||
metadata, staff = document['metadata'], document['staves'][0]
|
metadata, staff = document['metadata'], document['staves'][0]
|
||||||
voice_ids = list(dict.fromkeys(v['id'] for m in staff['measures'] for v in m['voices'] if v['id'] != 'voice-arranged'))
|
|
||||||
voice_slots = {identifier: str(index + 1) for index, identifier in enumerate(voice_ids)}
|
|
||||||
voice_slots['voice-arranged'] = '1'
|
|
||||||
element(element(root, 'work'), 'work-title', metadata['title'])
|
element(element(root, 'work'), 'work-title', metadata['title'])
|
||||||
identification = element(root, 'identification')
|
identification = element(root, 'identification')
|
||||||
element(identification, 'creator', metadata.get('composer', ''), type='composer')
|
element(identification, 'creator', metadata.get('composer', ''), type='composer')
|
||||||
@@ -300,7 +297,7 @@ def musicxml_tree(document: dict, *, engine_voices: bool = False) -> ElementTree
|
|||||||
onset = fraction(event['onset'])
|
onset = fraction(event['onset'])
|
||||||
if onset > cursor:
|
if onset > cursor:
|
||||||
element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions))
|
element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions))
|
||||||
write_note(bar, event, voice_slots[voice['id']] if engine_voices else voice['id'], divisions, staff.get('tuplets', []))
|
write_note(bar, event, voice['id'], divisions, staff.get('tuplets', []))
|
||||||
cursor = onset + fraction(event['duration'])
|
cursor = onset + fraction(event['duration'])
|
||||||
if cursor < fraction(measure['duration']):
|
if cursor < fraction(measure['duration']):
|
||||||
element(element(bar, 'forward'), 'duration', int((fraction(measure['duration']) - cursor) * divisions))
|
element(element(bar, 'forward'), 'duration', int((fraction(measure['duration']) - cursor) * divisions))
|
||||||
@@ -310,7 +307,7 @@ def musicxml_tree(document: dict, *, engine_voices: bool = False) -> ElementTree
|
|||||||
absolute += fraction(measure['duration'])
|
absolute += fraction(measure['duration'])
|
||||||
return ElementTree.ElementTree(root)
|
return ElementTree.ElementTree(root)
|
||||||
|
|
||||||
def write_musicxml(path: Path, document: dict, *, engine_voices: bool = False) -> None:
|
def write_musicxml(path: Path, document: dict) -> None:
|
||||||
tree = musicxml_tree(document, engine_voices=engine_voices)
|
tree = musicxml_tree(document)
|
||||||
ElementTree.indent(tree, space=' ')
|
ElementTree.indent(tree, space=' ')
|
||||||
tree.write(path, encoding='utf-8', xml_declaration=True)
|
tree.write(path, encoding='utf-8', xml_declaration=True)
|
||||||
|
|||||||
+2
-2
@@ -11,11 +11,11 @@ from generator.model import SCHEMA_VERSION, validate_document
|
|||||||
def load_json(path: Path) -> dict:
|
def load_json(path: Path) -> dict:
|
||||||
document = json.loads(path.read_text(encoding='utf-8'))
|
document = json.loads(path.read_text(encoding='utf-8'))
|
||||||
if 'music' in document:
|
if 'music' in document:
|
||||||
from generator.engraving import ENGINE_VERSION, PAGE_HEIGHT, PAGE_WIDTH, check_page
|
from generator.engraving import ENGINE_NAME, ENGINE_VERSION, PAGE_HEIGHT, PAGE_WIDTH, check_page
|
||||||
presentation = document.get('presentation', {})
|
presentation = document.get('presentation', {})
|
||||||
if document.get('schema_version') != SCHEMA_VERSION or not isinstance(presentation, dict) or not isinstance(presentation.get('pages'), list) or not presentation['pages']:
|
if document.get('schema_version') != SCHEMA_VERSION or not isinstance(presentation, dict) or not isinstance(presentation.get('pages'), list) or not presentation['pages']:
|
||||||
raise ValueError('invalid score bundle; regenerate score assets')
|
raise ValueError('invalid score bundle; regenerate score assets')
|
||||||
if (presentation.get('engine'), presentation.get('engine_version'), presentation.get('width'), presentation.get('height')) != ('MuseScore Studio', ENGINE_VERSION, PAGE_WIDTH, PAGE_HEIGHT):
|
if (presentation.get('engine'), presentation.get('engine_version'), presentation.get('width'), presentation.get('height')) != (ENGINE_NAME, ENGINE_VERSION, PAGE_WIDTH, PAGE_HEIGHT):
|
||||||
raise ValueError('unsupported score presentation; regenerate score assets')
|
raise ValueError('unsupported score presentation; regenerate score assets')
|
||||||
for page in presentation['pages']:
|
for page in presentation['pages']:
|
||||||
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
|
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
|
||||||
|
|||||||
+1
-8
@@ -138,7 +138,7 @@ WORKDIR /workspace
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def build_requirements_digest():
|
def build_requirements_digest():
|
||||||
return hashlib.sha256((repo_root() / "tools/requirements.txt").read_bytes() + (repo_root() / "tools/install_engraver.py").read_bytes()).hexdigest()
|
return hashlib.sha256((repo_root() / "tools/requirements.txt").read_bytes()).hexdigest()
|
||||||
|
|
||||||
def product_build_dockerfile():
|
def product_build_dockerfile():
|
||||||
return f"""FROM {DEFAULT_BUILD_BASE_IMAGE}
|
return f"""FROM {DEFAULT_BUILD_BASE_IMAGE}
|
||||||
@@ -202,13 +202,6 @@ RUN python3 -m venv /opt/modulus-python
|
|||||||
ENV PATH=/opt/modulus-python/bin:$PATH
|
ENV PATH=/opt/modulus-python/bin:$PATH
|
||||||
|
|
||||||
COPY tools/requirements.txt /tmp/vamp-build-requirements.txt
|
COPY tools/requirements.txt /tmp/vamp-build-requirements.txt
|
||||||
COPY tools/install_engraver.py /tmp/install-vamp-engraver.py
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
|
||||||
libasound2t64 libdbus-1-3 libfontconfig1 libglib2.0-0t64 libgpg-error0 libnss3 libopengl0 libxkbcommon0 \\
|
|
||||||
libxkbcommon-x11-0 libxcb-cursor0 libxcomposite1 libxrandr2 libxtst6 \\
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN python3 /tmp/install-vamp-engraver.py /opt/vamp-musescore
|
|
||||||
ENV VAMP_MUSESCORE=/opt/vamp-musescore/squashfs-root/AppRun
|
|
||||||
ARG MODULUS_TOOLS=build/build_tools
|
ARG MODULUS_TOOLS=build/build_tools
|
||||||
COPY ${{MODULUS_TOOLS}}/docker/format-requirements.txt /tmp/modulus-format-requirements.txt
|
COPY ${{MODULUS_TOOLS}}/docker/format-requirements.txt /tmp/modulus-format-requirements.txt
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# SMuFL Metadata
|
||||||
|
|
||||||
|
`smufl_glyphnames.json` is the unmodified glyph-name mapping from
|
||||||
|
https://github.com/w3c-cg/smufl at revision
|
||||||
|
`f9d12b22ea89978720ba9defb2f4df67fabd8564` (2026-09-05).
|
||||||
|
|
||||||
|
The upstream `mdbook/src/preamble/license.md` notice follows:
|
||||||
|
|
||||||
|
SMuFL is free to use, and is licensed under the terms of the
|
||||||
|
W3C Community Final Specification Agreement (FSA).
|
||||||
|
|
||||||
|
Copyright © 2022 the Contributors to the Standard Music Font Layout
|
||||||
|
Specification, published by the W3C Music Notation Community Group under the
|
||||||
|
[W3C Community Contributor License Agreement (CLA)](https://www.w3.org/community/about/agreements/cla/).
|
||||||
|
A [human-readable summary](https://www.w3.org/community/about/agreements/cla-deed/)
|
||||||
|
is available. The [FSA](https://www.w3.org/community/about/agreements/final/)
|
||||||
|
and [SMuFL licensing commitments](https://www.w3.org/community/music-notation/spec/193/commitments)
|
||||||
|
are published by W3C.
|
||||||
|
|
||||||
|
The Petaluma fonts and their font-specific metadata remain covered by OFL.txt.
|
||||||
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from generator import harmony
|
||||||
|
from generator.musicxml import load_musicxml
|
||||||
|
|
||||||
|
def same(left, right, location: str) -> None:
|
||||||
|
if left != right:
|
||||||
|
raise ValueError(f'{location}: engraver changed musical content ({left!r} -> {right!r})')
|
||||||
|
|
||||||
|
def event_positions(staff: dict) -> dict:
|
||||||
|
return {event['id']: (mi, vi, ni) for mi, measure in enumerate(staff['measures']) for vi, voice in enumerate(measure['voices']) for ni, event in enumerate(voice['notes'])}
|
||||||
|
|
||||||
|
def tuplet_members(staff: dict, identifier: str) -> list:
|
||||||
|
positions = event_positions(staff)
|
||||||
|
return [positions[event['id']] for measure in staff['measures'] for voice in measure['voices'] for event in voice['notes'] if identifier in event.get('tuplet_ids', [])]
|
||||||
|
|
||||||
|
def audit_roundtrip(source: Path, output: Path) -> None:
|
||||||
|
before = load_musicxml(source)['staves'][0]
|
||||||
|
after = load_musicxml(output)['staves'][0]
|
||||||
|
before_positions, after_positions = event_positions(before), event_positions(after)
|
||||||
|
before_instruments = {i['id']: i for i in before.get('instruments', [])}
|
||||||
|
after_instruments = {i['id']: i for i in after.get('instruments', [])}
|
||||||
|
same(len(before['measures']), len(after['measures']), 'measure count')
|
||||||
|
for original, rendered in zip(before['measures'], after['measures']):
|
||||||
|
location = original['id']
|
||||||
|
for field in ('duration', 'key', 'meter', 'clef', 'transpose', 'attributes'):
|
||||||
|
same(original[field], rendered[field], f'{location} {field}')
|
||||||
|
same({'capo': 0, **original['staff_details']}, {'capo': 0, **rendered['staff_details']}, f'{location} staff details')
|
||||||
|
for side, style in original.get('barlines', {}).items():
|
||||||
|
same(style, rendered.get('barlines', {}).get(side, 'regular'), f'{location} {side} barline')
|
||||||
|
same(original.get('tempos', []), rendered.get('tempos', []), f'{location} tempos')
|
||||||
|
same(original.get('section_label', ''), rendered.get('section_label', ''), f'{location} section label')
|
||||||
|
same(len(original['harmonies']), len(rendered['harmonies']), f'{location} harmony count')
|
||||||
|
for a, b in zip(original['harmonies'], rendered['harmonies']):
|
||||||
|
for field in ('root', 'bass', 'onset'):
|
||||||
|
same(a.get(field), b.get(field), f'{location} harmony {field}')
|
||||||
|
same(harmony.degrees(a), harmony.degrees(b), f'{location} harmony degrees')
|
||||||
|
same(len(original['voices']), len(rendered['voices']), f'{location} voice count')
|
||||||
|
for first, second in zip(original['voices'], rendered['voices']):
|
||||||
|
same(len(first['notes']), len(second['notes']), f'{location} voice {first["id"]} event count')
|
||||||
|
for a, b in zip(first['notes'], second['notes']):
|
||||||
|
event_location = f'{location}, voice {first["id"]}, {a["id"]}'
|
||||||
|
for field in ('kind', 'onset', 'duration', 'written', 'hidden'):
|
||||||
|
same(a[field], b[field], f'{event_location} {field}')
|
||||||
|
for field in ('grace', 'cue', 'measure_rest', 'rest_position', 'lyrics'):
|
||||||
|
same(a.get(field), b.get(field), f'{event_location} {field}')
|
||||||
|
for field in ('stem', 'beams', 'notehead'):
|
||||||
|
if field in a:
|
||||||
|
same(a[field], b.get(field), f'{event_location} explicit {field}')
|
||||||
|
same(len(a['pitches']), len(b['pitches']), f'{event_location} chord tone count')
|
||||||
|
for p, q in zip(a['pitches'], b['pitches']):
|
||||||
|
if p.get('instrument') and 'unpitched' in p:
|
||||||
|
first_instrument = before_instruments[p['instrument']]
|
||||||
|
second_instrument = after_instruments.get(q.get('instrument'), {})
|
||||||
|
same(first_instrument.get('midi', {}).get('midi-unpitched'), second_instrument.get('midi', {}).get('midi-unpitched'), f'{event_location} percussion instrument')
|
||||||
|
for field in ('letter', 'alter', 'octave', 'duration', 'written', 'unpitched', 'tablature', 'notehead', 'cue'):
|
||||||
|
same(p.get(field), q.get(field), f'{event_location} pitch {field}')
|
||||||
|
for field in ('stem', 'print'):
|
||||||
|
if field in p:
|
||||||
|
same(p[field], q.get(field), f'{event_location} pitch {field}')
|
||||||
|
for field in ('tie_from', 'tie_to'):
|
||||||
|
same(bool(p.get(field)), bool(q.get(field)), f'{event_location} {field}')
|
||||||
|
if display := p.get('accidental_display'):
|
||||||
|
other = q.get('accidental_display', {})
|
||||||
|
same(display.get('name'), other.get('name'), f'{event_location} accidental')
|
||||||
|
for field in ('parentheses', 'bracket'):
|
||||||
|
same(display.get(field, 'no'), other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||||
|
for field in ('cautionary', 'editorial'):
|
||||||
|
if field in display:
|
||||||
|
same(display[field], other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||||
|
if tremolo := a.get('tremolo'):
|
||||||
|
other = b.get('tremolo', {})
|
||||||
|
for field in ('type', 'marks'):
|
||||||
|
same(tremolo[field], other.get(field), f'{event_location} tremolo {field}')
|
||||||
|
if 'partner' in tremolo:
|
||||||
|
same(before_positions.get(tremolo['partner']), after_positions.get(other.get('partner')), f'{event_location} tremolo partner')
|
||||||
|
same(len(before.get('tuplets', [])), len(after.get('tuplets', [])), 'tuplet count')
|
||||||
|
for a, b in zip(before.get('tuplets', []), after.get('tuplets', [])):
|
||||||
|
for field in ('note_count', 'space_count', 'depth', 'show_number', 'show_type'):
|
||||||
|
same(a.get(field), b.get(field), f'{a["id"]} {field}')
|
||||||
|
if a['bracket'] != 'auto':
|
||||||
|
same(a['bracket'], b['bracket'], f'{a["id"]} bracket')
|
||||||
|
same(tuplet_members(before, a['id']), tuplet_members(after, b['id']), f'{a["id"]} members')
|
||||||
|
for endpoint in ('start', 'end'):
|
||||||
|
same(before_positions[a[endpoint]], after_positions[b[endpoint]], f'{a["id"]} {endpoint}')
|
||||||
|
for field in ('tuplet_actual', 'tuplet_normal'):
|
||||||
|
if a.get(field, {}).get('type'):
|
||||||
|
same(a[field], b.get(field), f'{a["id"]} {field}')
|
||||||
|
same([{k: v for k, v in s.items() if k != 'id'} for s in before.get('octave_shifts', [])],
|
||||||
|
[{k: v for k, v in s.items() if k != 'id'} for s in after.get('octave_shifts', [])], 'octave spans')
|
||||||
@@ -12,7 +12,7 @@ class BuildBootstrapTests(unittest.TestCase):
|
|||||||
spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py')
|
spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py')
|
||||||
bootstrap = importlib.util.module_from_spec(spec)
|
bootstrap = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(bootstrap)
|
spec.loader.exec_module(bootstrap)
|
||||||
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes() + (root / 'tools/install_engraver.py').read_bytes()).hexdigest()
|
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes()).hexdigest()
|
||||||
self.assertEqual(bootstrap.build_requirements_digest(), digest)
|
self.assertEqual(bootstrap.build_requirements_digest(), digest)
|
||||||
self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile())
|
self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile())
|
||||||
package = types.ModuleType('modulus')
|
package = types.ModuleType('modulus')
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import copy
|
||||||
|
from dataclasses import replace
|
||||||
|
from fractions import Fraction
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from generator.engraving import EngravingError, Fonts, Style, layout_score, rasterize_page
|
||||||
|
from generator.engraving.audit import audit_layout
|
||||||
|
from generator.engraving.notation import ScoreIndex
|
||||||
|
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, rational
|
||||||
|
from generator.musicxml import load_musicxml
|
||||||
|
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||||
|
|
||||||
|
class EngravingTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
||||||
|
def layout(self, source, style=None):
|
||||||
|
before = copy.deepcopy(source)
|
||||||
|
layout = layout_score(source, style or Style(), self.fonts)
|
||||||
|
self.assertEqual(source, before)
|
||||||
|
return layout
|
||||||
|
def primitives(self, layout, role):
|
||||||
|
return [p for page in layout.pages for p in page.primitives if p.role == role]
|
||||||
|
def test_notation_fixture_visual_golden(self):
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
layout = self.layout(load_musicxml(Path('tests/fixtures/notation.xml')))
|
||||||
|
self.assertEqual(len(layout.pages), 1)
|
||||||
|
actual = rasterize_page(layout.pages[0], self.fonts)
|
||||||
|
with Image.open('tests/fixtures/notation-python.png') as expected:
|
||||||
|
self.assertIsNone(ImageChops.difference(actual, expected.convert('RGB')).getbbox())
|
||||||
|
def test_every_written_note_and_rest_value(self):
|
||||||
|
for kind in NOTE_TYPES:
|
||||||
|
for dots in (0, 1, 3):
|
||||||
|
duration = TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots))
|
||||||
|
for rest in (False, True):
|
||||||
|
with self.subTest(kind=kind, dots=dots, rest=rest):
|
||||||
|
content = xml_note(duration=duration.numerator, kind=kind, extra='<dot/>' * dots)
|
||||||
|
if rest:
|
||||||
|
content = f'<note><rest/><duration>{duration.numerator}</duration><type>{kind}</type>' + '<dot/>' * dots + '</note>'
|
||||||
|
source = parse(xml_score(content, divisions=duration.denominator, implicit=True))
|
||||||
|
layout = self.layout(source)
|
||||||
|
self.assertEqual(len(self.primitives(layout, 'dot')), dots)
|
||||||
|
self.assertEqual(len(self.primitives(layout, 'rest' if rest else 'head')), 1)
|
||||||
|
def test_sixteen_dots_are_not_truncated(self):
|
||||||
|
duration = TYPE_LENGTHS['quarter'] * (2 - Fraction(1, 2 ** 16))
|
||||||
|
source = parse(xml_score(xml_note(duration=duration.numerator, kind='quarter', extra='<dot/>' * 16), divisions=duration.denominator, implicit=True))
|
||||||
|
self.assertEqual(len(self.primitives(self.layout(source), 'dot')), 16)
|
||||||
|
def test_arbitrary_voice_ids_and_more_than_four_voices(self):
|
||||||
|
content = ''
|
||||||
|
for i in range(6):
|
||||||
|
if i:
|
||||||
|
content += '<backup><duration>4</duration></backup>'
|
||||||
|
content += xml_note(extra=f'<voice>part-{i}</voice>')
|
||||||
|
source = parse(xml_score(content))
|
||||||
|
self.assertEqual(len(self.primitives(self.layout(source), 'head')), 6)
|
||||||
|
def test_microtonal_numeric_fallback_and_explicit_missing_glyph(self):
|
||||||
|
source = parse(xml_score(xml_note(alter='0.1')))
|
||||||
|
self.assertEqual(self.primitives(self.layout(source), 'accidental')[0].value, '(+1/10 st)')
|
||||||
|
notes(source)[0]['pitches'][0]['accidental_display'] = {'name': 'other', 'smufl': 'missingVampGlyph'}
|
||||||
|
with self.assertRaisesRegex(EngravingError, 'measure 1, voice 1.*missingVampGlyph'):
|
||||||
|
self.layout(source)
|
||||||
|
def test_accidentals_are_shared_by_simultaneous_voices_without_losing_conflicts(self):
|
||||||
|
content = xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
|
||||||
|
content += xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
|
||||||
|
content += xml_note(duration=2, kind='half', extra='<voice>a</voice>')
|
||||||
|
source = parse(xml_score(content))
|
||||||
|
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp', 'accidentalNatural'])
|
||||||
|
conflict = parse(xml_score(xml_note(alter=1, extra='<voice>a</voice>') + '<backup><duration>4</duration></backup>' + xml_note(extra='<voice>b</voice>')))
|
||||||
|
self.assertEqual(len(self.primitives(self.layout(conflict), 'accidental')), 2)
|
||||||
|
def test_dense_measure_splits_exactly_without_modifying_music(self):
|
||||||
|
content = ''.join(xml_note('G', 1, '64th', extra='<voice>fast</voice>') for _ in range(64))
|
||||||
|
content += '<backup><duration>64</duration></backup>' + xml_note('C', 64, 'whole', extra='<voice>held</voice>')
|
||||||
|
source = parse(xml_score(content, divisions=16))
|
||||||
|
layout = self.layout(source)
|
||||||
|
systems = [s for p in layout.pages for s in p.systems]
|
||||||
|
self.assertGreater(len(systems), 1)
|
||||||
|
held = source['staves'][0]['measures'][0]['voices'][1]['notes'][0]['pitches'][0]['id']
|
||||||
|
pieces = [f for s in systems for f in s.fragments if f.source == held]
|
||||||
|
self.assertEqual(sum(f.length() for f in pieces), 4)
|
||||||
|
self.assertGreater(len(pieces), 1)
|
||||||
|
self.assertTrue(self.primitives(layout, 'tie'))
|
||||||
|
for page in layout.pages:
|
||||||
|
self.assertEqual(rasterize_page(page, self.fonts).size, (976, 484))
|
||||||
|
def test_hidden_pitch_does_not_suppress_a_visible_accidental(self):
|
||||||
|
source = parse(xml_score(xml_note(duration=2, kind='half', alter=1) + xml_note(duration=2, kind='half', alter=1)))
|
||||||
|
notes(source)[0]['hidden'] = True
|
||||||
|
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp'])
|
||||||
|
def test_octave_shift_changes_display_not_pitch(self):
|
||||||
|
source = parse(xml_score(xml_note(octave=5)))
|
||||||
|
original = ScoreIndex(source).pitch_position(notes(source)[0]['pitches'][0], Fraction())
|
||||||
|
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': rational(0), 'end': rational(4), 'octaves': -1}]
|
||||||
|
index = ScoreIndex(source)
|
||||||
|
self.assertEqual(index.pitch_position(notes(source)[0]['pitches'][0], Fraction()), original - 7)
|
||||||
|
self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift')))
|
||||||
|
def test_wide_pitch_range_can_break_inside_a_measure_vertically(self):
|
||||||
|
source = parse(xml_score(xml_note(duration=2, kind='half', octave=10) + xml_note(duration=2, kind='half', octave=-1)))
|
||||||
|
layout = self.layout(source)
|
||||||
|
self.assertEqual(len(layout.pages), 2)
|
||||||
|
self.assertEqual(len(self.primitives(layout, 'head')), 2)
|
||||||
|
def test_cue_grace_and_tremolo_are_drawn(self):
|
||||||
|
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
|
||||||
|
source = parse(xml_score(grace + xml_note(duration=4, kind='whole', prefix='<cue/>')))
|
||||||
|
layout = self.layout(source)
|
||||||
|
self.assertEqual(len(self.primitives(layout, 'head')), 2)
|
||||||
|
self.assertEqual(len(self.primitives(layout, 'grace-slash')), 1)
|
||||||
|
source = parse(xml_score(xml_note(duration=2, kind='half', extra='<notations><ornaments><tremolo type="single">8</tremolo></ornaments></notations>'), implicit=True))
|
||||||
|
self.assertEqual(len(self.primitives(self.layout(source), 'tremolo')), 8)
|
||||||
|
def test_percussion_and_tab_use_explicit_display_positions(self):
|
||||||
|
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
|
||||||
|
source = parse(xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
|
||||||
|
self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole')
|
||||||
|
tab = parse(xml_score(xml_note(extra='<notations><technical><string>2</string><fret>1</fret></technical></notations>'), attributes='<clef><sign>TAB</sign><line>5</line></clef><staff-details><staff-lines>6</staff-lines></staff-details>'))
|
||||||
|
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '1')
|
||||||
|
def test_lyrics_hyphens_and_extenders(self):
|
||||||
|
begin = '<lyric number="1"><syllabic>begin</syllabic><text>hel</text></lyric>'
|
||||||
|
end = '<lyric number="1"><syllabic>end</syllabic><text>lo</text><extend type="start"/></lyric>'
|
||||||
|
source = parse(xml_score(xml_note(duration=1, kind='quarter', extra=begin) + xml_note(duration=1, kind='quarter', extra=end) + xml_note(duration=2, kind='half')))
|
||||||
|
layout = self.layout(source)
|
||||||
|
self.assertTrue(self.primitives(layout, 'lyric-hyphen'))
|
||||||
|
self.assertTrue(self.primitives(layout, 'lyric-extension'))
|
||||||
|
def test_nested_and_cross_bar_tuplets_keep_span_ownership(self):
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
||||||
|
content = ''.join(xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6))
|
||||||
|
tree = ElementTree.fromstring(xml_score(content, divisions=3))
|
||||||
|
second = ElementTree.SubElement(tree.find('part'), 'measure', number='2')
|
||||||
|
for element in ElementTree.fromstring(xml_score(content, divisions=3)).findall('part/measure/note'):
|
||||||
|
second.append(element)
|
||||||
|
source = parse(ElementTree.tostring(tree, encoding='unicode'))
|
||||||
|
events = notes(source) + notes(source, 1)
|
||||||
|
spans = [{'id': 'outer', 'voice': '1', 'start': events[0]['id'], 'end': events[-1]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'yes', 'show_number': 'both', 'show_type': 'both', 'line_shape': 'curved'}, {'id': 'inner', 'voice': '1', 'start': events[1]['id'], 'end': events[3]['id'], 'note_count': 3, 'space_count': 2, 'depth': 1, 'bracket': 'yes', 'show_number': 'actual'}]
|
||||||
|
source['staves'][0]['tuplets'] = spans
|
||||||
|
for i, event in enumerate(events):
|
||||||
|
event['tuplet_ids'] = ['outer'] + (['inner'] if 1 <= i <= 3 else [])
|
||||||
|
layout = self.layout(source, Style(width=600))
|
||||||
|
self.assertGreater(len([s for p in layout.pages for s in p.systems]), 1)
|
||||||
|
self.assertEqual({p.sources[0] for p in self.primitives(layout, 'tuplet-bracket')}, {'outer', 'inner'})
|
||||||
|
def test_paired_tremolo_and_double_stems(self):
|
||||||
|
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
|
||||||
|
content = ''.join(xml_note(step, 2, 'whole', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')])
|
||||||
|
self.assertEqual(len(self.primitives(self.layout(parse(xml_score(content))), 'tremolo')), 3)
|
||||||
|
source = parse(xml_score(xml_note(duration=1, kind='eighth', extra='<stem>double</stem>'), divisions=2, implicit=True))
|
||||||
|
layout = self.layout(source)
|
||||||
|
self.assertEqual(len(self.primitives(layout, 'stem')), 2)
|
||||||
|
self.assertEqual(len(self.primitives(layout, 'flag')), 2)
|
||||||
|
def test_meter_symbols_and_timed_attributes(self):
|
||||||
|
for symbol in ('common', 'cut', 'single-number', 'note', 'dotted-note', 'normal'):
|
||||||
|
source = parse(xml_score(xml_note()).replace('<time>', f'<time symbol="{symbol}">'))
|
||||||
|
with self.subTest(symbol=symbol):
|
||||||
|
self.assertTrue(self.primitives(self.layout(source), 'meter'))
|
||||||
|
change = '<attributes><key><fifths>-3</fifths></key><clef><sign>C</sign><line>3</line></clef></attributes>'
|
||||||
|
source = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half')))
|
||||||
|
layout = self.layout(source)
|
||||||
|
self.assertEqual([p.value for p in self.primitives(layout, 'clef')], ['gClef', 'cClef'])
|
||||||
|
def test_lyric_extender_continues_on_systems_without_syllables(self):
|
||||||
|
lyrics = '<lyric number="1"><text>Ah</text><extend type="start"/></lyric>'
|
||||||
|
content = ''.join(xml_note('G', 1, '64th', extra=lyrics if i == 0 else '') for i in range(64))
|
||||||
|
source = parse(xml_score(content, divisions=16))
|
||||||
|
layout = self.layout(source)
|
||||||
|
systems = [s for p in layout.pages for s in p.systems]
|
||||||
|
self.assertGreater(len(systems), 1)
|
||||||
|
self.assertTrue(all(any(p.role == 'lyric-extension' for p in s.primitives) for s in systems))
|
||||||
|
def test_audit_catches_removed_pitch_and_accidental_primitives(self):
|
||||||
|
source = parse(xml_score(xml_note(alter=1)))
|
||||||
|
layout = self.layout(source)
|
||||||
|
for role in ('head', 'accidental'):
|
||||||
|
pages = tuple(replace(p, primitives=tuple(g for g in p.primitives if g.role != role)) for p in layout.pages)
|
||||||
|
with self.assertRaisesRegex(EngravingError, 'no drawing primitive|accidental was omitted'):
|
||||||
|
audit_layout(source, replace(layout, pages=pages), self.fonts)
|
||||||
|
def test_fingerprint_includes_style_and_fixed_overflow_errors(self):
|
||||||
|
source = parse(xml_score(xml_note()))
|
||||||
|
a = self.layout(source)
|
||||||
|
b = self.layout(source, Style(column_gap=14))
|
||||||
|
self.assertNotEqual(a.fingerprint, b.fingerprint)
|
||||||
|
with self.assertRaisesRegex(EngravingError, 'page width'):
|
||||||
|
self.layout(source, Style(width=100))
|
||||||
+38
-16
@@ -1,17 +1,17 @@
|
|||||||
import copy
|
import copy
|
||||||
|
import argparse
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
from generator.arrange.generate import arrange_document
|
from generator.arrange.generate import arrange_document
|
||||||
from generator.engraving import EngravingError, check_page, engrave_tree, qualify
|
from generator.__main__ import generate_assets
|
||||||
from generator.engraving.audit import audit_roundtrip
|
from generator.engraving import ENGINE_NAME, ENGINE_VERSION, EngravingError, Fonts, Style, check_page, engrave_tree, layout_score
|
||||||
|
from tests.musicxml_roundtrip import audit_roundtrip
|
||||||
from generator.model import fraction, pitch_number, rational, validate_document
|
from generator.model import fraction, pitch_number, rational, validate_document
|
||||||
from generator.musicxml import load_musicxml
|
from generator.musicxml import load_musicxml
|
||||||
from generator.musicxml_writer import write_musicxml
|
from generator.musicxml_writer import write_musicxml
|
||||||
@@ -21,6 +21,29 @@ from generator.transposition import transpose_document
|
|||||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||||
|
|
||||||
class ScoreBundleTests(unittest.TestCase):
|
class ScoreBundleTests(unittest.TestCase):
|
||||||
|
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
data, output = root / 'data', root / 'output'
|
||||||
|
lesson = data / 'practice' / 'chord' / 'lesson'
|
||||||
|
lesson.mkdir(parents=True)
|
||||||
|
(data / 'songs').mkdir()
|
||||||
|
source = lesson / 'fixture.xml'
|
||||||
|
source.write_text(xml_score(xml_note()))
|
||||||
|
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=False, check=False)
|
||||||
|
generate_assets(args)
|
||||||
|
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||||
|
self.assertTrue(list(output.rglob('*.png')))
|
||||||
|
generate_assets(args)
|
||||||
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||||
|
source.write_text(xml_score(xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
|
||||||
|
with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'):
|
||||||
|
generate_assets(args)
|
||||||
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||||
|
source.unlink()
|
||||||
|
generate_assets(args)
|
||||||
|
self.assertFalse(list(output.rglob('*.png')))
|
||||||
|
self.assertFalse(list(output.rglob('*.json')))
|
||||||
def round_trip(self, document):
|
def round_trip(self, document):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
path = Path(temporary) / 'roundtrip.xml'
|
path = Path(temporary) / 'roundtrip.xml'
|
||||||
@@ -48,8 +71,8 @@ class ScoreBundleTests(unittest.TestCase):
|
|||||||
self.assertEqual(notes(restored)[0]['pitches'], notes(transposed)[0]['pitches'])
|
self.assertEqual(notes(restored)[0]['pitches'], notes(transposed)[0]['pitches'])
|
||||||
unqualified = parse(xml_score(xml_note(alter='0.1')))
|
unqualified = parse(xml_score(xml_note(alter='0.1')))
|
||||||
self.assertEqual(fraction(notes(unqualified)[0]['pitches'][0]['alter']), Fraction(1, 10))
|
self.assertEqual(fraction(notes(unqualified)[0]['pitches'][0]['alter']), Fraction(1, 10))
|
||||||
with self.assertRaisesRegex(EngravingError, 'microtonal'):
|
layout = layout_score(unqualified, Style(), Fonts(Path('package/assets/fonts/petaluma')))
|
||||||
qualify(unqualified)
|
self.assertTrue(any(p.role == 'accidental' and p.value == '(+1/10 st)' for page in layout.pages for p in page.primitives))
|
||||||
def test_octave_shift_stop_at_a_bar_boundary_is_emitted_once(self):
|
def test_octave_shift_stop_at_a_bar_boundary_is_emitted_once(self):
|
||||||
tree = ElementTree.fromstring(xml_score(xml_note()))
|
tree = ElementTree.fromstring(xml_score(xml_note()))
|
||||||
ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(ElementTree.fromstring(xml_note()))
|
ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(ElementTree.fromstring(xml_note()))
|
||||||
@@ -61,8 +84,9 @@ class ScoreBundleTests(unittest.TestCase):
|
|||||||
source = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='<chord/>')))
|
source = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='<chord/>')))
|
||||||
restored = self.round_trip(source)
|
restored = self.round_trip(source)
|
||||||
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||||
with self.assertRaisesRegex(EngravingError, 'independently sustained'):
|
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
|
||||||
qualify(source)
|
heads = [p.value for page in layout.pages for p in page.primitives if p.role == 'head']
|
||||||
|
self.assertEqual(heads, ['noteheadWhole', 'noteheadHalf'])
|
||||||
def test_overlapping_notes_within_one_voice_are_rejected(self):
|
def test_overlapping_notes_within_one_voice_are_rejected(self):
|
||||||
with self.assertRaisesRegex(ValueError, 'overlapping'):
|
with self.assertRaisesRegex(ValueError, 'overlapping'):
|
||||||
parse(xml_score(xml_note() + '<backup><duration>4</duration></backup>' + xml_note('E')))
|
parse(xml_score(xml_note() + '<backup><duration>4</duration></backup>' + xml_note('E')))
|
||||||
@@ -113,15 +137,14 @@ class ScoreBundleTests(unittest.TestCase):
|
|||||||
self.assertEqual(original['duration'], result['duration'])
|
self.assertEqual(original['duration'], result['duration'])
|
||||||
for v, w in zip(original['voices'], result['voices']):
|
for v, w in zip(original['voices'], result['voices']):
|
||||||
self.assertEqual(v['notes'], w['notes'])
|
self.assertEqual(v['notes'], w['notes'])
|
||||||
def test_wrong_engine_version_does_not_produce_a_bundle(self):
|
def test_missing_font_does_not_produce_a_bundle(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
root = Path(temporary)
|
root = Path(temporary)
|
||||||
source = parse(xml_score(xml_note()))
|
source = parse(xml_score(xml_note()))
|
||||||
path = root / 'fixture.json'
|
path = root / 'fixture.json'
|
||||||
path.write_text(json.dumps(source))
|
path.write_text(json.dumps(source))
|
||||||
with patch.dict(os.environ, {'VAMP_MUSESCORE': '/engine'}), patch('generator.engraving.run_engine', return_value='MuseScore 3.6'):
|
with self.assertRaisesRegex(EngravingError, 'cannot load bundled'):
|
||||||
with self.assertRaisesRegex(EngravingError, 'expected MuseScore'):
|
engrave_tree(root, root / 'missing-fonts')
|
||||||
engrave_tree(root, Path('package/assets/fonts/petaluma'))
|
|
||||||
self.assertEqual(json.loads(path.read_text()), source)
|
self.assertEqual(json.loads(path.read_text()), source)
|
||||||
def test_blank_and_wrong_sized_pages_are_rejected(self):
|
def test_blank_and_wrong_sized_pages_are_rejected(self):
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -176,7 +199,7 @@ class ScoreBundleTests(unittest.TestCase):
|
|||||||
page = assets / 'page.png'
|
page = assets / 'page.png'
|
||||||
page.write_bytes(data)
|
page.write_bytes(data)
|
||||||
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
||||||
bundle = {'schema_version': 3, 'music': {k: v for k, v in document.items() if k != 'schema_version'}, 'presentation': {'engine': 'MuseScore Studio', 'engine_version': '4.7.4', 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
|
bundle = {'schema_version': 3, 'music': {k: v for k, v in document.items() if k != 'schema_version'}, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
|
||||||
path = assets / 'score.json'
|
path = assets / 'score.json'
|
||||||
path.write_text(json.dumps(bundle))
|
path.write_text(json.dumps(bundle))
|
||||||
self.assertEqual(load_json(path), document)
|
self.assertEqual(load_json(path), document)
|
||||||
@@ -189,8 +212,7 @@ class ScoreBundleTests(unittest.TestCase):
|
|||||||
page.symlink_to(outside)
|
page.symlink_to(outside)
|
||||||
with self.assertRaisesRegex(ValueError, 'escapes'):
|
with self.assertRaisesRegex(ValueError, 'escapes'):
|
||||||
load_json(path)
|
load_json(path)
|
||||||
@unittest.skipUnless(os.environ.get('VAMP_MUSESCORE'), 'pinned engraver integration runs in the managed build image')
|
def test_python_engraver_visual_golden_and_determinism(self):
|
||||||
def test_pinned_engine_visual_golden_and_determinism(self):
|
|
||||||
from PIL import Image, ImageChops
|
from PIL import Image, ImageChops
|
||||||
from generator.score import write_json
|
from generator.score import write_json
|
||||||
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
||||||
@@ -204,7 +226,7 @@ class ScoreBundleTests(unittest.TestCase):
|
|||||||
self.assertEqual(bundle['music'], {k: v for k, v in document.items() if k != 'schema_version'})
|
self.assertEqual(bundle['music'], {k: v for k, v in document.items() if k != 'schema_version'})
|
||||||
self.assertEqual(len(bundle['presentation']['pages']), 1)
|
self.assertEqual(len(bundle['presentation']['pages']), 1)
|
||||||
page = root / bundle['presentation']['pages'][0]['path']
|
page = root / bundle['presentation']['pages'][0]['path']
|
||||||
with Image.open(page) as actual, Image.open('tests/fixtures/engraving.png') as expected:
|
with Image.open(page) as actual, Image.open('tests/fixtures/engraving-python.png') as expected:
|
||||||
self.assertIsNone(ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
|
self.assertIsNone(ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
|
||||||
if before is not None:
|
if before is not None:
|
||||||
self.assertEqual(bundle, before)
|
self.assertEqual(bundle, before)
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
from pathlib import Path
|
|
||||||
import platform
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
VERSION = '4.7.4.260706075'
|
|
||||||
SHA256 = {
|
|
||||||
'x86_64': '9233ed1b87d3e6b45722278f3c286dcd41e83da778bd0f80a1dd04949696ad93',
|
|
||||||
'aarch64': '162ae55b317660f196b2e73d566bdb45c1e990ed4cc140709d25d97b9ef366b0',
|
|
||||||
}
|
|
||||||
|
|
||||||
def install(destination: Path) -> Path:
|
|
||||||
architecture = platform.machine()
|
|
||||||
if architecture not in SHA256:
|
|
||||||
raise RuntimeError('the pinned engraver requires an x86_64 or aarch64 Linux build host')
|
|
||||||
destination = destination.resolve()
|
|
||||||
executable = destination / 'squashfs-root' / 'AppRun'
|
|
||||||
marker = destination / 'sha256'
|
|
||||||
if executable.is_file() and marker.is_file() and marker.read_text().strip() == SHA256[architecture]:
|
|
||||||
return executable
|
|
||||||
if destination.exists() and any(destination.iterdir()):
|
|
||||||
raise RuntimeError(f'{destination} contains a different or incomplete installation; choose an empty directory')
|
|
||||||
destination.mkdir(parents=True, exist_ok=True)
|
|
||||||
name = f'MuseScore-Studio-{VERSION}-{architecture}.AppImage'
|
|
||||||
url = f'https://github.com/musescore/MuseScore/releases/download/v4.7.4/{name}'
|
|
||||||
with tempfile.TemporaryDirectory(prefix='vamp-musescore-download-') as temporary:
|
|
||||||
archive = Path(temporary) / name
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with urllib.request.urlopen(url, timeout=120) as response, archive.open('wb') as output:
|
|
||||||
while chunk := response.read(1024 * 1024):
|
|
||||||
digest.update(chunk)
|
|
||||||
output.write(chunk)
|
|
||||||
if digest.hexdigest() != SHA256[architecture]:
|
|
||||||
raise RuntimeError('MuseScore AppImage checksum mismatch')
|
|
||||||
archive.chmod(0o755)
|
|
||||||
subprocess.run([str(archive), '--appimage-extract'], cwd=destination, check=True, stdout=subprocess.DEVNULL)
|
|
||||||
if not executable.is_file():
|
|
||||||
raise RuntimeError('MuseScore AppImage does not contain AppRun')
|
|
||||||
marker.write_text(SHA256[architecture] + '\n')
|
|
||||||
return executable
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description='Install the pinned build-only MuseScore engraver without FUSE.')
|
|
||||||
parser.add_argument('destination', type=Path)
|
|
||||||
print(install(parser.parse_args().destination))
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
Pillow==12.3.0
|
Pillow==12.3.0
|
||||||
defusedxml==0.7.1
|
defusedxml==0.7.1
|
||||||
|
fonttools==4.65.0
|
||||||
|
aggdraw==1.4.1
|
||||||
|
|||||||
Reference in New Issue
Block a user