Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15f64e4131 | ||
|
|
b6847a06ee |
+6
-3
@@ -51,9 +51,12 @@ two repos.
|
|||||||
**Project**:
|
**Project**:
|
||||||
The persistent state of slicing one song: the source PDF it points at, its cuts,
|
The persistent state of slicing one song: the source PDF it points at, its cuts,
|
||||||
discards, content rectangle, levels, staff-height overrides, markers and
|
discards, content rectangle, levels, staff-height overrides, markers and
|
||||||
metadata. Autosaved beside the PDF; the bundle is generated from it, so any
|
metadata. Autosaved beside the PDF; the bundle is generated from it. One PDF,
|
||||||
export can be regenerated without repeating human work. One PDF, one song, one
|
one song, one project, one bundle — never a many-to-one in any direction.
|
||||||
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
|
_Avoid_: session, document, edit list
|
||||||
|
|
||||||
**Bundle**:
|
**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 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 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 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,
|
- The geometry model stays **renderer-agnostic**, in normalised page coordinates,
|
||||||
so adding the SVG renderer later is an output stage rather than a redesign.
|
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
|
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
|
- noteman needs no SVG support (`image/svg+xml`, `.svg` content type, CSP header
|
||||||
on SVG responses) until the renderer ships.
|
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
|
and metadata. The bundle is *generated* from it, so export is a pure function of
|
||||||
the project file plus the PDF.
|
the project file plus the PDF.
|
||||||
|
|
||||||
It buys crash safety, resume across sessions (authoring is trickle-in), and
|
It buys crash safety and resume across sessions, since authoring is trickle-in:
|
||||||
**re-export** — change the 1920 cap, fix one cut, or add the SVG renderer later,
|
a session interrupted halfway through a 12-page scan picks up exactly where it
|
||||||
and every song's bundle regenerates without repeating any human work.
|
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
|
The project file references the PDF and never contains it; the hash lets the
|
||||||
editor warn if the PDF changed underneath.
|
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")
|
zf.write(project.source, "original.pdf")
|
||||||
for name, data in zip(names, images):
|
for name, data in zip(names, images):
|
||||||
zf.writestr(name, data, zipfile.ZIP_STORED)
|
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
|
return path
|
||||||
|
|||||||
+23
-38
@@ -51,32 +51,18 @@ def _detect(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _project(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)
|
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
|
||||||
path = default_path(source.path)
|
path = default_path(source.path)
|
||||||
|
|
||||||
if path.exists() and not args.force:
|
project = open_project(source, resume=args.resume and not args.force)
|
||||||
project = Project.load(path)
|
if project.path is None:
|
||||||
print(f"{path.name}: loaded")
|
print(f"{path.name}: fresh session from detection")
|
||||||
|
else:
|
||||||
|
print(f"{path.name}: resumed")
|
||||||
if project.source_changed():
|
if project.source_changed():
|
||||||
print(" WARNING: the PDF has changed since these cuts were made")
|
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()
|
kept = project.kept_slices()
|
||||||
for i, page in enumerate(project.pages):
|
for i, page in enumerate(project.pages):
|
||||||
@@ -92,23 +78,14 @@ def _project(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
def _export(args: argparse.Namespace) -> int:
|
def _export(args: argparse.Namespace) -> int:
|
||||||
from . import bundle
|
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)
|
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)
|
||||||
|
if project.path is None:
|
||||||
if path.exists():
|
print("no unspent project state; exporting straight from detection")
|
||||||
project = Project.load(path)
|
elif project.source_changed():
|
||||||
if project.source_changed():
|
print("WARNING: the PDF has changed since these cuts were made")
|
||||||
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")
|
out = Path(args.out) if args.out else source.path.with_suffix(".zip")
|
||||||
bundle.write(project, source, out)
|
bundle.write(project, source, out)
|
||||||
@@ -122,7 +99,9 @@ def _export(args: argparse.Namespace) -> int:
|
|||||||
def _edit(args: argparse.Namespace) -> int:
|
def _edit(args: argparse.Namespace) -> int:
|
||||||
from .editor import launch
|
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:
|
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("--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("--force", action="store_true", help="re-detect, discarding existing state")
|
||||||
proj.add_argument(
|
proj.add_argument(
|
||||||
"--refit",
|
"--resume",
|
||||||
action="store_true",
|
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.add_argument("--type", choices=[t.value for t in SourceType])
|
||||||
proj.set_defaults(func=_project)
|
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 = sub.add_parser("export", help="render the song and write a bundle")
|
||||||
exp.add_argument("pdf")
|
exp.add_argument("pdf")
|
||||||
exp.add_argument("--out", help="output zip (default: alongside the 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.add_argument("--type", choices=[t.value for t in SourceType])
|
||||||
exp.set_defaults(func=_export)
|
exp.set_defaults(func=_export)
|
||||||
|
|
||||||
ed = sub.add_parser("edit", help="open the editor")
|
ed = sub.add_parser("edit", help="open the editor")
|
||||||
ed.add_argument("pdf")
|
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.add_argument("--type", choices=[t.value for t in SourceType])
|
||||||
ed.set_defaults(func=_edit)
|
ed.set_defaults(func=_edit)
|
||||||
|
|
||||||
|
|||||||
+27
-48
@@ -48,7 +48,7 @@ from . import bundle
|
|||||||
from .bundle import METADATA_FIELDS
|
from .bundle import METADATA_FIELDS
|
||||||
from .detect import deskew, detect_page
|
from .detect import deskew, detect_page
|
||||||
from .pdf import Source, open_source, page_raster
|
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
|
from .render import apply_levels
|
||||||
|
|
||||||
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
|
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
|
||||||
@@ -356,17 +356,10 @@ class Editor(QMainWindow):
|
|||||||
discard = QPushButton("Toggle discard (D)")
|
discard = QPushButton("Toggle discard (D)")
|
||||||
discard.clicked.connect(self.view.toggle_discard)
|
discard.clicked.connect(self.view.toggle_discard)
|
||||||
form.addRow(discard)
|
form.addRow(discard)
|
||||||
rect_row = QHBoxLayout()
|
reset = QPushButton("Reset content rectangle")
|
||||||
fit = QPushButton("Auto-fit")
|
reset.setToolTip("Back to the rectangle detection proposed for this page")
|
||||||
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.clicked.connect(self._reset_rect)
|
reset.clicked.connect(self._reset_rect)
|
||||||
for button in (fit, fit_all, reset):
|
form.addRow(reset)
|
||||||
rect_row.addWidget(button)
|
|
||||||
form.addRow("Content rect", rect_row)
|
|
||||||
box.addWidget(page_box)
|
box.addWidget(page_box)
|
||||||
|
|
||||||
meta_box = QGroupBox("Song")
|
meta_box = QGroupBox("Song")
|
||||||
@@ -482,25 +475,16 @@ class Editor(QMainWindow):
|
|||||||
}
|
}
|
||||||
self.autosave.start()
|
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:
|
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.view.redraw()
|
||||||
self._touched()
|
self._touched()
|
||||||
|
|
||||||
@@ -524,7 +508,9 @@ class Editor(QMainWindow):
|
|||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"Exported",
|
"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:
|
def closeEvent(self, event) -> None:
|
||||||
@@ -532,27 +518,20 @@ class Editor(QMainWindow):
|
|||||||
super().closeEvent(event)
|
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])
|
app = QApplication(sys.argv[:1])
|
||||||
source = open_source(pdf, source_type)
|
source = open_source(pdf, source_type)
|
||||||
path = default_path(source.path)
|
|
||||||
|
|
||||||
if path.exists():
|
# An exported project is spent: this opens a fresh session from detection
|
||||||
project = Project.load(path)
|
# rather than resuming decisions that have already been shipped.
|
||||||
if project.source_changed():
|
project = open_project(source, resume=resume)
|
||||||
QMessageBox.warning(
|
if project.path is not None and project.source_changed():
|
||||||
None,
|
QMessageBox.warning(
|
||||||
"Source changed",
|
None,
|
||||||
"The PDF has changed since these cuts were made.\n"
|
"Source changed",
|
||||||
"Cuts may no longer line up with the music.",
|
"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 = Editor(source, project)
|
||||||
window.resize(1500, 950)
|
window.resize(1500, 950)
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ class Project:
|
|||||||
levels: tuple[int, int] = (0, 255)
|
levels: tuple[int, int] = (0, 255)
|
||||||
metadata: dict[str, str] = field(default_factory=dict)
|
metadata: dict[str, str] = field(default_factory=dict)
|
||||||
path: Path | None = None
|
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 -------------------------------------------------
|
# -- geometry helpers -------------------------------------------------
|
||||||
|
|
||||||
@@ -184,6 +189,7 @@ class Project:
|
|||||||
"v": FORMAT_VERSION,
|
"v": FORMAT_VERSION,
|
||||||
"source": self.source.name,
|
"source": self.source.name,
|
||||||
"source_hash": self.source_hash,
|
"source_hash": self.source_hash,
|
||||||
|
"exported": self.exported,
|
||||||
"content_rect": list(self.content_rect),
|
"content_rect": list(self.content_rect),
|
||||||
"levels": list(self.levels),
|
"levels": list(self.levels),
|
||||||
"metadata": self.metadata,
|
"metadata": self.metadata,
|
||||||
@@ -229,6 +235,7 @@ class Project:
|
|||||||
levels=tuple(data["levels"]),
|
levels=tuple(data["levels"]),
|
||||||
metadata=data.get("metadata", {}),
|
metadata=data.get("metadata", {}),
|
||||||
path=path,
|
path=path,
|
||||||
|
exported=data.get("exported", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
def source_changed(self) -> bool:
|
def source_changed(self) -> bool:
|
||||||
@@ -236,6 +243,30 @@ class Project:
|
|||||||
return self.source.exists() and hash_file(self.source) != self.source_hash
|
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:
|
def default_path(source: Path) -> Path:
|
||||||
return Path(source).with_suffix(SUFFIX)
|
return Path(source).with_suffix(SUFFIX)
|
||||||
|
|
||||||
|
|||||||
@@ -85,11 +85,15 @@ def main() -> int:
|
|||||||
editor.metadata["composer"].setText("trad.")
|
editor.metadata["composer"].setText("trad.")
|
||||||
assert project.metadata["title"] == "Ketun joululaulu"
|
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)
|
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)
|
assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98)
|
||||||
editor._reset_rect()
|
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.
|
# Autosave target, then a round-trip through disk.
|
||||||
editor._save()
|
editor._save()
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ def main() -> int:
|
|||||||
assert reloaded.source_hash == project.source_hash
|
assert reloaded.source_hash == project.source_hash
|
||||||
assert not reloaded.source_changed()
|
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.
|
# A PDF edited underneath must be reported, not silently re-cut.
|
||||||
pdf.write_bytes(b"%PDF-1.7 different bytes entirely")
|
pdf.write_bytes(b"%PDF-1.7 different bytes entirely")
|
||||||
assert reloaded.source_changed()
|
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 import bundle # noqa: E402
|
||||||
from noteman_slicer.detect import detect_page # 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.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
|
from noteman_slicer.render import ( # noqa: E402
|
||||||
ALPHA_LEVELS,
|
ALPHA_LEVELS,
|
||||||
apply_levels,
|
apply_levels,
|
||||||
@@ -151,8 +151,9 @@ def main() -> int:
|
|||||||
assert all(f in names for f in files)
|
assert all(f in names for f in files)
|
||||||
|
|
||||||
source.close()
|
source.close()
|
||||||
for f in (pdf, out):
|
# Exporting marks the project spent, which writes the project file.
|
||||||
f.unlink()
|
for f in (pdf, out, default_path(pdf)):
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
tmp.rmdir()
|
tmp.rmdir()
|
||||||
print("ok")
|
print("ok")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
Reference in New Issue
Block a user