Ask before reopening a bundle that carries no cuts
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.
This commit is contained in:
+124
-61
@@ -203,48 +203,20 @@ def write(project: Project, source: Source, path: Path) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def read(path: Path, into: Path | None = None, *, force: bool = False) -> tuple[Project, Path]:
|
||||
"""Unpack a bundle back into an editable project. Returns it and its PDF.
|
||||
class NoCuts(ValueError):
|
||||
"""The bundle carries no `source` block, so its cuts cannot be restored."""
|
||||
|
||||
The archived PDF is written out beside the bundle and becomes the project's
|
||||
source again, because the PDF is what the pipeline renders from — the slice
|
||||
images in the zip are output, and are discarded rather than re-imported.
|
||||
|
||||
The cuts, skew, levels and content rectangles come from the manifest's
|
||||
`source` block, so this is a real round trip rather than a re-detection
|
||||
that happens to land nearby. A bundle written without one cannot give them
|
||||
back, and that is refused rather than guessed at.
|
||||
"""
|
||||
from .project import Cut, Marker, Page, Project, Replacement, Voice, default_path, hash_file
|
||||
|
||||
path = Path(path)
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
names = set(zf.namelist())
|
||||
if "song.json" not in names:
|
||||
raise ValueError(f"{path.name} is not a bundle: no song.json")
|
||||
manifest = json.loads(zf.read("song.json"))
|
||||
if manifest.get("v") != FORMAT_VERSION:
|
||||
raise ValueError(f"unsupported bundle version {manifest.get('v')!r}")
|
||||
geometry = manifest.get("source")
|
||||
if not geometry:
|
||||
raise ValueError(
|
||||
f"{path.name} carries no cuts — it was written by a producer that "
|
||||
"does not record them, so the score would have to be cut again"
|
||||
)
|
||||
pdf_name = geometry.get("file", "original.pdf")
|
||||
if pdf_name not in names:
|
||||
raise ValueError(f"{path.name} names {pdf_name} but does not contain it")
|
||||
pdf_bytes = zf.read(pdf_name)
|
||||
|
||||
pages_json = geometry["pages"]
|
||||
slices = manifest["slices"]
|
||||
def _pages_from(geometry: dict, slices: list[dict]):
|
||||
"""Rebuild the pages from a manifest's source geometry."""
|
||||
from .project import Cut, Page
|
||||
|
||||
# Every slot on a page exists; the ones no slice claims were discarded.
|
||||
# That is the one thing the bundle states by omission rather than directly,
|
||||
# since shipping a discarded slice's image would defeat discarding it.
|
||||
claimed = {(s["page"], s["slot"]): i for i, s in enumerate(slices)}
|
||||
claimed = {(s["page"], s["slot"]) for s in slices}
|
||||
pages = []
|
||||
for i, page in enumerate(pages_json):
|
||||
for i, page in enumerate(geometry["pages"]):
|
||||
cuts = [Cut([tuple(p) for p in cut]) for cut in page["cuts"]]
|
||||
count = len(cuts) + 1
|
||||
pages.append(
|
||||
@@ -259,24 +231,51 @@ def read(path: Path, into: Path | None = None, *, force: bool = False) -> tuple[
|
||||
levels=tuple(page["levels"]) if page.get("levels") else None,
|
||||
)
|
||||
)
|
||||
return pages
|
||||
|
||||
position_of = {index: position for position, index in claimed.items()}
|
||||
for entry in slices:
|
||||
page, slot = entry["page"], entry["slot"]
|
||||
pages[page].bars[slot] = entry.get("bar")
|
||||
pages[page].markers[slot] = [
|
||||
|
||||
def _pages_from_detection(pdf: Path):
|
||||
"""Cut the PDF again from scratch, for a bundle that recorded no geometry."""
|
||||
from .detect import detect_page
|
||||
from .pdf import open_source, page_raster
|
||||
from .project import Project
|
||||
|
||||
source = open_source(pdf)
|
||||
detections, heights = [], []
|
||||
try:
|
||||
for i in range(len(source)):
|
||||
gray = page_raster(source, i)
|
||||
detections.append(detect_page(gray))
|
||||
heights.append(gray.shape[0])
|
||||
finally:
|
||||
source.close()
|
||||
return Project.from_detection(pdf, detections, heights)
|
||||
|
||||
|
||||
def _restore(project, slices: list[dict], positions: list[tuple[int, int]]) -> None:
|
||||
"""Put each slice's bar number, markers and engraving back on its slot."""
|
||||
from .project import Marker, Replacement, Voice
|
||||
|
||||
for entry, (page, slot) in zip(slices, positions):
|
||||
project.pages[page].bars[slot] = entry.get("bar")
|
||||
project.pages[page].markers[slot] = [
|
||||
Marker(
|
||||
type=marker["type"],
|
||||
label=marker.get("label"),
|
||||
# Back from an array index to the (page, slot) the editor works
|
||||
# in — the inverse of what export does.
|
||||
destination=position_of.get(marker.get("destination")),
|
||||
destination=(
|
||||
positions[marker["destination"]]
|
||||
if marker.get("destination") is not None
|
||||
and marker["destination"] < len(positions)
|
||||
else None
|
||||
),
|
||||
)
|
||||
for marker in entry.get("markers", [])
|
||||
]
|
||||
engraving = entry.get("engraving")
|
||||
if engraving and engraving.get("lang") == "lilypond":
|
||||
pages[page].replacements[slot] = Replacement(
|
||||
project.pages[page].replacements[slot] = Replacement(
|
||||
voices=[
|
||||
Voice(
|
||||
clef=v.get("clef", "treble"),
|
||||
@@ -287,6 +286,63 @@ def read(path: Path, into: Path | None = None, *, force: bool = False) -> tuple[
|
||||
],
|
||||
print_time=engraving.get("print_time", False),
|
||||
)
|
||||
for entry in slices:
|
||||
# Key and time are per slice in the bundle and per song here; the first
|
||||
# engraving that states them is as good a song default as exists.
|
||||
engraving = entry.get("engraving") or {}
|
||||
if engraving.get("key"):
|
||||
project.key = engraving["key"]
|
||||
project.time = engraving.get("time", project.time)
|
||||
break
|
||||
|
||||
|
||||
def has_cuts(path: Path) -> bool:
|
||||
"""Whether this bundle records the geometry its slices were cut with.
|
||||
|
||||
Worth asking before unpacking, since the answer decides whether reopening
|
||||
is a round trip or a fresh session with the same PDF.
|
||||
"""
|
||||
with zipfile.ZipFile(Path(path)) as zf:
|
||||
if "song.json" not in zf.namelist():
|
||||
return False
|
||||
return bool(json.loads(zf.read("song.json")).get("source"))
|
||||
|
||||
|
||||
def read(
|
||||
path: Path, into: Path | None = None, *, force: bool = False, detect: bool = False
|
||||
) -> tuple[Project, Path]:
|
||||
"""Unpack a bundle back into an editable project. Returns it and its PDF.
|
||||
|
||||
The archived PDF is written out beside the bundle and becomes the project's
|
||||
source again, because the PDF is what the pipeline renders from — the slice
|
||||
images in the zip are output, and are discarded rather than re-imported.
|
||||
|
||||
The cuts, skew, levels and content rectangles come from the manifest's
|
||||
`source` block, so this is a real round trip rather than a re-detection
|
||||
that happens to land nearby. A bundle written without one raises `NoCuts`;
|
||||
`detect` says to cut the PDF from scratch instead, which is a different
|
||||
thing and worth a caller asking about first.
|
||||
"""
|
||||
from .project import Project, default_path, hash_file
|
||||
|
||||
path = Path(path)
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
names = set(zf.namelist())
|
||||
if "song.json" not in names:
|
||||
raise ValueError(f"{path.name} is not a bundle: no song.json")
|
||||
manifest = json.loads(zf.read("song.json"))
|
||||
if manifest.get("v") != FORMAT_VERSION:
|
||||
raise ValueError(f"unsupported bundle version {manifest.get('v')!r}")
|
||||
geometry = manifest.get("source")
|
||||
if not geometry and not detect:
|
||||
raise NoCuts(
|
||||
f"{path.name} carries no cuts — it was written by a producer that "
|
||||
"does not record them"
|
||||
)
|
||||
pdf_name = (geometry or {}).get("file", "original.pdf")
|
||||
if pdf_name not in names:
|
||||
raise ValueError(f"{path.name} names {pdf_name} but does not contain it")
|
||||
pdf_bytes = zf.read(pdf_name)
|
||||
|
||||
# Unpacking writes two files. Refuse to land on either if it is already
|
||||
# there: the obvious place to open a bundle is next to the score it came
|
||||
@@ -300,25 +356,32 @@ def read(path: Path, into: Path | None = None, *, force: bool = False) -> tuple[
|
||||
)
|
||||
target.write_bytes(pdf_bytes)
|
||||
|
||||
metadata = {
|
||||
slices = manifest["slices"]
|
||||
if geometry:
|
||||
pages_json = geometry["pages"]
|
||||
first = pages_json[0] if pages_json else {}
|
||||
project = Project(
|
||||
source=target,
|
||||
source_hash=hash_file(target),
|
||||
pages=_pages_from(geometry, slices),
|
||||
content_rect=tuple(first.get("content", (0.0, 0.0, 1.0, 1.0))),
|
||||
levels=tuple(first.get("levels", (0, 255))),
|
||||
path=default_path(target),
|
||||
)
|
||||
positions = [(s["page"], s["slot"]) for s in slices]
|
||||
else:
|
||||
project = _pages_from_detection(target)
|
||||
project.path = default_path(target)
|
||||
# Detection's slices are in reading order and so are the bundle's, so
|
||||
# they can be lined up — but only if there are exactly as many. One
|
||||
# system found or missed shifts every marker onto the wrong slice,
|
||||
# which is worse than not placing them at all.
|
||||
positions = project.kept_slices()
|
||||
if len(positions) != len(slices):
|
||||
positions = []
|
||||
|
||||
project.metadata = {
|
||||
field: str(manifest[field]) for field in METADATA_FIELDS if manifest.get(field) is not None
|
||||
}
|
||||
first = pages_json[0] if pages_json else {}
|
||||
project = Project(
|
||||
source=target,
|
||||
source_hash=hash_file(target),
|
||||
pages=pages,
|
||||
content_rect=tuple(first.get("content", (0.0, 0.0, 1.0, 1.0))),
|
||||
levels=tuple(first.get("levels", (0, 255))),
|
||||
metadata=metadata,
|
||||
path=default_path(target),
|
||||
)
|
||||
# Key and time are per slice in the bundle and per song here; the first
|
||||
# engraving that states them is as good a song default as exists.
|
||||
for entry in slices:
|
||||
engraving = entry.get("engraving") or {}
|
||||
if engraving.get("key"):
|
||||
project.key = engraving["key"]
|
||||
project.time = engraving.get("time", project.time)
|
||||
break
|
||||
_restore(project, slices[: len(positions)], positions)
|
||||
return project, target
|
||||
|
||||
Reference in New Issue
Block a user