Files
Copies/copienator/commands/cutleft.py
T

621 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
import re
import threading
import tkinter as tk
from tkinter import messagebox
from collections.abc import Sequence
from functools import lru_cache
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
from copienator import configuration
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
read_json,
execute,
target_parser,
workspace_from_target,
)
from copienator.filesystem import staged_files
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
from copienator.left_margin import analyse_margins
from copienator.name_grids import name_bottom_height_fraction
from copienator.annotation_layout import fingerprint
DELIMITER_WIDTH = 5
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
AUTO_STRIP_WIDTH_FACTOR = 1.10
pdf_cache_lock = threading.Lock()
def personal_bindings_enabled() -> bool:
return bool(configuration.SHOW_PERSONAL_STEPS)
def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]:
"""Distribute pages into balanced chunks no larger than max_per_file."""
if total_pages == 0:
return []
number_of_files = (total_pages + max_per_file - 1) // max_per_file
base_count, remainder = divmod(total_pages, number_of_files)
return [
base_count + (1 if index < remainder else 0)
for index in range(number_of_files)
]
def stitch_images(image_list: list[Image.Image]) -> Image.Image | None:
if not image_list:
return None
# 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 += column_width
if index < len(image_list) - 1:
delimiter = Image.new(
"RGB", (DELIMITER_WIDTH, max_height), color=DELIMITER_COLOR
)
combined.paste(delimiter, (x_offset, 0))
x_offset += DELIMITER_WIDTH
return combined
@lru_cache(maxsize=3)
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, dpi=RENDER_DPI)
def get_pdf_pages(pdf_path: Path) -> list[Image.Image]:
"""Thread-safe wrapper around the small PDF conversion cache."""
with pdf_cache_lock:
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,
max_per_file: int = 5,
width_offset: int = 0,
*,
automatic_margin: bool = True,
margin_analysis: dict | None = None,
) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None:
"""Convert one PDF into a preview, full-resolution splits and metadata."""
try:
cropped_images = []
images = get_pdf_pages(pdf_path)
if not images:
return None
reference_width = normal_page_width(pdf_path, images)
if automatic_margin and max_per_file != 1 and margin_analysis is None:
name_bottom = name_bottom_height_fraction(
EvaluationWorkspace(pdf_path.parent.parent), pdf_path.stem)
margin_analysis = analyse_margins(
images, RENDER_DPI, reference_width, name_bottom_height_fraction=name_bottom)
full_width_pages = []
page_crops = []
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(
width,
width // 3 + 100 + shift_offset + max(0, width_offset),
)
if automatic_margin and max_per_file != 1:
left = min(width - 1, max(0, margin_analysis['starts_px'][page_index] + shift_offset))
strip_width = round(
((reference_width if width < NARROW_PAGE_RATIO * reference_width else width) // 3)
* AUTO_STRIP_WIDTH_FACTOR
)
right = min(width, left + strip_width + max(0, width_offset))
if right > left:
cropped_images.append(image.crop((left, 0, right, height)))
page_crops.append({'page': page_index + 1, 'left_px': left, 'right_px': right,
'width_px': width, 'height_px': height})
if left == 0 and right == width:
full_width_pages.append(page_index + 1)
if not cropped_images:
return None
distribution = distribute_pages(len(cropped_images), max_per_file)
split_images = []
current_index = 0
for count in distribution:
stitched = stitch_images(cropped_images[current_index : current_index + count])
if stitched is not None:
split_images.append(stitched)
current_index += count
full_stitch = stitch_images(cropped_images)
if full_stitch is None:
return None
preview = full_stitch.resize(OUTPUT_SIZE, Image.Resampling.BILINEAR)
schema: dict[str, object] = {
"original_filename": pdf_path.name,
"total_pages": len(cropped_images),
"number_of_files": len(split_images),
"columns_per_file": distribution,
"full_width_pages": full_width_pages,
"page_crops": page_crops,
"auto_margin": margin_analysis if automatic_margin and max_per_file != 1 else None,
}
return preview, split_images, schema
except Exception as exc: # noqa: BLE001 - interactive item failure
print(f"Error processing {pdf_path.name}: {exc}")
return None
def _previous_cutleft_outputs(output_dir: Path, base_name: str) -> set[str]:
if not output_dir.is_dir():
return set()
result = {f"{base_name}_schema.json"}
for path in output_dir.glob(f"{base_name}_*.jpg"):
suffix = path.stem.removeprefix(f"{base_name}_")
if suffix.isdigit():
result.add(path.name)
return result
def save_results(
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
pdf_path: Path,
output_dir: Path,
) -> None:
"""Atomically replace every Cutleft output associated with one copy."""
_, splits, schema = result
if pdf_path.is_file():
schema = {**schema, 'source_sha256': fingerprint(pdf_path)}
base_name = pdf_path.stem
previous = _previous_cutleft_outputs(output_dir, base_name)
with staged_files(output_dir, remove=previous) as staging:
for index, image in enumerate(splits, start=1):
filename = f"{base_name}_{index:02d}.jpg"
image.save(staging / filename, "JPEG", quality=95)
atomic_write_json(staging / f"{base_name}_schema.json", schema)
for index in range(1, len(splits) + 1):
print(f"Saved: {base_name}_{index:02d}.jpg")
print(f"Saved schema: {base_name}_schema.json")
if pdf_path.is_file():
from copienator.name_grids import save_crop, build_grids
pages = get_pdf_pages(pdf_path)
save_crop(pages[0], pdf_path, output_dir / "Names", RENDER_DPI)
build_grids(output_dir / "Names", pdf_path.parent)
def regenerate_full_page_image(workspace: EvaluationWorkspace, image_path: Path) -> None:
"""Replace one strip montage with the same pages at their full width."""
match = re.fullmatch(r"(.+)_(\d+)", image_path.stem)
if not match or image_path.parent != workspace.cutleft_dir:
raise ValueError(f"Cannot map Cutleft image to PDF pages: {image_path}")
stem, part = match.group(1), int(match.group(2)) - 1
schema_path = workspace.cutleft_dir / f"{stem}_schema.json"
schema = read_json(schema_path)
counts = schema["columns_per_file"]
if (not isinstance(counts, list) or not counts
or any(type(count) is not int or count < 1 for count in counts)
or not 0 <= part < len(counts)):
raise ValueError("Invalid Cutleft page distribution")
first = sum(counts[:part])
last = first + counts[part]
page_numbers = set(range(first + 1, last + 1))
if page_numbers <= set(schema.get("full_width_pages", [])):
raise ValueError("Cutleft image already contains full pages")
pages = get_pdf_pages(workspace.copies_dir / f"{stem}.pdf")
if len(pages) != sum(counts):
raise ValueError("Cutleft schema does not match the PDF page count")
combined = stitch_images(pages[first:last])
if combined is None:
raise ValueError("No pages available for full-page fallback")
crops = {crop["page"]: crop for crop in schema.get("page_crops", [])}
for index in range(first, last):
width, height = pages[index].size
crops[index + 1] = {"page": index + 1, "left_px": 0, "right_px": width,
"width_px": width, "height_px": height}
schema["page_crops"] = [crops[key] for key in sorted(crops)]
schema["full_width_pages"] = sorted(set(schema.get("full_width_pages", [])) | page_numbers)
with staged_files(workspace.cutleft_dir) as staging:
combined.save(staging / image_path.name, "JPEG", quality=95)
atomic_write_json(staging / schema_path.name, schema)
class ImageReviewer:
def __init__(
self,
files: list[Path],
output_dir: Path,
default_max_per_file: int = 5,
automatic_margin: bool = True,
) -> None:
self.files = files
self.output_dir = output_dir
self.workspace = EvaluationWorkspace(output_dir.parent)
self.completed = False
self.had_errors = False
self.stop_prefetch = threading.Event()
self.current_result = None
self.automatic_margin = automatic_margin
self.margin_analysis = None
self.index = 0
self.current_shift = 0
self.current_width_offset = 0
self.default_max_per_file = default_max_per_file
self.current_max_per_file = default_max_per_file
self.review_settings: dict[int, tuple[int, int, int]] = {}
self.current_preview: Image.Image | None = None
self.is_processing = False
self.manual_queue: Queue[
tuple[Image.Image, list[Image.Image], dict[str, object]] | None
] = Queue()
self.root = tk.Tk()
self.root.title("PDF Cropper")
self.root.geometry("+100+100")
self.label_img = tk.Label(self.root)
self.label_img.pack()
self.label_info = tk.Label(self.root, text="", font=("Arial", 12, "bold"))
self.label_info.pack(pady=5)
self.root.bind("<Return>", self.on_next)
self.root.bind("p", self.on_previous)
self.root.bind("P", self.on_previous)
self.root.bind("!", self.on_skip)
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.root.bind("<Left>", lambda _event: self.on_shift(-CROP_SHIFT_STEP))
self.root.bind("<Right>", lambda _event: self.on_shift(CROP_SHIFT_STEP))
self.root.bind("<Up>", lambda _event: self.on_enlarge(CROP_WIDTH_STEP))
self.root.bind("<Down>", lambda _event: self.on_enlarge(-CROP_WIDTH_STEP))
if personal_bindings_enabled():
self.root.bind("t", lambda _event: self.on_shift(-CROP_SHIFT_STEP))
self.root.bind("n", lambda _event: self.on_shift(CROP_SHIFT_STEP))
self.root.bind("r", lambda _event: self.on_enlarge(CROP_WIDTH_STEP))
self.root.bind("s", lambda _event: self.on_enlarge(-CROP_WIDTH_STEP))
self.root.bind("1", lambda _event: self.on_set_max_pages(1))
Thread(target=self.prefetch_worker, daemon=True).start()
self.load_current_image()
self.root.lift()
self.root.focus_force()
self.root.mainloop()
def on_set_max_pages(self, count: int) -> None:
if self.is_processing:
return
self.current_max_per_file = count
print(f"Setting max pages per file: {count}")
self.trigger_processing(self.files[self.index], self.current_shift)
def prefetch_worker(self) -> None:
processed_index = -1
while not self.stop_prefetch.is_set():
target = self.index + 1
if target < len(self.files) and target != processed_index:
try:
get_pdf_pages(self.files[target])
except Exception:
pass # The foreground review reports and flags conversion errors.
processed_index = target
self.stop_prefetch.wait(0.05)
def load_current_image(self) -> None:
if self.index >= len(self.files):
print("All files processed.")
self.completed = True
self.on_close()
return
self.is_processing = False
self.current_shift, self.current_width_offset, self.current_max_per_file = (
self.review_settings.get(self.index, (0, 0, self.default_max_per_file))
)
self.current_result = None
self.margin_analysis = None
self.trigger_processing(self.files[self.index], self.current_shift)
def trigger_processing(self, pdf_path: Path, shift: int) -> None:
self.is_processing = True
self.label_info.configure(
text=f"Processing {pdf_path.name} (Shift {shift})... Please wait.",
fg="red",
)
def worker() -> None:
self.manual_queue.put(
process_single_pdf(
pdf_path,
shift,
self.current_max_per_file,
self.current_width_offset,
automatic_margin=self.automatic_margin,
margin_analysis=self.margin_analysis,
)
)
Thread(target=worker, daemon=True).start()
self.check_manual_queue(pdf_path)
def check_manual_queue(self, pdf_path: Path) -> None:
try:
result = self.manual_queue.get_nowait()
self.is_processing = False
if result is None:
print(f"Failed to process {pdf_path.name}, skipping.")
self._mark_error(pdf_path, "Échec de la conversion pour la découpe des marges")
self._advance()
else:
self.handle_processing_result(result, pdf_path)
except Empty:
self.root.after(100, lambda: self.check_manual_queue(pdf_path))
def handle_processing_result(
self,
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
pdf_path: Path,
) -> None:
self.current_preview = result[0]
self.current_result = result
if result[2].get('auto_margin') is not None:
self.margin_analysis = result[2]['auto_margin']
self.update_display(pdf_path.name, result[2])
def update_display(self, filename: str, schema: dict[str, object]) -> None:
if self.current_preview is None:
return
tk_image = ImageTk.PhotoImage(self.current_preview)
self.label_img.configure(image=tk_image)
self.label_img.image = tk_image
personal_instructions = (
"\nt/s/r/n also available: left / narrower / wider / right"
if personal_bindings_enabled()
else ""
)
analysis = schema.get('auto_margin')
margin_info = ''
if analysis:
offsets = sorted({round(crop['left_px'] * 25.4 / RENDER_DPI, 1)
for crop in schema['page_crops']})
needs_review = any(group['status'] in {'inconsistent', 'uncertain', 'insufficient-ink'}
for group in analysis['groups'])
margin_info = (f"Auto margin: {offsets} mm from left"
+ (' | Check uncertain pages' if needs_review else '') + '\n')
for page in analysis.get('pages', []):
if page.get('status') == 'unknown':
value = page.get('effective_edge_mm')
fallback = f" (copy average: {value:.1f} mm)" if value is not None else ''
margin_info += f"Page {page['page']}: unknown margin{fallback}\n"
if analysis.get('no_margin_from_page') is not None:
margin_info += f"No margin from page {analysis['no_margin_from_page']} onwards\n"
self.label_info.configure(
text=(
f"[{self.index + 1}/{len(self.files)}] {filename} | "
f"Shift: {self.current_shift}px | "
f"Extra width: {self.current_width_offset}px\n"
f"Files: {schema['number_of_files']} | "
f"Cols: {schema['columns_per_file']}\n"
f"{margin_info}"
"Arrows: left/right move by 50px | up/down widen/narrow by 50px\n"
"Enter: Save and next | P/p: previous copy | !: flag error and skip | 1: use full pages"
f"{personal_instructions}"
),
fg="black",
)
def on_shift(self, amount: int) -> None:
if self.is_processing:
return
self.current_shift += amount
print(f"Applying shift: {self.current_shift}")
self.trigger_processing(self.files[self.index], self.current_shift)
def on_enlarge(self, amount: int) -> None:
if self.is_processing:
return
self.current_width_offset = max(0, self.current_width_offset + amount)
print(f"Applying extra width: {self.current_width_offset}")
self.trigger_processing(self.files[self.index], self.current_shift)
def on_next(self, _event: object) -> None:
if self.is_processing or self.current_result is None:
return
pdf_path = self.files[self.index]
try:
save_results(self.current_result, pdf_path, self.output_dir)
clear_copy_error(self.workspace, pdf_path)
except Exception as exc:
self._mark_error(pdf_path, f"Échec de l’enregistrement : {exc}")
messagebox.showerror("Enregistrement impossible", str(exc), parent=self.root)
return
self._advance()
def _mark_error(self, pdf_path: Path, reason: str) -> None:
mark_copy_error(self.workspace, pdf_path, reason)
self.had_errors = True
print(f"[Copie signalée] {pdf_path.name}: {reason}")
def on_skip(self, _event=None) -> None:
if self.is_processing or self.index >= len(self.files):
return
self._mark_error(self.files[self.index], "Problème repéré pendant la découpe des marges")
self._advance()
def on_close(self) -> None:
self.stop_prefetch.set()
self.root.destroy()
def _remember_settings(self) -> None:
self.review_settings[self.index] = (
self.current_shift, self.current_width_offset, self.current_max_per_file,
)
def on_previous(self, _event=None) -> None:
if self.is_processing or self.index == 0:
return
self._remember_settings()
self.index -= 1
self.load_current_image()
def _advance(self) -> None:
self._remember_settings()
self.index += 1
self.load_current_image()
def _selected_files(
workspace: EvaluationWorkspace,
target: Path,
) -> list[Path]:
workspace.require_directories("Copies")
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
return [target]
return sorted(
(
path
for path in workspace.copies_dir.glob("*.pdf")
if "nonc" not in path.name.casefold()
),
key=lambda path: path.name.casefold(),
)
def run(
workspace: EvaluationWorkspace,
target: Path,
*,
fullpage: bool = False,
no_visual_check: bool = False,
marked: bool = False,
automatic_margin: bool = True,
) -> ExitCode:
files = marked_copy_paths(workspace) if marked else _selected_files(workspace, target)
if not files:
print("No PDF files found.")
return ExitCode.SUCCESS
workspace.cutleft_dir.mkdir(parents=True, exist_ok=True)
_get_pdf_pages_cached.cache_clear()
if fullpage and no_visual_check:
had_errors = False
for pdf_path in files:
result = process_single_pdf(pdf_path, max_per_file=1)
if result is None:
mark_copy_error(
workspace,
pdf_path,
"Échec de la conversion en pages entières",
)
had_errors = True
continue
try:
save_results(result, pdf_path, workspace.cutleft_dir)
clear_copy_error(workspace, pdf_path)
except Exception as exc: # noqa: BLE001 - continue with remaining copies
mark_copy_error(
workspace,
pdf_path,
f"Échec de l’enregistrement : {exc}",
)
print(f"[Copie signalée] {pdf_path.name}: {exc}")
had_errors = True
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
reviewer = ImageReviewer(
files,
workspace.cutleft_dir,
default_max_per_file=1 if fullpage else 5,
automatic_margin=automatic_margin,
)
if not reviewer.completed:
return ExitCode.INTERRUPTED
return ExitCode.PARTIAL if reviewer.had_errors else ExitCode.SUCCESS
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 only margin-cropping flags after saving with Enter")
parser.add_argument(
'--manual-margin', action='store_true',
help='Use the previous fixed margin instead of automatically estimating body-text positions',
)
parser.add_argument(
"--fullpage",
action="store_true",
help="Use each complete page instead of cropping the label margin",
)
parser.add_argument(
"--no-visual-check",
action="store_true",
help="Save full pages without opening the visual reviewer (requires --fullpage)",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(
workspace,
target,
fullpage=args.fullpage,
no_visual_check=args.no_visual_check,
marked=args.marked,
automatic_margin=not args.manual_margin,
)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())