590 lines
23 KiB
Python
590 lines
23 KiB
Python
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 (
|
||
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.configuration import SHOW_PERSONAL_STEPS
|
||
from copienator.left_margin import analyse_margins
|
||
|
||
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()
|
||
|
||
# The requested spelling is kept as the public switch for this alternate layout.
|
||
personnal_binding = bool(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:
|
||
margin_analysis = analyse_margins(images, RENDER_DPI, reference_width)
|
||
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
|
||
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")
|
||
|
||
|
||
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.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("!", 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 personnal_binding:
|
||
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 = 0
|
||
self.current_width_offset = 0
|
||
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 personnal_binding
|
||
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')
|
||
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 | !: 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 _advance(self) -> None:
|
||
self.index += 1
|
||
self.current_shift = 0
|
||
self.current_width_offset = 0
|
||
self.current_max_per_file = self.default_max_per_file
|
||
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())
|