Re-engraving is a rescue path for the handful of systems a scan cannot deliver, so the window is an editing surface rather than an automation project. Three full-width rows - the scanned system, the render, the form - because a system is wide and short and the job is comparing one against the other bar by bar. The render is shown scaled to the scan's staff height, which is what export does anyway, so it previews the real thing. A form rather than a text box. Key and time are slice-level, clef, notes and lyrics per voice: every staff in a system carries the same key signature, and Kaipaava proves it across five-staff and two-staff systems alike. Notes and lyrics stay raw LilyPond, so slurs, dynamics, tuplets and the laissezVibrer/repeatTie idiom for ties crossing into the next slice all work untouched. Notes are entered in \relative mode, referenced to the middle of each clef's staff, so a part needs no octave marks at all in the common case. The time signature is used for spacing and bar checks but not printed: the printed score repeats the key at every system and the time only at the first, so a re-engraved middle slice showing one would stand out. Seeded from what can be known reliably. Voice count comes from counting staves in the slice; key, time and clefs are inherited from the song, because the slices being re-engraved are the illegible ones and reading a key signature off them is exactly the measurement that fails. After the first replacement in a song only the notes need typing. Staff counting needed two corrections against the corpus: compare gaps against line spacing rather than staff height, since adjacent staves can sit closer together than one staff is tall; and require five lines in a group, since Engel's 'uh______' lyric extenders are long horizontal runs too and each counted as a staff. Kaipaava now reads 2,2,2,2,5 on page 1, Ketun 6, Engel 4. Also in this change: - Title is required for export, every other metadata field optional, enforced in bundle.write so the CLI and the editor both get it. Tempo added; noteman already has a free-form column for it. - The panel is a splitter rather than a fixed width, sections collapse under bold grey disclosure headers, and it scrolls. - A re-engraved slice is washed amber with an ENGRAVED badge, and markers get badges too. Thin coloured text was invisible against a scan. Closes #31 Closes #32 Closes #33 Closes #34
172 lines
6.2 KiB
Python
172 lines
6.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_suffix(".zip")
|
||
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 _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)
|
||
|
||
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())
|