Automatic page splitting

This commit is contained in:
sebastien committed 2026-11-03 22:08:36 +01:00
1 parent d4404e4c27
commit 78f3e4b2f0
13 files changed
+1959 -40

No files matched your search

+100 -23
View File
@@ -27,7 +27,7 @@ from copienator import (
)
from copienator.platform import launch_pdf_arranger
from copienator.copy_errors import marked_copy_paths, clear_copy_error
from copienator.orientation import save_provenance, report_path
from copienator.orientation import save_provenance, report_path, provenance_path
# --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
@@ -49,7 +49,7 @@ def _temporary_sibling(path: Path, purpose: str) -> Path:
def commit_processed_pdf(
workspace: EvaluationWorkspace,
original_path: Path,
generated_path: Path,
generated_path: Path | None,
) -> Path:
"""Commit a processed copy and its original backup with rollback."""
backup_path = workspace.original_copies_dir / original_path.name
@@ -73,8 +73,9 @@ def commit_processed_pdf(
backup_replaced = True
if output_path.exists():
output_path.replace(saved_output)
generated_path.replace(output_path)
output_replaced = True
if generated_path is not None:
generated_path.replace(output_path)
output_replaced = True
if original_path.resolve() not in {
backup_path.resolve(),
output_path.resolve(),
@@ -120,6 +121,10 @@ class PDFPreviewer:
try:
self.doc = pymupdf.open(self.pdf_path)
self.original_page_ids = {page.xref: index + 1 for index, page in enumerate(self.doc)}
from copienator.commands.automatic_page_splitter import load_decisions
saved = load_decisions(self.workspace, self.pdf_path)
self.automatic_plans = {r['source_page']: r for r in saved} if saved else {}
self.automatic_review = bool(self.automatic_plans)
except (OSError, RuntimeError, ValueError) as e:
self.failed = True
self._temporary_directory.cleanup()
@@ -176,6 +181,12 @@ class PDFPreviewer:
f"{fmt('add_secondary_splitter')}: add secondary line, {fmt('delete_secondary_splitter')}: delete newest (arrows move newest; main line red, active secondary orange)\n"
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
)
if self.automatic_review:
instructions += (
"\nPropositions automatiques : la touche suivante valide le choix affiché. "
"Croix rouge : page retirée.\n"
"Déplacer une ligne reprend le découpage manuel pour cette page."
)
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
self.info_label.pack(pady=5, side=tk.TOP)
@@ -255,6 +266,25 @@ class PDFPreviewer:
self.current_line_x = page.rect.width / 2
self.current_rotation = 0
self.secondary_splitters = []
origin = getattr(self, 'original_page_ids', {}).get(page.xref, self.current_page_index+1)
plan = getattr(self, 'automatic_plans', {}).get(origin)
self.current_automatic_outputs = plan['outputs'] if plan else None
def _automatic_outputs(self):
"""Express saved asymmetric crops in the current preview orientation."""
outputs = getattr(self, 'current_automatic_outputs', None)
if outputs is None:
return None
flipped = (self.current_rotation+self.file_rotation+self.global_rotation) % 360
if not flipped:
return outputs
width, height = self.doc[self.current_page_index].rect.width, self.doc[self.current_page_index].rect.height
result = []
for output in outputs:
x0,y0,x1,y1 = output['crop_points']
result.append(dict(output, side={'left':'right','right':'left'}.get(output['side'], 'whole'),
crop_points=[width-x1,height-y1,width-x0,height-y0]))
return result
def load_page(self):
"""Loads and displays the current page on the canvas, scaled to fit."""
@@ -296,9 +326,20 @@ class PDFPreviewer:
# --- Draw Line and Rotate ---
draw = ImageDraw.Draw(img)
# The line position is scaled by the same zoom factor
line_x_scaled = self.current_line_x * self.current_zoom
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)],
fill="red", width=3)
proposed = self._automatic_outputs()
if proposed is not None:
for output in proposed:
x0,y0,x1,y1 = output['crop_points']
box = tuple(v*self.current_zoom for v in (x0,y0,x1,y1))
draw.rectangle(box, outline='green' if output['side']=='left' else 'purple', width=3)
if not output['keep']:
draw.line((box[0],box[1],box[2],box[3]), fill='red', width=3)
draw.line((box[0],box[3],box[2],box[1]), fill='red', width=3)
self.page_label.config(text=self.page_label.cget('text')+' — Proposition automatique (croix rouge : page retirée)')
else:
line_x_scaled = self.current_line_x * self.current_zoom
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)],
fill="red", width=3)
for index, position in enumerate(self.secondary_splitters):
x = position * self.current_zoom
active = index == len(self.secondary_splitters) - 1
@@ -353,6 +394,7 @@ class PDFPreviewer:
def add_secondary_splitter(self, event=None):
"""Start a new line in the widest remaining interval of this page."""
width = self.doc.load_page(self.current_page_index).rect.width
self.current_automatic_outputs = None
boundaries = sorted({0, width, self.current_line_x, *self.secondary_splitters})
left, right = max(zip(boundaries, boundaries[1:]), key=lambda pair: pair[1] - pair[0])
self.secondary_splitters.append((left + right) / 2)
@@ -364,6 +406,7 @@ class PDFPreviewer:
self.load_page()
def _move_active_line(self, delta):
self.current_automatic_outputs = None
width = self.doc.load_page(self.current_page_index).rect.width
if self.secondary_splitters:
self.secondary_splitters[-1] = min(width, max(0, self.secondary_splitters[-1] + delta))
@@ -415,13 +458,19 @@ class PDFPreviewer:
# Reload the canvas to show the new page
self.load_page()
def confirm_and_next_page(self, event=None, keep="both"):
def confirm_and_next_page(self, event=None, keep=None):
"""Saves the settings for the current page and moves to the next."""
proposed = self._automatic_outputs()
if keep is None:
sides = {o['side'] for o in proposed if o['keep']} if proposed is not None else {'left','right'}
keep = ('as_is' if 'whole' in sides else 'both' if len(sides)==2
else next(iter(sides)) if sides else 'none')
self.page_settings.append({
"line_x": self.current_line_x,
"secondary_splitters": list(self.secondary_splitters),
"rotation": self.current_rotation,
"keep": keep
"keep": keep,
"automatic_outputs": proposed,
})
self.current_page_index += 1
@@ -442,12 +491,18 @@ class PDFPreviewer:
def finish_and_process(self) -> bool:
"""Render and transactionally install the processed PDF."""
try:
self.split_pdf()
self.reorder_pdfs()
self.concate_files()
output = commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
save_provenance(self.workspace, output, self.output_origins)
if all(s['keep']=='none' for s in self.page_settings):
self.doc.close()
output = commit_processed_pdf(self.workspace, self.pdf_path, None)
provenance_path(self.workspace, output.stem).unlink(missing_ok=True)
else:
self.split_pdf()
self.reorder_pdfs()
self.concate_files()
output = commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
save_provenance(self.workspace, output, self.output_origins)
clear_copy_error(self.workspace, output, source='orientation')
clear_copy_error(self.workspace, output, source='page_splitter')
report_path(self.workspace, output.stem).unlink(missing_ok=True)
except Exception as exc: # noqa: BLE001 - interactive boundary
self.failed = True
@@ -514,15 +569,27 @@ class PDFPreviewer:
line_x = settings['line_x']
rotation_settings = settings['rotation']
keep = settings['keep']
# Bake source rotation and the preview's extra rotation into content,
# so all cut coordinates refer to the displayed page.
page.remove_rotation()
page.set_rotation((rotation_settings + self.file_rotation + self.global_rotation) % 360)
page.remove_rotation()
# Normalize the displayed page without losing an offset CropBox.
# This matches the automatic export's coordinate system.
source_rotation = page.rotation
width, height = page.rect.width, page.rect.height
normalized = pymupdf.open()
flat = normalized.new_page(width=width, height=height)
page.set_rotation(0)
if page.get_contents():
flat.show_pdf_page(flat.rect, self.doc, i, rotate=-source_rotation)
flat.set_rotation((rotation_settings+self.file_rotation+self.global_rotation) % 360)
flat.remove_rotation()
sides = []
if keep == "as_is":
sides.append((0, width, self.split_filename_left(i)))
elif settings.get('automatic_outputs') is not None:
for output in settings['automatic_outputs']:
side = output['side']
if keep == 'both' or keep == side:
x0,_,x1,_ = output['crop_points']
filename = self.split_filename_left(i) if side=='left' else self.split_filename_right(i)
sides.append((x0,x1,filename))
else:
if keep in {"both", "left"} and line_x > 0:
sides.append((0, line_x, self.split_filename_left(i)))
@@ -539,10 +606,12 @@ class PDFPreviewer:
for x0, x1 in zip(boundaries, boundaries[1:]):
clip = pymupdf.Rect(x0, 0, x1, height)
target = output.new_page(width=clip.width, height=clip.height)
target.show_pdf_page(target.rect, self.doc, i, clip=clip)
if flat.get_contents():
target.show_pdf_page(target.rect, normalized, 0, clip=clip)
origins.append({'origin': origin, 'crop_points': list(clip)})
output.save(output_path)
self.fragment_origins[str(output_path)] = origins
normalized.close()
self.doc.close()
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
@@ -564,7 +633,7 @@ class PDFPreviewer:
psk = ps[i]['keep']
# Si c'est une copie double (on s'assure qu'on a bien 2 pages consécutives modifiables)
if psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
if not getattr(self, 'automatic_review', False) and psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
# 1. Page de garde (Extérieur Droit)
if ps[i]['keep'] in ["both", "right"]:
@@ -678,11 +747,17 @@ def _selected_inputs(
]
else:
candidates = list_pdf_files(directory, workspace)
if target == workspace.root and not candidates:
candidates = list_pdf_files(workspace.original_copies_dir, workspace)
return list(reversed(candidates))
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode:
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False,
automatic: bool = False, workers: int = 4) -> ExitCode:
inputs = list(reversed(marked_copy_paths(workspace, originals=True))) if marked else _selected_inputs(workspace, target)
if automatic:
from copienator.commands.automatic_page_splitter import run as run_automatic
return run_automatic(workspace, list(reversed(inputs)), workers=workers)
if not inputs:
print(f"No PDF files found in {target}")
return ExitCode.SUCCESS
@@ -695,6 +770,8 @@ def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively split and reorder scanned PDF pages")
parser.add_argument("--marked", action="store_true", help="Process only copies flagged during margin review")
parser.add_argument("--automatic", action="store_true", help="Split automatically and save editable proposals without reordering")
parser.add_argument("--workers", type=int, default=4, help="Copies analysed simultaneously in automatic mode")
return parser
@@ -703,7 +780,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, marked=args.marked)
return run(workspace, target, marked=args.marked, automatic=args.automatic, workers=args.workers)
return execute(parser, argv, handle)