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.
238 lines
8.7 KiB
Python
238 lines
8.7 KiB
Python
"""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())
|