import tempfile import unittest import json from pathlib import Path import numpy as np import pymupdf from copienator.auto_page_split import analyse_ink, analyse_page, classify_format, plan_masks, run class AutomaticSplitTests(unittest.TestCase): def mask(self): return np.zeros((300, 420), bool) def plan(self, mask, kind='a3'): return plan_masks(mask, 25.4, kind) def test_formats_use_size_not_landscape_alone(self): pt = 72/25.4 for w,h,expected in [(420,297,'a3'),(210,297,'a4'),(297,210,'a4'), (297,420,'unknown'),(320,200,'unknown')]: self.assertEqual(classify_format(w*pt,h*pt),expected) def test_both_directions_extend_independently(self): mask = self.mask() mask[40:43,150:218] = True mask[100:103,203:280] = True plan = self.plan(mask) self.assertEqual([x['owner'] for x in plan['crossings']],['left','right']) self.assertEqual(plan['extensions_mm'],[10,9]) self.assertTrue(all(o['keep'] for o in plan['outputs'])) def test_disconnected_letters_form_a_line(self): mask = self.mask() for x in range(170,219,4): mask[40:44,x:x+2] = True plan = self.plan(mask) self.assertGreater(plan['extensions_mm'][0],0) self.assertEqual(plan['crossings'][0]['owner'],'left') def test_detached_terminal_strokes_follow_supported_text_on_either_side(self): mask=self.mask() mask[40:44,160:199]=True mask[41:45,205:219]=True mask[100:104,221:260]=True mask[101:105,201:215]=True plan=self.plan(mask) self.assertEqual([c['owner'] for c in plan['crossings']],['left','right']) self.assertGreater(plan['extensions_mm'][0],0) self.assertGreater(plan['extensions_mm'][1],0) def test_fold_only_groups_are_artifacts_without_page_anchors(self): mask=self.mask() mask[40:43,205:215]=True plan=self.plan(mask) self.assertEqual(plan['crossings'][0]['owner'],'artifact') self.assertEqual(plan['extensions_mm'],[0,0]) self.assertFalse(plan['review_reasons']) def test_pale_ruling_does_not_attach_to_nearby_text_but_dark_bar_does(self): mask=np.zeros((600,840),bool) mask[100:103,418:468]=True mask[100:120,490:580]=True credible=np.zeros_like(mask) credible[100:120,490:580]=True for colour,owner in [((134,162,200),'artifact'),((80,80,80),'right')]: with self.subTest(colour=colour): rgb=np.full((*mask.shape,3),255,np.uint8) rgb[mask]=(20,20,20) rgb[100:103,418:468]=colour plan=plan_masks(mask,200,'a3',content_ink=credible,rgb=rgb) self.assertEqual(plan['crossings'][0]['owner'],owner) def test_overflow_does_not_make_other_page_nonblank(self): mask = self.mask() mask[40:43,150:218] = True plan = self.plan(mask) self.assertEqual([o['state'] for o in plan['outputs']],['content','blank']) self.assertFalse(plan['outputs'][1]['keep']) def test_ambiguous_crossing_preserved_on_both(self): mask = self.mask() mask[40:43,180:250] = True plan = self.plan(mask) self.assertIn('ambiguous-centre-writing',plan['review_reasons']) self.assertLess(plan['outputs'][1]['rect_px'][0],180) self.assertGreater(plan['outputs'][0]['rect_px'][2],250) def test_unknown_format_and_a4(self): mask = self.mask() self.assertTrue(self.plan(mask,kind='unknown')['outputs'][0]['keep']) mask[40:44,50:54] = True self.assertEqual(self.plan(mask,kind='a4')['outputs'][0]['rect_px'],[0,0,420,300]) def test_unseeded_scan_noise_does_not_veto_blank(self): rgb = np.full((600,420,3),255,np.uint8) rgb[200:203,200:202] = 195 plan = analyse_page(rgb,100,'a4') self.assertEqual(plan['outputs'][0]['state'],'blank') self.assertEqual(plan['analysis']['early_written'],[False]) def test_faint_strokes_near_reliable_ink_are_recovered(self): rgb = np.full((600,420,3),255,np.uint8) rgb[200:210,200:204] = 195 rgb[200:210,204:208] = 30 ink,_ = analyse_ink(rgb,100) self.assertTrue(ink[200:210,204:208].any()) self.assertEqual(analyse_page(rgb,100,'a4')['outputs'][0]['state'],'content') def test_spread_artifacts_cannot_veto_blank_logical_half(self): spread=self.mask() spread[100:105,208:213]=True content=self.mask() content[30:40,300:350]=True plan=plan_masks(spread,25.4,'a3',content_ink=content) self.assertEqual([o['state'] for o in plan['outputs']],['blank','content']) self.assertEqual(plan['crossings'][0]['owner'],'artifact') self.assertFalse(plan['review_reasons']) def test_export_preserves_visible_rotation_cropbox_and_sources(self): for rotation in (0,90,180,270): with self.subTest(rotation=rotation), tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root/'source.pdf' with pymupdf.open() as doc: # Crop offsets and all rotations; displayed geometry is A3. w,h = (420,297) if rotation%180==0 else (297,420) pt = 72/25.4 p = doc.new_page(width=w*pt+20,height=h*pt+30) p.draw_rect(pymupdf.Rect(50,50,w*pt-50,h*pt-50),fill=(0,0,1)) p.draw_rect(pymupdf.Rect(100,100,150,180),fill=(1,0,0)) p.set_cropbox(pymupdf.Rect(10,15,w*pt+10,h*pt+15)) p.set_rotation(rotation) doc.save(source) before = source.read_bytes() summary = run(source,root/'result',dpi=100) self.assertEqual(summary['output_pages'],2) self.assertEqual(source.read_bytes(),before) with pymupdf.open(source) as original, pymupdf.open(root/'result/pdfs/source.pdf') as result: outputs=json.loads((root/'result/report.json').read_text())['pages'][0]['outputs'] for page,output in zip(result,outputs): self.assertEqual(page.rotation,0) self.assertAlmostEqual(page.rect.height,original[0].rect.height,places=3) # Ambiguous full-width rectangle is deliberately duplicated. a = original[0].get_pixmap(matrix=pymupdf.Matrix(.3,.3)) b = page.get_pixmap(matrix=pymupdf.Matrix(.3,.3)) self.assertGreater(b.width, a.width*.8) expected=original[0].get_pixmap(clip=pymupdf.Rect(output['crop_points'])) actual=page.get_pixmap() aa=np.frombuffer(expected.samples,np.uint8).reshape(expected.height,expected.width,3) bb=np.frombuffer(actual.samples,np.uint8).reshape(actual.height,actual.width,3) h,w=min(aa.shape[0],bb.shape[0]),min(aa.shape[1],bb.shape[1]) self.assertLess(np.abs(aa[:h,:w].astype(float)-bb[:h,:w]).mean(),2) with self.assertRaises(FileExistsError): run(source,root/'result',dpi=100) def test_all_blank_copy_has_report_without_invalid_empty_pdf(self): with tempfile.TemporaryDirectory() as directory: root=Path(directory) with pymupdf.open() as doc: doc.new_page(width=210*72/25.4,height=297*72/25.4) doc.save(root/'blank.pdf') summary=run(root/'blank.pdf',root/'result',dpi=100) self.assertEqual(summary['blank_removed'],1) self.assertFalse((root/'result/pdfs/blank.pdf').exists()) self.assertTrue((root/'result/report.json').exists())