Better (?) default left margin
This commit is contained in:
1 parent
b6c2a634ca
commit
247b124754
7 files changed
+421
-9
No files matched your search
@@ -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'])
|
||||
Reference in new issue
Block a user