Better (?) default left margin

This commit is contained in:
sebastien committed 2026-10-12 18:52:05 +02:00
1 parent b6c2a634ca
commit 247b124754
7 files changed
+421 -9

No files matched your search

+4 -4
View File
@@ -17,7 +17,7 @@ class NarrowPageCropTests(unittest.TestCase):
narrow.paste('blue', (0, 0, 300, 600))
thin = Image.new('RGB', (200, 600), 'yellow')
with patch.object(cutleft, 'get_pdf_pages', return_value=[normal, narrow, thin]):
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'))
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'), automatic_margin=False)
_, images, schema = result
image = images[0]
self.assertEqual(schema['full_width_pages'], [3])
@@ -39,7 +39,7 @@ class NarrowPageCropTests(unittest.TestCase):
narrow.paste('blue', (50, 0, 450, 600))
with patch.object(cutleft, 'get_pdf_pages', return_value=[normal, narrow]):
result = cutleft.process_single_pdf(
Path('/tmp/no-original/Copy.pdf'), shift_offset=50, width_offset=100)
Path('/tmp/no-original/Copy.pdf'), shift_offset=50, width_offset=100, automatic_margin=False)
self.assertEqual(result[1][0].size, (805, 600))
self.assertEqual(result[1][0].getpixel((405, 0)), (0, 0, 255))
self.assertEqual(result[1][0].getpixel((804, 0)), (0, 0, 255))
@@ -54,14 +54,14 @@ class NarrowPageCropTests(unittest.TestCase):
document.save(originals / 'Copy.pdf')
images = [Image.new('RGB', (900, 2339)), Image.new('RGB', (750, 2339))]
with patch.object(cutleft, 'get_pdf_pages', return_value=images):
result = cutleft.process_single_pdf(root / 'Copies' / 'Copy.pdf')
result = cutleft.process_single_pdf(root / 'Copies' / 'Copy.pdf', automatic_margin=False)
self.assertEqual(result[2]['full_width_pages'], [])
self.assertEqual(result[1][0].width, 2 * int((1191 / 2 * 200 / 72) // 3) + 5)
def test_small_main_split_adjustments_keep_margin_cropping(self):
images = [Image.new('RGB', (1600, 2339)), Image.new('RGB', (1694, 2339))]
with patch.object(cutleft, 'get_pdf_pages', return_value=images):
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'))
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'), automatic_margin=False)
self.assertEqual(result[2]['full_width_pages'], [])
self.assertEqual(result[1][0].width, 2 * (1694 // 3) + 5)
+184
View File
@@ -0,0 +1,184 @@
import unittest
from pathlib import Path
from unittest.mock import 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_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'], 21)
self.assertEqual(group['discarded_bands'], 14)
self.assertEqual(result['starts_px'], [round(21 * 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_the_trimmed_margin(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'], 21)
self.assertEqual(result['no_margin_from_page'], 3)
self.assertEqual(result['starts_px'], [round(21 * 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_half_standard_margin_threshold(self):
for edge, expected in ((19.9, 0), (20, 10), (20.1, 10.05), (40, 20)):
with self.subTest(edge=edge):
result = self.analyse([edge] * 4)
self.assertEqual(result['starts_px'][1:], [round(expected * 200 / 25.4)] * 3)
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_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(20.5 * 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'])
+3 -3
View File
@@ -115,9 +115,9 @@ class MarkedCopiesTests(unittest.TestCase):
def test_process_single_pdf_enlarges_crop_and_caps_it_at_page_edge(self):
page = Image.new("RGB", (900, 300), "white")
with patch.object(cutleft, "get_pdf_pages", return_value=[page]):
regular = cutleft.process_single_pdf(self.files[0])
enlarged = cutleft.process_single_pdf(self.files[0], width_offset=200)
capped = cutleft.process_single_pdf(self.files[0], width_offset=1000)
regular = cutleft.process_single_pdf(self.files[0], automatic_margin=False)
enlarged = cutleft.process_single_pdf(self.files[0], width_offset=200, automatic_margin=False)
capped = cutleft.process_single_pdf(self.files[0], width_offset=1000, automatic_margin=False)
fullpage = cutleft.process_single_pdf(
self.files[0], max_per_file=1, width_offset=200
)