Compare commits
3
Commits
c36001f25f
...
6ce45bb1d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce45bb1d8 | ||
|
|
97c8e8a709 | ||
|
|
ff1cc6740e |
@@ -29,6 +29,9 @@ METADATA_FIELDS = (
|
||||
"arranger",
|
||||
"lyricist",
|
||||
"translator",
|
||||
# Free-form, matching noteman's own column: scores notate tempo as a mix of
|
||||
# BPM ("♩=72"), Italian ("Andante") and prose.
|
||||
"tempo",
|
||||
"voices",
|
||||
)
|
||||
|
||||
@@ -39,13 +42,51 @@ def song_json(project: Project, files: list[str]) -> dict:
|
||||
value = project.metadata.get(field)
|
||||
if value:
|
||||
payload[field] = value
|
||||
payload["slices"] = [{"file": name} for name in files]
|
||||
|
||||
kept = project.kept_slices()
|
||||
# Markers reference slices by (page, slot) while editing, because that is
|
||||
# what survives adding and removing cuts. In the bundle they become the
|
||||
# array index, which is the only cross-reference the format has.
|
||||
index_of = {position: i for i, position in enumerate(kept)}
|
||||
|
||||
slices: list[dict] = []
|
||||
for name, (page, slot) in zip(files, kept):
|
||||
entry: dict = {"file": name}
|
||||
markers = []
|
||||
for marker in project.pages[page].markers[slot]:
|
||||
item: dict = {"type": marker.type}
|
||||
if marker.label:
|
||||
item["label"] = marker.label
|
||||
if marker.destination is not None:
|
||||
target = index_of.get(tuple(marker.destination))
|
||||
# A jump whose target was discarded or re-cut away is dropped
|
||||
# rather than exported dangling: noteman would have nothing to
|
||||
# resolve it to.
|
||||
if target is None:
|
||||
continue
|
||||
item["destination"] = target
|
||||
markers.append(item)
|
||||
if markers:
|
||||
entry["markers"] = markers
|
||||
slices.append(entry)
|
||||
|
||||
payload["slices"] = slices
|
||||
return payload
|
||||
|
||||
|
||||
def write(project: Project, source: Source, path: Path) -> Path:
|
||||
"""Render the song and write the bundle. Returns the zip path."""
|
||||
"""Render the song and write the bundle. Returns the zip path.
|
||||
|
||||
A title is required; every other metadata field is optional. noteman's own
|
||||
rule is that a song needs a title and at least one slice, and a bundle that
|
||||
cannot become a song is not worth writing.
|
||||
"""
|
||||
if not project.metadata.get("title", "").strip():
|
||||
raise ValueError("a title is required before a song can be exported")
|
||||
|
||||
images = render_song(project, source)
|
||||
if not images:
|
||||
raise ValueError("no slices to export — every slice is discarded")
|
||||
names = [f"{i + 1:03}.webp" for i in range(len(images))]
|
||||
|
||||
path = Path(path)
|
||||
|
||||
@@ -88,7 +88,13 @@ def _export(args: argparse.Namespace) -> int:
|
||||
print("WARNING: the PDF has changed since these cuts were made")
|
||||
|
||||
out = Path(args.out) if args.out else source.path.with_suffix(".zip")
|
||||
bundle.write(project, source, out)
|
||||
try:
|
||||
bundle.write(project, source, out)
|
||||
except ValueError as error:
|
||||
print(f"cannot export: {error}")
|
||||
print(" set one with: noteman-slicer edit … (Song → Title)")
|
||||
source.close()
|
||||
return 1
|
||||
size = out.stat().st_size
|
||||
slices = len(project.kept_slices())
|
||||
print(f"{out} {slices} slices, {size / 1024:.0f} KB ({size / max(slices, 1) / 1024:.1f} KB/slice)")
|
||||
|
||||
@@ -32,6 +32,8 @@ _STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width
|
||||
_STAFF_MIN_WIDTH = 0.2 # a staff line spans at least this share of the page
|
||||
_CONTENT_MARGIN = 0.01 # slack past the staff ends, for ledger lines and lyrics
|
||||
_EDGE_PERCENTILE = 15 # tolerate this share of staff lines merged into scan artefacts
|
||||
_STAFF_BREAK = 2.5 # a gap this many line-spacings wide separates two staves
|
||||
_STAFF_LINES = 4 # lines a group needs to be a staff rather than an extender (5, minus one for a broken line)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -190,6 +192,46 @@ def content_columns(
|
||||
return max(0.0, left - margin) / width, min(float(width), right + margin) / width
|
||||
|
||||
|
||||
def staff_count(gray: np.ndarray) -> int:
|
||||
"""How many staves are in this slice — i.e. how many voices it holds.
|
||||
|
||||
Kaipaava's first four systems have two staves and its fifth has five, so
|
||||
this cannot be a song-level constant. Counts long horizontal runs and
|
||||
divides by the five lines a staff has; the same signal that finds the music
|
||||
area, so it degrades the same way and no worse.
|
||||
"""
|
||||
height, width = gray.shape
|
||||
binary = (gray < _INK).astype(np.uint8)
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(3, int(width * _STAFF_KERNEL)), 1))
|
||||
lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
|
||||
|
||||
count, _, stats, _ = cv2.connectedComponentsWithStats(lines, 8)
|
||||
rows = sorted(
|
||||
stats[i, cv2.CC_STAT_TOP]
|
||||
for i in range(1, count)
|
||||
if stats[i, cv2.CC_STAT_WIDTH] > width * _STAFF_MIN_WIDTH
|
||||
)
|
||||
if not rows:
|
||||
return 1
|
||||
|
||||
# Compare against the *line* spacing, not the staff height: adjacent staves
|
||||
# can sit closer together than one staff is tall, so a staff-height
|
||||
# threshold merges them into one.
|
||||
line_spacing = (staff_height(gray, 0, height) or height * 0.05) / 4
|
||||
|
||||
groups: list[list[int]] = [[rows[0]]]
|
||||
for row in rows[1:]:
|
||||
if row - groups[-1][-1] > line_spacing * _STAFF_BREAK:
|
||||
groups.append([])
|
||||
groups[-1].append(row)
|
||||
|
||||
# A staff is five evenly spaced lines. Lone long runs are lyric extenders —
|
||||
# Engel's "uh______" — and hairpins, which are just as horizontal as a
|
||||
# staff line and would otherwise each count as a staff.
|
||||
staves = sum(1 for group in groups if len(group) >= _STAFF_LINES)
|
||||
return max(1, staves)
|
||||
|
||||
|
||||
def ink_runs(gray: np.ndarray) -> list[tuple[int, int]]:
|
||||
"""Rows containing ink, despeckled — specks are the known failure mode."""
|
||||
profile = row_darkness(cv2.medianBlur(gray, 3))
|
||||
|
||||
+312
-23
@@ -32,26 +32,40 @@ from PySide6.QtWidgets import (
|
||||
QFormLayout,
|
||||
QGraphicsScene,
|
||||
QGraphicsView,
|
||||
QGroupBox,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QSplitter,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import bundle
|
||||
from . import bundle, lilypond
|
||||
from .bundle import METADATA_FIELDS
|
||||
from .detect import deskew, detect_page
|
||||
from .pdf import Source, open_source, page_raster
|
||||
from .project import Cut, Project, open_project
|
||||
from .project import (
|
||||
JUMP_TYPES,
|
||||
LABELLED_TYPES,
|
||||
MARKER_TYPES,
|
||||
Cut,
|
||||
Marker,
|
||||
Project,
|
||||
open_project,
|
||||
)
|
||||
from .render import apply_levels
|
||||
|
||||
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
|
||||
PANEL_WIDTH = 340 # starting width only; the splitter takes over from there
|
||||
HIT = 6 # grab distance in screen pixels
|
||||
AUTOSAVE_MS = 800
|
||||
|
||||
@@ -60,6 +74,10 @@ _CUT_ACTIVE = QColor(255, 120, 0)
|
||||
_VERTEX = QColor(255, 200, 0)
|
||||
_DISCARD = QColor(120, 120, 140, 90)
|
||||
_RECT = QColor(40, 140, 220)
|
||||
_MARKER = QColor(150, 60, 190)
|
||||
_ENGRAVED = QColor(200, 120, 0)
|
||||
_ENGRAVED_WASH = QColor(230, 160, 30, 55)
|
||||
_BADGE_Z = 10
|
||||
|
||||
|
||||
class PageView(QGraphicsView):
|
||||
@@ -67,6 +85,8 @@ class PageView(QGraphicsView):
|
||||
|
||||
changed = Signal()
|
||||
selection_changed = Signal()
|
||||
picked = Signal(int, int) # page, slot — a jump target chosen by clicking
|
||||
engrave_requested = Signal()
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -80,6 +100,7 @@ class PageView(QGraphicsView):
|
||||
self.pixmap: QPixmap | None = None
|
||||
self.selected_cut: int | None = None
|
||||
self.selected_slice = 0
|
||||
self.picking = False
|
||||
self._drag: tuple[str, int, int] | None = None
|
||||
|
||||
# -- state ------------------------------------------------------------
|
||||
@@ -124,6 +145,65 @@ class PageView(QGraphicsView):
|
||||
pen.setCosmetic(True)
|
||||
scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen)
|
||||
|
||||
for slot in range(self.page.slice_count):
|
||||
above, _ = self.page.bounds(slot)
|
||||
top = 0 if above is None else int(above.lowest * h)
|
||||
x = w * 0.015
|
||||
|
||||
if self.page.replacements[slot]:
|
||||
# A wash over the whole slice, not just a label: this slice
|
||||
# will not ship the pixels underneath it, which is worth
|
||||
# noticing without hunting for small text.
|
||||
scene.addPolygon(
|
||||
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_ENGRAVED_WASH)
|
||||
)
|
||||
x = self._badge(scene, x, top + h * 0.004, "ENGRAVED", _ENGRAVED, w)
|
||||
|
||||
markers = self.page.markers[slot]
|
||||
if markers:
|
||||
self._badge(
|
||||
scene, x, top + h * 0.004, " · ".join(m.describe() for m in markers), _MARKER, w
|
||||
)
|
||||
|
||||
for i, cut in enumerate(self.page.cuts):
|
||||
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
|
||||
pen = QPen(colour, 2)
|
||||
pen.setCosmetic(True)
|
||||
points = [QPointF(px * w, py * h) for px, py in cut.points]
|
||||
for a, b in zip(points, points[1:]):
|
||||
line = scene.addLine(a.x(), a.y(), b.x(), b.y(), pen)
|
||||
line.setZValue(_BADGE_Z)
|
||||
if i == self.selected_cut:
|
||||
r = HIT * 1.5 / max(self.transform().m11(), 1e-6)
|
||||
for p in points:
|
||||
handle = scene.addEllipse(
|
||||
p.x() - r, p.y() - r, r * 2, r * 2, QPen(Qt.NoPen), QBrush(_VERTEX)
|
||||
)
|
||||
handle.setZValue(_BADGE_Z)
|
||||
|
||||
def _badge(self, scene, x: float, y: float, label: str, colour: QColor, w: int) -> float:
|
||||
"""A filled chip with light text. Returns the x to place the next one."""
|
||||
text = scene.addText(label)
|
||||
text.setDefaultTextColor(QColor(255, 255, 255))
|
||||
scale = max(1.0, w / 900)
|
||||
text.setScale(scale)
|
||||
box = text.boundingRect()
|
||||
pad = 4 * scale
|
||||
plate = scene.addRect(
|
||||
x - pad,
|
||||
y - pad / 2,
|
||||
box.width() * scale + pad * 2,
|
||||
box.height() * scale + pad,
|
||||
QPen(Qt.NoPen),
|
||||
QBrush(colour),
|
||||
)
|
||||
# Above the page pixmap, which sits at z 0: a negative z would put the
|
||||
# plate behind the scan and the white text with it.
|
||||
plate.setZValue(_BADGE_Z - 1)
|
||||
text.setZValue(_BADGE_Z)
|
||||
text.setPos(x, y)
|
||||
return x + box.width() * scale + pad * 3
|
||||
|
||||
for i, cut in enumerate(self.page.cuts):
|
||||
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
|
||||
pen = QPen(colour, 2)
|
||||
@@ -189,6 +269,15 @@ class PageView(QGraphicsView):
|
||||
return super().mousePressEvent(event)
|
||||
x, y = self._norm(event.position().toPoint())
|
||||
|
||||
if self.picking:
|
||||
# Choosing a jump's target: click the slice it lands on. Cheaper
|
||||
# than a thumbnail picker and it reads the score rather than a list.
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.picked.emit(self.page_index, self._slice_at(x, y))
|
||||
self.picking = False
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
return
|
||||
|
||||
if event.button() == Qt.RightButton:
|
||||
hit = self._hit_cut(x, y)
|
||||
if hit:
|
||||
@@ -271,10 +360,18 @@ class PageView(QGraphicsView):
|
||||
if self.project is None or self.pixmap is None:
|
||||
return
|
||||
x, y = self._norm(event.position().toPoint())
|
||||
if self._hit_cut(x, y) is None:
|
||||
self.selected_cut = self.page.add_cut(Cut.straight(y))
|
||||
self.redraw()
|
||||
self.changed.emit()
|
||||
if self._hit_cut(x, y) is not None:
|
||||
return
|
||||
if event.modifiers() & Qt.ShiftModifier:
|
||||
# Shift-double-click opens the engrave window on this slice; a
|
||||
# plain double-click adds a cut, which is by far the commoner one.
|
||||
self.selected_slice = self._slice_at(x, y)
|
||||
self.selection_changed.emit()
|
||||
self.engrave_requested.emit()
|
||||
return
|
||||
self.selected_cut = self.page.add_cut(Cut.straight(y))
|
||||
self.redraw()
|
||||
self.changed.emit()
|
||||
|
||||
def wheelEvent(self, event) -> None:
|
||||
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
||||
@@ -299,29 +396,80 @@ class Editor(QMainWindow):
|
||||
self._raw: dict[int, np.ndarray] = {}
|
||||
|
||||
self.setWindowTitle(f"noteman-slicer — {source.path.name}")
|
||||
self._targeting = 0
|
||||
self.view = PageView()
|
||||
self.view.changed.connect(self._touched)
|
||||
self.view.selection_changed.connect(self._sync)
|
||||
self.view.picked.connect(self._target_picked)
|
||||
self.view.engrave_requested.connect(self._open_engrave)
|
||||
|
||||
self.autosave = QTimer(self)
|
||||
self.autosave.setSingleShot(True)
|
||||
self.autosave.setInterval(AUTOSAVE_MS)
|
||||
self.autosave.timeout.connect(self._save)
|
||||
|
||||
central = QWidget()
|
||||
layout = QHBoxLayout(central)
|
||||
layout.addWidget(self.view, 1)
|
||||
layout.addWidget(self._panel())
|
||||
self.setCentralWidget(central)
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
splitter.addWidget(self.view)
|
||||
splitter.addWidget(self._panel())
|
||||
splitter.setStretchFactor(0, 1) # the page takes the slack when resized
|
||||
splitter.setStretchFactor(1, 0)
|
||||
splitter.setSizes([1100, PANEL_WIDTH])
|
||||
splitter.setCollapsible(0, False)
|
||||
self.setCentralWidget(splitter)
|
||||
self._shortcuts()
|
||||
self._load_page(0)
|
||||
|
||||
# -- 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:
|
||||
panel = QWidget()
|
||||
panel.setFixedWidth(320)
|
||||
box = QVBoxLayout(panel)
|
||||
inner = QWidget()
|
||||
box = QVBoxLayout(inner)
|
||||
panel = QScrollArea()
|
||||
panel.setWidget(inner)
|
||||
panel.setWidgetResizable(True)
|
||||
panel.setMinimumWidth(260)
|
||||
|
||||
nav = QHBoxLayout()
|
||||
self.page_label = QLabel()
|
||||
@@ -333,8 +481,9 @@ class Editor(QMainWindow):
|
||||
nav.addWidget(nxt)
|
||||
box.addLayout(nav)
|
||||
|
||||
page_box = QGroupBox("Page")
|
||||
form = QFormLayout(page_box)
|
||||
page_section = self._section("Page", box)
|
||||
form = QFormLayout()
|
||||
page_section.addLayout(form)
|
||||
self.skew = QDoubleSpinBox()
|
||||
self.skew.setRange(-15.0, 15.0)
|
||||
self.skew.setSingleStep(0.1)
|
||||
@@ -360,17 +509,62 @@ class Editor(QMainWindow):
|
||||
reset.setToolTip("Back to the rectangle detection proposed for this page")
|
||||
reset.clicked.connect(self._reset_rect)
|
||||
form.addRow(reset)
|
||||
box.addWidget(page_box)
|
||||
|
||||
meta_box = QGroupBox("Song")
|
||||
meta_form = QFormLayout(meta_box)
|
||||
marker_layout = self._section("Markers on this slice", box)
|
||||
self.marker_list = QListWidget()
|
||||
self.marker_list.setMaximumHeight(110)
|
||||
marker_layout.addWidget(self.marker_list)
|
||||
|
||||
add_row = QHBoxLayout()
|
||||
self.marker_type = QComboBox()
|
||||
self.marker_type.addItems(MARKER_TYPES)
|
||||
self.marker_type.currentTextChanged.connect(self._marker_type_changed)
|
||||
add_row.addWidget(self.marker_type, 1)
|
||||
self.marker_label = QLineEdit()
|
||||
self.marker_label.setPlaceholderText("label")
|
||||
self.marker_label.setFixedWidth(70)
|
||||
add_row.addWidget(self.marker_label)
|
||||
marker_layout.addLayout(add_row)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
add = QPushButton("Add")
|
||||
add.clicked.connect(self._add_marker)
|
||||
remove = QPushButton("Remove")
|
||||
remove.clicked.connect(self._remove_marker)
|
||||
self.retarget = QPushButton("Set target…")
|
||||
self.retarget.clicked.connect(self._pick_target)
|
||||
for button in (add, remove, self.retarget):
|
||||
button_row.addWidget(button)
|
||||
marker_layout.addLayout(button_row)
|
||||
self._marker_type_changed(self.marker_type.currentText())
|
||||
|
||||
# Optional feature: without LilyPond installed the pane never appears,
|
||||
# and nothing else about the tool changes. Collapsed by default — most
|
||||
# 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)
|
||||
open_engrave = QPushButton("Open engrave window…")
|
||||
open_engrave.setToolTip("Or double-click the slice on the page")
|
||||
open_engrave.clicked.connect(self._open_engrave)
|
||||
ly_layout.addWidget(open_engrave)
|
||||
self.ly_status = QLabel()
|
||||
self.ly_status.setWordWrap(True)
|
||||
self.ly_status.setStyleSheet("color: #808080;")
|
||||
ly_layout.addWidget(self.ly_status)
|
||||
|
||||
meta_layout = self._section("Song", box)
|
||||
meta_form = QFormLayout()
|
||||
meta_layout.addLayout(meta_form)
|
||||
self.metadata: dict[str, QLineEdit] = {}
|
||||
for field in METADATA_FIELDS:
|
||||
edit = QLineEdit(self.project.metadata.get(field, ""))
|
||||
edit.textChanged.connect(self._metadata_changed)
|
||||
self.metadata[field] = edit
|
||||
meta_form.addRow(field.replace("_", " ").title(), edit)
|
||||
box.addWidget(meta_box)
|
||||
required = field == "title"
|
||||
if required:
|
||||
edit.setPlaceholderText("required")
|
||||
meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit)
|
||||
|
||||
self.summary = QLabel()
|
||||
self.summary.setWordWrap(True)
|
||||
@@ -386,7 +580,9 @@ class Editor(QMainWindow):
|
||||
"Drag: move cut · Ctrl-click: add vertex\n"
|
||||
"Right-click: delete cut or vertex\n"
|
||||
"Click a slice, then D to discard\n"
|
||||
"Drag the blue edges: content rectangle"
|
||||
"Drag the blue edges: content rectangle\n"
|
||||
"Jump markers: add, then click the target slice\n"
|
||||
"Shift-double-click a slice: re-engrave it"
|
||||
)
|
||||
help_text.setStyleSheet("color: palette(mid);")
|
||||
box.addWidget(help_text)
|
||||
@@ -444,6 +640,8 @@ class Editor(QMainWindow):
|
||||
widget.blockSignals(True)
|
||||
widget.setValue(value)
|
||||
widget.blockSignals(False)
|
||||
self._sync_markers()
|
||||
self._sync_replacement()
|
||||
kept = len(self.project.kept_slices())
|
||||
total = sum(p.slice_count for p in self.project.pages)
|
||||
state = "discarded" if page.discards[self.view.selected_slice] else "kept"
|
||||
@@ -469,6 +667,91 @@ class Editor(QMainWindow):
|
||||
self.view.show_page(self.project, self.index, self._preview(self.index))
|
||||
self._touched()
|
||||
|
||||
# -- markers ----------------------------------------------------------
|
||||
|
||||
def _slot_markers(self) -> list[Marker]:
|
||||
return self.project.pages[self.index].markers[self.view.selected_slice]
|
||||
|
||||
def _marker_type_changed(self, kind: str) -> None:
|
||||
self.marker_label.setEnabled(kind in LABELLED_TYPES)
|
||||
self.retarget.setEnabled(kind in JUMP_TYPES)
|
||||
|
||||
def _add_marker(self) -> None:
|
||||
kind = self.marker_type.currentText()
|
||||
label = self.marker_label.text().strip() or None
|
||||
marker = Marker(type=kind, label=label if kind in LABELLED_TYPES else None)
|
||||
self._slot_markers().append(marker)
|
||||
self.marker_label.clear()
|
||||
self.view.redraw()
|
||||
self._touched()
|
||||
if marker.is_jump:
|
||||
# A jump is useless without a target, so ask for it immediately
|
||||
# rather than leaving it to be noticed at export.
|
||||
self._pick_target()
|
||||
|
||||
def _remove_marker(self) -> None:
|
||||
row = self.marker_list.currentRow()
|
||||
markers = self._slot_markers()
|
||||
if 0 <= row < len(markers):
|
||||
markers.pop(row)
|
||||
self.view.redraw()
|
||||
self._touched()
|
||||
|
||||
def _pick_target(self) -> None:
|
||||
"""Arm click-to-pick for the selected jump marker."""
|
||||
markers = self._slot_markers()
|
||||
row = self.marker_list.currentRow()
|
||||
candidates = [i for i, m in enumerate(markers) if m.is_jump]
|
||||
if not candidates:
|
||||
return
|
||||
self._targeting = row if row in candidates else candidates[-1]
|
||||
self.view.picking = True
|
||||
self.view.setCursor(Qt.CrossCursor)
|
||||
self.statusBar().showMessage(
|
||||
"Click the slice this jump goes to — any page, PageUp/PageDown to move"
|
||||
)
|
||||
|
||||
def _target_picked(self, page: int, slot: int) -> None:
|
||||
markers = self._slot_markers()
|
||||
if 0 <= self._targeting < len(markers):
|
||||
markers[self._targeting].destination = (page, slot)
|
||||
self.view.redraw()
|
||||
self._touched()
|
||||
self.statusBar().showMessage(f"target set to p{page + 1} slice {slot + 1}", 2000)
|
||||
|
||||
def _sync_markers(self) -> None:
|
||||
self.marker_list.clear()
|
||||
for marker in self._slot_markers():
|
||||
self.marker_list.addItem(marker.describe())
|
||||
|
||||
# -- re-engraving -----------------------------------------------------
|
||||
|
||||
def _open_engrave(self) -> None:
|
||||
"""Open the engrave window on the selected slice, showing its pixels."""
|
||||
from .engrave import EngraveWindow
|
||||
from .render import cut_slice, slice_mask
|
||||
|
||||
slot = self.view.selected_slice
|
||||
page = self._preview(self.index)
|
||||
original = cut_slice(page, slice_mask(self.project, self.index, slot, page.shape))
|
||||
if original is None:
|
||||
self.statusBar().showMessage("this slice has no ink to replace", 3000)
|
||||
return
|
||||
|
||||
window = EngraveWindow(self.project, self.index, slot, original, self)
|
||||
window.finished.connect(lambda _: (self.view.redraw(), self._touched()))
|
||||
window.show()
|
||||
|
||||
def _sync_replacement(self) -> None:
|
||||
if self.ly_status is None:
|
||||
return
|
||||
replacement = self.project.pages[self.index].replacements[self.view.selected_slice]
|
||||
if replacement is None:
|
||||
self.ly_status.setText("scanned — not re-engraved")
|
||||
else:
|
||||
voices = len(replacement.voices)
|
||||
self.ly_status.setText(f"re-engraved · {voices} voice{'s' if voices != 1 else ''}")
|
||||
|
||||
def _metadata_changed(self) -> None:
|
||||
self.project.metadata = {
|
||||
field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip()
|
||||
@@ -494,6 +777,12 @@ class Editor(QMainWindow):
|
||||
|
||||
def _export(self) -> None:
|
||||
self._save()
|
||||
if not self.project.metadata.get("title", "").strip():
|
||||
QMessageBox.warning(
|
||||
self, "Title required", "A song needs a title before it can be exported."
|
||||
)
|
||||
self.metadata["title"].setFocus()
|
||||
return
|
||||
target, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""The engrave window: re-cut a system in LilyPond when the scan is past saving.
|
||||
|
||||
Three full-width rows — the scanned original, the render, and the form —
|
||||
because a system is wide and short, and the job is comparing one against the
|
||||
other bar by bar.
|
||||
|
||||
The form only builds the scaffolding: staff group, clef, key, time. Notes and
|
||||
lyrics are raw LilyPond, so everything expressive still works, including the
|
||||
`\\laissezVibrer` / `\\repeatTie` idiom for a tie crossing into the next slice.
|
||||
"""
|
||||
|
||||
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.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import lilypond
|
||||
from .detect import staff_height
|
||||
from .project import Project, Replacement, Voice
|
||||
|
||||
|
||||
def _pixmap(gray: np.ndarray, width: int = 1200) -> QPixmap:
|
||||
if gray.shape[1] > width:
|
||||
k = width / gray.shape[1]
|
||||
gray = cv2.resize(gray, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
|
||||
gray = np.ascontiguousarray(gray)
|
||||
h, w = gray.shape
|
||||
return QPixmap.fromImage(QImage(gray.data, w, h, w, QImage.Format_Grayscale8).copy())
|
||||
|
||||
|
||||
class VoiceRow(QWidget):
|
||||
"""Clef, notes and lyrics for one staff."""
|
||||
|
||||
def __init__(self, voice: Voice, index: int, on_change) -> None:
|
||||
super().__init__()
|
||||
self.voice = voice
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 2, 0, 2)
|
||||
|
||||
self.number = QLabel(f"{index + 1}.")
|
||||
self.number.setFixedWidth(20)
|
||||
layout.addWidget(self.number)
|
||||
|
||||
self.clef = QComboBox()
|
||||
for label, value in lilypond.CLEFS:
|
||||
self.clef.addItem(label, value)
|
||||
self.clef.setCurrentIndex(max(0, [v for _, v in lilypond.CLEFS].index(voice.clef)))
|
||||
self.clef.setFixedWidth(130)
|
||||
self.clef.currentIndexChanged.connect(lambda: (self._pull(), on_change()))
|
||||
layout.addWidget(self.clef)
|
||||
|
||||
self.notes = QLineEdit(voice.notes)
|
||||
self.notes.setPlaceholderText("notes — c4 d e f | g2 e2")
|
||||
self.notes.textChanged.connect(lambda: (self._pull(), on_change()))
|
||||
layout.addWidget(self.notes, 3)
|
||||
|
||||
self.lyrics = QLineEdit(voice.lyrics)
|
||||
self.lyrics.setPlaceholderText("lyrics")
|
||||
self.lyrics.textChanged.connect(lambda: (self._pull(), on_change()))
|
||||
layout.addWidget(self.lyrics, 2)
|
||||
|
||||
def set_index(self, index: int) -> None:
|
||||
self.number.setText(f"{index + 1}.")
|
||||
|
||||
def _pull(self) -> None:
|
||||
self.voice.clef = self.clef.currentData()
|
||||
self.voice.notes = self.notes.text()
|
||||
self.voice.lyrics = self.lyrics.text()
|
||||
|
||||
|
||||
class EngraveWindow(QDialog):
|
||||
def __init__(self, project: Project, page: int, slot: int, original: np.ndarray, parent=None):
|
||||
super().__init__(parent)
|
||||
self.project = project
|
||||
self.page_index = page
|
||||
self.slot = slot
|
||||
self.original = original
|
||||
self.setWindowTitle(f"Re-engrave — page {page + 1}, slice {slot + 1}")
|
||||
self.setModal(False)
|
||||
|
||||
state = project.pages[page]
|
||||
self.replacement = state.replacements[slot] or self._seed()
|
||||
state.replacements[slot] = self.replacement
|
||||
|
||||
rows = QSplitter(Qt.Vertical)
|
||||
rows.addWidget(self._image_panel("Scanned", _pixmap(original)))
|
||||
self.render_label = QLabel("not rendered yet")
|
||||
self.render_label.setAlignment(Qt.AlignCenter)
|
||||
rows.addWidget(self._image_panel("Engraved", None, self.render_label))
|
||||
rows.addWidget(self._form())
|
||||
rows.setSizes([260, 260, 420])
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(rows)
|
||||
self.resize(1400, 980)
|
||||
|
||||
QShortcut(QKeySequence("Ctrl+Return"), self, self.render)
|
||||
QShortcut(QKeySequence("Ctrl+Enter"), self, self.render)
|
||||
if any(v.notes.strip() for v in self.replacement.voices):
|
||||
self.render()
|
||||
|
||||
# -- construction -----------------------------------------------------
|
||||
|
||||
def _seed(self) -> Replacement:
|
||||
"""A fresh replacement: voice count from the slice, the rest from the song.
|
||||
|
||||
Detecting key or clef on the slice itself would mean reading the very
|
||||
scan that is too degraded to use, so those are inherited instead — the
|
||||
song's key does not change, and the clef order repeats system to system.
|
||||
"""
|
||||
from .detect import staff_count
|
||||
|
||||
count = staff_count(self.original)
|
||||
clefs = self.project.clefs
|
||||
return Replacement(
|
||||
voices=[
|
||||
Voice(clef=clefs[i] if i < len(clefs) else "treble") for i in range(count)
|
||||
]
|
||||
)
|
||||
|
||||
def _image_panel(self, title: str, pixmap: QPixmap | None, label: QLabel | None = None):
|
||||
panel = QWidget()
|
||||
box = QVBoxLayout(panel)
|
||||
box.setContentsMargins(0, 0, 0, 0)
|
||||
heading = QLabel(title)
|
||||
heading.setStyleSheet("font-weight: 700; color: #808080;")
|
||||
box.addWidget(heading)
|
||||
|
||||
view = label or QLabel()
|
||||
view.setAlignment(Qt.AlignCenter)
|
||||
if pixmap is not None:
|
||||
view.setPixmap(pixmap)
|
||||
area = QScrollArea()
|
||||
area.setWidget(view)
|
||||
area.setWidgetResizable(True)
|
||||
box.addWidget(area)
|
||||
return panel
|
||||
|
||||
def _form(self) -> QWidget:
|
||||
panel = QWidget()
|
||||
box = QVBoxLayout(panel)
|
||||
|
||||
top = QFormLayout()
|
||||
self.key = QComboBox()
|
||||
for label, value in lilypond.KEY_SIGNATURES:
|
||||
self.key.addItem(label, value)
|
||||
current = self.replacement.key or self.project.key
|
||||
self.key.setCurrentIndex(
|
||||
max(0, [v for _, v in lilypond.KEY_SIGNATURES].index(current))
|
||||
if current in [v for _, v in lilypond.KEY_SIGNATURES]
|
||||
else 7
|
||||
)
|
||||
self.key.currentIndexChanged.connect(self._settings_changed)
|
||||
top.addRow("Key", self.key)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.time = QLineEdit(self.replacement.time or self.project.time)
|
||||
self.time.setFixedWidth(70)
|
||||
self.time.textChanged.connect(self._settings_changed)
|
||||
row.addWidget(self.time)
|
||||
self.print_time = QCheckBox("print it (only the song's first system shows one)")
|
||||
self.print_time.setChecked(self.replacement.print_time)
|
||||
self.print_time.toggled.connect(self._settings_changed)
|
||||
row.addWidget(self.print_time, 1)
|
||||
top.addRow("Time", row)
|
||||
box.addLayout(top)
|
||||
|
||||
voices_label = QLabel("Voices")
|
||||
voices_label.setStyleSheet("font-weight: 700; color: #808080;")
|
||||
box.addWidget(voices_label)
|
||||
|
||||
self.voice_box = QVBoxLayout()
|
||||
box.addLayout(self.voice_box)
|
||||
self.rows: list[VoiceRow] = []
|
||||
for voice in self.replacement.voices:
|
||||
self._add_row(voice)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
add = QPushButton("Add voice")
|
||||
add.clicked.connect(self._add_voice)
|
||||
remove = QPushButton("Remove last voice")
|
||||
remove.clicked.connect(self._remove_voice)
|
||||
render = QPushButton("Render (Ctrl+↵)")
|
||||
render.clicked.connect(self.render)
|
||||
drop = QPushButton("Discard replacement")
|
||||
drop.clicked.connect(self._discard)
|
||||
for button in (add, remove, render, drop):
|
||||
buttons.addWidget(button)
|
||||
box.addLayout(buttons)
|
||||
|
||||
self.status = QLabel()
|
||||
self.status.setWordWrap(True)
|
||||
box.addWidget(self.status)
|
||||
|
||||
self.generated = QPlainTextEdit()
|
||||
self.generated.setReadOnly(True)
|
||||
self.generated.setMaximumHeight(120)
|
||||
self.generated.setStyleSheet("color: #808080;")
|
||||
box.addWidget(self.generated)
|
||||
self._refresh_source()
|
||||
return panel
|
||||
|
||||
# -- edits ------------------------------------------------------------
|
||||
|
||||
def _add_row(self, voice: Voice) -> None:
|
||||
row = VoiceRow(voice, len(self.rows), self._refresh_source)
|
||||
self.rows.append(row)
|
||||
self.voice_box.addWidget(row)
|
||||
|
||||
def _add_voice(self) -> None:
|
||||
clefs = self.project.clefs
|
||||
index = len(self.replacement.voices)
|
||||
voice = Voice(clef=clefs[index] if index < len(clefs) else "treble")
|
||||
self.replacement.voices.append(voice)
|
||||
self._add_row(voice)
|
||||
self._refresh_source()
|
||||
|
||||
def _remove_voice(self) -> None:
|
||||
if not self.rows:
|
||||
return
|
||||
self.replacement.voices.pop()
|
||||
row = self.rows.pop()
|
||||
row.setParent(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.
|
||||
self.project.key = self.key.currentData()
|
||||
self.project.time = self.time.text().strip() or "4/4"
|
||||
self.replacement.key = None
|
||||
self.replacement.time = None
|
||||
self.replacement.print_time = self.print_time.isChecked()
|
||||
self._refresh_source()
|
||||
|
||||
def _discard(self) -> None:
|
||||
self.project.pages[self.page_index].replacements[self.slot] = None
|
||||
self.accept()
|
||||
|
||||
def _refresh_source(self) -> None:
|
||||
self.generated.setPlainText(
|
||||
lilypond.generate(self.replacement, self.project.key, self.project.time)
|
||||
)
|
||||
|
||||
# -- rendering --------------------------------------------------------
|
||||
|
||||
def render(self) -> None:
|
||||
source = lilypond.generate(self.replacement, self.project.key, self.project.time)
|
||||
self.status.setStyleSheet("color: #808080;")
|
||||
self.status.setText("rendering…")
|
||||
self.repaint()
|
||||
try:
|
||||
image = lilypond.render(source)
|
||||
except lilypond.LilypondError as error:
|
||||
self.status.setStyleSheet("color: #c0392b;")
|
||||
self.status.setText(str(error)[-600:])
|
||||
return
|
||||
|
||||
# Shown at the original's staff height rather than its native size:
|
||||
# LilyPond renders ~4300px wide against a ~1500px scan, and matching
|
||||
# staff heights is what export does anyway — so this is a preview of
|
||||
# the real thing rather than of an intermediate.
|
||||
theirs = staff_height(image, 0, image.shape[0])
|
||||
ours = staff_height(self.original, 0, self.original.shape[0])
|
||||
if theirs and ours:
|
||||
k = ours / theirs
|
||||
image = cv2.resize(image, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
|
||||
|
||||
self.render_label.setPixmap(_pixmap(image))
|
||||
self.status.setText(f"rendered — {image.shape[1]}×{image.shape[0]}px at the scan's scale")
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
replacement = self.project.pages[self.page_index].replacements[self.slot]
|
||||
if replacement and not any(v.notes.strip() for v in replacement.voices):
|
||||
# Nothing was written, so leave the slice as a scanned one rather
|
||||
# than exporting an empty engraving.
|
||||
self.project.pages[self.page_index].replacements[self.slot] = None
|
||||
else:
|
||||
self.project.pages[self.page_index].remember_clefs(self.project, self.slot)
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Re-engrave a slice with LilyPond, when the scan is past saving.
|
||||
|
||||
Optional. LilyPond is a system package rather than a wheel, so its absence
|
||||
hides the feature and nothing else changes.
|
||||
|
||||
The tool renders a tight-cropped PNG and hands it to the ordinary render
|
||||
pipeline at the trim stage, so a replaced slice flows through staff-height
|
||||
normalisation, song scale, pad and encode untouched — which is what makes it
|
||||
sit at the same note size as the scanned systems around it without any manual
|
||||
scaling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
RENDER_DPI = 600
|
||||
TIMEOUT_S = 120
|
||||
|
||||
# Read off the page by counting accidentals, which is how you actually read a
|
||||
# key signature. Both names are shown because either identifies the same
|
||||
# signature; the major spelling is what LilyPond gets, and it prints the same
|
||||
# accidentals as the relative minor would.
|
||||
KEY_SIGNATURES: tuple[tuple[str, str], ...] = (
|
||||
("7♭ — C♭ major / A♭ minor", "ces"),
|
||||
("6♭ — G♭ major / E♭ minor", "ges"),
|
||||
("5♭ — D♭ major / B♭ minor", "des"),
|
||||
("4♭ — A♭ major / F minor", "aes"),
|
||||
("3♭ — E♭ major / C minor", "ees"),
|
||||
("2♭ — B♭ major / G minor", "bes"),
|
||||
("1♭ — F major / D minor", "f"),
|
||||
("— C major / A minor", "c"),
|
||||
("1♯ — G major / E minor", "g"),
|
||||
("2♯ — D major / B minor", "d"),
|
||||
("3♯ — A major / F♯ minor", "a"),
|
||||
("4♯ — E major / C♯ minor", "e"),
|
||||
("5♯ — B major / G♯ minor", "b"),
|
||||
("6♯ — F♯ major / D♯ minor", "fis"),
|
||||
("7♯ — C♯ major / A♯ minor", "cis"),
|
||||
)
|
||||
|
||||
# Kaipaava's five-staff system uses all but the alto.
|
||||
CLEFS: tuple[tuple[str, str], ...] = (
|
||||
("Treble", "treble"),
|
||||
("Treble 8 (tenor)", "treble_8"),
|
||||
("Bass", "bass"),
|
||||
("Alto", "alto"),
|
||||
)
|
||||
|
||||
# Notes are entered in \relative mode, so only intervals larger than a fourth
|
||||
# need an octave mark. The reference pitch is the middle of each clef's staff,
|
||||
# so the first note of a part usually needs no mark either.
|
||||
RELATIVE_REFERENCE = {
|
||||
"treble": "c''",
|
||||
"treble_8": "c'",
|
||||
"alto": "c'",
|
||||
"bass": "c",
|
||||
}
|
||||
|
||||
_PREAMBLE = """\\version "2.24.0"
|
||||
\\paper {
|
||||
indent = 0\\mm
|
||||
ragged-right = ##f
|
||||
oddHeaderMarkup = ##f evenHeaderMarkup = ##f
|
||||
oddFooterMarkup = ##f evenFooterMarkup = ##f
|
||||
print-page-number = ##f
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def generate(replacement, key: str, time: str) -> str:
|
||||
"""Build LilyPond source from a slice's structured replacement.
|
||||
|
||||
The time signature is used for spacing and bar checks but not printed
|
||||
unless asked for: the printed score repeats the key at every system and the
|
||||
time signature only at the first, so a re-engraved middle slice showing one
|
||||
would stand out immediately in the scroll.
|
||||
"""
|
||||
key = replacement.key or key
|
||||
time = replacement.time or time
|
||||
|
||||
staves = []
|
||||
for voice in replacement.voices:
|
||||
hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n"
|
||||
body = voice.notes.strip() or "s1"
|
||||
reference = RELATIVE_REFERENCE.get(voice.clef, "c'")
|
||||
staff = (
|
||||
" \\new Staff {\n"
|
||||
f"{hide}"
|
||||
f" \\clef {voice.clef}\n"
|
||||
f" \\key {key} \\major\n"
|
||||
f" \\time {time}\n"
|
||||
f" \\relative {reference} {{ {body} }}\n"
|
||||
" }\n"
|
||||
)
|
||||
if voice.lyrics.strip():
|
||||
staff += f" \\addlyrics {{ {voice.lyrics.strip()} }}\n"
|
||||
staves.append(staff)
|
||||
|
||||
if not staves:
|
||||
staves.append(" \\new Staff { s1 }\n")
|
||||
|
||||
return (
|
||||
_PREAMBLE
|
||||
+ "\\score {\n \\new ChoirStaff <<\n"
|
||||
+ "".join(staves)
|
||||
+ " >>\n \\layout { }\n}\n"
|
||||
)
|
||||
|
||||
|
||||
class LilypondError(RuntimeError):
|
||||
"""LilyPond refused the source. Carries its diagnostics verbatim."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return shutil.which("lilypond") is not None
|
||||
|
||||
|
||||
def version() -> str | None:
|
||||
if not available():
|
||||
return None
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["lilypond", "--version"], capture_output=True, text=True, timeout=20
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return out.stdout.splitlines()[0] if out.stdout else None
|
||||
|
||||
|
||||
def render(source: str, dpi: int = RENDER_DPI) -> np.ndarray:
|
||||
"""Engrave `source` and return it as a grayscale array, cropped to the ink.
|
||||
|
||||
Raises LilypondError with LilyPond's own message on failure — a syntax
|
||||
error has to be readable without leaving the editor.
|
||||
"""
|
||||
if not available():
|
||||
raise LilypondError("LilyPond is not installed")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="noteman-slicer-ly-") as workdir:
|
||||
work = Path(workdir)
|
||||
(work / "slice.ly").write_text(source, encoding="utf-8")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"lilypond",
|
||||
"-dcrop=#t",
|
||||
"-dbackend=cairo",
|
||||
"--png",
|
||||
f"-dresolution={dpi}",
|
||||
"-o",
|
||||
"out",
|
||||
"slice.ly",
|
||||
],
|
||||
cwd=work,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=TIMEOUT_S,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise LilypondError(f"LilyPond timed out after {TIMEOUT_S}s") from error
|
||||
|
||||
# LilyPond still writes a page when it rejects the source, so the exit
|
||||
# code has to be checked first — otherwise a broken snippet silently
|
||||
# becomes a garbage slice.
|
||||
if result.returncode != 0:
|
||||
raise LilypondError(result.stderr.strip() or f"exit status {result.returncode}")
|
||||
|
||||
# -dcrop writes out.cropped.png; the uncropped page is the fallback if
|
||||
# a LilyPond build ever stops honouring it.
|
||||
for name in ("out.cropped.png", "out.png"):
|
||||
image = work / name
|
||||
if image.exists():
|
||||
gray = cv2.imread(str(image), cv2.IMREAD_GRAYSCALE)
|
||||
if gray is not None:
|
||||
return gray
|
||||
|
||||
raise LilypondError(result.stderr.strip() or result.stdout.strip() or "no output")
|
||||
+180
-1
@@ -67,6 +67,90 @@ class Cut:
|
||||
return min(y for _, y in self.points)
|
||||
|
||||
|
||||
# noteman's enum, verbatim. Real coupling between two repos: adding a type
|
||||
# means changing both. Order is the order they appear in the editor's picker.
|
||||
MARKER_TYPES = (
|
||||
"rehearsal_letter",
|
||||
"section_label",
|
||||
"segno",
|
||||
"coda",
|
||||
"fine",
|
||||
"repeat_start",
|
||||
"repeat_end",
|
||||
"volta",
|
||||
"to_coda",
|
||||
"ds_al_coda",
|
||||
"ds_al_fine",
|
||||
"dc_al_coda",
|
||||
"dc_al_fine",
|
||||
"generic_jump",
|
||||
)
|
||||
|
||||
# The types that carry free text.
|
||||
LABELLED_TYPES = frozenset({"rehearsal_letter", "section_label", "volta"})
|
||||
|
||||
# The types that send the reader elsewhere. Every one stores its target
|
||||
# explicitly rather than resolving by type at read time, so the bundle is
|
||||
# self-describing and a score with two codas simply works.
|
||||
JUMP_TYPES = frozenset(
|
||||
{"to_coda", "ds_al_coda", "ds_al_fine", "dc_al_coda", "dc_al_fine", "generic_jump"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Marker:
|
||||
"""A semantic tag on a slice, used by noteman's navigation."""
|
||||
|
||||
type: str
|
||||
label: str | None = None
|
||||
# (page, slot) of the target slice, for jump sources. Positional like the
|
||||
# slices themselves; resolved to a bundle index at export.
|
||||
destination: tuple[int, int] | None = None
|
||||
|
||||
@property
|
||||
def is_jump(self) -> bool:
|
||||
return self.type in JUMP_TYPES
|
||||
|
||||
def describe(self) -> str:
|
||||
text = self.type
|
||||
if self.label:
|
||||
text += f" “{self.label}”"
|
||||
if self.destination:
|
||||
text += f" → p{self.destination[0] + 1}s{self.destination[1] + 1}"
|
||||
return text
|
||||
|
||||
|
||||
@dataclass
|
||||
class Voice:
|
||||
"""One staff of a re-engraved system.
|
||||
|
||||
`notes` and `lyrics` are raw LilyPond, so slurs, dynamics, tuplets and the
|
||||
`\\laissezVibrer` / `\\repeatTie` idiom for ties crossing a slice boundary
|
||||
all work without the form knowing anything about them.
|
||||
"""
|
||||
|
||||
clef: str = "treble"
|
||||
notes: str = ""
|
||||
lyrics: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Replacement:
|
||||
"""A system engraved with LilyPond in place of the scanned one.
|
||||
|
||||
Key and time are per song in practice — Kaipaava is 4♭ and 4/4 from first
|
||||
system to last — so they live on the project and are only set here when a
|
||||
slice genuinely differs.
|
||||
"""
|
||||
|
||||
voices: list[Voice] = field(default_factory=list)
|
||||
key: str | None = None
|
||||
time: str | 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
|
||||
|
||||
|
||||
@dataclass
|
||||
class Page:
|
||||
"""One page's decisions. `cuts` are ordered top to bottom."""
|
||||
@@ -74,6 +158,11 @@ class Page:
|
||||
skew: float = 0.0
|
||||
cuts: list[Cut] = field(default_factory=list)
|
||||
discards: list[bool] = field(default_factory=lambda: [False])
|
||||
# One list per slice, parallel to `discards`.
|
||||
markers: list[list[Marker]] = field(default_factory=lambda: [[]])
|
||||
# A re-engraved system per slice, when the scan is past saving. None for
|
||||
# the ordinary case, which is nearly all of them.
|
||||
replacements: list[Replacement | None] = field(default_factory=lambda: [None])
|
||||
content_rect: tuple[float, float, float, float] | None = None
|
||||
levels: tuple[int, int] | None = None
|
||||
|
||||
@@ -92,8 +181,13 @@ class Page:
|
||||
y = cut.points[0][1]
|
||||
index = sum(1 for c in self.cuts if c.points[0][1] < y)
|
||||
self.cuts.insert(index, cut)
|
||||
# The split slice keeps its flag on both halves.
|
||||
# The split slice keeps its flag on both halves. Its markers stay with
|
||||
# the upper half: a marker sits on a printed symbol, and splitting a
|
||||
# slice cannot say which side that symbol landed on — leaving them put
|
||||
# is at least predictable, and moving one is a click.
|
||||
self.discards.insert(index, self.discards[index])
|
||||
self.markers.insert(index + 1, [])
|
||||
self.replacements.insert(index + 1, None)
|
||||
return index
|
||||
|
||||
def remove_cut(self, index: int) -> None:
|
||||
@@ -102,6 +196,16 @@ class Page:
|
||||
merged = self.discards[index] and self.discards[index + 1]
|
||||
self.discards.pop(index + 1)
|
||||
self.discards[index] = merged
|
||||
self.markers[index].extend(self.markers.pop(index + 1))
|
||||
# Two engraved halves cannot be merged, so the upper one wins.
|
||||
below = self.replacements.pop(index + 1)
|
||||
self.replacements[index] = self.replacements[index] or below
|
||||
|
||||
def remember_clefs(self, project: Project, slot: int) -> None:
|
||||
"""Carry this slice's clefs forward as the song's defaults."""
|
||||
replacement = self.replacements[slot]
|
||||
if replacement and replacement.voices:
|
||||
project.clefs = [v.clef for v in replacement.voices]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -112,6 +216,12 @@ class Project:
|
||||
content_rect: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
|
||||
levels: tuple[int, int] = (0, 255)
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
# Engraving defaults for the song. Key and time are set once and inherited
|
||||
# by every replacement; `clefs` remembers what each voice position was last
|
||||
# given, so the second re-engraved system in a song opens already filled in.
|
||||
key: str = "c"
|
||||
time: str = "4/4"
|
||||
clefs: list[str] = field(default_factory=list)
|
||||
path: Path | None = None
|
||||
# Set once the song has been exported. A project is spent at that point:
|
||||
# opening the PDF again starts a fresh session from detection rather than
|
||||
@@ -175,6 +285,8 @@ class Project:
|
||||
skew=detection.skew,
|
||||
cuts=[Cut.straight(y / height) for y in ys],
|
||||
discards=discards,
|
||||
markers=[[] for _ in discards],
|
||||
replacements=[None] * len(discards),
|
||||
# Per page, not per song: scans drift, so the margin junk
|
||||
# sits in a different place on each one.
|
||||
content_rect=detection.content,
|
||||
@@ -193,11 +305,43 @@ class Project:
|
||||
"content_rect": list(self.content_rect),
|
||||
"levels": list(self.levels),
|
||||
"metadata": self.metadata,
|
||||
"key": self.key,
|
||||
"time": self.time,
|
||||
"clefs": self.clefs,
|
||||
"pages": [
|
||||
{
|
||||
"skew": page.skew,
|
||||
"cuts": [[list(p) for p in cut.points] for cut in page.cuts],
|
||||
"discards": page.discards,
|
||||
"markers": [
|
||||
[
|
||||
{
|
||||
"type": m.type,
|
||||
**({"label": m.label} if m.label else {}),
|
||||
**(
|
||||
{"destination": list(m.destination)}
|
||||
if m.destination
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for m in slot
|
||||
]
|
||||
for slot in page.markers
|
||||
],
|
||||
"replacements": [
|
||||
None
|
||||
if r is None
|
||||
else {
|
||||
"voices": [
|
||||
{"clef": v.clef, "notes": v.notes, "lyrics": v.lyrics}
|
||||
for v in r.voices
|
||||
],
|
||||
**({"key": r.key} if r.key else {}),
|
||||
**({"time": r.time} if r.time else {}),
|
||||
**({"print_time": True} if r.print_time else {}),
|
||||
}
|
||||
for r in page.replacements
|
||||
],
|
||||
"content_rect": list(page.content_rect) if page.content_rect else None,
|
||||
"levels": list(page.levels) if page.levels else None,
|
||||
}
|
||||
@@ -222,6 +366,38 @@ class Project:
|
||||
skew=page["skew"],
|
||||
cuts=[Cut([tuple(p) for p in cut]) for cut in page["cuts"]],
|
||||
discards=page["discards"],
|
||||
markers=[
|
||||
[
|
||||
Marker(
|
||||
type=m["type"],
|
||||
label=m.get("label"),
|
||||
destination=tuple(m["destination"]) if m.get("destination") else None,
|
||||
)
|
||||
for m in slot
|
||||
]
|
||||
for slot in page.get("markers", [[] for _ in page["discards"]])
|
||||
],
|
||||
replacements=[
|
||||
# A bare string is the short-lived raw-source form, which
|
||||
# never shipped: dropped rather than migrated, so the rest
|
||||
# of the project still opens.
|
||||
None
|
||||
if not isinstance(r, dict)
|
||||
else Replacement(
|
||||
voices=[
|
||||
Voice(
|
||||
clef=v.get("clef", "treble"),
|
||||
notes=v.get("notes", ""),
|
||||
lyrics=v.get("lyrics", ""),
|
||||
)
|
||||
for v in r.get("voices", [])
|
||||
],
|
||||
key=r.get("key"),
|
||||
time=r.get("time"),
|
||||
print_time=r.get("print_time", False),
|
||||
)
|
||||
for r in page.get("replacements", [None] * len(page["discards"]))
|
||||
],
|
||||
content_rect=tuple(page["content_rect"]) if page["content_rect"] else None,
|
||||
levels=tuple(page["levels"]) if page["levels"] else None,
|
||||
)
|
||||
@@ -236,6 +412,9 @@ class Project:
|
||||
metadata=data.get("metadata", {}),
|
||||
path=path,
|
||||
exported=data.get("exported", False),
|
||||
key=data.get("key", "c"),
|
||||
time=data.get("time", "4/4"),
|
||||
clefs=data.get("clefs", []),
|
||||
)
|
||||
|
||||
def source_changed(self) -> bool:
|
||||
|
||||
@@ -19,6 +19,7 @@ from dataclasses import dataclass
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from . import lilypond
|
||||
from .detect import deskew, staff_height
|
||||
from .pdf import Source, page_raster
|
||||
from .project import Cut, Project
|
||||
@@ -146,11 +147,25 @@ def render_slices(project: Project, source: Source) -> list[SliceImage]:
|
||||
"""Every kept slice, trimmed but not yet scaled."""
|
||||
out: list[SliceImage] = []
|
||||
for index in range(len(project.pages)):
|
||||
page = page_pixels(project, source, index)
|
||||
for slot in range(project.pages[index].slice_count):
|
||||
if project.pages[index].discards[slot]:
|
||||
page_state = project.pages[index]
|
||||
# Only rasterize the page if some slice on it still comes from the scan.
|
||||
page = None
|
||||
for slot in range(page_state.slice_count):
|
||||
if page_state.discards[slot]:
|
||||
continue
|
||||
gray = cut_slice(page, slice_mask(project, index, slot, page.shape))
|
||||
|
||||
engraved = page_state.replacements[slot]
|
||||
if engraved and engraved.voices:
|
||||
# A re-engraved system enters here, at the trim stage, so it
|
||||
# flows through staff-height normalisation and the rest exactly
|
||||
# as a scanned one does.
|
||||
gray = lilypond.render(
|
||||
lilypond.generate(engraved, project.key, project.time)
|
||||
)
|
||||
else:
|
||||
if page is None:
|
||||
page = page_pixels(project, source, index)
|
||||
gray = cut_slice(page, slice_mask(project, index, slot, page.shape))
|
||||
if gray is None:
|
||||
continue # a kept slice that turned out to hold no ink
|
||||
out.append(SliceImage(index, slot, gray, staff_height(gray, 0, gray.shape[0])))
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Runnable check for LilyPond slice replacement.
|
||||
|
||||
Skips cleanly when LilyPond is not installed — that is the point of the
|
||||
availability gate, so the check has to honour it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from noteman_slicer import lilypond # noqa: E402
|
||||
from noteman_slicer.detect import detect_page # noqa: E402
|
||||
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
||||
from noteman_slicer.detect import staff_count # noqa: E402
|
||||
from noteman_slicer.project import ( # noqa: E402
|
||||
Cut,
|
||||
Project,
|
||||
Replacement,
|
||||
Voice,
|
||||
default_path,
|
||||
)
|
||||
from noteman_slicer.render import cut_slice, render_slices, scale_song, slice_mask # noqa: E402
|
||||
|
||||
# Notes are relative, so no octave marks except where a leap needs one.
|
||||
SATB = Replacement(
|
||||
voices=[
|
||||
Voice("treble", "c4 d e f | g2 e2", "la la la la la la"),
|
||||
Voice("bass", "c4 d e f | g2 c2", "la la la la la la"),
|
||||
]
|
||||
)
|
||||
|
||||
W, H = 1200, 1600
|
||||
|
||||
|
||||
def _scan_pdf(path: Path) -> None:
|
||||
art = np.full((H, W), 255, np.uint8)
|
||||
for top in (300, 800):
|
||||
art[top : top + 200, 100:104] = 0
|
||||
for staff in (top, top + 140):
|
||||
for i in range(5):
|
||||
art[staff + i * 15 : staff + i * 15 + 2, 110:1100] = 0
|
||||
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
|
||||
doc = pymupdf.open()
|
||||
doc.new_page(width=595, height=842).insert_image(pymupdf.Rect(0, 0, 595, 842), pixmap=pix)
|
||||
doc.save(path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not lilypond.available():
|
||||
print("ok (skipped: LilyPond not installed)")
|
||||
return 0
|
||||
|
||||
tmp = Path(__file__).with_name("_tmp")
|
||||
tmp.mkdir(exist_ok=True)
|
||||
pdf = tmp / "scan.pdf"
|
||||
_scan_pdf(pdf)
|
||||
|
||||
# A syntax error must come back readable rather than as a stack trace.
|
||||
try:
|
||||
lilypond.render("\\score { this is not lilypond }")
|
||||
except lilypond.LilypondError as error:
|
||||
assert str(error), "the error must carry LilyPond's own message"
|
||||
else:
|
||||
raise AssertionError("bad source should raise")
|
||||
|
||||
# The generator: key at slice level, time used but not printed.
|
||||
source = lilypond.generate(SATB, "aes", "4/4")
|
||||
assert source.count("\\new Staff") == 2
|
||||
assert source.count("\\key aes \\major") == 2, "every staff carries the key"
|
||||
assert "\\omit Staff.TimeSignature" in source, "a middle system prints no time signature"
|
||||
assert "\\addlyrics" in source
|
||||
# Relative entry, referenced to the middle of each clef's staff, so notes
|
||||
# carry no octave marks.
|
||||
assert "\\relative c'' { c4 d e f | g2 e2 }" in source
|
||||
assert "\\relative c { c4 d e f | g2 c2 }" in source
|
||||
|
||||
printed = lilypond.generate(
|
||||
Replacement(voices=SATB.voices, print_time=True), "aes", "4/4"
|
||||
)
|
||||
assert "\\omit Staff.TimeSignature" not in printed
|
||||
|
||||
override = lilypond.generate(Replacement(voices=SATB.voices, key="d"), "aes", "4/4")
|
||||
assert "\\key d \\major" in override, "a slice-level key must win over the song's"
|
||||
|
||||
# Every key signature and clef the form offers must be real LilyPond.
|
||||
assert len(lilypond.KEY_SIGNATURES) == 15
|
||||
assert ("4♭ — A♭ major / F minor", "aes") in lilypond.KEY_SIGNATURES
|
||||
assert [v for _, v in lilypond.CLEFS] == ["treble", "treble_8", "bass", "alto"]
|
||||
|
||||
engraved = lilypond.render(source, dpi=200)
|
||||
assert engraved.ndim == 2 and engraved.dtype == np.uint8
|
||||
# -dcrop trims to the ink, so the result is far smaller than a page.
|
||||
assert engraved.shape[0] < 1200, engraved.shape
|
||||
assert engraved.min() == 0 and engraved.max() == 255
|
||||
|
||||
source = open_source(pdf)
|
||||
gray = page_raster(source, 0)
|
||||
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
|
||||
page = project.pages[0]
|
||||
assert len(page.replacements) == page.slice_count
|
||||
|
||||
kept = project.kept_slices()
|
||||
(_, first), (_, second) = kept
|
||||
|
||||
# Voice count is seeded from the slice: the fixture draws two staves.
|
||||
preview = cut_slice(gray, slice_mask(project, 0, second, gray.shape))
|
||||
assert staff_count(preview) == 2, staff_count(preview)
|
||||
|
||||
page.replacements[second] = SATB
|
||||
|
||||
slices = render_slices(project, source)
|
||||
assert len(slices) == 2
|
||||
scanned, replaced = slices
|
||||
assert scanned.staff and replaced.staff
|
||||
|
||||
# The whole point: after normalisation both sit at the same staff height,
|
||||
# with no manual scaling, even though the sources differ wildly in scale.
|
||||
factors = [target / s.staff for s, target in ((scanned, 1.0), (replaced, 1.0))]
|
||||
assert factors # keep the intent readable
|
||||
out = scale_song(slices)
|
||||
heights = []
|
||||
for image, original in zip(out, slices):
|
||||
k = image.shape[0] / original.gray.shape[0]
|
||||
heights.append(original.staff * k)
|
||||
assert abs(heights[0] - heights[1]) < 2.0, f"staff heights should match: {heights}"
|
||||
|
||||
# Cut edits keep the replacement aligned with its slice.
|
||||
index = page.add_cut(Cut.straight(0.97))
|
||||
assert len(page.replacements) == page.slice_count
|
||||
assert page.replacements[second] is SATB
|
||||
page.remove_cut(index)
|
||||
assert page.replacements[second] is SATB
|
||||
|
||||
# Round-trip, including the song-level engraving defaults.
|
||||
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
|
||||
saved = project.save()
|
||||
reloaded = Project.load(saved)
|
||||
assert (reloaded.key, reloaded.time, reloaded.clefs) == ("aes", "3/4", ["treble", "bass"])
|
||||
restored = reloaded.pages[0].replacements[second]
|
||||
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 reloaded.pages[0].replacements[first] is None
|
||||
|
||||
source.close()
|
||||
for f in (pdf, saved, default_path(pdf)):
|
||||
f.unlink(missing_ok=True)
|
||||
tmp.rmdir()
|
||||
print("ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Runnable check for markers: model, cut edits, and export resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from noteman_slicer import bundle # noqa: E402
|
||||
from noteman_slicer.bundle import song_json # noqa: E402
|
||||
from noteman_slicer.detect import detect_page # noqa: E402
|
||||
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
||||
from noteman_slicer.project import ( # noqa: E402
|
||||
JUMP_TYPES,
|
||||
MARKER_TYPES,
|
||||
Cut,
|
||||
Marker,
|
||||
Project,
|
||||
default_path,
|
||||
)
|
||||
|
||||
W, H = 1200, 1600
|
||||
|
||||
|
||||
def _scan_pdf(path: Path) -> None:
|
||||
art = np.full((H, W), 255, np.uint8)
|
||||
for top in (300, 800):
|
||||
art[top : top + 200, 100:104] = 0
|
||||
for staff in (top, top + 140):
|
||||
for i in range(5):
|
||||
art[staff + i * 15 : staff + i * 15 + 2, 110:1100] = 0
|
||||
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
|
||||
doc = pymupdf.open()
|
||||
doc.new_page(width=595, height=842).insert_image(pymupdf.Rect(0, 0, 595, 842), pixmap=pix)
|
||||
doc.save(path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tmp = Path(__file__).with_name("_tmp")
|
||||
tmp.mkdir(exist_ok=True)
|
||||
pdf = tmp / "scan.pdf"
|
||||
_scan_pdf(pdf)
|
||||
|
||||
# noteman's enum, verbatim — this is real coupling between two repos.
|
||||
assert len(MARKER_TYPES) == 14, MARKER_TYPES
|
||||
assert len(JUMP_TYPES) == 6
|
||||
assert "generic_jump" in JUMP_TYPES and "segno" not in JUMP_TYPES
|
||||
|
||||
source = open_source(pdf)
|
||||
gray = page_raster(source, 0)
|
||||
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
|
||||
page = project.pages[0]
|
||||
assert len(page.markers) == page.slice_count
|
||||
|
||||
kept = project.kept_slices()
|
||||
assert len(kept) == 2, kept
|
||||
(_, first), (_, second) = kept
|
||||
|
||||
page.markers[first].append(Marker("rehearsal_letter", label="A"))
|
||||
page.markers[second].append(Marker("coda"))
|
||||
page.markers[first].append(Marker("to_coda", destination=(0, second)))
|
||||
|
||||
# Cut edits keep markers aligned with their slices.
|
||||
before = list(page.markers[first])
|
||||
index = page.add_cut(Cut.straight(0.95))
|
||||
assert len(page.markers) == page.slice_count
|
||||
assert page.markers[first] == before, "markers must not move when a later slice splits"
|
||||
page.remove_cut(index)
|
||||
assert len(page.markers) == page.slice_count
|
||||
|
||||
# Export resolves (page, slot) to the slice's index in the bundle.
|
||||
names = [f"{i + 1:03}.webp" for i in range(len(project.kept_slices()))]
|
||||
payload = song_json(project, names)
|
||||
slices = payload["slices"]
|
||||
assert [s["file"] for s in slices] == names
|
||||
assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"}
|
||||
assert slices[1]["markers"][0] == {"type": "coda"}
|
||||
assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1}
|
||||
|
||||
# A jump whose target got discarded is dropped, not exported dangling.
|
||||
project.pages[0].discards[second] = True
|
||||
dropped = song_json(project, ["001.webp"])
|
||||
assert all(m["type"] != "to_coda" for m in dropped["slices"][0].get("markers", []))
|
||||
project.pages[0].discards[second] = False
|
||||
|
||||
# Round-trip through the project file.
|
||||
saved = project.save()
|
||||
reloaded = Project.load(saved)
|
||||
assert reloaded.pages[0].markers[first][0].label == "A"
|
||||
assert reloaded.pages[0].markers[first][1].destination == (0, second)
|
||||
assert reloaded.pages[0].markers[second][0].type == "coda"
|
||||
|
||||
# And through a real bundle.
|
||||
reloaded.metadata["title"] = "Test song"
|
||||
out = bundle.write(reloaded, source, tmp / "song.zip")
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
meta = json.loads(zf.read("song.json"))
|
||||
assert meta["slices"][0]["markers"][1]["destination"] == 1, meta["slices"]
|
||||
|
||||
source.close()
|
||||
for f in (pdf, out, saved, default_path(pdf)):
|
||||
f.unlink(missing_ok=True)
|
||||
tmp.rmdir()
|
||||
print("ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -139,7 +139,15 @@ def main() -> int:
|
||||
src2.close()
|
||||
labelled.unlink()
|
||||
|
||||
# Bundle.
|
||||
# Bundle. A title is required; everything else is optional.
|
||||
try:
|
||||
bundle.write(project, source, tmp / "untitled.zip")
|
||||
except ValueError as error:
|
||||
assert "title" in str(error)
|
||||
else:
|
||||
raise AssertionError("export without a title should be refused")
|
||||
|
||||
project.metadata["title"] = "Test song"
|
||||
out = bundle.write(project, source, tmp / "song.zip")
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
Reference in New Issue
Block a user