Files
Esa Kataja cdf37302d7 Ask before reopening a bundle that carries no cuts
A bundle without a source block cannot be round-tripped, but its PDF can
still be cut from scratch. Offer that instead of refusing: confirm, run
detection, and line the markers up by position when the slice counts
match exactly.
2026-07-29 15:06:45 +03:00

238 lines
8.7 KiB
Python
Raw Permalink 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 default_path, open_project
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path)
project = open_project(source, resume=args.resume and not args.force)
if project.path is None:
print(f"{path.name}: fresh session from detection")
else:
print(f"{path.name}: resumed")
if project.source_changed():
print(" WARNING: the PDF has changed since these cuts were made")
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 open_project
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
project = open_project(source, resume=args.resume)
if project.path is None:
print("no unspent project state; exporting straight from detection")
elif project.source_changed():
print("WARNING: the PDF has changed since these cuts were made")
out = Path(args.out) if args.out else source.path.with_name(bundle.filename(project))
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)")
source.close()
return 0
def _confirm(question: str) -> bool:
"""Ask before doing something the caller did not ask for. No tty, no."""
if not sys.stdin.isatty():
print(f"{question} (not a terminal — pass --detect to say yes)")
return False
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
def _open(args: argparse.Namespace) -> int:
from . import bundle
from .editor import launch
zip_path, where = Path(args.zip), Path(args.pdf) if args.pdf else None
try:
cuts = bundle.has_cuts(zip_path)
except (OSError, ValueError) as error:
print(f"cannot open: {error}")
return 1
if not cuts and not args.detect:
print(f"{zip_path.name} carries no cuts — it was written by a producer that")
print("does not record them. Its PDF can be cut again from scratch, but that")
print("is a fresh session: the cuts will be detection's, and the markers land")
print("only if detection happens to find the same number of slices.")
if not _confirm("Open it that way?"):
return 1
try:
project, pdf = bundle.read(zip_path, where, force=args.force, detect=not cuts)
except (ValueError, KeyError) as error:
print(f"cannot open: {error}")
return 1
saved = project.save()
kept = project.kept_slices()
print(f"{pdf.name}: {len(project.pages)} pages, {len(kept)} slices")
if not cuts:
marked = sum(len(m) for page in project.pages for m in page.markers)
print(" cut from scratch by detection — check every cut before exporting")
print(
f" {marked} markers placed by position"
if marked
else " markers not placed: detection found a different number of slices"
)
print(f" project written to {saved.name}")
if args.no_edit:
return 0
return launch(pdf, resume=True)
def _edit(args: argparse.Namespace) -> int:
from .editor import launch
return launch(
Path(args.pdf), SourceType(args.type) if args.type else None, resume=args.resume
)
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(
"--resume",
action="store_true",
help="reopen an already-exported project instead of starting fresh",
)
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("--resume", action="store_true", help="use already-exported project state")
exp.add_argument("--type", choices=[t.value for t in SourceType])
exp.set_defaults(func=_export)
opn = sub.add_parser("open", help="unpack a bundle back into an editable project")
opn.add_argument("zip")
opn.add_argument("--pdf", help="where to write the archived PDF (default: beside the bundle)")
opn.add_argument(
"--no-edit", action="store_true", help="write the project and stop, without the editor"
)
opn.add_argument(
"--force", action="store_true", help="overwrite an existing PDF or project file"
)
opn.add_argument(
"--detect",
action="store_true",
help="for a bundle with no cuts: cut its PDF from scratch, without asking",
)
opn.set_defaults(func=_open)
ed = sub.add_parser("edit", help="open the editor")
ed.add_argument("pdf")
ed.add_argument(
"--resume",
action="store_true",
help="reopen an already-exported project instead of starting fresh",
)
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())