import unittest from pathlib import Path from unittest.mock import Mock, patch import numpy as np from PIL import Image, ImageDraw, ImageFont from copienator import left_margin from copienator.commands import cutleft class LeftMarginTests(unittest.TestCase): def test_review_previous_restores_settings_and_respects_navigation_guards(self): reviewer = cutleft.ImageReviewer.__new__(cutleft.ImageReviewer) reviewer.files = [Path('Copie01.pdf'), Path('Copie02.pdf')] reviewer.index = 0 reviewer.is_processing = False reviewer.current_shift = -50 reviewer.current_width_offset = 100 reviewer.current_max_per_file = 1 reviewer.default_max_per_file = 5 reviewer.review_settings = {} reviewer.trigger_processing = Mock() reviewer.on_previous() reviewer.trigger_processing.assert_not_called() reviewer._advance() self.assertEqual(reviewer.index, 1) self.assertEqual((reviewer.current_shift, reviewer.current_width_offset, reviewer.current_max_per_file), (0, 0, 5)) reviewer.current_shift = 50 reviewer.is_processing = True reviewer.on_previous() self.assertEqual(reviewer.index, 1) reviewer.is_processing = False reviewer.on_previous() self.assertEqual(reviewer.index, 0) self.assertEqual((reviewer.current_shift, reviewer.current_width_offset, reviewer.current_max_per_file), (-50, 100, 1)) reviewer.trigger_processing.assert_called_with(Path('Copie01.pdf'), -50) reviewer._advance() self.assertEqual(reviewer.current_shift, 50) def test_trim_discards_exactly_ten_percent_without_interpolation(self): self.assertEqual(left_margin.trimmed_left_edge([100, 1, 9, 8, 7, 6, 5, 4, 3, 2]), 2) self.assertEqual(left_margin.trimmed_left_edge([5] * 9), 5) self.assertIsNone(left_margin.trimmed_left_edge([])) def test_one_margin_comment_does_not_veto_other_pages(self): images = [Image.new('RGB', (1654, 200)) for _ in range(4)] bands = [[42] * 40, [42] * 40, [42] * 40, [4] * 8 + [42] * 12] estimates = [{'edge_mm': left_margin.trimmed_left_edge(values), 'bands': len(values), 'band_starts_mm': values} for values in bands] with patch.object(left_margin, 'estimate_page', side_effect=estimates): result = left_margin.analyse_margins(images, 200, 1654) group = result['groups'][0] self.assertEqual(group['edge_mm'], 42) self.assertEqual(group['left_mm'], 22) self.assertEqual(group['discarded_bands'], 14) self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 4) def test_repeated_left_writing_beyond_ten_percent_is_retained(self): self.assertEqual(left_margin.trimmed_left_edge([10] * 20 + [42] * 80), 10) def test_disagreeing_pages_use_half_margin_when_it_removes_more(self): result = self.analyse([25, 31, 68, 49]) self.assertEqual(result['groups'][0]['left_mm'], 12.5) self.assertEqual(result['starts_px'][1:], [round(12.5 * 200 / 25.4)] * 3) def test_small_margin_starts_at_left(self): result = self.analyse([10, 15, 8, 13]) self.assertEqual(result['groups'][0]['left_mm'], 0) self.assertEqual(result['starts_px'], [0] * 4) def test_two_consecutive_no_margin_pages_enforce_left_edge_afterwards(self): images = [Image.new('RGB', (1654, 200)) for _ in range(8)] # Low pages have fewer occupied bands, so pooling still proposes a margin. edges = [42, 42, 10, 12, 42, 42, 42, 42] by_id = {id(image): edge for image, edge in zip(images, edges)} def estimate(image, _dpi): edge = by_id[id(image)] bands = 6 if edge < 20 else 100 return {'edge_mm': edge, 'bands': bands, 'band_starts_mm': [edge] * bands} with patch.object(left_margin, 'estimate_page', side_effect=estimate): result = left_margin.analyse_margins(images, 200, 1654) self.assertEqual(result['groups'][0]['left_mm'], 22) self.assertEqual(result['no_margin_from_page'], 3) self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 2 + [0] * 6) def test_isolated_low_or_blank_pages_do_not_trigger_no_margin_rule(self): for edges in ([42, 10, 42, 12], [42, 10, None, 12]): with self.subTest(edges=edges): result = self.analyse(edges) self.assertIsNone(result['no_margin_from_page']) def test_two_no_margin_pages_enforce_rule_across_later_width_changes(self): result = self.analyse([42, 42, 10, 12, 42, 42], widths=[1654, 1654, 1654, 1654, 2000, 2000], band_counts=[100, 100, 6, 6, 100, 100]) self.assertEqual(result['no_margin_from_page'], 3) self.assertGreater(result['starts_px'][0], 0) self.assertEqual(result['starts_px'][2:], [0] * 4) def test_larger_cut_distance_and_no_margin_threshold(self): for edge, expected in ((0, 0), (19.9, 0), (20, 10), (20.1, 10.05), (28.4, 14.2), (40, 20), (60, 40)): with self.subTest(edge=edge): result = self.analyse([edge] * 4) self.assertEqual(result['starts_px'][1:], [round(expected * 200 / 25.4)] * 3) self.assertEqual(result['groups'][0]['status'], 'near-edge' if edge < 20 else 'margin') self.assertEqual(result['margin_clearance_mm'], 20) def test_body_bands_ignore_header_footer_and_abstain_on_sparse_ink(self): mask = np.zeros((1169, 827), bool) mask[60:340, 2:120] = True # Name/class block extends past 25% height. mask[1000:1050, 2:50] = True # Pagination. for y in range(330, 940, 35): mask[y:y + 5, 170:270] = True result = left_margin.body_edge(mask) self.assertAlmostEqual(result['edge_mm'], 43.2, delta=.3) self.assertGreaterEqual(result['bands'], 6) mask[300:960] = False self.assertIsNone(left_margin.body_edge(mask)['edge_mm']) def test_bands_starting_beyond_midpoint_are_empty_for_both_windows(self): mask = np.zeros((1169, 827), bool) for y in range(370, 1050, 35): mask[y:y + 5, 450:550] = True result = left_margin.body_edge(mask) self.assertIsNone(result['edge_mm']) self.assertEqual(result['band_starts_mm'], []) self.assertEqual(result['label_band_starts_mm'], []) mask[400:410, 100:200] = True self.assertTrue(left_margin.body_edge(mask)['band_starts_mm']) def test_label_window_keeps_lower_body_but_excludes_names_and_bottom_eight_percent(self): mask = np.zeros((1169, 827), bool) mask[100:300, 5:80] = True # Name/header; exclude from both pools. mask[1100:1150, 5:80] = True # Footer; below 92%. for y in range(370, 920, 35): mask[y:y + 5, 170:270] = True mask[1000:1010, 40:140] = True # Label at 85.5%; new window only. result = left_margin.body_edge(mask) self.assertGreater(min(result['band_starts_mm']), 40) self.assertAlmostEqual(min(result['label_band_starts_mm']), 10.16) def test_fifth_percentile_detects_labels_while_page_decile_stays_at_body(self): images = [Image.new('RGB', (1654, 200)) for _ in range(6)] bands = [5.] * 7 + [42.] * 93 estimate = {'edge_mm': left_margin.trimmed_left_edge(bands), 'bands': len(bands), 'band_starts_mm': bands, 'label_band_starts_mm': bands} with patch.object(left_margin, 'estimate_page', return_value=estimate): result = left_margin.analyse_margins(images, 200, 1654) group = result['groups'][0] self.assertEqual(group['edge_mm'], 42) self.assertEqual(group['body_left_mm'], 22) self.assertEqual(group['label_edge_mm'], 5) self.assertTrue(group['labels_in_margin']) self.assertEqual(result['starts_px'], [0] * 6) def test_unsampled_coherent_page_can_move_the_whole_group_left(self): images = [Image.new('RGB', (1654, 200)) for _ in range(6)] def estimate(image, _dpi): labels = [5.] * 20 if image is images[2] else [] return {'edge_mm': 42, 'bands': 12, 'band_starts_mm': [42.] * 12, 'label_band_starts_mm': labels + [42.] * 12} with patch.object(left_margin, 'estimate_page', side_effect=estimate): result = left_margin.analyse_margins(images, 200, 1654) group = result['groups'][0] self.assertNotIn(3, [p['page'] for p in group['samples']]) self.assertIn(3, group['coherent_pages']) self.assertEqual(result['starts_px'], [0] * 6) def test_sparse_or_incoherent_pages_do_not_supply_label_evidence(self): images = [Image.new('RGB', (1654, 200)) for _ in range(6)] edges = [42, 42, None, 42, 65, 42] def estimate(image, _dpi): edge = edges[next(i for i, candidate in enumerate(images) if candidate is image)] return {'edge_mm': edge, 'bands': 12, 'band_starts_mm': [edge or 42.] * 12, 'label_band_starts_mm': [1.] * 20 if edge in (None, 65) else [42.] * 12} with patch.object(left_margin, 'estimate_page', side_effect=estimate): result = left_margin.analyse_margins(images, 200, 1654) group = result['groups'][0] self.assertEqual(group['coherent_pages'], [1, 2, 4, 6]) self.assertFalse(group['labels_in_margin']) self.assertGreater(result['starts_px'][0], 0) def test_less_than_five_percent_noise_or_small_gap_does_not_trigger_labels(self): for bands in ([1.] * 4 + [42.] * 96, [35.] * 7 + [42.] * 93): with self.subTest(bands=bands[:8]): images = [Image.new('RGB', (1654, 200)) for _ in range(2)] estimate = {'edge_mm': 42, 'bands': 100, 'band_starts_mm': [42.] * 100, 'label_band_starts_mm': bands} with patch.object(left_margin, 'estimate_page', return_value=estimate): result = left_margin.analyse_margins(images, 200, 1654) self.assertFalse(result['groups'][0]['labels_in_margin']) self.assertGreater(result['starts_px'][0], 0) def test_real_mask_rejects_ruling_and_preserves_handwriting(self): image = Image.new('RGB', (827, 1169), 'white') draw = ImageDraw.Draw(image) for x in range(12, 827, 20): draw.line((x, 0, x, 1169), fill=(170, 190, 210)) for y in range(10, 1169, 20): draw.line((0, y, 827, y), fill=(170, 190, 210)) font = ImageFont.truetype('DejaVuSans.ttf', 16) for y in range(330, 920, 35): draw.text((170, y), '1) Pour tout x, f(x) = x + 1', font=font, fill=(20, 20, 100)) result = left_margin.estimate_page(image, 100) self.assertAlmostEqual(result['edge_mm'], 43.2, delta=3) def test_width_groups_tolerate_split_adjustments_and_separate_narrow_pages(self): pages = [Image.new('RGB', (w, 200)) for w in (1600, 1694, 1654, 800, 810, 2000)] self.assertEqual(left_margin.format_groups(pages, 1654), [[0, 1, 2], [3, 4], [5]]) def analyse(self, edges, widths=None, band_counts=None): images = [Image.new('RGB', (w, 200)) for w in (widths or [1654] * len(edges))] by_id = {id(image): edge for image, edge in zip(images, edges)} counts = {id(image): count for image, count in zip(images, band_counts or [12] * len(edges))} def estimate(image, _dpi): edge = by_id[id(image)] return {'edge_mm': edge, 'bands': counts[id(image)] if edge is not None else 0, 'band_starts_mm': [edge] * counts[id(image)] if edge is not None else []} with patch.object(left_margin, 'estimate_page', side_effect=estimate): return left_margin.analyse_margins(images, 200, max(im.width for im in images)) def test_clear_margin_uses_same_conservative_edge_on_every_page(self): result = self.analyse([43, 41, 46, 44]) self.assertEqual(result['groups'][0]['status'], 'margin') self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 4) self.assertNotIn('first_page_body_start_px', result) def test_sparse_groups_stay_at_left(self): for edges in ([None] * 4, [40, None, None, None]): with self.subTest(edges=edges): self.assertEqual(self.analyse(edges)['starts_px'], [0] * 4) def test_narrow_subpages_stay_at_left_even_with_indented_text(self): result = self.analyse([45, 45, 70, 72], widths=[1654, 1654, 800, 800]) self.assertEqual(result['starts_px'][2:], [0, 0]) self.assertEqual(result['groups'][1]['status'], 'narrow-page') def test_auto_crop_applies_manual_offsets_to_cached_estimate(self): images = [Image.new('RGB', (900, 600), 'white') for _ in range(2)] images[0].paste('blue', (150, 0, 190, 100)) analysis = {'starts_px': [150, 150], 'groups': []} with patch.object(cutleft, 'get_pdf_pages', return_value=images), \ patch.object(cutleft, 'analyse_margins', return_value=analysis) as analyse: result = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf')) shifted = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf'), shift_offset=-50, width_offset=50, margin_analysis=analysis) analyse.assert_called_once() self.assertEqual(result[1][0].getpixel((0, 0)), (0, 0, 255)) self.assertEqual([p['left_px'] for p in result[2]['page_crops']], [150, 150]) self.assertEqual([p['left_px'] for p in shifted[2]['page_crops']], [100, 100]) self.assertEqual(result[2]['columns_per_file'], [2]) self.assertEqual(result[1][0].size, (665, 600)) self.assertEqual(shifted[1][0].size, (765, 600)) def test_fullpage_bypasses_analysis(self): with patch.object(cutleft, 'get_pdf_pages', return_value=[Image.new('RGB', (900, 600))]), \ patch.object(cutleft, 'analyse_margins') as analyse: result = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf'), max_per_file=1) analyse.assert_not_called() self.assertIsNone(result[2]['auto_margin']) def test_wider_auto_strip_is_capped_at_page_edge(self): images = [Image.new('RGB', (900, 600)), Image.new('RGB', (200, 600))] with patch.object(cutleft, 'get_pdf_pages', return_value=images): result = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf'), margin_analysis={'starts_px': [750, 0], 'groups': []}) self.assertEqual([crop['right_px'] for crop in result[2]['page_crops']], [900, 200]) self.assertEqual(result[2]['full_width_pages'], [2]) def test_first_page_uses_same_crop_width_as_later_pages(self): images = [Image.new('RGB', (900, 600), 'white') for _ in range(2)] images[0].paste('blue', (420, 100, 450, 200)) analysis = {'starts_px': [150, 150], 'groups': []} with patch.object(cutleft, 'get_pdf_pages', return_value=images): result = cutleft.process_single_pdf(Path('/tmp/left-margin/Copy.pdf'), margin_analysis=analysis) self.assertEqual(result[2]['page_crops'][0]['right_px'], 480) self.assertEqual(result[1][0].getpixel((290, 120)), (0, 0, 255)) self.assertEqual(result[1][0].size, (665, 600)) self.assertEqual([crop['left_px'] for crop in result[2]['page_crops']], [150, 150]) def test_manual_margin_cli_disables_automatic_positioning(self): with patch.object(cutleft, 'run', return_value=0) as run, \ patch.object(cutleft, 'workspace_from_target', return_value=(None, Path('/tmp'))): self.assertEqual(cutleft.main(['/tmp', '--manual-margin']), 0) self.assertFalse(run.call_args.kwargs['automatic_margin'])