diff --git a/copienator/commands/page_splitter.py b/copienator/commands/page_splitter.py index 38775a6..2290530 100644 --- a/copienator/commands/page_splitter.py +++ b/copienator/commands/page_splitter.py @@ -28,8 +28,13 @@ from copienator import ( from copienator.platform import launch_pdf_arranger from copienator.copy_errors import marked_copy_paths -# Keep the new shortcut available with older personal configuration files. -PAGE_SPLITTER_KB = {"reverse_pages": "i", **PAGE_SPLITTER_KB} +# Keep new shortcuts available with older personal configuration files. +PAGE_SPLITTER_KB = { + "reverse_pages": "i", + "add_secondary_splitter": "", + "delete_secondary_splitter": "", + **PAGE_SPLITTER_KB, +} # --- Constants --- # Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch) @@ -166,10 +171,11 @@ class PDFPreviewer: # Dynamic instructions text instructions = ( - f"{fmt('move_left')} / {fmt('move_right')} : Move line 1cm left/right\n" + f"{fmt('move_left')} / {fmt('move_right')} : Move active line 0.5cm left/right\n" f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n" f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n" f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n" + f"{fmt('add_secondary_splitter')}: add secondary line, {fmt('delete_secondary_splitter')}: delete newest (arrows move newest; orange = active)\n" f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n" ) @@ -185,6 +191,8 @@ class PDFPreviewer: # --- Bindings --- action_map = { + "add_secondary_splitter": self.add_secondary_splitter, + "delete_secondary_splitter": self.delete_secondary_splitter, "move_left": self.move_line_left, "move_right": self.move_line_right, "rotate_page": self.rotate_page, @@ -248,6 +256,7 @@ class PDFPreviewer: page = self.doc.load_page(self.current_page_index) self.current_line_x = page.rect.width / 2 self.current_rotation = 0 + self.secondary_splitters = [] def load_page(self): """Loads and displays the current page on the canvas, scaled to fit.""" @@ -290,7 +299,14 @@ class PDFPreviewer: 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) + draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)], + fill="red" if self.secondary_splitters else "orange", width=3) + for index, position in enumerate(self.secondary_splitters): + x = position * self.current_zoom + active = index == len(self.secondary_splitters) - 1 + draw.line([(x, 0), (x, pix.height)], + fill="orange" if active else "cyan", width=3) + draw.text((x + 5, 8), str(index + 1), fill="orange" if active else "cyan") # --- Display on Canvas --- @@ -335,17 +351,33 @@ class PDFPreviewer: self._initialize_current_page_settings() self.load_page() - def move_line_left(self, event=None): - """Moves the split line to the left.""" - self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2) + 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 + 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) self.load_page() - def move_line_right(self, event=None): - """Moves the split line to the right.""" - page = self.doc.load_page(self.current_page_index) - self.current_line_x = min(page.rect.width, self.current_line_x + CM_TO_POINTS / 2) + def delete_secondary_splitter(self, event=None): + if self.secondary_splitters: + self.secondary_splitters.pop() + self.load_page() + + def _move_active_line(self, delta): + 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)) + else: + self.current_line_x = min(width, max(0, self.current_line_x + delta)) self.load_page() + def move_line_left(self, event=None): + self._move_active_line(-CM_TO_POINTS / 2) + + def move_line_right(self, event=None): + self._move_active_line(CM_TO_POINTS / 2) + def rotate_page(self, event=None): """Toggles the page rotation between 0 and 180 degrees.""" self.current_rotation = 180 if self.current_rotation == 0 else 0 @@ -388,6 +420,7 @@ class PDFPreviewer: """Saves the settings for the current page and moves to the next.""" self.page_settings.append({ "line_x": self.current_line_x, + "secondary_splitters": list(self.secondary_splitters), "rotation": self.current_rotation, "keep": keep }) @@ -477,50 +510,32 @@ class PDFPreviewer: line_x = settings['line_x'] rotation_settings = settings['rotation'] keep = settings['keep'] - rotation = (page.rotation + rotation_settings + - self.file_rotation + self.global_rotation) % 360 - + # 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() + width, height = page.rect.width, page.rect.height + sides = [] if keep == "as_is": - doc_full = pymupdf.open() - page_full = doc_full.new_page(width=page.rect.width, height=page.rect.height) - page_full.show_pdf_page(page_full.rect, self.doc, i) - page_full.set_rotation(rotation) - - output_path_full = self.split_filename_left(i) - doc_full.save(output_path_full) - doc_full.close() - continue # Skip left/right generation - - # --- Create Left Part --- - if rotation == 0: - rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height) + sides.append((0, width, self.split_filename_left(i))) else: - rect_left = pymupdf.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height) + if keep in {"both", "left"} and line_x > 0: + sides.append((0, line_x, self.split_filename_left(i))) + if keep in {"both", "right"} and line_x < width: + sides.append((line_x, width, self.split_filename_right(i))) - if (keep == "both" or keep == "left") and line_x > 0: - - doc_left = pymupdf.open() - page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height) - page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left) - page_left.set_rotation(rotation) - - output_path_left = self.split_filename_left(i) - doc_left.save(output_path_left) - doc_left.close() - - # --- Create Right Part --- - if rotation == 0: - rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height) - else: - rect_right = pymupdf.Rect(0, 0, page.rect.width-line_x, page.rect.height) - if (keep == "both" or keep == "right") and line_x < page.rect.width: - doc_right = pymupdf.open() - page_right = doc_right.new_page(width=rect_right.width, height=rect_right.height) - page_right.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right) - page_right.set_rotation(rotation) - output_path_right = self.split_filename_right(i) - doc_right.save(output_path_right) - doc_right.close() + for left, right, output_path in sides: + cuts = settings.get("secondary_splitters", []) + boundaries = [left, *sorted({x for x in cuts if left < x < right}), right] + # Each side is a multi-page PDF, keeping its subpages together + # when the existing booklet ordering moves this side. + with pymupdf.open() as output: + 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) + output.save(output_path) self.doc.close() print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.") @@ -531,6 +546,11 @@ class PDFPreviewer: ps = self.page_settings ri = 0 i = 0 + + def copy_side(source, destination): + if os.path.isfile(source): + shutil.copy2(source, destination) + while i < len(ps): psk = ps[i]['keep'] @@ -539,32 +559,32 @@ class PDFPreviewer: # 1. Page de garde (Extérieur Droit) if ps[i]['keep'] in ["both", "right"]: - shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) + copy_side(self.split_filename_right(i), self.reorder_filename(ri)) ri += 1 # 2. Intérieur Gauche if ps[i+1]['keep'] in ["both", "left"]: - shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri)) + copy_side(self.split_filename_left(i+1), self.reorder_filename(ri)) ri += 1 # 3. Intérieur Droit if ps[i+1]['keep'] in ["both", "right"]: - shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri)) + copy_side(self.split_filename_right(i+1), self.reorder_filename(ri)) ri += 1 # 4. Dos de la copie (Extérieur Gauche) if ps[i]['keep'] in ["both", "left"]: - shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) + copy_side(self.split_filename_left(i), self.reorder_filename(ri)) ri += 1 i += 2 else: # Si c'est une page simple (ou as_is) if psk in ["left", "both", "as_is"]: - shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) + copy_side(self.split_filename_left(i), self.reorder_filename(ri)) ri += 1 if psk in ["right", "both"]: - shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) + copy_side(self.split_filename_right(i), self.reorder_filename(ri)) ri += 1 i += 1 # def reorder_pdfs(self): @@ -577,25 +597,25 @@ class PDFPreviewer: # # Si c'est une copie double # if (ps[i]['keep'] == "both" or ps[i]['keep'] == "right") \ # and i < len(ps)-1 and (ps[i+1]['keep'] != "right"): - # shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) + # copy_side(self.split_filename_right(i), self.reorder_filename(ri)) # ri += 1 # if ps[i+1]['keep'] != "none": - # shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri)) + # copy_side(self.split_filename_left(i+1), self.reorder_filename(ri)) # ri += 1 # if ps[i+1]['keep'] != "left": - # shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri)) + # copy_side(self.split_filename_right(i+1), self.reorder_filename(ri)) # ri += 1 # if ps[i]['keep'] == "both": - # shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) + # copy_side(self.split_filename_left(i), self.reorder_filename(ri)) # ri += 1 # i += 2 # else: # psk = ps[i]['keep'] # if psk == "left" or psk == "both" or psk == "as_is": - # shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) + # copy_side(self.split_filename_left(i), self.reorder_filename(ri)) # ri += 1 # if psk == "right" or psk == "both": - # shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) + # copy_side(self.split_filename_right(i), self.reorder_filename(ri)) # ri += 1 # i += 1 diff --git a/default_config.py b/default_config.py index b75b716..b3fc2ce 100644 --- a/default_config.py +++ b/default_config.py @@ -49,9 +49,12 @@ THINKING_LEVEL_FLASH = "medium" # Modèle pour des choses dures MODEL_PRO_ID = "gemini-3.8-flash" THINKING_LEVEL_PRO = "medium" +# THINKING_LEVEL_PRO = "high" # MODEL_PRO_ID = "gemini-3.1-pro-preview" PAGE_SPLITTER_KB = { + "add_secondary_splitter": "", # Current page only; arrows move newest + "delete_secondary_splitter": "", # Remove newest secondary splitter "move_left": "", "move_right": "", "rotate_page": "r", diff --git a/tests/test_page_splitter_secondary.py b/tests/test_page_splitter_secondary.py new file mode 100644 index 0000000..22ff5f4 --- /dev/null +++ b/tests/test_page_splitter_secondary.py @@ -0,0 +1,92 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock + +import pymupdf + +from copienator.commands.page_splitter import CM_TO_POINTS, PDFPreviewer + + +class SecondarySplitterTests(unittest.TestCase): + def preview(self): + preview = PDFPreviewer.__new__(PDFPreviewer) + preview.doc = pymupdf.open() + self.addCleanup(lambda: None if preview.doc.is_closed else preview.doc.close()) + for number in range(2): + page = preview.doc.new_page(width=400, height=100) + for column in range(4): + page.insert_text((column * 100 + 30, 50), f'{number}{column}') + preview.current_page_index = 0 + preview.file_rotation = preview.global_rotation = 0 + preview.page_settings = [] + preview.load_page = Mock() + preview._initialize_current_page_settings() + return preview + + def export(self, preview): + with tempfile.TemporaryDirectory() as directory: + preview.base_name = 'copy' + preview.split_dir = Path(directory) / 'split' + preview.reorder_dir = Path(directory) / 'reorder' + preview.final_file = Path(directory) / 'result.pdf' + preview.output_dir = None + preview.split_pdf() + preview.reorder_pdfs() + preview.concate_files() + with pymupdf.open(preview.final_file) as output: + return [(page.get_text().strip(), page.rect.width) for page in output] + + def settings(self, keep='both', cuts=None, rotation=0, line=200): + return dict(keep=keep, secondary_splitters=cuts or [], rotation=rotation, line_x=line) + + def test_newest_line_moves_and_deletes_and_settings_are_page_local(self): + preview = self.preview() + preview.add_secondary_splitter() + self.assertEqual(preview.secondary_splitters, [100]) + preview.add_secondary_splitter() + self.assertEqual(preview.secondary_splitters, [100, 300]) + preview.move_line_left() + self.assertAlmostEqual(preview.secondary_splitters[-1], 300 - CM_TO_POINTS / 2) + self.assertEqual(preview.current_line_x, 200) + preview.delete_secondary_splitter() + preview.move_line_right() + self.assertAlmostEqual(preview.secondary_splitters[-1], 100 + CM_TO_POINTS / 2) + saved = preview.secondary_splitters.copy() + preview.confirm_and_next_page() + self.assertEqual(preview.page_settings[0]['secondary_splitters'], saved) + self.assertEqual(preview.secondary_splitters, []) + preview.delete_secondary_splitter() + preview.move_line_left() + self.assertAlmostEqual(preview.current_line_x, 200 - CM_TO_POINTS / 2) + + def test_subpages_replace_each_side_in_booklet_order(self): + preview = self.preview() + preview.page_settings = [self.settings(cuts=[300, 100]), self.settings(cuts=[100, 300])] + self.assertEqual(self.export(preview), [(label, 100) for label in ['02', '03', '10', '11', '12', '13', '00', '01']]) + + def test_discarded_sides_and_duplicate_boundary_cuts(self): + preview = self.preview() + preview.page_settings = [self.settings('left', [0, 100, 100, 200, 300, 400]), self.settings('none', [100])] + self.assertEqual(self.export(preview), [('00', 100), ('01', 100)]) + + def test_keep_as_is_ignores_main_splitter_but_applies_secondary_cuts(self): + preview = self.preview() + preview.page_settings = [self.settings('as_is', [100, 300]), self.settings('right', [300])] + self.assertEqual(self.export(preview), [('00', 100), ('01\n02', 200), ('03', 100), ('12', 100), ('13', 100)]) + + def test_rotated_preview_orders_subpages_visually_left_to_right(self): + preview = self.preview() + preview.page_settings = [self.settings(cuts=[100, 300], rotation=180), self.settings('none')] + self.assertEqual(self.export(preview), [(label, 100) for label in ['01', '00', '03', '02']]) + + def test_source_rotation_matches_preview(self): + preview = self.preview() + preview.doc[0].set_rotation(180) + preview.page_settings = [self.settings(cuts=[100, 300]), self.settings('none')] + self.assertEqual(self.export(preview), [(label, 100) for label in ['01', '00', '03', '02']]) + + def test_main_splitter_at_edge_does_not_break_reordering(self): + preview = self.preview() + preview.page_settings = [self.settings(cuts=[100, 200, 300], line=0), self.settings('none')] + self.assertEqual(self.export(preview), [(label, 100) for label in ['00', '01', '02', '03']])