The panel now uses the same colours the page is drawn with, on dark chrome so the scan is the brightest thing on screen. Levels move from two anonymous sliders to the scan's own histogram with draggable black and white points and a tone strip, since getting them wrong is the one mistake that only shows up on the tablet. A page rail replaces the stepper and carries each page's slice count; export is pinned below the scroll instead of below the fold; marker types read as prose.
147 lines
5.2 KiB
Python
147 lines
5.2 KiB
Python
"""Runnable check that the editor builds and its edits reach project state.
|
|
|
|
Runs offscreen, so it verifies wiring rather than appearance: that the widgets
|
|
construct, that an edit changes the model, and that autosave and export work.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
import numpy as np # noqa: E402
|
|
import pymupdf # noqa: E402
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
|
|
|
from noteman_slicer import bundle # noqa: E402
|
|
from noteman_slicer.detect import detect_page # noqa: E402
|
|
from noteman_slicer.editor import Editor # noqa: E402
|
|
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
|
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
|
|
|
|
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)
|
|
|
|
app = QApplication.instance() or QApplication(sys.argv[:1])
|
|
source = open_source(pdf)
|
|
gray = page_raster(source, 0)
|
|
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
|
|
|
|
editor = Editor(source, project)
|
|
page = project.pages[0]
|
|
|
|
# Cuts.
|
|
before = page.slice_count
|
|
editor.view.selected_cut = page.add_cut(Cut.straight(0.5))
|
|
editor.view.redraw()
|
|
assert page.slice_count == before + 1
|
|
|
|
# A vertex turns a straight cut into a stepped one.
|
|
cut = page.cuts[editor.view.selected_cut]
|
|
cut.points.insert(1, (0.4, cut.y_at(0.4)))
|
|
cut.points[1] = (0.4, cut.points[1][1] + 0.03)
|
|
assert cut.straight_y is None, "the cut should no longer be straight"
|
|
editor.view.redraw()
|
|
|
|
# Discard.
|
|
editor.view.selected_slice = 1
|
|
was = page.discards[1]
|
|
editor.view.toggle_discard()
|
|
assert page.discards[1] != was
|
|
|
|
# Skew and levels reach the model and re-render without raising.
|
|
editor.skew.setValue(-1.4)
|
|
assert abs(page.skew + 1.4) < 1e-6
|
|
# The levels bar carries the page's own histogram and drives the model
|
|
# directly — there are no sliders behind it to keep in step.
|
|
assert editor.levels.hist.sum() > 0, "the histogram should hold the scan"
|
|
editor.levels.set_levels(40, 210)
|
|
editor._levels_changed(40, 210)
|
|
assert project.page_levels(0) == (40, 210)
|
|
editor.rail.picked.emit(0) # the page rail navigates
|
|
assert editor.index == 0 and len(editor.rail.buttons) == len(project.pages)
|
|
|
|
# Metadata.
|
|
editor.metadata["title"].setText("Ketun joululaulu")
|
|
editor.metadata["composer"].setText("trad.")
|
|
assert project.metadata["title"] == "Ketun joululaulu"
|
|
|
|
# Content rectangle edits, and reset going back to detection's proposal for
|
|
# the page as it now stands — not to the whole page, which would undo the
|
|
# thing the rectangle exists for.
|
|
expected = detect_page(editor._preview(0), skew=0.0).content
|
|
page.content_rect = (0.05, 0.02, 0.95, 0.98)
|
|
assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98)
|
|
editor._reset_rect()
|
|
assert project.page_content_rect(0) == expected, (project.page_content_rect(0), expected)
|
|
assert project.page_content_rect(0) != (0.0, 0.0, 1.0, 1.0)
|
|
|
|
# Autosave target, then a round-trip through disk.
|
|
editor._save()
|
|
saved = default_path(pdf)
|
|
assert saved.exists()
|
|
reloaded = Project.load(saved)
|
|
assert reloaded.metadata["title"] == "Ketun joululaulu"
|
|
assert reloaded.pages[0].skew == -1.4
|
|
assert reloaded.pages[0].levels == (40, 210)
|
|
assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts]
|
|
|
|
# The page fits the viewport once the window has a real size. show_page's
|
|
# own fit runs before layout, when the viewport is still its default.
|
|
editor.resize(900, 700)
|
|
editor.show()
|
|
app.processEvents()
|
|
scene = editor.view.sceneRect()
|
|
scale = editor.view.transform().m11()
|
|
viewport = editor.view.viewport()
|
|
fill = max(
|
|
scale * scene.width() / viewport.width(),
|
|
scale * scene.height() / viewport.height(),
|
|
)
|
|
# Fit means nearly touching one edge — Qt leaves a small margin of its own.
|
|
# A "≤ 1" check alone would pass a page zoomed down to a dot.
|
|
assert 0.9 <= fill <= 1.02, f"page is not fitted to the window: {fill:.3f}"
|
|
|
|
# The bundle is named after the song, not the PDF.
|
|
assert bundle.filename(project) == "Ketun-joululaulu.zip"
|
|
project.metadata["title"] = "AC/DC: T.N.T. (live)"
|
|
assert bundle.filename(project) == "ACDC-T.N.T.-live.zip"
|
|
project.metadata["title"] = "Ketun joululaulu"
|
|
|
|
editor.close()
|
|
source.close()
|
|
for f in (pdf, saved):
|
|
f.unlink()
|
|
tmp.rmdir()
|
|
print("ok")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|