A bundle was a one-way trip. The slice images are output and the cuts that produced them lived only in the producer's own project file, so a bundle someone handed you meant cutting the score again from scratch. The manifest now carries the geometry, in a `source` block: per page the cut polylines, skew, levels and content rectangle, all in normalised coordinates so they survive any render resolution, and per slice the page and slot it came from. Discards are stated by omission — a slot no slice claims was discarded — since shipping a discarded slice's image would defeat discarding it. `noteman-slicer open song.zip` unpacks the archived PDF, rebuilds the project from that geometry, restores markers, engravings and the title block, and opens the editor. Jump destinations go back from an array index to the (page, slot) the editor works in. The images in the zip are discarded: the PDF is what the pipeline renders from. Re-exporting a reopened bundle reproduces its manifest exactly. It refuses to overwrite a PDF or project file already sitting there, because the obvious place to unpack is where someone's unfinished cuts live. Separately, every slice can now carry the measure it starts at, not just a re-engraved one — a scanned system is numbered in the score the same way, and noteman wants to answer "take it from bar 33" about either. It moves off the replacement onto the page, alongside markers and discards, and out of the bundle's engraving object onto the slice.
203 lines
7.2 KiB
Python
203 lines
7.2 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 _open(args: argparse.Namespace) -> int:
|
||
from . import bundle
|
||
from .editor import launch
|
||
|
||
try:
|
||
project, pdf = bundle.read(
|
||
Path(args.zip), Path(args.pdf) if args.pdf else None, force=args.force
|
||
)
|
||
except (ValueError, KeyError) as error:
|
||
print(f"cannot open: {error}")
|
||
return 1
|
||
|
||
saved = project.save()
|
||
print(f"{pdf.name}: {len(project.pages)} pages, {len(project.kept_slices())} 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.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())
|