import tempfile import unittest from pathlib import Path from unittest.mock import patch import numpy as np import pymupdf from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json from copienator.commands.resolve_manual import parse_instruction_text, resolve_manual from copienator.pdf_cut import cut_position, split_pdf from copienator_gui.cut_helper import PAGE_GAP, cut_operator, percentage_at_y, y_at_percentage def make_pdf(path, heights=(300, 700), rotation=0, cropped=False): with pymupdf.open() as document: for index, height in enumerate(heights): page = document.new_page(width=200, height=height) page.draw_rect(pymupdf.Rect(0, 0, 200, height / 2), color=None, fill=(1, 0, 0)) page.draw_rect(pymupdf.Rect(0, height / 2, 200, height), color=None, fill=(0, 0, 1)) page.insert_text((30, 30), f"Page {index + 1}") if cropped: page.set_cropbox(pymupdf.Rect(10, 20, 180, height - 30)) page.set_rotation(rotation) document.save(path) class ManualCutTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) def test_parser_accepts_new_operators_and_rejects_invalid_cuts(self): for keep in (1, 2): for action in (">", "x"): item = parse_instruction_text(f"Copie16 Ex 4 : 1) c{{43.125}}{keep}{action} |Ex 4 : 2)")[0] self.assertEqual(item.cut, (43.125, keep)) self.assertEqual(item.should_merge, action == ">") self.assertTrue(item.pipe_first) for operator in ("c{0}1>", "c{100}1>", "c{-1}1>", "c{101}1>", "c{43}3>", "c{43}1s", "c{NaN}1>"): with self.assertRaises(CliError): parse_instruction_text(f"Copie16 A {operator} B") with self.assertRaises(CliError): parse_instruction_text("Copie16 A c{43}1> A") def test_page_boundary_preserves_whole_pages_without_clipping(self): source, first, second = [self.root / name for name in ("source.pdf", "first.pdf", "second.pdf")] make_pdf(source, heights=(100, 200), rotation=180) before = source.read_bytes() with patch("pymupdf.Page.show_pdf_page", side_effect=AssertionError("must not clip whole pages")): split_pdf(source, 33.333333, first, second) for path, height in ((first, 100), (second, 200)): with pymupdf.open(path) as pdf: self.assertEqual(len(pdf), 1) self.assertEqual(pdf[0].rect.height, height) self.assertEqual(pdf[0].rotation, 180) self.assertEqual(source.read_bytes(), before) def test_in_page_cut_preserves_visible_pixels_for_cropped_rotated_pages(self): source, first, second = [self.root / name for name in ("source.pdf", "first.pdf", "second.pdf")] for rotation in (0, 90, 180, 270): with self.subTest(rotation=rotation): make_pdf(source, heights=(400,), rotation=rotation, cropped=True) with pymupdf.open(source) as pdf: pix = pdf[0].get_pixmap() expected = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3) split_pdf(source, 50, first, second) for path, pixels in ((first, expected[:len(expected)//2]), (second, expected[len(expected)//2:])): with pymupdf.open(path) as pdf: pix = pdf[0].get_pixmap() actual = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3) self.assertEqual(actual.shape, pixels.shape) self.assertLess(np.abs(actual.astype(float) - pixels).mean(), 0.1) def test_cut_within_page_preserves_subsequent_pages(self): source, first, second = [self.root / name for name in ("source.pdf", "first.pdf", "second.pdf")] make_pdf(source) split_pdf(source, 15, first, second) with pymupdf.open(first) as pdf: self.assertEqual([page.rect.height for page in pdf], [150]) with pymupdf.open(second) as pdf: self.assertEqual([page.rect.height for page in pdf], [150, 700]) def test_resolver_archives_source_and_recorrrects_both_labels(self): for keep in (1, 2): for mode in (">", "x"): for pipe_first in (False, True): with self.subTest(keep=keep, mode=mode, pipe_first=pipe_first): root = self.root / f"{keep}{mode == '>'}{pipe_first}" copies = root / "Copies" / "Copie16" copies.mkdir(parents=True) source, target = copies / "A.pdf", copies / "B.pdf" make_pdf(source) make_pdf(target, heights=(80,)) original, destination = source.read_bytes(), target.read_bytes() atomic_write_json(root / "correction.json", { label: [[{"id": "16", "result": {}}]] for label in ("A", "B") }) new_label = "|B" if pipe_first else "B|" (root / "manual_resolutions.txt").write_text(f"Copie16 A c{{30}}{keep}{mode} {new_label}\n") self.assertEqual(resolve_manual(EvaluationWorkspace(root)), 0) self.assertEqual((copies / "A_old.pdf").read_bytes(), original) self.assertEqual((copies / "B_old.pdf").read_bytes(), destination) retained, moved = (300, 700) if keep == 1 else (700, 300) with pymupdf.open(copies / "A_new.pdf") as pdf: self.assertEqual([page.rect.height for page in pdf], [retained]) with pymupdf.open(copies / "B_new.pdf") as pdf: expected = ([moved, 80] if pipe_first else [80, moved]) if mode == ">" else [moved] self.assertEqual([page.rect.height for page in pdf], expected) self.assertEqual(read_json(root / "refaire.json"), [["Copie16", ["A", "B"]]]) self.assertTrue(all(read_json(root / "correction.json")[label][0][0]["result"]["suffix"] == "_new" for label in ("A", "B"))) self.assertFalse(list(copies.glob("temp_*.pdf"))) def test_helper_snaps_to_gap_and_round_trips_percentage(self): heights = [100, 200] for y in (95, 100, 100 + PAGE_GAP / 2, 100 + PAGE_GAP + 5): percent = percentage_at_y(y, heights, 1) self.assertEqual(cut_position(heights, percent), (1, 0)) self.assertEqual(y_at_percentage(percent, heights, 1), 100 + PAGE_GAP / 2) self.assertEqual(cut_operator(43, 1, ">"), "c{43}1>") self.assertEqual(cut_operator(100/3, 2, "x"), "c{33.333333}2x") self.assertEqual(cut_position(heights, 33.333333), (1, 0)) self.assertNotEqual(cut_position(heights, 33.3)[1], 0)