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

@@ -0,0 +1,183 @@
"""Publish automatic splits into an evaluation, preserving sources and review data."""
from __future__ import annotations
import hashlib
import json
import shutil
import tempfile
from pathlib import Path
from copienator import CliError, ExitCode
from copienator.auto_page_split import run as analyse_batch
from copienator.copy_errors import clear_copy_error, copy_errors, mark_copy_error
from copienator.filesystem import publish_file_sets
from copienator.orientation import report_path
def decision_path(workspace, stem):
return workspace.metadata_dir / "page_splitting" / f"{stem}.json"
def load_decisions(workspace, source):
"""Only reuse decisions for the exact preserved input and published output."""
try:
data = json.loads(decision_path(workspace, source.stem).read_text())
if data["source_sha256"] != hashlib.sha256(source.read_bytes()).hexdigest():
return None
output = workspace.copies_dir / source.name
digest = (
hashlib.sha256(output.read_bytes()).hexdigest() if output.exists() else None
)
if digest != data["output_sha256"]:
return None
pages = data["pages"]
if not isinstance(pages, list) or not all(
isinstance(row, dict)
and type(row.get("source_page")) is int
and isinstance(row.get("outputs"), list)
and all(
isinstance(o, dict)
and o.get("side") in {"left", "right", "whole"}
and type(o.get("keep")) is bool
and isinstance(o.get("crop_points"), list)
and len(o["crop_points"]) == 4
for o in row["outputs"]
)
for row in pages
):
return None
return pages
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return None
def run(workspace, inputs: list[Path], *, workers=4):
if workers < 1:
raise CliError(
"Le nombre de traitements parallèles doit être positif.",
ExitCode.INVALID_ARGUMENTS,
)
if not inputs:
print("Aucune copie PDF à traiter.", flush=True)
return ExitCode.SUCCESS
if len({p.name for p in inputs}) != len(inputs):
raise CliError(
"Plusieurs copies portent le même nom.", ExitCode.INVALID_ARGUMENTS
)
copy_errors(workspace) # Validate existing review metadata before publishing.
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
run_dir = Path(tempfile.mkdtemp(prefix="page-split-", dir=workspace.runs_dir))
snapshots = run_dir / "inputs"
snapshots.mkdir()
for source in inputs:
shutil.copy2(source, snapshots / source.name)
result = run_dir / "result"
print(f"Découpage automatique de {len(inputs)} copie(s).", flush=True)
summary = analyse_batch(snapshots, result, workers=workers)
records = json.loads((result / "report.json").read_text())["pages"]
stages = {
name: run_dir / name for name in ("copies", "originals", "origins", "decisions")
}
for stage in stages.values():
stage.mkdir()
for source in inputs:
rows = [r for r in records if r["file"] == source.name]
expected = rows[0]["source_sha256"]
if hashlib.sha256(source.read_bytes()).hexdigest() != expected:
raise CliError(
f"{source.name} a changé pendant l’analyse. Aucun PDF remplacé."
)
shutil.copy2(snapshots / source.name, stages["originals"] / source.name)
for directory, label in (
(workspace.copies_dir, "previous_copies"),
(workspace.original_copies_dir, "previous_originals"),
):
prior = directory / source.name
if prior.exists():
backup = run_dir / label
backup.mkdir(exist_ok=True)
shutil.copy2(prior, backup / source.name)
output = result / "pdfs" / source.name
digest = (
hashlib.sha256(output.read_bytes()).hexdigest() if output.exists() else None
)
if digest:
shutil.copy2(output, stages["copies"] / source.name)
origins = [
{"origin": r["source_page"], "crop_points": o["crop_points"]}
for r in rows
for o in r["outputs"]
if o["keep"]
]
if digest:
(stages["origins"] / (source.stem + ".json")).write_text(
json.dumps({"version": 1, "sha256": digest, "pages": origins}),
encoding="utf-8",
)
(stages["decisions"] / (source.stem + ".json")).write_text(
json.dumps(
{
"version": 1,
"source_sha256": expected,
"output_sha256": digest,
"pages": rows,
}
),
encoding="utf-8",
)
names = [p.name for p in inputs]
for source in inputs:
if source.read_bytes() != (snapshots / source.name).read_bytes():
raise CliError(
f"{source.name} a changé pendant l’analyse. Aucun PDF remplacé."
)
publish_file_sets(
{
workspace.copies_dir: (stages["copies"], names),
workspace.original_copies_dir: (stages["originals"], []),
workspace.metadata_dir / "page_origins": (
stages["origins"],
[p.stem + ".json" for p in inputs],
),
workspace.metadata_dir / "page_splitting": (stages["decisions"], []),
}
)
for source in inputs:
if (
source.parent.resolve()
not in {
workspace.copies_dir.resolve(),
workspace.original_copies_dir.resolve(),
}
and source.read_bytes() == (snapshots / source.name).read_bytes()
):
# Root scans become preserved originals, as in the manual workflow.
source.unlink()
rows = [r for r in records if r["file"] == source.name]
output = workspace.copies_dir / source.name
clear_copy_error(workspace, output, source="orientation")
report_path(workspace, source.stem).unlink(missing_ok=True)
reasons = {reason for r in rows for reason in r["review_reasons"]}
if reasons or not output.exists():
mark_copy_error(
workspace,
output,
"Découpage automatique à vérifier manuellement."
if reasons
else "Toutes les pages semblent vides : vérifier l’original.",
source="page_splitter",
)
else:
clear_copy_error(workspace, output, source="page_splitter")
print(
f"Terminé : {summary['output_pages']} pages conservées, {summary['blank_removed']} pages vides retirées. "
"Ordre des pages inchangé.",
flush=True,
)
print(
f"Rapport et sauvegardes : {run_dir}\n"
"Choisissez Manuellement pour vérifier ou modifier les propositions.",
flush=True,
)
return ExitCode.SUCCESS
+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)