Add the editor: page view, cut editing, discard, levels, metadata
QGraphicsView for the viewport, with cuts and the content rectangle manipulated by hit-testing in the view rather than as movable items — the geometry is normalised, so what the screen shows and what the renderer uses are the same numbers at a different zoom. Cuts are edited as polylines: double-click adds one, drag moves it, Ctrl-click inserts a vertex, right-click deletes a vertex or the whole cut. That is how a straight cut becomes the stepped cut Engel needs. Slice boundaries are drawn, not just cut lines, so a trim anomaly is visible before export rather than after. Discarded slices are shaded. Pages preview at 1800px regardless of source resolution, cached per page, because re-reading a 4959x7017 vector render on every slider move is unusable. Autosave is debounced at 800ms and also fires on close. Closes #12 Closes #14 Closes #15 Closes #16 Closes #17 Closes #18 Closes #19 Closes #20 Closes #26
This commit is contained in:
@@ -111,6 +111,12 @@ def _export(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _edit(args: argparse.Namespace) -> int:
|
||||
from .editor import launch
|
||||
|
||||
return launch(Path(args.pdf), SourceType(args.type) if args.type else None)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="noteman-slicer",
|
||||
@@ -148,6 +154,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
exp.add_argument("--type", choices=[t.value for t in SourceType])
|
||||
exp.set_defaults(func=_export)
|
||||
|
||||
ed = sub.add_parser("edit", help="open the editor")
|
||||
ed.add_argument("pdf")
|
||||
ed.add_argument("--type", choices=[t.value for t in SourceType])
|
||||
ed.set_defaults(func=_edit)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
"""The editor: the human-in-the-loop half of the tool.
|
||||
|
||||
Detection proposes; everything here is how you dispose (ADR 0004). Cuts can be
|
||||
authored entirely by hand with detection producing nothing.
|
||||
|
||||
Geometry is edited in normalised page coordinates, so what the screen shows and
|
||||
what the renderer uses are the same numbers at a different zoom.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QBrush,
|
||||
QColor,
|
||||
QImage,
|
||||
QKeySequence,
|
||||
QPainter,
|
||||
QPen,
|
||||
QPixmap,
|
||||
QPolygonF,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QGraphicsScene,
|
||||
QGraphicsView,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSlider,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import bundle
|
||||
from .bundle import METADATA_FIELDS
|
||||
from .detect import deskew, detect_page
|
||||
from .pdf import Source, open_source, page_raster
|
||||
from .project import Cut, Project, default_path
|
||||
from .render import apply_levels
|
||||
|
||||
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
|
||||
HIT = 6 # grab distance in screen pixels
|
||||
AUTOSAVE_MS = 800
|
||||
|
||||
_CUT = QColor(220, 40, 40)
|
||||
_CUT_ACTIVE = QColor(255, 120, 0)
|
||||
_VERTEX = QColor(255, 200, 0)
|
||||
_DISCARD = QColor(120, 120, 140, 90)
|
||||
_RECT = QColor(40, 140, 220)
|
||||
|
||||
|
||||
class PageView(QGraphicsView):
|
||||
"""Pan, zoom, and direct manipulation of cuts and the content rectangle."""
|
||||
|
||||
changed = Signal()
|
||||
selection_changed = Signal()
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setScene(QGraphicsScene(self))
|
||||
self.setRenderHint(QPainter.Antialiasing)
|
||||
self.setDragMode(QGraphicsView.ScrollHandDrag)
|
||||
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||||
|
||||
self.project: Project | None = None
|
||||
self.page_index = 0
|
||||
self.pixmap: QPixmap | None = None
|
||||
self.selected_cut: int | None = None
|
||||
self.selected_slice = 0
|
||||
self._drag: tuple[str, int, int] | None = None
|
||||
|
||||
# -- state ------------------------------------------------------------
|
||||
|
||||
def show_page(self, project: Project, index: int, image: np.ndarray) -> None:
|
||||
self.project = project
|
||||
self.page_index = index
|
||||
h, w = image.shape
|
||||
qimage = QImage(image.data, w, h, w, QImage.Format_Grayscale8).copy()
|
||||
self.pixmap = QPixmap.fromImage(qimage)
|
||||
self.selected_cut = None
|
||||
self.selected_slice = 0
|
||||
self.scene().setSceneRect(0, 0, w, h)
|
||||
self.redraw()
|
||||
self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio)
|
||||
|
||||
@property
|
||||
def page(self):
|
||||
return self.project.pages[self.page_index]
|
||||
|
||||
def redraw(self) -> None:
|
||||
scene = self.scene()
|
||||
scene.clear()
|
||||
if self.pixmap is None:
|
||||
return
|
||||
scene.addPixmap(self.pixmap)
|
||||
w, h = self.pixmap.width(), self.pixmap.height()
|
||||
|
||||
for slot in range(self.page.slice_count):
|
||||
if self.page.discards[slot]:
|
||||
scene.addPolygon(
|
||||
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD)
|
||||
)
|
||||
|
||||
# The selected slice, outlined so trim anomalies are visible.
|
||||
pen = QPen(QColor(0, 170, 0), 2)
|
||||
pen.setCosmetic(True)
|
||||
scene.addPolygon(self._slice_polygon(self.selected_slice, w, h), pen)
|
||||
|
||||
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
|
||||
pen = QPen(_RECT, 2, Qt.DashLine)
|
||||
pen.setCosmetic(True)
|
||||
scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen)
|
||||
|
||||
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(x * w, y * h) for x, y in cut.points]
|
||||
for a, b in zip(points, points[1:]):
|
||||
scene.addLine(a.x(), a.y(), b.x(), b.y(), pen)
|
||||
if i == self.selected_cut:
|
||||
r = HIT * 1.5 / max(self.transform().m11(), 1e-6)
|
||||
for p in points:
|
||||
scene.addEllipse(
|
||||
p.x() - r, p.y() - r, r * 2, r * 2, QPen(Qt.NoPen), QBrush(_VERTEX)
|
||||
)
|
||||
|
||||
def _slice_polygon(self, slot: int, w: int, h: int) -> QPolygonF:
|
||||
above, below = self.page.bounds(slot)
|
||||
top = [(0.0, 0.0), (1.0, 0.0)] if above is None else above.points
|
||||
bottom = [(0.0, 1.0), (1.0, 1.0)] if below is None else below.points
|
||||
pts = [QPointF(x * w, y * h) for x, y in top]
|
||||
pts += [QPointF(x * w, y * h) for x, y in reversed(bottom)]
|
||||
return QPolygonF(pts)
|
||||
|
||||
# -- hit testing ------------------------------------------------------
|
||||
|
||||
def _norm(self, pos) -> tuple[float, float]:
|
||||
p = self.mapToScene(pos)
|
||||
return p.x() / self.pixmap.width(), p.y() / self.pixmap.height()
|
||||
|
||||
def _tolerance(self) -> tuple[float, float]:
|
||||
scale = max(self.transform().m11(), 1e-6)
|
||||
return HIT / scale / self.pixmap.width(), HIT / scale / self.pixmap.height()
|
||||
|
||||
def _hit_cut(self, x: float, y: float) -> tuple[int, int | None] | None:
|
||||
"""(cut index, vertex index or None) under the cursor."""
|
||||
tx, ty = self._tolerance()
|
||||
for i, cut in enumerate(self.page.cuts):
|
||||
for v, (vx, vy) in enumerate(cut.points):
|
||||
if abs(vx - x) <= tx * 2 and abs(vy - y) <= ty * 2:
|
||||
return i, v
|
||||
if abs(cut.y_at(x) - y) <= ty:
|
||||
return i, None
|
||||
return None
|
||||
|
||||
def _hit_rect_edge(self, x: float, y: float) -> str | None:
|
||||
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
|
||||
tx, ty = self._tolerance()
|
||||
if y0 - ty <= y <= y1 + ty:
|
||||
if abs(x - x0) <= tx:
|
||||
return "left"
|
||||
if abs(x - x1) <= tx:
|
||||
return "right"
|
||||
if x0 - tx <= x <= x1 + tx:
|
||||
if abs(y - y0) <= ty:
|
||||
return "top"
|
||||
if abs(y - y1) <= ty:
|
||||
return "bottom"
|
||||
return None
|
||||
|
||||
# -- interaction ------------------------------------------------------
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
if self.project is None or self.pixmap is None:
|
||||
return super().mousePressEvent(event)
|
||||
x, y = self._norm(event.position().toPoint())
|
||||
|
||||
if event.button() == Qt.RightButton:
|
||||
hit = self._hit_cut(x, y)
|
||||
if hit:
|
||||
index, vertex = hit
|
||||
if vertex is not None and len(self.page.cuts[index].points) > 2:
|
||||
self.page.cuts[index].points.pop(vertex)
|
||||
else:
|
||||
self.page.remove_cut(index)
|
||||
self.selected_cut = None
|
||||
self.redraw()
|
||||
self.changed.emit()
|
||||
return
|
||||
|
||||
if event.button() == Qt.LeftButton:
|
||||
edge = self._hit_rect_edge(x, y)
|
||||
hit = self._hit_cut(x, y)
|
||||
if hit and event.modifiers() & Qt.ControlModifier and hit[1] is None:
|
||||
# Ctrl-click on a cut inserts a vertex: this is how a straight
|
||||
# cut becomes a stepped one.
|
||||
cut = self.page.cuts[hit[0]]
|
||||
at = next(i for i, p in enumerate(cut.points) if p[0] > x)
|
||||
cut.points.insert(at, (x, cut.y_at(x)))
|
||||
self.selected_cut = hit[0]
|
||||
self._drag = ("vertex", hit[0], at)
|
||||
elif hit:
|
||||
self.selected_cut = hit[0]
|
||||
self._drag = ("vertex" if hit[1] is not None else "cut", hit[0], hit[1] or 0)
|
||||
elif edge:
|
||||
self._drag = ("rect", 0, 0)
|
||||
self._edge = edge
|
||||
else:
|
||||
self.selected_cut = None
|
||||
self.selected_slice = self._slice_at(x, y)
|
||||
self.selection_changed.emit()
|
||||
self.setDragMode(
|
||||
QGraphicsView.NoDrag if self._drag else QGraphicsView.ScrollHandDrag
|
||||
)
|
||||
self.redraw()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event) -> None:
|
||||
if self._drag and self.pixmap is not None:
|
||||
x, y = self._norm(event.position().toPoint())
|
||||
kind, index, vertex = self._drag
|
||||
if kind == "cut":
|
||||
cut = self.page.cuts[index]
|
||||
shift = y - cut.y_at(x)
|
||||
cut.points = [(px, min(1.0, max(0.0, py + shift))) for px, py in cut.points]
|
||||
elif kind == "vertex":
|
||||
cut = self.page.cuts[index]
|
||||
lo = cut.points[vertex - 1][0] if vertex > 0 else 0.0
|
||||
hi = cut.points[vertex + 1][0] if vertex + 1 < len(cut.points) else 1.0
|
||||
px = cut.points[vertex][0] if vertex in (0, len(cut.points) - 1) else min(
|
||||
max(x, lo), hi
|
||||
)
|
||||
cut.points[vertex] = (px, min(1.0, max(0.0, y)))
|
||||
else:
|
||||
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
|
||||
x, y = min(max(x, 0.0), 1.0), min(max(y, 0.0), 1.0)
|
||||
box = {
|
||||
"left": (x, y0, x1, y1),
|
||||
"right": (x0, y0, x, y1),
|
||||
"top": (x0, y, x1, y1),
|
||||
"bottom": (x0, y0, x1, y),
|
||||
}[self._edge]
|
||||
self.project.pages[self.page_index].content_rect = box
|
||||
self.redraw()
|
||||
return
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None:
|
||||
if self._drag:
|
||||
self._drag = None
|
||||
self.setDragMode(QGraphicsView.ScrollHandDrag)
|
||||
self.page.cuts.sort(key=lambda c: c.points[0][1])
|
||||
self.changed.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event) -> None:
|
||||
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()
|
||||
|
||||
def wheelEvent(self, event) -> None:
|
||||
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
||||
self.scale(factor, factor)
|
||||
self.redraw()
|
||||
|
||||
def _slice_at(self, x: float, y: float) -> int:
|
||||
return sum(1 for cut in self.page.cuts if cut.y_at(x) < y)
|
||||
|
||||
def toggle_discard(self) -> None:
|
||||
self.page.discards[self.selected_slice] = not self.page.discards[self.selected_slice]
|
||||
self.redraw()
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
class Editor(QMainWindow):
|
||||
def __init__(self, source: Source, project: Project) -> None:
|
||||
super().__init__()
|
||||
self.source = source
|
||||
self.project = project
|
||||
self.index = 0
|
||||
self._raw: dict[int, np.ndarray] = {}
|
||||
|
||||
self.setWindowTitle(f"noteman-slicer — {source.path.name}")
|
||||
self.view = PageView()
|
||||
self.view.changed.connect(self._touched)
|
||||
self.view.selection_changed.connect(self._sync)
|
||||
|
||||
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)
|
||||
self._shortcuts()
|
||||
self._load_page(0)
|
||||
|
||||
# -- ui ---------------------------------------------------------------
|
||||
|
||||
def _panel(self) -> QWidget:
|
||||
panel = QWidget()
|
||||
panel.setFixedWidth(320)
|
||||
box = QVBoxLayout(panel)
|
||||
|
||||
nav = QHBoxLayout()
|
||||
self.page_label = QLabel()
|
||||
prev, nxt = QPushButton("◀"), QPushButton("▶")
|
||||
prev.clicked.connect(lambda: self._load_page(self.index - 1))
|
||||
nxt.clicked.connect(lambda: self._load_page(self.index + 1))
|
||||
nav.addWidget(prev)
|
||||
nav.addWidget(self.page_label, 1)
|
||||
nav.addWidget(nxt)
|
||||
box.addLayout(nav)
|
||||
|
||||
page_box = QGroupBox("Page")
|
||||
form = QFormLayout(page_box)
|
||||
self.skew = QDoubleSpinBox()
|
||||
self.skew.setRange(-15.0, 15.0)
|
||||
self.skew.setSingleStep(0.1)
|
||||
self.skew.setDecimals(2)
|
||||
self.skew.setSuffix("°")
|
||||
self.skew.valueChanged.connect(self._skew_changed)
|
||||
form.addRow("Skew", self.skew)
|
||||
|
||||
self.black = QSlider(Qt.Horizontal)
|
||||
self.black.setRange(0, 255)
|
||||
self.white = QSlider(Qt.Horizontal)
|
||||
self.white.setRange(0, 255)
|
||||
self.white.setValue(255)
|
||||
for s in (self.black, self.white):
|
||||
s.valueChanged.connect(self._levels_changed)
|
||||
form.addRow("Black point", self.black)
|
||||
form.addRow("White point", self.white)
|
||||
|
||||
discard = QPushButton("Toggle discard (D)")
|
||||
discard.clicked.connect(self.view.toggle_discard)
|
||||
form.addRow(discard)
|
||||
reset = QPushButton("Reset content rectangle")
|
||||
reset.clicked.connect(self._reset_rect)
|
||||
form.addRow(reset)
|
||||
box.addWidget(page_box)
|
||||
|
||||
meta_box = QGroupBox("Song")
|
||||
meta_form = QFormLayout(meta_box)
|
||||
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)
|
||||
|
||||
self.summary = QLabel()
|
||||
self.summary.setWordWrap(True)
|
||||
box.addWidget(self.summary)
|
||||
|
||||
export = QPushButton("Export bundle…")
|
||||
export.clicked.connect(self._export)
|
||||
box.addWidget(export)
|
||||
box.addStretch(1)
|
||||
|
||||
help_text = QLabel(
|
||||
"Double-click: add cut\n"
|
||||
"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"
|
||||
)
|
||||
help_text.setStyleSheet("color: palette(mid);")
|
||||
box.addWidget(help_text)
|
||||
return panel
|
||||
|
||||
def _shortcuts(self) -> None:
|
||||
for key, slot in (
|
||||
(QKeySequence("D"), self.view.toggle_discard),
|
||||
(QKeySequence(Qt.Key_PageDown), lambda: self._load_page(self.index + 1)),
|
||||
(QKeySequence(Qt.Key_PageUp), lambda: self._load_page(self.index - 1)),
|
||||
(QKeySequence.Save, self._save),
|
||||
):
|
||||
action = QAction(self)
|
||||
action.setShortcut(key)
|
||||
action.triggered.connect(slot)
|
||||
self.addAction(action)
|
||||
|
||||
# -- page handling ----------------------------------------------------
|
||||
|
||||
def _raster(self, index: int) -> np.ndarray:
|
||||
"""Page pixels at preview resolution, cached — the PDF is slow to read."""
|
||||
if index not in self._raw:
|
||||
import cv2
|
||||
|
||||
gray = page_raster(self.source, index)
|
||||
if gray.shape[1] > PREVIEW_MAX:
|
||||
k = PREVIEW_MAX / gray.shape[1]
|
||||
gray = cv2.resize(gray, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
|
||||
self._raw[index] = gray
|
||||
return self._raw[index]
|
||||
|
||||
def _preview(self, index: int) -> np.ndarray:
|
||||
page = self.project.pages[index]
|
||||
black, white = self.project.page_levels(index)
|
||||
return np.ascontiguousarray(
|
||||
apply_levels(deskew(self._raster(index), page.skew), black, white)
|
||||
)
|
||||
|
||||
def _load_page(self, index: int) -> None:
|
||||
if not 0 <= index < len(self.project.pages):
|
||||
return
|
||||
self.index = index
|
||||
self.view.show_page(self.project, index, self._preview(index))
|
||||
self._sync()
|
||||
|
||||
def _sync(self) -> None:
|
||||
page = self.project.pages[self.index]
|
||||
self.page_label.setText(f"Page {self.index + 1} / {len(self.project.pages)}")
|
||||
for widget, value in ((self.skew, page.skew),):
|
||||
widget.blockSignals(True)
|
||||
widget.setValue(value)
|
||||
widget.blockSignals(False)
|
||||
black, white = self.project.page_levels(self.index)
|
||||
for widget, value in ((self.black, black), (self.white, white)):
|
||||
widget.blockSignals(True)
|
||||
widget.setValue(value)
|
||||
widget.blockSignals(False)
|
||||
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"
|
||||
self.summary.setText(
|
||||
f"{page.slice_count} slices on this page · slice "
|
||||
f"{self.view.selected_slice + 1} is {state}\n"
|
||||
f"{kept} of {total} slices kept in the song"
|
||||
)
|
||||
|
||||
# -- edits ------------------------------------------------------------
|
||||
|
||||
def _touched(self) -> None:
|
||||
self._sync()
|
||||
self.autosave.start()
|
||||
|
||||
def _skew_changed(self, value: float) -> None:
|
||||
self.project.pages[self.index].skew = value
|
||||
self.view.show_page(self.project, self.index, self._preview(self.index))
|
||||
self._touched()
|
||||
|
||||
def _levels_changed(self) -> None:
|
||||
self.project.pages[self.index].levels = (self.black.value(), self.white.value())
|
||||
self.view.show_page(self.project, self.index, self._preview(self.index))
|
||||
self._touched()
|
||||
|
||||
def _metadata_changed(self) -> None:
|
||||
self.project.metadata = {
|
||||
field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip()
|
||||
}
|
||||
self.autosave.start()
|
||||
|
||||
def _reset_rect(self) -> None:
|
||||
self.project.pages[self.index].content_rect = None
|
||||
self.view.redraw()
|
||||
self._touched()
|
||||
|
||||
def _save(self) -> None:
|
||||
path = self.project.save()
|
||||
self.statusBar().showMessage(f"saved {path.name}", 2000)
|
||||
|
||||
def _export(self) -> None:
|
||||
self._save()
|
||||
target, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)"
|
||||
)
|
||||
if not target:
|
||||
return
|
||||
try:
|
||||
out = bundle.write(self.project, self.source, Path(target))
|
||||
except Exception as error: # noqa: BLE001 - surfaced to the user
|
||||
QMessageBox.critical(self, "Export failed", str(error))
|
||||
return
|
||||
size = out.stat().st_size / 1024
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Exported",
|
||||
f"{out.name}\n{len(self.project.kept_slices())} slices, {size:.0f} KB",
|
||||
)
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
self._save()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def launch(pdf: Path, source_type=None) -> int:
|
||||
app = QApplication(sys.argv[:1])
|
||||
source = open_source(pdf, source_type)
|
||||
path = default_path(source.path)
|
||||
|
||||
if path.exists():
|
||||
project = Project.load(path)
|
||||
if project.source_changed():
|
||||
QMessageBox.warning(
|
||||
None,
|
||||
"Source changed",
|
||||
"The PDF has changed since these cuts were made.\n"
|
||||
"Cuts may no longer line up with the music.",
|
||||
)
|
||||
else:
|
||||
detections, heights = [], []
|
||||
for i in range(len(source)):
|
||||
gray = page_raster(source, i)
|
||||
detections.append(detect_page(gray))
|
||||
heights.append(gray.shape[0])
|
||||
project = Project.from_detection(source.path, detections, heights)
|
||||
|
||||
window = Editor(source, project)
|
||||
window.resize(1500, 950)
|
||||
window.show()
|
||||
return app.exec()
|
||||
Reference in New Issue
Block a user