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

+19 -6
View File
@@ -61,13 +61,26 @@ def move_answer_pdf(source, destination):
metadata_path(source).unlink(missing_ok=True) metadata_path(source).unlink(missing_ok=True)
def added_margin(margin_mm):
"""Interpolate the configured (millimetres, pixels) points, clamping at ends."""
if margin_mm is None:
return configuration.ANNOTATION_MARGIN_LEFT
points = configuration.ANNOTATION_MARGIN_POINTS
if (not points or any(x < 0 or type(y) is not int or y < 0 for x, y in points)
or any(a[0] >= b[0] for a, b in zip(points, points[1:]))):
raise ValueError('Annotation margin points must have increasing mm and non-negative integer pixels')
if margin_mm <= points[0][0]:
return points[0][1]
for (x0, y0), (x1, y1) in zip(points, points[1:]):
if margin_mm <= x1:
return round(y0 + (y1 - y0) * (margin_mm - x0) / (x1 - x0))
return points[-1][1]
def layout_for(pdf): def layout_for(pdf):
low = any(part.get('margin_status') == 'low' for part in answer_parts(pdf)) margins = [added_margin(part.get('margin_mm')) for part in answer_parts(pdf)]
margin = (configuration.ANNOTATION_LOW_MARGIN_LEFT if low return {'version': 2, 'margin_left': max(margins, default=added_margin(None)),
else configuration.ANNOTATION_MARGIN_LEFT) 'page_margins': margins, 'narrow_left_pad_limit': 0}
if type(margin) is not int or margin < 0:
raise ValueError('Annotation left margins must be non-negative integers')
return {'version': 1, 'margin_left': margin, 'narrow_left_pad_limit': 300}
def legacy_layout(): def legacy_layout():
+38 -8
View File
@@ -63,9 +63,13 @@ def make_base_image(pdf_path):
base_img = Image.new("RGBA", (max_w, total_h), "white") base_img = Image.new("RGBA", (max_w, total_h), "white")
current_y = 0 current_y = 0
page_regions = []
for page in pages: for page in pages:
base_img.paste(page.convert("RGBA"), ((max_w - page.width) // 2, current_y)) x = (max_w - page.width) // 2
base_img.paste(page.convert("RGBA"), (x, current_y))
page_regions.append({'x': x, 'y': current_y, 'width': page.width, 'height': page.height})
current_y += page.height current_y += page.height
base_img.info['page_regions'] = page_regions
return (base_img, total_h, max_w) return (base_img, total_h, max_w)
def normalize_mathtext(text): def normalize_mathtext(text):
@@ -352,6 +356,10 @@ def compose_label_image(base_img, label, result, hmin,
layout = dict(layout if layout is not None else layout_for(source_pdf)) layout = dict(layout if layout is not None else layout_for(source_pdf))
margin_left = layout['margin_left'] margin_left = layout['margin_left']
page_regions = base_img.info.get('page_regions', [
{'x': 0, 'y': 0, 'width': base_img.width, 'height': base_img.height}])
page_margins = layout.get('page_margins', [])
per_page = layout.get('version', 1) >= 2
left_pad = 0 left_pad = 0
if base_img.width < TARGET_MIN_WIDTH: if base_img.width < TARGET_MIN_WIDTH:
total_missing = TARGET_MIN_WIDTH - base_img.width total_missing = TARGET_MIN_WIDTH - base_img.width
@@ -411,7 +419,11 @@ def compose_label_image(base_img, label, result, hmin,
total_height = base_img.height + header_height total_height = base_img.height + header_height
# Create Canvas # Create Canvas
final_img = Image.new("RGB", (base_img.width + margin_left, total_height), "white") final_width = (max(TARGET_MIN_WIDTH + margin_left,
max(region['width'] + (page_margins[i] if i < len(page_margins) else margin_left)
for i, region in enumerate(page_regions)))
if per_page else base_img.width + margin_left)
final_img = Image.new("RGB", (final_width, total_height), "white")
# Draw Headers # Draw Headers
current_y = 0 current_y = 0
@@ -434,7 +446,13 @@ def compose_label_image(base_img, label, result, hmin,
# Paste Base Image # Paste Base Image
image_offset_y = current_y image_offset_y = current_y
final_img.paste(base_img, (margin_left, image_offset_y)) if per_page:
for i, region in enumerate(page_regions):
x, y, w, h = (region[key] for key in ('x', 'y', 'width', 'height'))
margin = page_margins[i] if i < len(page_margins) else margin_left
final_img.paste(base_img.crop((x, y, x + w, y + h)), (margin, image_offset_y + y))
else:
final_img.paste(base_img, (margin_left, image_offset_y))
# 2. Draw Local Annotations # 2. Draw Local Annotations
draw = ImageDraw.Draw(final_img, "RGBA") # Refresh draw object draw = ImageDraw.Draw(final_img, "RGBA") # Refresh draw object
@@ -451,12 +469,24 @@ def compose_label_image(base_img, label, result, hmin,
# Draw Rectangle (if not suppressed) # Draw Rectangle (if not suppressed)
if "norectangle" not in fb: if "norectangle" not in fb:
draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3) boxes = [[target_xmin, target_ymin, target_xmax, target_ymax]]
if per_page:
boxes = []
for i, region in enumerate(page_regions):
top = max(ymin - hmin, region['y'])
bottom = min(ymax - hmin, region['y'] + region['height'])
if bottom <= top:
continue
margin = page_margins[i] if i < len(page_margins) else margin_left
shift = margin - region['x']
boxes.append([xmin + shift, top + image_offset_y,
xmax + shift, bottom + image_offset_y])
for box in boxes:
draw.rectangle(box, outline="red", width=3)
if draw_callback: if draw_callback:
callback_items.append(("local_rect", callback_items.append(("local_rect", {"box": box},
{"box": [target_xmin, target_ymin, target_xmax, target_ymax]}, {"data": fb, "index": idx}))
{"data": fb, "index": idx}))
# Render Text # Render Text
txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH, txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH,
+5
View File
@@ -428,6 +428,11 @@ class ImageReviewer:
for group in analysis['groups']) for group in analysis['groups'])
margin_info = (f"Auto margin: {offsets} mm from left" margin_info = (f"Auto margin: {offsets} mm from left"
+ (' | Check uncertain pages' if needs_review else '') + '\n') + (' | Check uncertain pages' if needs_review else '') + '\n')
for page in analysis.get('pages', []):
if page.get('status') == 'unknown':
value = page.get('effective_edge_mm')
fallback = f" (copy average: {value:.1f} mm)" if value is not None else ''
margin_info += f"Page {page['page']}: unknown margin{fallback}\n"
if analysis.get('no_margin_from_page') is not None: if analysis.get('no_margin_from_page') is not None:
margin_info += f"No margin from page {analysis['no_margin_from_page']} onwards\n" margin_info += f"No margin from page {analysis['no_margin_from_page']} onwards\n"
self.label_info.configure( self.label_info.configure(
+5 -3
View File
@@ -23,6 +23,7 @@ from copienator import (
) )
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
from copienator.annotation_layout import fingerprint, metadata_path, save_parts from copienator.annotation_layout import fingerprint, metadata_path, save_parts
from copienator.left_margin import margins_with_fallback
SQUARE = 1000 // 38 SQUARE = 1000 // 38
ANSWER_TOP_PADDING_POINTS = 4 * 72 / 25.4 ANSWER_TOP_PADDING_POINTS = 4 * 72 / 25.4
@@ -133,7 +134,7 @@ def _render_split_outputs(
provenance: defaultdict[str, list[dict]] = defaultdict(list) provenance: defaultdict[str, list[dict]] = defaultdict(list)
schema = read_json(input_pdf.parent.parent / 'Cutleft' / f'{input_pdf.stem}_schema.json', default={}) schema = read_json(input_pdf.parent.parent / 'Cutleft' / f'{input_pdf.stem}_schema.json', default={})
analysis = schema.get('auto_margin') or {} analysis = schema.get('auto_margin') or {}
measurements = ({item['page']: item for item in analysis.get('pages', [])} measurements = ({item['page']: item for item in margins_with_fallback(analysis.get('pages', []))}
if schema.get('source_sha256') == fingerprint(input_pdf) else {}) if schema.get('source_sha256') == fingerprint(input_pdf) else {})
with tempfile.TemporaryDirectory(prefix="copienator-split-") as temp_directory: with tempfile.TemporaryDirectory(prefix="copienator-split-") as temp_directory:
temporary = Path(temp_directory) temporary = Path(temp_directory)
@@ -206,13 +207,14 @@ def _render_split_outputs(
part_path, part_path,
) )
parts_by_label[clean_label].append(part_path) parts_by_label[clean_label].append(part_path)
edge = measurements.get(page_number + 1, {}).get('edge_mm') measurement = measurements.get(page_number + 1, {})
edge = measurement.get('effective_edge_mm')
remaining = None if edge is None else max(0, edge - clip.x0 * 25.4 / 72) remaining = None if edge is None else max(0, edge - clip.x0 * 25.4 / 72)
provenance[clean_label].append({ provenance[clean_label].append({
'source': input_pdf.name, 'source_sha256': schema.get('source_sha256'), 'source': input_pdf.name, 'source_sha256': schema.get('source_sha256'),
'page': page_number + 1, 'crop_points': list(clip), 'page': page_number + 1, 'crop_points': list(clip),
'margin_mm': remaining, 'margin_mm': remaining,
'margin_status': ('unknown' if remaining is None else 'margin_status': ('unknown' if measurement.get('edge_mm') is None else
'low' if remaining < 20 else 'normal'), 'low' if remaining < 20 else 'normal'),
}) })
+1 -1
View File
@@ -40,7 +40,7 @@ THINKING_LEVEL_FLASH = "medium"
THINKING_LEVEL_PRO = "high" THINKING_LEVEL_PRO = "high"
ALWAYS_CROP = False ALWAYS_CROP = False
ANNOTATION_MARGIN_LEFT = 300 ANNOTATION_MARGIN_LEFT = 300
ANNOTATION_LOW_MARGIN_LEFT = 400 ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)]
ANNOTATION_MAX_HEIGHT_PX = 33000 ANNOTATION_MAX_HEIGHT_PX = 33000
ANNOTATION_MAX_PIXELS = 67_108_864 ANNOTATION_MAX_PIXELS = 67_108_864
SHOW_PERSONAL_STEPS = False SHOW_PERSONAL_STEPS = False
+21 -3
View File
@@ -27,6 +27,15 @@ STANDARD_LINE_HEIGHT_MM = 8
MAX_EXCEPTION_LINES = 4 MAX_EXCEPTION_LINES = 4
def margins_with_fallback(pages: list[dict]) -> list[dict]:
"""Keep measurements intact; unknown pages borrow the copy's measured mean."""
known = [page['edge_mm'] for page in pages if page.get('edge_mm') is not None]
average = sum(known) / len(known) if known else None
return [{**page, 'effective_edge_mm': (
page['edge_mm'] if page.get('edge_mm') is not None else average
)} for page in pages]
def trimmed_left_edge(starts: list[float]) -> float | None: def trimmed_left_edge(starts: list[float]) -> float | None:
"""Discard the leftmost 10% of occupied bands; retain the next minimum.""" """Discard the leftmost 10% of occupied bands; retain the next minimum."""
if not starts: if not starts:
@@ -142,9 +151,13 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
if name_localized else estimate_page(images[index], render_dpi)) if name_localized else estimate_page(images[index], render_dpi))
return page_estimates[index] return page_estimates[index]
page_estimates = dict(enumerate(margins_with_fallback(
[page_estimate(index) for index in range(len(images))])))
for indices in format_groups(images, reference_width): for indices in format_groups(images, reference_width):
estimates = {index: page_estimate(index) for index in indices} estimates = {index: page_estimate(index) for index in indices}
values = [item['edge_mm'] for item in estimates.values() if item['edge_mm'] is not None] values = [item['effective_edge_mm'] for item in estimates.values()
if item['effective_edge_mm'] is not None]
band_starts = [start for item in estimates.values() for start in item['band_starts_mm']] band_starts = [start for item in estimates.values() for start in item['band_starts_mm']]
status = 'insufficient-ink' status = 'insufficient-ink'
edge = round(float(np.median(values)), 1) if values else None edge = round(float(np.median(values)), 1) if values else None
@@ -178,10 +191,15 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
previous_coherent = 0 previous_coherent = 0
for index in indices: for index in indices:
estimate = page_estimate(index) estimate = page_estimate(index)
low = estimate['edge_mm'] is not None and estimate['edge_mm'] < MIN_MARGIN_MM effective_edge = estimate['effective_edge_mm']
low = effective_edge is not None and effective_edge < MIN_MARGIN_MM
tolerated = (status == 'margin' and low and previous_coherent >= 2 tolerated = (status == 'margin' and low and previous_coherent >= 2
and short_margin_exception(estimate, edge)) and short_margin_exception(estimate, edge))
starts[index] = 0 if low and not tolerated else start starts[index] = 0 if low and not tolerated else start
if estimate['edge_mm'] is None and status not in {'narrow-page', 'labels-in-margin'}:
fallback_left = (max(effective_edge / 2, effective_edge - MARGIN_CLEARANCE_MM)
if effective_edge is not None and effective_edge >= MIN_MARGIN_MM else 0)
starts[index] = round(fallback_left * render_dpi / 25.4)
estimate['short_margin_exception'] = tolerated estimate['short_margin_exception'] = tolerated
estimate['decision'] = ('short-exception' if tolerated else estimate['decision'] = ('short-exception' if tolerated else
'page-near-edge' if low else status) 'page-near-edge' if low else status)
@@ -209,7 +227,7 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
'status': 'unknown' if edge is None else 'status': 'unknown' if edge is None else
('low' if edge < MIN_MARGIN_MM else 'normal')}) ('low' if edge < MIN_MARGIN_MM else 'normal')})
return {'version': 13, 'pages': pages, 'starts_px': starts, 'groups': groups, return {'version': 14, 'pages': pages, 'starts_px': starts, 'groups': groups,
'no_margin_from_page': None, 'no_margin_from_page': None,
'discard_left_fraction': DISCARD_LEFT_FRACTION, 'margin_clearance_mm': MARGIN_CLEARANCE_MM, 'discard_left_fraction': DISCARD_LEFT_FRACTION, 'margin_clearance_mm': MARGIN_CLEARANCE_MM,
'cut_fraction_of_margin': .5, 'cut_rule': 'maximum', 'cut_fraction_of_margin': .5, 'cut_rule': 'maximum',
+3 -1
View File
@@ -8,7 +8,9 @@ IMPORT_DIR = Path("Import")
# Espace ajouté à gauche des réponses annotées, en pixels à 200 dpi. # Espace ajouté à gauche des réponses annotées, en pixels à 200 dpi.
ANNOTATION_MARGIN_LEFT = 300 ANNOTATION_MARGIN_LEFT = 300
ANNOTATION_LOW_MARGIN_LEFT = 400 # (Marge mesurée en mm, espace ajouté en pixels à 200 dpi).
# Interpolation linéaire entre les points ; valeurs constantes aux extrémités.
ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)]
# Hauteur maximale des documents Bnot et des groupes BGnot, en pixels. # Hauteur maximale des documents Bnot et des groupes BGnot, en pixels.
# Les blocs d'élèves trop grands sont séparés entre deux réponses. # Les blocs d'élèves trop grands sont séparés entre deux réponses.
+14 -6
View File
@@ -10,13 +10,18 @@ extension **`.jpg`**, not `.jpeg`.
```python ```python
ANNOTATION_MARGIN_LEFT = 300 ANNOTATION_MARGIN_LEFT = 300
ANNOTATION_LOW_MARGIN_LEFT = 400 ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)]
``` ```
These are added pixels at the 200 dpi annotation rendering scale. An answer The reference points are (measured margin in mm, added pixels at 200 dpi).
uses the larger margin if any of its source fragments has a measured left Each page fragment gets its own spacing: 450 px at or below 10 mm, 400 px at
margin below 20 mm (half the standard 40 mm). Unknown or insufficiently written 20 mm, and 300 px at or above 35 mm, with linear interpolation between points.
pages retain the normal spacing. The 600 px comment width is unchanged, so Unknown pages retain their unknown status but use the arithmetic mean of the
copy's measured page margins, including during label-strip positioning.
Inferred values never contribute to that mean. If no page has a measurement,
`ANNOTATION_MARGIN_LEFT` supplies the default 300 px.
The old `ANNOTATION_LOW_MARGIN_LEFT` setting is replaced by the reference points.
The 600 px comment width is unchanged, so
comments can still overlap handwriting on pages with very little whitespace. comments can still overlap handwriting on pages with very little whitespace.
Automatic `crop-labels` saves per-page measurements in the Cutleft schema. Automatic `crop-labels` saves per-page measurements in the Cutleft schema.
@@ -33,7 +38,10 @@ save their rendering geometry and reuse it when read back, even after settings
change. Older batches without saved geometry keep their original 300 px offset. change. Older batches without saved geometry keep their original 300 px offset.
Do not overwrite a batch that still contains handwritten corrections to import. Do not overwrite a batch that still contains handwritten corrections to import.
All fragments of one answer share one left offset. BGnot pads narrower answer Each fragment is positioned independently, with its annotation rectangles shifted
by the same amount. Rectangles crossing page boundaries are split at those boundaries.
Narrow pages receive any additional width on the right, so left padding does not
stack with the configured allowance. BGnot pads narrower answer
images with white without resizing handwriting; fitting the wider document to images with white without resizing handwriting; fitting the wider document to
screen or paper may reduce the displayed scale, and return-PDF page breaks can screen or paper may reduce the displayed scale, and return-PDF page breaks can
change. The extra space is also used by Anot and Bnot. change. The extra space is also used by Anot and Bnot.
+52 -8
View File
@@ -9,7 +9,7 @@ from PIL import Image
from copienator import atomic_write_json, configuration, read_json from copienator import atomic_write_json, configuration, read_json
from copienator.annotation_layout import ( from copienator.annotation_layout import (
answer_parts, copy_answer_pdf, fingerprint, layout_for, legacy_layout, 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 import annotating, annotating_by_label, splitting_int
from copienator.commands.annotating_with_checks import CheckboxRenderer 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): with patch('copienator.left_margin.estimate_page', side_effect=estimate):
result = analyse_margins(images, 200, 600) result = analyse_margins(images, 200, 600)
self.assertEqual([p['status'] for p in result['pages']], 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): def test_split_records_pages_and_rejects_stale_measurements(self):
schema_path = self.root / 'Cutleft' / 'Copie01_schema.json' 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) splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging)
answer = staging / 'Ex 1.pdf' answer = staging / 'Ex 1.pdf'
self.assertEqual([p['page'] for p in answer_parts(answer)], [1, 2]) 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': { atomic_write_json(schema_path, {'source_sha256': 'stale', 'auto_margin': {
'pages': [{'page': 1, 'edge_mm': 10}]}}) 'pages': [{'page': 1, 'edge_mm': 10}]}})
splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging) 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): def test_configurable_margin_unknown_fallback_and_stale_sidecar(self):
self.assertEqual(layout_for(self.pdf)['margin_left'], 300) self.assertEqual(layout_for(self.pdf)['margin_left'], 300)
save_parts(self.pdf, [{'margin_status': 'normal'}, {'margin_status': 'low'}]) save_parts(self.pdf, [{'margin_status': 'normal', 'margin_mm': 40},
with patch.object(configuration, 'ANNOTATION_LOW_MARGIN_LEFT', 450): {'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.assertEqual(layout_for(self.pdf)['margin_left'], 450)
self.pdf.write_bytes(self.pdf.read_bytes() + b'\n') self.pdf.write_bytes(self.pdf.read_bytes() + b'\n')
self.assertEqual(layout_for(self.pdf)['margin_left'], 300) 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. # 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) splitting_int._render_split_outputs(self.pdf, [('|Ex 1', 0, 0, 10, 100, 110)], staging)
answer = staging / 'Ex 1.pdf' 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) self.assertEqual(answer_parts(answer)[0]['margin_mm'], 0)
def test_copy_move_and_vertical_cut_preserve_page_classification(self): 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' copied = self.root / 'copied.pdf'
moved = self.root / 'moved.pdf' moved = self.root / 'moved.pdf'
copy_answer_pdf(self.pdf, copied) 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') box = next(call.args[2]['box'] for call in callback.call_args_list if call.args[0] == 'local_rect')
return image, header, box 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): def test_wider_layout_translates_copy_and_feedback_without_scaling(self):
normal, height, box = self.render(legacy_layout()) normal, height, box = self.render(legacy_layout())
low, low_height, low_box = self.render({**legacy_layout(), 'margin_left': 400}) 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) self.assertEqual(meta['width'], low.width)
with Image.open(output / 'Reference.jpg') as reference: with Image.open(output / 'Reference.jpg') as reference:
self.assertGreater(min(reference.getpixel((low.width - 10, 80))), 245) 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) _, notes = reader._scan_annotation_directory(output)
self.assertEqual(notes['02']['Ex 1']['layout']['margin_left'], 400) self.assertEqual(notes['02']['Ex 1']['layout']['margin_left'], 400)
rerender, _, _ = self.render(notes['02']['Ex 1']['layout']) 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) self.assertNotIn('first_page_body_start_px', result)
def test_sparse_groups_stay_at_left(self): def test_sparse_groups_stay_at_left(self):
for edges in ([None] * 4, [40, None, None, None]): result = self.analyse([None] * 4)
with self.subTest(edges=edges): self.assertEqual(result['starts_px'], [0] * 4)
self.assertEqual(self.analyse(edges)['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): def test_narrow_subpages_stay_at_left_even_with_indented_text(self):
result = self.analyse([45, 45, 70, 72], widths=[1654, 1654, 800, 800]) result = self.analyse([45, 45, 70, 72], widths=[1654, 1654, 800, 800])