Use per-page annotation spacing with inferred margin fallback

This commit is contained in:
sebastien committed 2026-11-03 12:02:06 +01:00
1 parent b069805988
commit 0bb6f7d97c
10 files changed
+170 -39

No files matched your search

+52 -8
View File
@@ -9,7 +9,7 @@ from PIL import Image
from copienator import atomic_write_json, configuration, read_json
from copienator.annotation_layout import (
answer_parts, copy_answer_pdf, fingerprint, layout_for, legacy_layout,
move_answer_pdf, save_parts,
move_answer_pdf, save_parts, added_margin,
)
from copienator.commands import annotating, annotating_by_label, splitting_int
from copienator.commands.annotating_with_checks import CheckboxRenderer
@@ -42,7 +42,8 @@ class AnnotationLayoutTests(unittest.TestCase):
with patch('copienator.left_margin.estimate_page', side_effect=estimate):
result = analyse_margins(images, 200, 600)
self.assertEqual([p['status'] for p in result['pages']],
['low', 'low', 'normal', 'unknown', 'low', 'normal'])
['low', 'low', 'normal', 'unknown', 'low', 'low'])
self.assertAlmostEqual(result['pages'][3]['effective_edge_mm'], 19.98)
def test_split_records_pages_and_rejects_stale_measurements(self):
schema_path = self.root / 'Cutleft' / 'Copie01_schema.json'
@@ -55,7 +56,7 @@ class AnnotationLayoutTests(unittest.TestCase):
splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging)
answer = staging / 'Ex 1.pdf'
self.assertEqual([p['page'] for p in answer_parts(answer)], [1, 2])
self.assertEqual(layout_for(answer)['margin_left'], 400)
self.assertEqual(layout_for(answer)['page_margins'], [300, 450])
atomic_write_json(schema_path, {'source_sha256': 'stale', 'auto_margin': {
'pages': [{'page': 1, 'edge_mm': 10}]}})
splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging)
@@ -63,8 +64,9 @@ class AnnotationLayoutTests(unittest.TestCase):
def test_configurable_margin_unknown_fallback_and_stale_sidecar(self):
self.assertEqual(layout_for(self.pdf)['margin_left'], 300)
save_parts(self.pdf, [{'margin_status': 'normal'}, {'margin_status': 'low'}])
with patch.object(configuration, 'ANNOTATION_LOW_MARGIN_LEFT', 450):
save_parts(self.pdf, [{'margin_status': 'normal', 'margin_mm': 40},
{'margin_status': 'low', 'margin_mm': 20}])
with patch.object(configuration, 'ANNOTATION_MARGIN_POINTS', [(10, 500), (20, 450), (35, 300)]):
self.assertEqual(layout_for(self.pdf)['margin_left'], 450)
self.pdf.write_bytes(self.pdf.read_bytes() + b'\n')
self.assertEqual(layout_for(self.pdf)['margin_left'], 300)
@@ -79,11 +81,12 @@ class AnnotationLayoutTests(unittest.TestCase):
# 20% of a 600-point page removes 42.3 mm from the left.
splitting_int._render_split_outputs(self.pdf, [('|Ex 1', 0, 0, 10, 100, 110)], staging)
answer = staging / 'Ex 1.pdf'
self.assertEqual(layout_for(answer)['margin_left'], 400)
self.assertEqual(layout_for(answer)['margin_left'], 450)
self.assertEqual(answer_parts(answer)[0]['margin_mm'], 0)
def test_copy_move_and_vertical_cut_preserve_page_classification(self):
save_parts(self.pdf, [{'margin_status': 'normal'}, {'margin_status': 'low'}])
save_parts(self.pdf, [{'margin_status': 'normal', 'margin_mm': 40},
{'margin_status': 'low', 'margin_mm': 20}])
copied = self.root / 'copied.pdf'
moved = self.root / 'moved.pdf'
copy_answer_pdf(self.pdf, copied)
@@ -105,6 +108,47 @@ class AnnotationLayoutTests(unittest.TestCase):
box = next(call.args[2]['box'] for call in callback.call_args_list if call.args[0] == 'local_rect')
return image, header, box
def test_interpolation_and_clamping(self):
for mm, pixels in [(None, 300), (0, 450), (10, 450), (15, 425),
(20, 400), (27.5, 350), (35, 300), (80, 300)]:
with self.subTest(mm=mm):
self.assertEqual(added_margin(mm), pixels)
def test_unknown_measurement_inherits_average_during_answer_extraction(self):
atomic_write_json(self.root / 'Cutleft' / 'Copie01_schema.json', {
'source_sha256': fingerprint(self.pdf),
'auto_margin': {'pages': [{'page': 1, 'edge_mm': 15}, {'page': 2, 'edge_mm': None}]},
})
staging = self.root / 'inferred'
staging.mkdir()
splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging)
answer = staging / 'Ex 1.pdf'
self.assertEqual(answer_parts(answer)[1]['margin_status'], 'unknown')
self.assertEqual(answer_parts(answer)[1]['margin_mm'], 15)
self.assertEqual(layout_for(answer)['page_margins'], [425, 425])
def test_per_page_offsets_narrow_pages_and_saved_layout(self):
save_parts(self.pdf, [{'margin_mm': 10}, {'margin_mm': 35}])
pages = [Image.new('RGB', (1000, 100), 'blue'), Image.new('RGB', (800, 100), 'green')]
with patch.object(annotating, 'convert_from_path', return_value=pages):
base, _, _ = annotating.make_base_image(self.pdf)
result = {'score': 2, 'feedback': [{'text': 'Across pages', 'box_2d': [80, 150, 120, 200]}]}
callback = Mock()
with patch.object(annotating, 'render_score_text', return_value=Image.new('RGB', (200, 40))):
def render(**kwargs):
return annotating.compose_label_image(
base, 'Ex 1', result, 0, render_fn=lambda *a, **k: Image.new('RGBA', (10, 10)),
**kwargs)
image, header = render(source_pdf=self.pdf, draw_callback=callback)
boxes = [call.args[2]['box'] for call in callback.call_args_list if call.args[0] == 'local_rect']
self.assertEqual(boxes, [[600, 120, 650, 140], [350, 140, 400, 160]])
for x, y, color in [(450, header + 20, (0, 0, 255)), (300, header + 150, (0, 128, 0))]:
self.assertEqual(image.getpixel((x, y)), color)
self.assertEqual(image.getpixel((x - 1, y)), (255, 255, 255))
with patch.object(configuration, 'ANNOTATION_MARGIN_POINTS', [(10, 900)]):
restored, _ = render(layout=image.info['annotation_layout'])
self.assertEqual(restored.tobytes(), image.tobytes())
def test_wider_layout_translates_copy_and_feedback_without_scaling(self):
normal, height, box = self.render(legacy_layout())
low, low_height, low_box = self.render({**legacy_layout(), 'margin_left': 400})
@@ -145,7 +189,7 @@ class AnnotationLayoutTests(unittest.TestCase):
self.assertEqual(meta['width'], low.width)
with Image.open(output / 'Reference.jpg') as reference:
self.assertGreater(min(reference.getpixel((low.width - 10, 80))), 245)
with patch.object(reader, 'detect_checks_and_notes', return_value=([], Image.new('RGBA', (low.width, low.height * 2)))), patch.object(configuration, 'ANNOTATION_LOW_MARGIN_LEFT', 500):
with patch.object(reader, 'detect_checks_and_notes', return_value=([], Image.new('RGBA', (low.width, low.height * 2)))), patch.object(configuration, 'ANNOTATION_MARGIN_POINTS', [(10, 500)]):
_, notes = reader._scan_annotation_directory(output)
self.assertEqual(notes['02']['Ex 1']['layout']['margin_left'], 400)
rerender, _, _ = self.render(notes['02']['Ex 1']['layout'])
+12 -3
View File
@@ -324,9 +324,18 @@ class LeftMarginTests(unittest.TestCase):
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)
result = self.analyse([None] * 4)
self.assertEqual(result['starts_px'], [0] * 4)
self.assertTrue(all(page['effective_edge_mm'] is None for page in result['pages']))
def test_unknown_pages_borrow_only_measured_copy_average(self):
result = self.analyse([40, 50, None, None])
self.assertEqual([page['effective_edge_mm'] for page in result['pages']], [40, 50, 45, 45])
self.assertEqual(result['pages'][2]['status'], 'unknown')
self.assertIsNone(result['pages'][2]['edge_mm'])
self.assertEqual(result['starts_px'][2:], [round(25 * 200 / 25.4)] * 2)
sparse = self.analyse([40, None, None, None])
self.assertEqual(sparse['starts_px'], [round(20 * 200 / 25.4)] * 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])