Compare commits
2
Commits
29d9129bb7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce4fc8f99 | ||
|
|
8f670cf7db |
@@ -174,6 +174,7 @@ it came from, in an `engraving` object.
|
||||
"key": "aes",
|
||||
"time": "4/4",
|
||||
"print_time": false,
|
||||
"bar": 33,
|
||||
"voices": [
|
||||
{ "clef": "treble", "notes": "c4 des ees f | ees2. r4", "lyrics": "Kai -- paa -- va sy -- dän" },
|
||||
{ "clef": "treble_8", "notes": "aes,4 aes aes aes | aes2. r4" },
|
||||
@@ -190,6 +191,7 @@ it came from, in an `engraving` object.
|
||||
| `key` | string | optional | Key signature, in `lang`'s spelling. For `lilypond`, the tonic of the major spelling: `"aes"`, `"c"`, `"fis"`. |
|
||||
| `time` | string | optional | Time signature, as `"4/4"`. |
|
||||
| `print_time` | boolean | optional | Whether the time signature is printed on this system. Default `false`. |
|
||||
| `bar` | integer | optional | The measure this system starts at, as printed above its first bar. Omitted when the system is not numbered. |
|
||||
|
||||
Each entry of `voices`:
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ def _engraving(project: Project, page: int, slot: int) -> dict | None:
|
||||
"key": replacement.key or project.key,
|
||||
"time": replacement.time or project.time,
|
||||
"print_time": replacement.print_time,
|
||||
**({"bar": replacement.bar} if replacement.bar else {}),
|
||||
"voices": [
|
||||
{"clef": v.clef, "notes": v.notes.strip()}
|
||||
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
|
||||
|
||||
+47
-46
@@ -84,6 +84,49 @@ _ENGRAVED_WASH = QColor(230, 160, 30, 55)
|
||||
_BADGE_Z = 10
|
||||
|
||||
|
||||
def section(title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayout:
|
||||
"""A collapsible section. Returns the layout its contents go into.
|
||||
|
||||
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
|
||||
header reads as "enable this feature" rather than "expand this", and a
|
||||
column of framed boxes with checkboxes is hard to scan.
|
||||
"""
|
||||
header = QToolButton()
|
||||
header.setText(title)
|
||||
header.setCheckable(True)
|
||||
header.setChecked(expanded)
|
||||
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
|
||||
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
header.setAutoRaise(True)
|
||||
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
# Bold and greyed: a mid grey reads as a heading against both light and
|
||||
# dark palettes without needing a second stylesheet.
|
||||
header.setStyleSheet(
|
||||
"QToolButton {"
|
||||
" border: none;"
|
||||
" font-weight: 700;"
|
||||
" color: #808080;"
|
||||
" padding: 7px 0 4px 0;"
|
||||
" text-align: left;"
|
||||
"}"
|
||||
"QToolButton:hover { color: #a0a0a0; }"
|
||||
)
|
||||
|
||||
body = QWidget()
|
||||
layout = QVBoxLayout(body)
|
||||
layout.setContentsMargins(10, 6, 0, 10)
|
||||
body.setVisible(expanded)
|
||||
|
||||
def toggled(open_: bool) -> None:
|
||||
body.setVisible(open_)
|
||||
header.setArrowType(Qt.DownArrow if open_ else Qt.RightArrow)
|
||||
|
||||
header.toggled.connect(toggled)
|
||||
box.addWidget(header)
|
||||
box.addWidget(body)
|
||||
return layout
|
||||
|
||||
|
||||
class PageView(QGraphicsView):
|
||||
"""Pan, zoom, and direct manipulation of cuts and the content rectangle."""
|
||||
|
||||
@@ -426,48 +469,6 @@ class Editor(QMainWindow):
|
||||
|
||||
# -- ui ---------------------------------------------------------------
|
||||
|
||||
def _section(self, title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayout:
|
||||
"""A collapsible section. Returns the layout its contents go into.
|
||||
|
||||
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
|
||||
header reads as "enable this feature" rather than "expand this", and a
|
||||
column of framed boxes with checkboxes is hard to scan.
|
||||
"""
|
||||
header = QToolButton()
|
||||
header.setText(title)
|
||||
header.setCheckable(True)
|
||||
header.setChecked(expanded)
|
||||
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
|
||||
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
header.setAutoRaise(True)
|
||||
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
# Bold and greyed: a mid grey reads as a heading against both light and
|
||||
# dark palettes without needing a second stylesheet.
|
||||
header.setStyleSheet(
|
||||
"QToolButton {"
|
||||
" border: none;"
|
||||
" font-weight: 700;"
|
||||
" color: #808080;"
|
||||
" padding: 7px 0 4px 0;"
|
||||
" text-align: left;"
|
||||
"}"
|
||||
"QToolButton:hover { color: #a0a0a0; }"
|
||||
)
|
||||
|
||||
body = QWidget()
|
||||
layout = QVBoxLayout(body)
|
||||
layout.setContentsMargins(10, 6, 0, 10)
|
||||
body.setVisible(expanded)
|
||||
|
||||
def toggled(open_: bool) -> None:
|
||||
body.setVisible(open_)
|
||||
header.setArrowType(Qt.DownArrow if open_ else Qt.RightArrow)
|
||||
|
||||
header.toggled.connect(toggled)
|
||||
box.addWidget(header)
|
||||
box.addWidget(body)
|
||||
return layout
|
||||
|
||||
def _panel(self) -> QWidget:
|
||||
inner = QWidget()
|
||||
box = QVBoxLayout(inner)
|
||||
@@ -486,7 +487,7 @@ class Editor(QMainWindow):
|
||||
nav.addWidget(nxt)
|
||||
box.addLayout(nav)
|
||||
|
||||
page_section = self._section("Page", box)
|
||||
page_section = section("Page", box)
|
||||
form = QFormLayout()
|
||||
page_section.addLayout(form)
|
||||
self.skew = QDoubleSpinBox()
|
||||
@@ -515,7 +516,7 @@ class Editor(QMainWindow):
|
||||
reset.clicked.connect(self._reset_rect)
|
||||
form.addRow(reset)
|
||||
|
||||
marker_layout = self._section("Markers on this slice", box)
|
||||
marker_layout = section("Markers on this slice", box)
|
||||
self.marker_list = QListWidget()
|
||||
self.marker_list.setMaximumHeight(110)
|
||||
marker_layout.addWidget(self.marker_list)
|
||||
@@ -548,7 +549,7 @@ class Editor(QMainWindow):
|
||||
# slices are never re-engraved, and it is the tallest block here.
|
||||
self.ly_status = None
|
||||
if lilypond.available():
|
||||
ly_layout = self._section("Re-engrave this slice", box, expanded=False)
|
||||
ly_layout = section("Re-engrave this slice", box, expanded=False)
|
||||
open_engrave = QPushButton("Open engrave window…")
|
||||
open_engrave.setToolTip("Or double-click the slice on the page")
|
||||
open_engrave.clicked.connect(self._open_engrave)
|
||||
@@ -558,7 +559,7 @@ class Editor(QMainWindow):
|
||||
self.ly_status.setStyleSheet("color: #808080;")
|
||||
ly_layout.addWidget(self.ly_status)
|
||||
|
||||
meta_layout = self._section("Song", box)
|
||||
meta_layout = section("Song", box)
|
||||
meta_form = QFormLayout()
|
||||
meta_layout.addLayout(meta_form)
|
||||
self.metadata: dict[str, QLineEdit] = {}
|
||||
|
||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QImage, QKeySequence, QPixmap, QShortcut
|
||||
from PySide6.QtGui import QImage, QIntValidator, QKeySequence, QPixmap, QShortcut
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from . import lilypond
|
||||
from .detect import staff_height
|
||||
from .editor import section
|
||||
from .project import Project, Replacement, Voice
|
||||
|
||||
|
||||
@@ -180,6 +181,16 @@ class EngraveWindow(QDialog):
|
||||
self.print_time.toggled.connect(self._settings_changed)
|
||||
row.addWidget(self.print_time, 1)
|
||||
top.addRow("Time", row)
|
||||
|
||||
# Per slice, unlike key and time: which measure a system starts at is
|
||||
# the one thing that changes with every slice and cannot be inherited.
|
||||
self.bar = QLineEdit("" if self.replacement.bar is None else str(self.replacement.bar))
|
||||
self.bar.setValidator(QIntValidator(1, 9999, self.bar))
|
||||
self.bar.setFixedWidth(70)
|
||||
self.bar.setPlaceholderText("none")
|
||||
self.bar.setToolTip("Printed above the first bar, as a printed score numbers its systems")
|
||||
self.bar.textChanged.connect(self._bar_changed)
|
||||
top.addRow("First bar", self.bar)
|
||||
box.addLayout(top)
|
||||
|
||||
voices_label = QLabel("Voices")
|
||||
@@ -209,11 +220,15 @@ class EngraveWindow(QDialog):
|
||||
self.status.setWordWrap(True)
|
||||
box.addWidget(self.status)
|
||||
|
||||
# Collapsed: the source is what the form writes for you, so it is for
|
||||
# checking what a field did, not for working in. Open it and it stays
|
||||
# open for the life of the window.
|
||||
raw = section("LilyPond source", box, expanded=False)
|
||||
self.generated = QPlainTextEdit()
|
||||
self.generated.setReadOnly(True)
|
||||
self.generated.setMaximumHeight(120)
|
||||
self.generated.setMaximumHeight(220)
|
||||
self.generated.setStyleSheet("color: #808080;")
|
||||
box.addWidget(self.generated)
|
||||
raw.addWidget(self.generated)
|
||||
self._refresh_source()
|
||||
return panel
|
||||
|
||||
@@ -240,6 +255,10 @@ class EngraveWindow(QDialog):
|
||||
row.setParent(None)
|
||||
self._refresh_source()
|
||||
|
||||
def _bar_changed(self, text: str) -> None:
|
||||
self.replacement.bar = int(text) if text.strip().isdigit() else None
|
||||
self._refresh_source()
|
||||
|
||||
def _settings_changed(self) -> None:
|
||||
# Set on the song, not the slice: they are song properties in practice,
|
||||
# and this is what makes the next re-engraved slice open pre-filled.
|
||||
|
||||
@@ -12,6 +12,7 @@ scaling.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -63,6 +64,25 @@ RELATIVE_REFERENCE = {
|
||||
"bass": "c",
|
||||
}
|
||||
|
||||
# LilyPond renamed the repeat barlines and silently draws *nothing* for the old
|
||||
# names — no error, no warning, just a missing repeat that you find on the
|
||||
# tablet. Every book, every forum answer and every score anyone has typed before
|
||||
# uses the old ones, so translate them.
|
||||
_BAR_ALIASES = {
|
||||
"|:": ".|:",
|
||||
":|": ":|.",
|
||||
":|:": ":|.|:",
|
||||
"||:": ".|:",
|
||||
":||": ":|.",
|
||||
":||:": ":|.|:",
|
||||
}
|
||||
_BAR = re.compile(r'(\\bar\s*")([^"]*)(")')
|
||||
|
||||
|
||||
def _modernise_bars(notes: str) -> str:
|
||||
return _BAR.sub(lambda m: m[1] + _BAR_ALIASES.get(m[2], m[2]) + m[3], notes)
|
||||
|
||||
|
||||
_PREAMBLE = """\\version "2.24.0"
|
||||
\\paper {
|
||||
indent = 0\\mm
|
||||
@@ -85,17 +105,35 @@ def generate(replacement, key: str, time: str) -> str:
|
||||
key = replacement.key or key
|
||||
time = replacement.time or time
|
||||
|
||||
# Bar numbering is a Score property, so it is set once, on the first staff.
|
||||
# Visible at the beginning of a line and nowhere else — which in a
|
||||
# one-system slice means exactly one number, above the first bar, the way a
|
||||
# printed score numbers its systems. The empty bar line is what gives the
|
||||
# number a line beginning to attach to.
|
||||
number = ""
|
||||
if replacement.bar:
|
||||
number = (
|
||||
f" \\set Score.currentBarNumber = #{int(replacement.bar)}\n"
|
||||
" \\override Score.BarNumber.break-visibility = #'#(#f #f #t)\n"
|
||||
' \\bar ""\n'
|
||||
)
|
||||
|
||||
staves = []
|
||||
for voice in replacement.voices:
|
||||
hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n"
|
||||
body = voice.notes.strip() or "s1"
|
||||
body = _modernise_bars(voice.notes.strip()) or "s1"
|
||||
reference = RELATIVE_REFERENCE.get(voice.clef, "c'")
|
||||
staff = (
|
||||
" \\new Staff {\n"
|
||||
f"{hide}"
|
||||
f" \\clef {voice.clef}\n"
|
||||
# Quoted, because an octavated name has to be: unquoted,
|
||||
# `\clef treble_8` parses as a plain treble clef with a stray "8"
|
||||
# markup that lands under the first note, and the staff then reads
|
||||
# an octave off.
|
||||
f' \\clef "{voice.clef}"\n'
|
||||
f" \\key {key} \\major\n"
|
||||
f" \\time {time}\n"
|
||||
f"{number if not staves else ''}"
|
||||
f" \\relative {reference} {{ {body} }}\n"
|
||||
" }\n"
|
||||
)
|
||||
|
||||
@@ -147,6 +147,10 @@ class Replacement:
|
||||
voices: list[Voice] = field(default_factory=list)
|
||||
key: str | None = None
|
||||
time: str | None = None
|
||||
# The measure this system starts at, printed above its first bar the way a
|
||||
# score numbers its systems. Per slice and nothing else: it is the one thing
|
||||
# about a replacement that cannot be inherited or guessed.
|
||||
bar: int | None = None
|
||||
# The printed score repeats the key signature at every system but not the
|
||||
# time signature, so a re-engraved middle slice must not show one.
|
||||
print_time: bool = False
|
||||
@@ -356,6 +360,7 @@ class Project:
|
||||
**({"key": r.key} if r.key else {}),
|
||||
**({"time": r.time} if r.time else {}),
|
||||
**({"print_time": True} if r.print_time else {}),
|
||||
**({"bar": r.bar} if r.bar else {}),
|
||||
}
|
||||
for r in page.replacements
|
||||
],
|
||||
@@ -412,6 +417,7 @@ class Project:
|
||||
key=r.get("key"),
|
||||
time=r.get("time"),
|
||||
print_time=r.get("print_time", False),
|
||||
bar=r.get("bar"),
|
||||
)
|
||||
for r in page.get("replacements", [None] * len(page["discards"]))
|
||||
],
|
||||
|
||||
@@ -52,6 +52,39 @@ def _scan_pdf(path: Path) -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Bar aliases are string work, so they are checked whether or not LilyPond
|
||||
# is installed. The old repeat names draw nothing at all in 2.24 — silently,
|
||||
# which is how a missing repeat reaches a tablet.
|
||||
aliased = lilypond.generate(
|
||||
Replacement(voices=[Voice("treble", 'c4 d \\bar ":|" e f \\bar "|:" g', "")]), "c", "4/4"
|
||||
)
|
||||
assert '\\bar ":|."' in aliased and '\\bar ".|:"' in aliased, aliased
|
||||
kept = lilypond.generate(
|
||||
Replacement(voices=[Voice("treble", 'c4 \\bar "|." d', "")]), "c", "4/4"
|
||||
)
|
||||
assert '\\bar "|."' in kept, "a name LilyPond still knows is left alone"
|
||||
|
||||
# An octavated clef name must be quoted. Unquoted, `\clef treble_8` is a
|
||||
# plain treble with a stray "8" markup under the first note, an octave off.
|
||||
tenor = lilypond.generate(
|
||||
Replacement(voices=[Voice("treble_8", "c4 d", "")]), "c", "4/4"
|
||||
)
|
||||
assert '\\clef "treble_8"' in tenor, tenor
|
||||
|
||||
# A bar number is set once, on the first staff, since it is a Score
|
||||
# property, and is visible only at a line beginning — one number above the
|
||||
# first bar, as a printed score numbers its systems.
|
||||
numbered = lilypond.generate(
|
||||
Replacement(voices=[Voice("treble", "c4 d", ""), Voice("bass", "c4 d", "")], bar=33),
|
||||
"c",
|
||||
"4/4",
|
||||
)
|
||||
assert numbered.count("currentBarNumber = #33") == 1, numbered
|
||||
assert "break-visibility = #'#(#f #f #t)" in numbered
|
||||
assert "currentBarNumber" not in lilypond.generate(
|
||||
Replacement(voices=[Voice("treble", "c4 d", "")]), "c", "4/4"
|
||||
), "an unnumbered system prints no number"
|
||||
|
||||
if not lilypond.available():
|
||||
print("ok (skipped: LilyPond not installed)")
|
||||
return 0
|
||||
@@ -138,6 +171,7 @@ def main() -> int:
|
||||
assert page.replacements[second] is SATB
|
||||
|
||||
# Round-trip, including the song-level engraving defaults.
|
||||
SATB.bar = 33
|
||||
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
|
||||
saved = project.save()
|
||||
reloaded = Project.load(saved)
|
||||
@@ -146,6 +180,7 @@ def main() -> int:
|
||||
assert restored is not None
|
||||
assert [v.clef for v in restored.voices] == ["treble", "bass"]
|
||||
assert restored.voices[0].lyrics == "la la la la la la"
|
||||
assert restored.bar == 33, "the slice's bar number survives a save"
|
||||
assert reloaded.pages[0].replacements[first] is None
|
||||
|
||||
source.close()
|
||||
|
||||
@@ -98,14 +98,15 @@ def main() -> int:
|
||||
# from the bundle alone.
|
||||
project.key, project.time = "aes", "3/4"
|
||||
page.replacements[second] = Replacement(
|
||||
voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")]
|
||||
voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")],
|
||||
bar=33,
|
||||
)
|
||||
engraved = song_json(project, names)["slices"]
|
||||
assert "engraving" not in engraved[0], "a scanned slice has no notation"
|
||||
ly = engraved[1]["engraving"]
|
||||
assert ly["lang"] == "lilypond"
|
||||
# Song defaults are resolved per slice: reading one slice needs no context.
|
||||
assert (ly["key"], ly["time"], ly["print_time"]) == ("aes", "3/4", False)
|
||||
assert (ly["key"], ly["time"], ly["print_time"], ly["bar"]) == ("aes", "3/4", False, 33)
|
||||
assert ly["voices"][0] == {"clef": "treble", "notes": "c4 d e f", "lyrics": "la la la la"}
|
||||
assert "lyrics" not in ly["voices"][1], "an empty field is absent, not empty"
|
||||
assert ly["voices"][1]["notes"] == "c4 d e f"
|
||||
|
||||
Reference in New Issue
Block a user