Allow signaling issues in label verification
This commit is contained in:
1 parent
3754c0c11a
commit
9925362352
21 files changed
+1347
-36
No files matched your search
@@ -0,0 +1,237 @@
|
||||
"""Reproducible edits to reviewed answer bodies, never to headers or supplements."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator import CliError, atomic_write_json, read_json, utils
|
||||
from copienator.commands.resolve_manual import ManualInstruction, parse_instruction_text
|
||||
|
||||
DRAFT = "annotation_modifications.txt"
|
||||
APPLIED = "annotation_modifications_applied.txt"
|
||||
BASELINE = "annotation_baseline.json"
|
||||
MODES = ("BGnot", "Bnot")
|
||||
|
||||
|
||||
def read_instructions(text: str, mode: str) -> list[ManualInstruction]:
|
||||
declared = re.findall(r"^### Source:\s*(\S+)\s*$", text, re.MULTILINE)
|
||||
if declared != [mode] or mode not in MODES:
|
||||
raise CliError(f"Le fichier doit contenir exactement une ligne '### Source: {mode}'.")
|
||||
return parse_instruction_text(text)
|
||||
|
||||
|
||||
def normalize_copy(root: Path, value: str) -> str:
|
||||
number = value.strip().removeprefix("Copie")
|
||||
if not number.isdecimal():
|
||||
raise CliError("Numéro de copie invalide.")
|
||||
matches = [p.name.removeprefix("Copie") for p in (root / "Copies").glob("Copie*")
|
||||
if p.is_dir() and p.name.removeprefix("Copie").isdecimal()
|
||||
and int(p.name.removeprefix("Copie")) == int(number)]
|
||||
if len(matches) != 1:
|
||||
raise CliError(f"Copie {value} introuvable ou ambiguë.")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def baseline(root: Path) -> dict:
|
||||
path = root / BASELINE
|
||||
if not path.is_file():
|
||||
raise CliError(f"Relancez « Lire les annotations manuscrites » pour {root.name} : "
|
||||
"la séparation exacte entre en-tête et réponse est indisponible.")
|
||||
return read_json(path)
|
||||
|
||||
|
||||
def load_image(directory: Path, entry: dict) -> Image.Image:
|
||||
filename = entry["file"]
|
||||
if Path(filename).name != filename:
|
||||
raise CliError("Chemin d’image de référence invalide.")
|
||||
with Image.open(directory / filename) as image:
|
||||
result = image.convert("RGB").copy()
|
||||
height = entry["header_height"]
|
||||
if not isinstance(height, int) or not 0 <= height < result.height:
|
||||
raise CliError("Séparation en-tête/réponse invalide ; relancez la lecture des annotations.")
|
||||
return result
|
||||
|
||||
|
||||
def body_image(directory: Path, label: str) -> Image.Image:
|
||||
entry = baseline(directory)["images"].get(label)
|
||||
if entry is None:
|
||||
raise CliError(f"Pas de réponse annotée pour {label} dans {directory.name}.")
|
||||
image = load_image(directory, entry)
|
||||
return image.crop((0, entry["header_height"], image.width, image.height))
|
||||
|
||||
|
||||
def validate_instructions(root: Path, mode: str, instructions: list[ManualInstruction]) -> None:
|
||||
labels = set(utils.read_all_labels(root))
|
||||
sources = set()
|
||||
targets = set()
|
||||
for item in instructions:
|
||||
for label in (item.old_label, item.new_label):
|
||||
if label not in labels or Path(label).name != label or label in {".", ".."}:
|
||||
raise CliError(f"Label inconnu ou invalide : {label}")
|
||||
if item.old_label == item.new_label:
|
||||
raise CliError("La source et la cible doivent être différentes.")
|
||||
if normalize_copy(root, item.copy_id) != item.copy_id:
|
||||
raise CliError(f"Utilisez Copie{normalize_copy(root, item.copy_id)} dans le fichier.")
|
||||
key = (item.copy_id, item.old_label)
|
||||
if key in sources:
|
||||
raise CliError("Une seule instruction par label source et copie est autorisée.")
|
||||
sources.add(key)
|
||||
targets.add((item.copy_id, item.new_label))
|
||||
directory = root / mode / f"Copie{item.copy_id}"
|
||||
source = body_image(directory, item.old_label)
|
||||
if item.cut:
|
||||
y = round(source.height * item.cut[0] / 100)
|
||||
if not 0 < y < source.height:
|
||||
raise CliError("Chaque partie de la coupe doit contenir au moins un pixel.")
|
||||
target = baseline(directory)["images"].get(item.new_label)
|
||||
if target:
|
||||
load_image(directory, target)
|
||||
# Also report damaged current outputs instead of silently replacing them.
|
||||
for label in (item.old_label, item.new_label):
|
||||
path = directory / f"{label}.jpg"
|
||||
if path.exists():
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if sources & targets:
|
||||
raise CliError("Une cible ne peut pas également être source dans le même fichier.")
|
||||
|
||||
|
||||
def stack(images: list[Image.Image]) -> Image.Image | None:
|
||||
images = [image for image in images if image.height]
|
||||
if not images:
|
||||
return None
|
||||
result = Image.new("RGB", (max(i.width for i in images), sum(i.height for i in images)), "white")
|
||||
y = 0
|
||||
for image in images:
|
||||
result.paste(image, (0, y))
|
||||
y += image.height
|
||||
return result
|
||||
|
||||
|
||||
def edited_bodies(directory: Path, meta: dict, instructions: list[ManualInstruction]):
|
||||
headers, bodies = {}, {}
|
||||
for label, entry in meta["images"].items():
|
||||
image = load_image(directory, entry)
|
||||
h = entry["header_height"]
|
||||
headers[label] = image.crop((0, 0, image.width, h))
|
||||
bodies[label] = image.crop((0, h, image.width, image.height))
|
||||
originals = bodies.copy()
|
||||
changed = set()
|
||||
for item in instructions:
|
||||
source, target = item.old_label, item.new_label
|
||||
if source not in originals:
|
||||
raise CliError(f"Source annotée absente : {source}. Les modifications n’ont pas été appliquées.")
|
||||
piece = originals[source]
|
||||
if item.cut:
|
||||
percent, keep = item.cut
|
||||
y = round(piece.height * percent / 100)
|
||||
if not 0 < y < piece.height:
|
||||
raise CliError(f"Coupe invalide pour {source}.")
|
||||
top, bottom = piece.crop((0, 0, piece.width, y)), piece.crop((0, y, piece.width, piece.height))
|
||||
bodies[source], piece = (top, bottom) if keep == 1 else (bottom, top)
|
||||
changed.add(source)
|
||||
elif item.operator.startswith("x"):
|
||||
bodies.pop(source, None)
|
||||
changed.add(source)
|
||||
if item.should_merge:
|
||||
parts = ([bodies[target]] if target in bodies else [])
|
||||
parts.insert(0 if item.pipe_first else len(parts), piece)
|
||||
bodies[target] = stack(parts)
|
||||
changed.add(target)
|
||||
elif item.should_copy:
|
||||
bodies[target] = piece
|
||||
changed.add(target)
|
||||
elif item.operator.endswith("x"):
|
||||
bodies.pop(target, None)
|
||||
changed.add(target)
|
||||
return headers, bodies, changed
|
||||
|
||||
|
||||
def output_removals(directory: Path) -> tuple[str, ...]:
|
||||
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
|
||||
return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
|
||||
*(f"{label}.jpg" for label, entry in info.items()
|
||||
if entry.get("not_empty") and Path(label).name == label))
|
||||
|
||||
|
||||
def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
|
||||
instructions: list[ManualInstruction], scores: dict) -> None:
|
||||
# Imported lazily: both readers use this module while saving their baseline.
|
||||
from copienator.commands import annotating
|
||||
from copienator.commands.reading_grouped_annotations import (
|
||||
get_extra_pdfs_as_images,
|
||||
save_paginated_pdf,
|
||||
)
|
||||
|
||||
headers, bodies, changed = edited_bodies(directory, meta, instructions)
|
||||
info = deepcopy(meta["info"])
|
||||
filtered = set(meta["filtered"]) | changed
|
||||
all_labels = utils.read_all_labels(root)
|
||||
images, groups = [], []
|
||||
seen_contexts = set()
|
||||
order = sorted(bodies, key=utils.natural_key)
|
||||
for label in order:
|
||||
if label not in bodies:
|
||||
continue
|
||||
body = bodies[label]
|
||||
header = headers.get(label)
|
||||
if header is None:
|
||||
# No prior answer means no prior header. Do not invent a score or
|
||||
# resurrect Gemini feedback from correction.json.
|
||||
header = Image.new("RGB", (max(body.width, 800), 80), "white")
|
||||
ImageDraw.Draw(header).text((20, 20), f"{label} Note : {scores.get(label, '')}",
|
||||
fill="black", font_size=30)
|
||||
image = stack([header, body])
|
||||
image.save(staging / f"{label}.jpg")
|
||||
images.append(image)
|
||||
if label in filtered:
|
||||
extras = get_extra_pdfs_as_images(root, label, annotating, all_labels,
|
||||
seen_contexts=seen_contexts) if mode == "BGnot" else []
|
||||
groups.append([*extras, image])
|
||||
for label, entry in info.items():
|
||||
entry["not_empty"] = label in bodies
|
||||
if label in changed and label in bodies:
|
||||
entry["present"] = True
|
||||
entry["touched"] = mode == "BGnot" and label in bodies and label in filtered
|
||||
entry["score"] = scores.get(label, entry["score"])
|
||||
atomic_write_json(staging / "info.json", info)
|
||||
if images:
|
||||
stack(images).save(staging / "Concat.jpg")
|
||||
if groups:
|
||||
if mode == "BGnot":
|
||||
save_paginated_pdf(groups, staging / "Concat_F.pdf")
|
||||
else:
|
||||
stack([image for group in groups for image in group]).save(staging / "Concat_F.jpg")
|
||||
|
||||
|
||||
def stage_baseline(root: Path, mode: str, output: Path, staging: Path,
|
||||
images: dict[str, Image.Image], heights: dict[str, int],
|
||||
info: dict, filtered: list[str], scores: dict) -> None:
|
||||
"""Save exact lossless reviewed layers, then replay only applied edits."""
|
||||
if mode not in MODES:
|
||||
return
|
||||
applied = output.parent / APPLIED
|
||||
instructions = read_instructions(applied.read_text(encoding="utf-8"), mode) if applied.is_file() else []
|
||||
selected = [i for i in instructions if f"Copie{i.copy_id}" == output.name]
|
||||
if not images.keys() <= heights.keys():
|
||||
if selected:
|
||||
raise CliError("Relancez la lecture principale des annotations avant de réappliquer les modifications.")
|
||||
# Legacy selective redo can still preserve its old images. A full read is
|
||||
# required before body editing; never guess the old header boundaries.
|
||||
return
|
||||
meta = {"version": 1, "images": {}, "info": info, "filtered": filtered}
|
||||
for label, image in images.items():
|
||||
filename = ".annotation-base-" + sha256(label.encode()).hexdigest() + ".png"
|
||||
image.save(staging / filename)
|
||||
meta["images"][label] = {"file": filename, "header_height": heights[label]}
|
||||
atomic_write_json(staging / BASELINE, meta)
|
||||
if selected:
|
||||
# Remove freshly staged aggregates before rebuilding; empty filtered
|
||||
# output must not retain the reader's previous version.
|
||||
for name in ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", *(f"{label}.jpg" for label in images)):
|
||||
(staging / name).unlink(missing_ok=True)
|
||||
rebuild(root, mode, staging, staging, meta, selected, scores)
|
||||
@@ -64,7 +64,7 @@ def make_base_image(pdf_path):
|
||||
|
||||
current_y = 0
|
||||
for page in pages:
|
||||
base_img.paste(page.convert("RGBA"), (0, current_y))
|
||||
base_img.paste(page.convert("RGBA"), ((max_w - page.width) // 2, current_y))
|
||||
current_y += page.height
|
||||
return (base_img, total_h, max_w)
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ def run(
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = target_parser("Interactively crop the label margin from PDF copies")
|
||||
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear each flag after saving with Enter")
|
||||
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear only margin-cropping flags after saving with Enter")
|
||||
parser.add_argument(
|
||||
"--fullpage",
|
||||
action="store_true",
|
||||
|
||||
@@ -16,6 +16,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator.gemini_config import thinking_config
|
||||
from copienator.copy_errors import marked_copy_paths
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
@@ -530,6 +531,7 @@ def run(
|
||||
targets: list[Path],
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
marked: bool = False,
|
||||
client=None,
|
||||
sleep: Sleep = time.sleep,
|
||||
max_workers: int = 12,
|
||||
@@ -538,6 +540,12 @@ def run(
|
||||
raise CliError("Le nombre de requêtes simultanées doit être positif.",
|
||||
ExitCode.INVALID_ARGUMENTS)
|
||||
workspace.require_files("labels")
|
||||
if marked:
|
||||
targets = marked_copy_paths(workspace)
|
||||
if not targets:
|
||||
print("No flagged copies to detect labels for.")
|
||||
return ExitCode.SUCCESS
|
||||
overwrite = True
|
||||
images, warnings = selected_images(workspace, targets)
|
||||
for warning in warnings:
|
||||
print(f"Warning: {warning}")
|
||||
@@ -626,6 +634,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
type=Path,
|
||||
help="Additional copy PDFs or Cutleft images from the same evaluation",
|
||||
)
|
||||
parser.add_argument("--marked", action="store_true",
|
||||
help="Regenerate labels only for copies with remaining flags")
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
@@ -664,7 +674,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
)
|
||||
targets.append(resolved)
|
||||
return run(workspace, targets, overwrite=args.overwrite, max_workers=args.workers)
|
||||
return run(workspace, targets, overwrite=args.overwrite, marked=args.marked, max_workers=args.workers)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ def group_files(file_list):
|
||||
return [g['items'] for g in groups]
|
||||
|
||||
def stitch_pdf_pages(images_list):
|
||||
"""Vertically concatenates a list of PIL images with no separator."""
|
||||
"""Stack PIL images vertically, centering each within the widest image."""
|
||||
if not images_list:
|
||||
return None
|
||||
if len(images_list) == 1:
|
||||
@@ -130,7 +130,7 @@ def stitch_pdf_pages(images_list):
|
||||
|
||||
y_offset = 0
|
||||
for img in images_list:
|
||||
combined.paste(img, (0, y_offset))
|
||||
combined.paste(img, ((max_width - img.width) // 2, y_offset))
|
||||
y_offset += img.height
|
||||
|
||||
return combined
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_modifications import (
|
||||
APPLIED,
|
||||
DRAFT,
|
||||
MODES,
|
||||
baseline,
|
||||
output_removals,
|
||||
read_instructions,
|
||||
rebuild,
|
||||
validate_instructions,
|
||||
)
|
||||
from copienator.filesystem import publish_file_sets
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, annotation_dir: str = "BGnot") -> ExitCode:
|
||||
workspace.require_files("labels", DRAFT)
|
||||
workspace.require_directories("Copies", annotation_dir)
|
||||
text = (workspace.root / DRAFT).read_text(encoding="utf-8")
|
||||
instructions = read_instructions(text, annotation_dir)
|
||||
validate_instructions(workspace.root, annotation_dir, instructions)
|
||||
directory = workspace.root / annotation_dir
|
||||
applied = directory / APPLIED
|
||||
previous = read_instructions(applied.read_text(encoding="utf-8"), annotation_dir) if applied.exists() else []
|
||||
copy_ids = sorted({item.copy_id for item in [*previous, *instructions]})
|
||||
# Prepare every copy before publishing anything, including the applied file.
|
||||
with TemporaryDirectory(prefix=".annotation-edits-", dir=directory) as temporary:
|
||||
staging_root = Path(temporary)
|
||||
file_sets = {}
|
||||
for copy_id in copy_ids:
|
||||
output = directory / f"Copie{copy_id}"
|
||||
meta = baseline(output)
|
||||
staging = staging_root / output.name
|
||||
staging.mkdir()
|
||||
selected = [item for item in instructions if item.copy_id == copy_id]
|
||||
rebuild(workspace.root, annotation_dir, output, staging, meta, selected,
|
||||
read_json(output / "score.json"))
|
||||
file_sets[output] = (staging, output_removals(output))
|
||||
manifest = staging_root / "manifest"
|
||||
manifest.mkdir()
|
||||
(manifest / APPLIED).write_text(text, encoding="utf-8")
|
||||
file_sets[directory] = (manifest, ())
|
||||
publish_file_sets(file_sets)
|
||||
print(f"Modifications appliquées à {len(copy_ids)} copie(s). Les scores sont conservés.")
|
||||
print("Relancez « Attribuer les noms et préparer A Rendre » pour actualiser tous les livrables.")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = evaluation_parser("Modify reviewed answer bodies using annotation_modifications.txt")
|
||||
parser.add_argument("--annotation-dir", choices=MODES, default="BGnot")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
return execute(build_parser(), argv, lambda args: run(
|
||||
workspace_from_args(args), annotation_dir=args.annotation_dir))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -21,6 +21,7 @@ from copienator import (
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
|
||||
from copienator.platform import open_path
|
||||
from copienator.utils import natural_key, read_all_labels
|
||||
|
||||
@@ -307,6 +308,7 @@ class ImageViewer:
|
||||
self.forward_stack = []
|
||||
self.current_pil_image = None
|
||||
self.failed = False
|
||||
self.skipped_copies = set()
|
||||
|
||||
from config import PLOTTING_KB
|
||||
|
||||
@@ -314,6 +316,7 @@ class ImageViewer:
|
||||
self.root.bind(PLOTTING_KB["OK"], self.on_enter)
|
||||
self.root.bind(PLOTTING_KB["previous"], self.on_previous)
|
||||
self.root.bind("P", self.on_previous)
|
||||
self.root.bind("!", self.on_skip)
|
||||
self.root.bind(PLOTTING_KB["edit"], self.on_edit)
|
||||
self.root.bind(PLOTTING_KB["open pdf"], self.on_open_pdf)
|
||||
self.root.bind(PLOTTING_KB["open original pdf"], self.on_open_ori_pdf)
|
||||
@@ -345,6 +348,10 @@ class ImageViewer:
|
||||
self.root.quit()
|
||||
return
|
||||
|
||||
if metadata["copie"] in self.skipped_copies:
|
||||
self.root.after(0, self.poll_queue)
|
||||
return
|
||||
|
||||
# Check if we switched to a new "Copie" group
|
||||
if self.active_copie_name != metadata["copie"]:
|
||||
self.save_current_batch() # Write previous group to disk
|
||||
@@ -361,8 +368,21 @@ class ImageViewer:
|
||||
"""Writes the accumulated data to the main JSON file."""
|
||||
if self.active_copie_name and self.accumulated_results:
|
||||
main_json_path = self.base_dir / "Copies" / f"{self.active_copie_name}.json"
|
||||
accepted = [item[2] for item in self.history
|
||||
if item[3] == self.active_copie_name]
|
||||
parts = {item["part"] for item in accepted}
|
||||
expected = (set(range(1, len(accepted[-1]["schema"]["columns_per_file"]) + 1))
|
||||
if accepted else set())
|
||||
if not expected or parts != expected:
|
||||
mark_copy_error(self.workspace, main_json_path.with_suffix(".pdf"),
|
||||
"Vérification des labels incomplète", source="plotting")
|
||||
self.failed = True
|
||||
self.accumulated_results = None
|
||||
return
|
||||
print(f"Writing aggregated result to {main_json_path}")
|
||||
atomic_write_json(main_json_path, self.accumulated_results)
|
||||
clear_copy_error(self.workspace, main_json_path.with_suffix(".pdf"),
|
||||
source="plotting")
|
||||
self.accumulated_results = None
|
||||
|
||||
def close(self):
|
||||
@@ -467,6 +487,21 @@ class ImageViewer:
|
||||
self.is_viewing = False
|
||||
self.label.config(image="", text="Loading next...")
|
||||
|
||||
def on_skip(self, _event=None):
|
||||
if not self.is_viewing:
|
||||
return
|
||||
copie = self.current_meta["copie"]
|
||||
mark_copy_error(self.workspace, self.workspace.copies_dir / f"{copie}.pdf",
|
||||
"Problème repéré pendant la vérification des labels", source="plotting")
|
||||
self.failed = True
|
||||
self.skipped_copies.add(copie)
|
||||
# Discard accepted parts of this copy without replacing its previous result.
|
||||
self.accumulated_results = None
|
||||
self.history = [item for item in self.history if item[3] != copie]
|
||||
self.forward_stack = [item for item in self.forward_stack if item[2]["copie"] != copie]
|
||||
self.is_viewing = False
|
||||
self.label.config(image="", text="Copie signalée. Chargement de la suivante…")
|
||||
|
||||
def on_open_pdf(self, event):
|
||||
if self.is_viewing and self.current_json_path:
|
||||
a = self.current_json_path.stem.split('_')[0] + ".pdf"
|
||||
@@ -530,17 +565,24 @@ def _selected_images(workspace: EvaluationWorkspace, target: Path) -> list[Path]
|
||||
return sorted(workspace.cutleft_dir.glob("*.jpg"), key=natural_key)
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
|
||||
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode:
|
||||
workspace.require_files("labels")
|
||||
all_labels = read_all_labels(workspace.root)
|
||||
files_to_process = _selected_images(workspace, target)
|
||||
if marked:
|
||||
copies = marked_copy_paths(workspace)
|
||||
if not copies:
|
||||
print("No flagged copies to verify.")
|
||||
return ExitCode.SUCCESS
|
||||
files_to_process = [image for pdf in copies for image in _selected_images(workspace, pdf)]
|
||||
else:
|
||||
files_to_process = _selected_images(workspace, target)
|
||||
if not files_to_process:
|
||||
print(f"No Cutleft images found for {target}")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
print(
|
||||
"o to open pdf, O original pdf, e to edit part, p to go back, "
|
||||
"i to open the statement, click for coordinates"
|
||||
"i to open the statement, ! to flag and skip the copy, click for coordinates"
|
||||
)
|
||||
input_queue = queue.Queue(maxsize=5)
|
||||
worker = threading.Thread(
|
||||
@@ -556,7 +598,9 @@ def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return target_parser("Interactively verify detected label coordinates")
|
||||
parser = target_parser("Interactively verify detected label coordinates")
|
||||
parser.add_argument("--marked", action="store_true", help="Verify only copies with remaining flags")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
@@ -564,7 +608,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target)
|
||||
return run(workspace, target, marked=args.marked)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
+40
-14
@@ -1,4 +1,4 @@
|
||||
"""Persistent copy flags shared by page splitting, margin review and the GUI."""
|
||||
"""Persistent per-step copy flags shared by repair commands and the GUI."""
|
||||
from pathlib import Path
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, atomic_update_json, read_json
|
||||
@@ -8,31 +8,57 @@ def _path(workspace: EvaluationWorkspace) -> Path:
|
||||
return workspace.metadata_dir / "copy_errors.json"
|
||||
|
||||
|
||||
def _validate(value) -> dict[str, str]:
|
||||
if not isinstance(value, dict) or any(
|
||||
not isinstance(name, str) or not isinstance(reason, str)
|
||||
or "/" in name or "\\" in name or Path(name).suffix.casefold() != ".pdf"
|
||||
for name, reason in value.items()
|
||||
):
|
||||
raise CliError("Invalid copy_errors.json: expected PDF filenames and error descriptions")
|
||||
return value
|
||||
def _validate(value) -> dict[str, dict[str, str]]:
|
||||
if not isinstance(value, dict):
|
||||
raise CliError("Invalid copy_errors.json: expected PDF filenames and step flags")
|
||||
normalized = {}
|
||||
for name, flags in value.items():
|
||||
if (not isinstance(name, str) or "/" in name or "\\" in name
|
||||
or Path(name).suffix.casefold() != ".pdf"):
|
||||
raise CliError("Invalid copy_errors.json: invalid PDF filename")
|
||||
# Older versions only reported errors from margin cropping.
|
||||
if isinstance(flags, str):
|
||||
flags = {"cutleft": flags}
|
||||
if (not isinstance(flags, dict) or not flags
|
||||
or any(step not in {"cutleft", "plotting"} or not isinstance(reason, str)
|
||||
for step, reason in flags.items())):
|
||||
raise CliError("Invalid copy_errors.json: invalid step flags")
|
||||
normalized[name] = dict(flags)
|
||||
return normalized
|
||||
|
||||
|
||||
def copy_errors(workspace: EvaluationWorkspace) -> dict[str, str]:
|
||||
return _validate(read_json(_path(workspace), default={}))
|
||||
"""Return a summary per copy, preserving the existing GUI-facing API."""
|
||||
flags = _validate(read_json(_path(workspace), default={}))
|
||||
return {name: "; ".join(reasons.values()) for name, reasons in flags.items()}
|
||||
|
||||
|
||||
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str) -> None:
|
||||
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str,
|
||||
*, source: str = "cutleft") -> None:
|
||||
if source not in {"cutleft", "plotting"}:
|
||||
raise ValueError(f"Unknown copy flag source: {source}")
|
||||
def update(errors):
|
||||
_validate(errors)[pdf.name] = reason
|
||||
normalized = _validate(errors)
|
||||
normalized.setdefault(pdf.name, {})[source] = reason
|
||||
errors.clear()
|
||||
errors.update(normalized)
|
||||
atomic_update_json(_path(workspace), update, default_factory=dict)
|
||||
|
||||
|
||||
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path) -> None:
|
||||
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path,
|
||||
*, source: str = "cutleft") -> None:
|
||||
if source not in {"cutleft", "plotting"}:
|
||||
raise ValueError(f"Unknown copy flag source: {source}")
|
||||
if not _path(workspace).exists():
|
||||
return
|
||||
def update(errors):
|
||||
_validate(errors).pop(pdf.name, None)
|
||||
normalized = _validate(errors)
|
||||
flags = normalized.get(pdf.name, {})
|
||||
flags.pop(source, None)
|
||||
if not flags:
|
||||
normalized.pop(pdf.name, None)
|
||||
errors.clear()
|
||||
errors.update(normalized)
|
||||
atomic_update_json(_path(workspace), update, default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ import json
|
||||
|
||||
|
||||
def get_single_image_bytes(pdf_path):
|
||||
"""Converts a multi-page PDF into a single stitched JPEG in memory."""
|
||||
"""Convert PDF pages into a single JPEG, centering pages within the widest."""
|
||||
imgs = convert_from_path(pdf_path, dpi=200) # Same DPI as grouping.py
|
||||
if not imgs:
|
||||
raise ValueError(f"No pages in {pdf_path}")
|
||||
@@ -250,7 +250,7 @@ def get_single_image_bytes(pdf_path):
|
||||
combined = Image.new('RGB', (max_width, total_height), 'white')
|
||||
y_offset = 0
|
||||
for img in imgs:
|
||||
combined.paste(img, (0, y_offset))
|
||||
combined.paste(img, ((max_width - img.width) // 2, y_offset))
|
||||
y_offset += img.height
|
||||
|
||||
img_byte_arr = io.BytesIO()
|
||||
|
||||
Reference in new issue
Block a user