Automatic page splitting
This commit is contained in:
1 parent
d4404e4c27
commit
78f3e4b2f0
13 files changed
+1959
-40
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user