import hashlib import json import tempfile import unittest from pathlib import Path from unittest.mock import Mock, patch import numpy as np import pymupdf from pydantic import ValidationError from copienator.auto_page_split import run from copienator.split_orientation import GeminiVerifier, PageOrder, sheets, validate_order class SheetOrientationTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.source = self.root/'copy.pdf' self.statement = self.root/'enonce.pdf' with pymupdf.open() as d: d.new_page().insert_text((40,40),'Full exam statement') d.save(self.statement) self.analyses = [] def create(self, formats=('a3','a3'), rotations=None, mixed=False): with pymupdf.open() as d: for i,kind in enumerate(formats): p = d.new_page(width=1190 if kind=='a3' else 595,height=842) if kind=='a3': p.insert_text((180,280) if mixed and i==0 else (50,70), str(4 if i==0 else 2), fontsize=24, rotate=180 if mixed and i==0 else 0) p.insert_text((650,70),str(1 if i==0 else 3),fontsize=24) else: p.insert_text((50,70),f'A4-{i}',fontsize=24) p.set_rotation((rotations or [0]*len(formats))[i]) d.save(self.source) self.original = self.source.read_bytes() def analyse(self, page, dpi, kind, *, blank=()): self.analyses.append((page.number,page.rotation)) w = 200 if kind=='a3' else 100 outputs = [] for j,side in enumerate(['left','right'] if kind=='a3' else ['whole']): keep = (page.number,j) not in blank outputs.append(dict(side=side, keep=keep, state='content' if keep else 'blank', rect_px=[j*100,0,(j+1)*100,100])) return dict(format=kind,size_px=[w,100],outputs=outputs,crossings=[], extensions_mm=[0,0],review_reasons=[]),np.full((100,w,3),255,np.uint8) def execute(self, flags, verdicts=(), permutation=None, blank=()): screen = Mock() screen.predict.side_effect = [dict(flag=f,score=.8 if f else .1,threshold=.3)for f in flags] verifier = Mock() verifier.orientation.side_effect = list(verdicts) verifier.order.return_value = permutation with (patch('copienator.fast_page_split.analyse_pdf_page_fast', side_effect=lambda p,d,k:self.analyse(p,d,k,blank=blank)), patch('copienator.split_orientation.detector',return_value=screen), patch('copienator.split_orientation.GeminiVerifier',return_value=verifier)): summary = run(self.source,self.root/'result',dpi=100,orientation=True,statement=self.statement) self.assertEqual(self.source.read_bytes(),self.original) report = json.loads((self.root/'result/report.json').read_text()) return summary,report,screen,verifier def texts(self): with pymupdf.open(self.root/'result/pdfs/copy.pdf')as d: return [p.get_text().strip()for p in d] def test_upright_a3_booklet_order_and_no_requests(self): self.create() _,report,_,verifier = self.execute([False]*4) self.assertEqual(self.texts(),['1','2','3','4']) verifier.orientation.assert_not_called() verifier.order.assert_not_called() self.assertEqual([o['output_page']for p in report['pages']for o in p['outputs']],[4,1,2,3]) def test_a4_inversion_is_independent_and_keeps_positions(self): self.create(('a4','a4'),rotations=[180,0]) _,report,_,verifier = self.execute([True,False],['inverted']) self.assertEqual(self.texts(),['A4-0','A4-1']) self.assertEqual(verifier.orientation.call_count,1) verifier.order.assert_not_called() self.assertEqual(report['pages'][0]['applied_rotation'],180) self.assertNotIn('applied_rotation',report['pages'][1]) self.assertEqual(self.analyses,[(0,180),(1,0),(0,0)]) def test_entire_a3_sheet_inverted_reanalyses_both_source_faces(self): self.create(rotations=[180,180]) _,report,_,verifier = self.execute([True,False,False,False],['inverted']*4) self.assertEqual(self.texts(),['1','2','3','4']) self.assertEqual(verifier.orientation.call_count,4) verifier.order.assert_not_called() self.assertEqual(self.analyses,[(0,180),(1,180),(0,0),(1,0)]) self.assertTrue(all(p['applied_rotation']==180 for p in report['pages'])) self.assertTrue(all('orientation_before'in p for p in report['pages'])) def test_blank_siblings_are_not_sent_to_gemini_and_are_retained_until_ordering(self): self.create(rotations=[180,180]) summary,_,screen,verifier=self.execute([True],['inverted'],blank=((0,1),(1,0),(1,1))) self.assertEqual(screen.predict.call_count,1) self.assertEqual(verifier.orientation.call_count,1) self.assertEqual(summary['blank_removed'],3) self.assertEqual(len(self.texts()),1) self.assertEqual(len(self.analyses),4) def test_inconclusive_candidate_assumed_upright_without_sibling_requests(self): self.create() _,report,_,verifier=self.execute([True,False,False,False],['inconclusive']) self.assertEqual(verifier.orientation.call_count,1) self.assertEqual(len(self.analyses),2) self.assertEqual(self.texts(),['1','2','3','4']) self.assertEqual(report['pages'][0]['outputs'][0]['orientation']['verdict'],'upright') def test_mixed_sheet_rotates_only_inverted_logical_page_and_uses_given_order(self): self.create(mixed=True) _,report,_,verifier=self.execute([True,False,False,False], ['inverted','upright','inconclusive','upright'],[2,3,4,1]) self.assertEqual(len(self.analyses),2) self.assertEqual(self.texts(),['1','2','3','4']) verifier.order.assert_called_once() images,blank=verifier.order.call_args.args self.assertEqual(len(images),4) self.assertEqual(blank,[False]*4) self.assertEqual(report['pages'][0]['outputs'][0]['rotation'],180) with pymupdf.open(self.root/'result/pdfs/copy.pdf')as d: self.assertGreater(d[3].get_text('blocks')[0][1],400) def test_inconclusive_sibling_takes_mixed_branch(self): self.create() _,_,_,verifier=self.execute([True,False,False,False], ['inverted','inconclusive','inverted','inverted'],[2,3,4,1]) verifier.order.assert_called_once() self.assertEqual(len(self.analyses),2) def test_invalid_gemini_order_aborts_without_touching_source(self): self.create() with self.assertRaises(ValueError): self.execute([True,False,False,False],['inverted','upright','upright','upright'],[1,1,3,4]) self.assertEqual(self.source.read_bytes(),self.original) self.assertFalse((self.root/'result/pdfs/copy.pdf').exists()) def test_api_failure_aborts_without_guessing_upright(self): self.create() with self.assertRaises(RuntimeError): self.execute([True,False,False,False],[RuntimeError('API unavailable')]) self.assertEqual(self.source.read_bytes(),self.original) def test_orphan_a3_is_flagged_and_a4_breaks_pairing(self): self.assertEqual(list(sheets([{'format':k}for k in ['a3','a4','a3','a3','a4']])), [[0],[1],[2,3],[4]]) self.create(('a3',)) _,report,_,_=self.execute([False,False]) self.assertIn('unpaired-a3-sheet',report['pages'][0]['review_reasons']) def review_saved(self, report, restore_blank=False): import shutil from copienator import EvaluationWorkspace from copienator.commands.automatic_page_splitter import decision_path from copienator.commands.page_splitter import PDFPreviewer workspace=EvaluationWorkspace(self.root) (self.root/'labels').write_text('Ex 1\n') workspace.original_copies_dir.mkdir() workspace.copies_dir.mkdir() original=workspace.original_copies_dir/self.source.name output=workspace.copies_dir/self.source.name shutil.copy2(self.source,original) shutil.copy2(self.root/'result/pdfs/copy.pdf',output) path=decision_path(workspace,'copy');path.parent.mkdir(parents=True) path.write_text(json.dumps(dict(source_sha256=hashlib.sha256(original.read_bytes()).hexdigest(), output_sha256=hashlib.sha256(output.read_bytes()).hexdigest(),pages=report['pages']))) expected=pymupdf.open(output) self.addCleanup(lambda document=expected: None if document.is_closed else document.close()) preview=PDFPreviewer.__new__(PDFPreviewer) preview.workspace=workspace;preview.inputs=[original] preview.num=preview.global_rotation=0;preview.history=[];preview.output_dir=None preview.failed=False;preview.master=Mock();preview.load_page=Mock() self.assertTrue(preview.setup_next_file()) preview._initialize_current_page_settings() for _ in range(2):preview.confirm_and_next_page(keep='both' if restore_blank else None) self.assertFalse(preview.failed) self.assertEqual(original.read_bytes(),self.original) with pymupdf.open(output)as actual: if restore_blank: self.assertEqual([p.get_text().strip()for p in actual],['1','2','3','4']) expected.close() expected=pymupdf.open(output) self.addCleanup(lambda document=expected: None if document.is_closed else document.close()) self.assertEqual(len(actual),len(expected)) for a,b in zip(actual,expected): pa,pb=a.get_pixmap(dpi=40),b.get_pixmap(dpi=40) self.assertEqual((pa.width,pa.height),(pb.width,pb.height)) diff=np.abs(np.frombuffer(pa.samples,np.uint8).astype(float)-np.frombuffer(pb.samples,np.uint8)) self.assertLess(diff.mean(),.1) from copienator.orientation import load_provenance self.assertEqual([p['origin']for p in load_provenance(workspace,output,4)],[1,2,2,1]) def test_manual_reinstatement_of_a_blank_page_uses_its_original_booklet_slot(self): self.create() _,report,_,_=self.execute([False]*3,blank=((0,0),)) self.assertEqual(report['pages'][0]['outputs'][0]['reading_order'],4) self.assertEqual(self.texts(),['1','2','3']) self.review_saved(report,restore_blank=True) def test_manual_acceptance_preserves_uniform_source_rotation_and_booklet_order(self): self.create(rotations=[180,180]) _,report,_,_=self.execute([True,False,False,False],['inverted']*4) self.review_saved(report) def test_manual_acceptance_preserves_mixed_logical_rotation_and_gemini_order(self): self.create(mixed=True) _,report,_,_=self.execute([True,False,False,False], ['inverted','upright','upright','upright'],[2,3,4,1]) self.review_saved(report) def test_manual_review_keeps_a4_before_following_a3_pages(self): from copienator.commands.page_splitter import PDFPreviewer with pymupdf.open()as d: d.new_page(width=595,height=842).insert_text((50,70),'A4') p=d.new_page(width=1190,height=842) p.insert_text((50,70),'B');p.insert_text((650,70),'C');d.save(self.source) preview=PDFPreviewer.__new__(PDFPreviewer) preview.doc=pymupdf.open(self.source) preview.file_rotation=preview.global_rotation=0 preview.base_name='copy';preview.split_dir=self.root/'split';preview.reorder_dir=self.root/'reorder' preview.final_file=self.root/'final.pdf';preview.output_dir=None;preview.automatic_review=True preview.page_settings=[ dict(line_x=297.5,rotation=0,keep='as_is',automatic_outputs=[ dict(side='whole',crop_points=[0,0,595,842],reading_order=1)]), dict(line_x=595,rotation=0,keep='both',automatic_outputs=[ dict(side='left',crop_points=[0,0,595,842],reading_order=2), dict(side='right',crop_points=[595,0,1190,842],reading_order=3)])] preview.split_pdf();preview.reorder_pdfs();preview.concate_files() with pymupdf.open(preview.final_file)as d: self.assertEqual([p.get_text().strip()for p in d],['A4','B','C']) def test_secondary_cuts_follow_the_corrected_half_display(self): from copienator.commands.page_splitter import PDFPreviewer with pymupdf.open()as d: p=d.new_page(width=1190,height=842) for x,label in [(50,'A'),(400,'B'),(650,'C')]:p.insert_text((x,70),label,fontsize=24) d.save(self.source) preview=PDFPreviewer.__new__(PDFPreviewer) preview.doc=pymupdf.open(self.source) preview.file_rotation=preview.global_rotation=0 preview.base_name='copy';preview.split_dir=self.root/'split';preview.reorder_dir=self.root/'reorder' preview.final_file=self.root/'final.pdf';preview.output_dir=None;preview.automatic_review=True preview.page_settings=[dict(line_x=595,rotation=0,keep='both',secondary_splitters=[297.5], automatic_outputs=[dict(side='left',crop_points=[0,0,595,842],rotation=180,reading_order=1), dict(side='right',crop_points=[595,0,1190,842],reading_order=2)])] preview.split_pdf();preview.reorder_pdfs();preview.concate_files() with pymupdf.open(preview.final_file)as d: self.assertEqual([p.get_text().strip()for p in d],['B','A','C']) def test_order_schema_rejects_booleans_strings_duplicates_and_omissions(self): for order in [[True,2,3,4],['1',2,3,4]]: with self.assertRaises(ValidationError):PageOrder.model_validate({'order':order}) for order in [[1,2,2,4],[1,2,3],[1,2,3,5]]: with self.assertRaises(ValueError):validate_order(order,4) def test_gemini_order_includes_entire_statement_and_all_images(self): verifier=GeminiVerifier(self.statement) verifier.client=Mock() verifier.client.models.generate_content.return_value.text='{"order":[2,3,4,1]}' self.assertEqual(verifier.order([b'png']*4,[False,True,False,False]),[2,3,4,1]) config=verifier.client.models.generate_content.call_args.kwargs['config'] self.assertEqual(config.thinking_config.thinking_level.value,'MEDIUM') parts=verifier.client.models.generate_content.call_args.kwargs['contents'][0].parts blobs=[p.inline_data for p in parts if p.inline_data] self.assertEqual([b.mime_type for b in blobs],['application/pdf']+['image/png']*4) self.assertEqual(blobs[0].data,self.statement.read_bytes()) self.assertEqual(sum(b.data==b'png'for b in blobs),4) def test_gemini_orientation_uses_low_thinking(self): verifier=GeminiVerifier() verifier.client=Mock() verifier.client.models.generate_content.return_value.text='{"orientation":"upright"}' self.assertEqual(verifier.orientation(b'png'),'upright') config=verifier.client.models.generate_content.call_args.kwargs['config'] self.assertEqual(config.thinking_config.thinking_level.value,'LOW') def test_missing_statement_is_an_error_for_mixed_ordering(self): verifier=GeminiVerifier() verifier.client=Mock() with self.assertRaisesRegex(ValueError,'énoncé'): verifier.order([b'png']*4,[False]*4) verifier.client.models.generate_content.assert_not_called()