diff --git a/copienator/commands/correction.py b/copienator/commands/correction.py index fb03fd6..cc0cad4 100644 --- a/copienator/commands/correction.py +++ b/copienator/commands/correction.py @@ -532,6 +532,19 @@ def process_single_task(task_tuple, precomputed_response=None): json_data = parse_correction_response(full_response_text) + # Validate the whole group before moving PDFs or applying corrections. + returned_ids = [entry["id"] for entry in json_data] + unexpected = sorted(set(returned_ids) - set(d_data)) + missing = sorted(set(d_data) - set(returned_ids)) + duplicates = sorted({pid for pid in returned_ids if returned_ids.count(pid) > 1}) + if unexpected or missing or duplicates: + raise ValueError( + f"Correction ID mismatch: expected {list(d_data)}, " + f"received {returned_ids}; unexpected IDs: {unexpected}; " + f"missing IDs: {missing}; duplicate IDs: {duplicates}. " + "No corrections from this response were applied." + ) + # Ensure consistency of answer placements for p in json_data: pid = p["id"] @@ -899,7 +912,10 @@ def run_configured(args: argparse.Namespace) -> ExitCode: "data": b64_img, } }, - {"text": prompting.make_prompt(INPUT_DIR, label)}, + {"text": prompting.make_prompt( + INPUT_DIR, label, + expected_ids=[row[0] for row in group_data], + )}, ], } ], diff --git a/copienator/commands/cutleft.py b/copienator/commands/cutleft.py index dc60125..56a0672 100644 --- a/copienator/commands/cutleft.py +++ b/copienator/commands/cutleft.py @@ -10,6 +10,7 @@ from pathlib import Path from queue import Empty, Queue from threading import Thread +import pymupdf from pdf2image import convert_from_path from PIL import Image, ImageTk @@ -31,6 +32,8 @@ DELIMITER_COLOR = (0, 0, 0) OUTPUT_SIZE = (1800, 1000) CROP_SHIFT_STEP = 50 CROP_WIDTH_STEP = 50 +RENDER_DPI = 200 +NARROW_PAGE_RATIO = 0.8 pdf_cache_lock = threading.Lock() # The requested spelling is kept as the public switch for this alternate layout. @@ -52,14 +55,17 @@ def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]: def stitch_images(image_list: list[Image.Image]) -> Image.Image | None: if not image_list: return None - total_width = sum(image.width for image in image_list) + # Downstream label coordinates assume equally spaced page columns. Pad + # narrower strips instead of scaling their content or vertical coordinates. + column_width = max(image.width for image in image_list) + total_width = column_width * len(image_list) total_width += (len(image_list) - 1) * DELIMITER_WIDTH max_height = max(image.height for image in image_list) combined = Image.new("RGB", (total_width, max_height), color="white") x_offset = 0 for index, image in enumerate(image_list): combined.paste(image, (x_offset, 0)) - x_offset += image.width + x_offset += column_width if index < len(image_list) - 1: delimiter = Image.new( "RGB", (DELIMITER_WIDTH, max_height), color=DELIMITER_COLOR @@ -73,7 +79,7 @@ def stitch_images(image_list: list[Image.Image]) -> Image.Image | None: def _get_pdf_pages_cached(pdf_path: Path) -> list[Image.Image]: # Label coordinates use the full, displayed MediaBox, including on PDFs # previously cropped by crop-margins. Keep this in sync with split-answers. - return convert_from_path(pdf_path, use_cropbox=False) + return convert_from_path(pdf_path, use_cropbox=False, dpi=RENDER_DPI) def get_pdf_pages(pdf_path: Path) -> list[Image.Image]: @@ -82,6 +88,29 @@ def get_pdf_pages(pdf_path: Path) -> list[Image.Image]: return _get_pdf_pages_cached(pdf_path) +def normal_page_width(pdf_path: Path, images: list[Image.Image]) -> float: + """Infer the unsplit sheet width, including for legacy PDFs without metadata.""" + reference = max(image.width for image in images) + original = pdf_path.parent.parent / "Copies Originales" / pdf_path.name + try: + if not original.is_file(): + return reference + with pymupdf.open(original) as document: + # Landscape scans contain two normal booklet pages; portrait scans + # contain one. MediaBox dimensions match get_pdf_pages above. + widths = [] + for page in document: + rect = page.mediabox * page.rotation_matrix + width = rect.width / 2 if rect.width > rect.height else rect.width + widths.append(width * RENDER_DPI / 72) + if widths: + reference = max(widths) + except (OSError, RuntimeError, ValueError): + # A missing/unreadable backup must not prevent reviewing a valid copy. + pass + return reference + + def process_single_pdf( pdf_path: Path, shift_offset: int = 0, @@ -91,10 +120,20 @@ def process_single_pdf( """Convert one PDF into a preview, full-resolution splits and metadata.""" try: cropped_images = [] - for image in get_pdf_pages(pdf_path): + images = get_pdf_pages(pdf_path) + if not images: + return None + reference_width = normal_page_width(pdf_path, images) + full_width_pages = [] + for page_index, image in enumerate(images): width, height = image.size if max_per_file == 1: left, right = 0, width + elif width < NARROW_PAGE_RATIO * reference_width: + # Secondary subpages start at their own left edge. Keep the + # normal margin width, capped to the available page content. + left = min(width - 1, max(0, shift_offset)) + right = min(width, left + int(reference_width // 3) + max(0, width_offset)) else: left = max(0, 100 + shift_offset) right = min( @@ -103,6 +142,8 @@ def process_single_pdf( ) if right > left: cropped_images.append(image.crop((left, 0, right, height))) + if left == 0 and right == width: + full_width_pages.append(page_index + 1) if not cropped_images: return None @@ -123,6 +164,7 @@ def process_single_pdf( "total_pages": len(cropped_images), "number_of_files": len(split_images), "columns_per_file": distribution, + "full_width_pages": full_width_pages, } return preview, split_images, schema except Exception as exc: # noqa: BLE001 - interactive item failure diff --git a/copienator/prompting.py b/copienator/prompting.py index 78e4bea..785bafd 100644 --- a/copienator/prompting.py +++ b/copienator/prompting.py @@ -1,6 +1,8 @@ from pathlib import Path import io +import json from . import utils +from . import read_json from . import configuration as settings from .gemini_config import thinking_config @@ -103,7 +105,7 @@ sur les autres questions.""" from .utils import get_label_text_content, get_label_sol_content, get_label_persp_content -def make_prompt(input_dir,full_label): +def make_prompt(input_dir,full_label, *, expected_ids=None): text = get_label_text_content(input_dir, full_label) or "" corr = get_label_sol_content(input_dir, full_label) or "" persp = get_label_persp_content(input_dir, full_label) or "" @@ -117,7 +119,18 @@ def make_prompt(input_dir,full_label): + persp + "\n```\n" ) - return main_prompt.replace("<>", text).replace("<>", corr).replace("<>", persp).replace("<