Compare commits
2
Commits
54b8e37657
...
15f64e4131
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15f64e4131 | ||
|
|
b6847a06ee |
+6
-3
@@ -51,9 +51,12 @@ two repos.
|
||||
**Project**:
|
||||
The persistent state of slicing one song: the source PDF it points at, its cuts,
|
||||
discards, content rectangle, levels, staff-height overrides, markers and
|
||||
metadata. Autosaved beside the PDF; the bundle is generated from it, so any
|
||||
export can be regenerated without repeating human work. One PDF, one song, one
|
||||
project, one bundle — never a many-to-one in any direction.
|
||||
metadata. Autosaved beside the PDF; the bundle is generated from it. One PDF,
|
||||
one song, one project, one bundle — never a many-to-one in any direction.
|
||||
|
||||
**Spent** once its song has been exported: opening the PDF again begins a fresh
|
||||
session from detection rather than resuming, so a re-cut never inherits
|
||||
decisions that have already shipped.
|
||||
_Avoid_: session, document, edit list
|
||||
|
||||
**Bundle**:
|
||||
|
||||
@@ -59,3 +59,4 @@ Decisions that were expensive to reach, each with the evidence behind it:
|
||||
| [ADR 0004](docs/adr/0004-detection-proposes-the-human-disposes.md) | No unattended mode: detection suggests, a human confirms. |
|
||||
| [ADR 0005](docs/adr/0005-pymupdf-for-all-pdf-access.md) | PyMuPDF for all PDF access, accepting AGPL. |
|
||||
| [ADR 0006](docs/adr/0006-systems-are-found-by-brackets-not-row-gaps.md) | Systems are found by vertical brackets; row-darkness gaps get it wrong. |
|
||||
| [ADR 0007](docs/adr/0007-a-project-is-spent-once-exported.md) | A project is spent once exported; reopening starts fresh. Reverses an earlier decision. |
|
||||
|
||||
@@ -54,8 +54,11 @@ degraded fallback.
|
||||
|
||||
- The geometry model stays **renderer-agnostic**, in normalised page coordinates,
|
||||
so adding the SVG renderer later is an output stage rather than a redesign.
|
||||
- **Re-export from the project file** regenerates every song's bundle without
|
||||
- ~~**Re-export from the project file** regenerates every song's bundle without
|
||||
repeating human work, so songs cut before the SVG renderer exists are not
|
||||
stranded.
|
||||
stranded.~~ **No longer true** — see
|
||||
[ADR 0007](0007-a-project-is-spent-once-exported.md). A project is spent once
|
||||
its song has been exported, so songs cut before the SVG renderer ships stay
|
||||
WebP unless they are cut again by hand.
|
||||
- noteman needs no SVG support (`image/svg+xml`, `.svg` content type, CSP header
|
||||
on SVG responses) until the renderer ships.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# A project is spent once its song has been exported
|
||||
|
||||
Exporting a song marks its project file spent. Opening the PDF again starts a
|
||||
**fresh session from detection** — no cuts, no discards, no metadata carried
|
||||
over — rather than resuming. `--resume` on `edit`, `export` and `project`
|
||||
overrides it when the old state really is wanted.
|
||||
|
||||
This **reverses an earlier decision**, which is the reason it needs recording:
|
||||
the project file was introduced specifically so that state would persist, and
|
||||
`docs/spec.md` and `CONTEXT.md` promised resume-across-sessions and re-export
|
||||
until this ADR was written.
|
||||
|
||||
## What was decided before, and why it changed
|
||||
|
||||
The project file was chosen over "bundle only" for three benefits: crash safety,
|
||||
resume across sessions, and re-export. The third was the strongest argument —
|
||||
change the width cap, fix one cut, or add the SVG renderer later, and every
|
||||
song's bundle regenerates without repeating any human work. ADR 0002 leans on it
|
||||
explicitly when deferring the SVG renderer: "re-export from the project file
|
||||
regenerates every song's bundle without repeating human work, so songs cut
|
||||
before the SVG renderer exists are not stranded."
|
||||
|
||||
In use, persistence was the wrong default. Re-opening an exported song silently
|
||||
resurrected old decisions, so a deliberate re-cut began from stale state instead
|
||||
of a clean page — and because autosave writes that state straight back, closing
|
||||
the window did not clear it either. An export is a natural end of a unit of
|
||||
work; carrying its decisions past that point makes "start over" impossible to
|
||||
express.
|
||||
|
||||
Crash safety and resume within a session are untouched, and those are what the
|
||||
day-to-day authoring loop actually depends on: a session interrupted halfway
|
||||
through a 12-page scan still picks up where it stopped.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Re-export is no longer free.** Changing the 1920px cap, changing the encoder,
|
||||
or adding the SVG renderer means re-cutting each song by hand. ADR 0002's
|
||||
"not stranded" reasoning no longer holds; if the SVG renderer ships, already
|
||||
exported songs stay WebP unless they are cut again.
|
||||
- The flag is written in `bundle.write`, not in its callers, so no export path
|
||||
can forget it.
|
||||
- The project file is kept rather than deleted, so `--resume` remains possible
|
||||
and the state is still there to inspect after the fact.
|
||||
- A CLI export from a PDF with no project file now writes one, marked spent.
|
||||
That is the record that this PDF has already been exported.
|
||||
+13
-3
@@ -263,9 +263,19 @@ discards, content rectangle, skew angles, levels, staff-height overrides, marker
|
||||
and metadata. The bundle is *generated* from it, so export is a pure function of
|
||||
the project file plus the PDF.
|
||||
|
||||
It buys crash safety, resume across sessions (authoring is trickle-in), and
|
||||
**re-export** — change the 1920 cap, fix one cut, or add the SVG renderer later,
|
||||
and every song's bundle regenerates without repeating any human work.
|
||||
It buys crash safety and resume across sessions, since authoring is trickle-in:
|
||||
a session interrupted halfway through a 12-page scan picks up exactly where it
|
||||
stopped.
|
||||
|
||||
**A project is spent once its song has been exported.** Export records that in
|
||||
the file, and opening the PDF again starts a *fresh session from detection*
|
||||
rather than resuming. A re-cut therefore never inherits decisions that have
|
||||
already shipped. `--resume` overrides it on the `edit`, `export` and `project`
|
||||
commands when the old state really is wanted.
|
||||
|
||||
The cost is deliberate: re-export is no longer free. Changing the width cap or
|
||||
adding the SVG renderer later means re-cutting each song by hand rather than
|
||||
regenerating every bundle from its project file.
|
||||
|
||||
The project file references the PDF and never contains it; the hash lets the
|
||||
editor warn if the PDF changed underneath.
|
||||
|
||||
@@ -62,4 +62,10 @@ def write(project: Project, source: Source, path: Path) -> Path:
|
||||
zf.write(project.source, "original.pdf")
|
||||
for name, data in zip(names, images):
|
||||
zf.writestr(name, data, zipfile.ZIP_STORED)
|
||||
|
||||
# The project is spent once its song has been exported: the next edit
|
||||
# session starts fresh from detection rather than resuming these decisions.
|
||||
# Recorded here so no caller can forget it.
|
||||
project.exported = True
|
||||
project.save()
|
||||
return path
|
||||
|
||||
+22
-37
@@ -51,32 +51,18 @@ def _detect(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _project(args: argparse.Namespace) -> int:
|
||||
from .project import Project, default_path
|
||||
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)
|
||||
|
||||
if path.exists() and not args.force:
|
||||
project = Project.load(path)
|
||||
print(f"{path.name}: loaded")
|
||||
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")
|
||||
if args.refit:
|
||||
# Re-propose the content rectangle without disturbing cuts,
|
||||
# discards or metadata — for projects made before detection
|
||||
# proposed one, or whose rectangle was dragged wrong.
|
||||
for i in range(len(project.pages)):
|
||||
gray = page_raster(source, i)
|
||||
project.pages[i].content_rect = detect_page(gray).content
|
||||
print(f" content rectangle re-fitted on {len(project.pages)} pages")
|
||||
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):
|
||||
@@ -92,23 +78,14 @@ def _project(args: argparse.Namespace) -> int:
|
||||
|
||||
def _export(args: argparse.Namespace) -> int:
|
||||
from . import bundle
|
||||
from .project import Project, default_path
|
||||
from .project import open_project
|
||||
|
||||
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():
|
||||
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")
|
||||
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)
|
||||
@@ -122,7 +99,9 @@ def _export(args: argparse.Namespace) -> int:
|
||||
def _edit(args: argparse.Namespace) -> int:
|
||||
from .editor import launch
|
||||
|
||||
return launch(Path(args.pdf), SourceType(args.type) if args.type else None)
|
||||
return launch(
|
||||
Path(args.pdf), SourceType(args.type) if args.type else None, resume=args.resume
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -154,9 +133,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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(
|
||||
"--refit",
|
||||
"--resume",
|
||||
action="store_true",
|
||||
help="re-propose the content rectangle, keeping cuts and metadata",
|
||||
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)
|
||||
@@ -164,11 +143,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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)
|
||||
|
||||
|
||||
+21
-42
@@ -48,7 +48,7 @@ from . import bundle
|
||||
from .bundle import METADATA_FIELDS
|
||||
from .detect import deskew, detect_page
|
||||
from .pdf import Source, open_source, page_raster
|
||||
from .project import Cut, Project, default_path
|
||||
from .project import Cut, Project, open_project
|
||||
from .render import apply_levels
|
||||
|
||||
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
|
||||
@@ -356,17 +356,10 @@ class Editor(QMainWindow):
|
||||
discard = QPushButton("Toggle discard (D)")
|
||||
discard.clicked.connect(self.view.toggle_discard)
|
||||
form.addRow(discard)
|
||||
rect_row = QHBoxLayout()
|
||||
fit = QPushButton("Auto-fit")
|
||||
fit.setToolTip("Propose the content rectangle from the staff lines on this page")
|
||||
fit.clicked.connect(lambda: self._autofit_rect(False))
|
||||
fit_all = QPushButton("Auto-fit all")
|
||||
fit_all.clicked.connect(lambda: self._autofit_rect(True))
|
||||
reset = QPushButton("Reset")
|
||||
reset = QPushButton("Reset content rectangle")
|
||||
reset.setToolTip("Back to the rectangle detection proposed for this page")
|
||||
reset.clicked.connect(self._reset_rect)
|
||||
for button in (fit, fit_all, reset):
|
||||
rect_row.addWidget(button)
|
||||
form.addRow("Content rect", rect_row)
|
||||
form.addRow(reset)
|
||||
box.addWidget(page_box)
|
||||
|
||||
meta_box = QGroupBox("Song")
|
||||
@@ -482,25 +475,16 @@ class Editor(QMainWindow):
|
||||
}
|
||||
self.autosave.start()
|
||||
|
||||
def _autofit_rect(self, every_page: bool) -> None:
|
||||
"""Re-propose the content rectangle from the staff lines.
|
||||
|
||||
Detection does this for new projects; this is how a project made before
|
||||
it existed, or one whose rectangle was dragged wrong, gets it back.
|
||||
"""
|
||||
pages = range(len(self.project.pages)) if every_page else [self.index]
|
||||
for i in pages:
|
||||
# The preview is already deskewed, so the sweep is skipped.
|
||||
proposal = detect_page(self._preview(i), skew=0.0)
|
||||
self.project.pages[i].content_rect = proposal.content
|
||||
self.view.redraw()
|
||||
self._touched()
|
||||
self.statusBar().showMessage(
|
||||
f"content rectangle fitted on {len(list(pages))} page(s)", 2000
|
||||
)
|
||||
|
||||
def _reset_rect(self) -> None:
|
||||
self.project.pages[self.index].content_rect = None
|
||||
"""Back to what detection proposed for this page.
|
||||
|
||||
Not to the whole page: the proposal is what excludes the scan-edge
|
||||
junk, so clearing to full width would undo the thing the rectangle
|
||||
exists for. The preview is already deskewed, so the sweep is skipped.
|
||||
"""
|
||||
self.project.pages[self.index].content_rect = detect_page(
|
||||
self._preview(self.index), skew=0.0
|
||||
).content
|
||||
self.view.redraw()
|
||||
self._touched()
|
||||
|
||||
@@ -524,7 +508,9 @@ class Editor(QMainWindow):
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Exported",
|
||||
f"{out.name}\n{len(self.project.kept_slices())} slices, {size:.0f} KB",
|
||||
f"{out.name}\n{len(self.project.kept_slices())} slices, {size:.0f} KB\n\n"
|
||||
"This project is now spent — opening the PDF again starts a fresh "
|
||||
"session from detection.",
|
||||
)
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
@@ -532,27 +518,20 @@ class Editor(QMainWindow):
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def launch(pdf: Path, source_type=None) -> int:
|
||||
def launch(pdf: Path, source_type=None, resume: bool = False) -> int:
|
||||
app = QApplication(sys.argv[:1])
|
||||
source = open_source(pdf, source_type)
|
||||
path = default_path(source.path)
|
||||
|
||||
if path.exists():
|
||||
project = Project.load(path)
|
||||
if project.source_changed():
|
||||
# An exported project is spent: this opens a fresh session from detection
|
||||
# rather than resuming decisions that have already been shipped.
|
||||
project = open_project(source, resume=resume)
|
||||
if project.path is not None and project.source_changed():
|
||||
QMessageBox.warning(
|
||||
None,
|
||||
"Source changed",
|
||||
"The PDF has changed since these cuts were made.\n"
|
||||
"Cuts may no longer line up with the music.",
|
||||
)
|
||||
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)
|
||||
|
||||
window = Editor(source, project)
|
||||
window.resize(1500, 950)
|
||||
|
||||
@@ -113,6 +113,11 @@ class Project:
|
||||
levels: tuple[int, int] = (0, 255)
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
path: Path | None = None
|
||||
# Set once the song has been exported. A project is spent at that point:
|
||||
# opening the PDF again starts a fresh session from detection rather than
|
||||
# resuming, so a re-cut never begins from stale decisions. `--resume`
|
||||
# overrides it when the old state really is wanted.
|
||||
exported: bool = False
|
||||
|
||||
# -- geometry helpers -------------------------------------------------
|
||||
|
||||
@@ -184,6 +189,7 @@ class Project:
|
||||
"v": FORMAT_VERSION,
|
||||
"source": self.source.name,
|
||||
"source_hash": self.source_hash,
|
||||
"exported": self.exported,
|
||||
"content_rect": list(self.content_rect),
|
||||
"levels": list(self.levels),
|
||||
"metadata": self.metadata,
|
||||
@@ -229,6 +235,7 @@ class Project:
|
||||
levels=tuple(data["levels"]),
|
||||
metadata=data.get("metadata", {}),
|
||||
path=path,
|
||||
exported=data.get("exported", False),
|
||||
)
|
||||
|
||||
def source_changed(self) -> bool:
|
||||
@@ -236,6 +243,30 @@ class Project:
|
||||
return self.source.exists() and hash_file(self.source) != self.source_hash
|
||||
|
||||
|
||||
def open_project(source, *, resume: bool = False) -> Project:
|
||||
"""The project for a PDF: resumed, or a fresh session from detection.
|
||||
|
||||
A project that has been exported is spent. Opening the PDF again starts
|
||||
over from detection rather than resuming, so a re-cut never inherits stale
|
||||
decisions. `resume` overrides that when the old state really is wanted.
|
||||
"""
|
||||
from .detect import detect_page
|
||||
from .pdf import page_raster
|
||||
|
||||
path = default_path(source.path)
|
||||
if path.exists():
|
||||
existing = Project.load(path)
|
||||
if resume or not existing.exported:
|
||||
return existing
|
||||
|
||||
detections, heights = [], []
|
||||
for i in range(len(source)):
|
||||
gray = page_raster(source, i)
|
||||
detections.append(detect_page(gray))
|
||||
heights.append(gray.shape[0])
|
||||
return Project.from_detection(source.path, detections, heights)
|
||||
|
||||
|
||||
def default_path(source: Path) -> Path:
|
||||
return Path(source).with_suffix(SUFFIX)
|
||||
|
||||
|
||||
@@ -85,11 +85,15 @@ def main() -> int:
|
||||
editor.metadata["composer"].setText("trad.")
|
||||
assert project.metadata["title"] == "Ketun joululaulu"
|
||||
|
||||
# Content rectangle edits and reset.
|
||||
# Content rectangle edits, and reset going back to detection's proposal for
|
||||
# the page as it now stands — not to the whole page, which would undo the
|
||||
# thing the rectangle exists for.
|
||||
expected = detect_page(editor._preview(0), skew=0.0).content
|
||||
page.content_rect = (0.05, 0.02, 0.95, 0.98)
|
||||
assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98)
|
||||
editor._reset_rect()
|
||||
assert project.page_content_rect(0) == project.content_rect
|
||||
assert project.page_content_rect(0) == expected, (project.page_content_rect(0), expected)
|
||||
assert project.page_content_rect(0) != (0.0, 0.0, 1.0, 1.0)
|
||||
|
||||
# Autosave target, then a round-trip through disk.
|
||||
editor._save()
|
||||
|
||||
@@ -67,6 +67,12 @@ def main() -> int:
|
||||
assert reloaded.source_hash == project.source_hash
|
||||
assert not reloaded.source_changed()
|
||||
|
||||
# A project is spent once exported: reopening starts fresh.
|
||||
assert not reloaded.exported
|
||||
reloaded.exported = True
|
||||
reloaded.save()
|
||||
assert Project.load(saved).exported
|
||||
|
||||
# A PDF edited underneath must be reported, not silently re-cut.
|
||||
pdf.write_bytes(b"%PDF-1.7 different bytes entirely")
|
||||
assert reloaded.source_changed()
|
||||
|
||||
@@ -16,7 +16,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from noteman_slicer import bundle # noqa: E402
|
||||
from noteman_slicer.detect import detect_page # noqa: E402
|
||||
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
||||
from noteman_slicer.project import Cut, Project # noqa: E402
|
||||
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
|
||||
from noteman_slicer.render import ( # noqa: E402
|
||||
ALPHA_LEVELS,
|
||||
apply_levels,
|
||||
@@ -151,8 +151,9 @@ def main() -> int:
|
||||
assert all(f in names for f in files)
|
||||
|
||||
source.close()
|
||||
for f in (pdf, out):
|
||||
f.unlink()
|
||||
# Exporting marks the project spent, which writes the project file.
|
||||
for f in (pdf, out, default_path(pdf)):
|
||||
f.unlink(missing_ok=True)
|
||||
tmp.rmdir()
|
||||
print("ok")
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user