Files
noteman-slicer/noteman_slicer/cli.py
T
Esa Kataja bc19eae111 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
2026-07-28 23:03:49 +03:00

168 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Command line entry point."""
from __future__ import annotations
import argparse
import sys
import numpy as np
from pathlib import Path
from . import __version__, overlay
from .detect import detect_page
from .pdf import SourceType, open_source, page_raster
def _info(args: argparse.Namespace) -> int:
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
note = f" (detected {source.detected.value}, overridden)" if source.overridden else ""
print(f"{source.path.name}: {source.type.value}{note}, {len(source)} pages")
for i in range(len(source)):
h, w = page_raster(source, i).shape
print(f" p{i + 1:<3} {w}x{h}")
source.close()
return 0
def _detect(args: argparse.Namespace) -> int:
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
pages = range(len(source)) if args.page is None else [args.page - 1]
for i in pages:
gray = page_raster(source, i)
detection = detect_page(gray)
staves = [s.staff_height for s in detection.systems if s.staff_height]
note = f", staff {np.median(staves):.0f}px" if staves else ""
print(
f"p{i + 1:<3} skew {detection.skew:+.2f}° "
f"{len(detection.systems)} systems{note}"
f"{' (no bracket)' if detection.bracketless else ''}"
)
for n, system in enumerate(detection.systems, 1):
print(f" sys{n}: {system.top}{system.bottom} h={system.height}")
overlay.write(gray, detection, out / f"{source.path.stem}-p{i + 1:02}.png")
print(f"overlays written to {out}/")
source.close()
return 0
def _project(args: argparse.Namespace) -> int:
from .project import Project, default_path
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path)
if path.exists() and not args.force:
project = Project.load(path)
print(f"{path.name}: loaded")
if project.source_changed():
print(" WARNING: the PDF has changed since these cuts were made")
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)
print(f"{path.name}: created from detection")
kept = project.kept_slices()
for i, page in enumerate(project.pages):
flags = "".join("." if d else "#" for d in page.discards)
print(f" p{i + 1:<3} skew {page.skew:+.2f}° {page.slice_count} slices [{flags}]")
print(f" {len(kept)} slices kept, {sum(p.slice_count for p in project.pages) - len(kept)} discarded")
if args.save:
print(f" saved to {project.save(path)}")
source.close()
return 0
def _export(args: argparse.Namespace) -> int:
from . import bundle
from .project import Project, default_path
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path)
if path.exists():
project = Project.load(path)
if project.source_changed():
print("WARNING: the PDF has changed since these cuts were made")
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)
print("no project file; exporting straight from detection")
out = Path(args.out) if args.out else source.path.with_suffix(".zip")
bundle.write(project, source, out)
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)")
source.close()
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",
description="Cut score PDFs into noteman's slice images and markers.",
)
parser.add_argument("--version", action="version", version=__version__)
sub = parser.add_subparsers(dest="command", required=True)
info = sub.add_parser("info", help="classify a PDF and report its page rasters")
info.add_argument("pdf")
info.add_argument(
"--type",
choices=[t.value for t in SourceType],
help="override source-type detection",
)
info.set_defaults(func=_info)
det = sub.add_parser("detect", help="run detection and write debug overlays")
det.add_argument("pdf")
det.add_argument("--out", default="overlays", help="output directory")
det.add_argument("--page", type=int, help="single 1-based page instead of all")
det.add_argument("--type", choices=[t.value for t in SourceType])
det.set_defaults(func=_detect)
proj = sub.add_parser("project", help="create or inspect the project file for a PDF")
proj.add_argument("pdf")
proj.add_argument("--save", action="store_true", help="write the project file")
proj.add_argument("--force", action="store_true", help="re-detect, discarding existing state")
proj.add_argument("--type", choices=[t.value for t in SourceType])
proj.set_defaults(func=_project)
exp = sub.add_parser("export", help="render the song and write a bundle")
exp.add_argument("pdf")
exp.add_argument("--out", help="output zip (default: alongside the PDF)")
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)
if __name__ == "__main__":
sys.exit(main())