From 247b124754a537114bd4ea55d168ad60ddb068d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Mon, 12 Oct 2026 18:52:05 +0200 Subject: [PATCH] Better (?) default left margin --- copienator/commands/cutleft.py | 46 ++++++++ copienator/left_margin.py | 151 +++++++++++++++++++++++ copienator_gui/workflow.py | 10 ++ docs/guide_utilisateur_gui.md | 25 +++- tests/test_cutleft_narrow_pages.py | 8 +- tests/test_left_margin.py | 184 +++++++++++++++++++++++++++++ tests/test_marked_copies.py | 6 +- 7 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 copienator/left_margin.py create mode 100644 tests/test_left_margin.py diff --git a/copienator/commands/cutleft.py b/copienator/commands/cutleft.py index e777d1c..e087640 100644 --- a/copienator/commands/cutleft.py +++ b/copienator/commands/cutleft.py @@ -26,6 +26,7 @@ from copienator import ( from copienator.filesystem import staged_files from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths from copienator.configuration import SHOW_PERSONAL_STEPS +from copienator.left_margin import analyse_margins DELIMITER_WIDTH = 5 DELIMITER_COLOR = (0, 0, 0) @@ -34,6 +35,7 @@ CROP_SHIFT_STEP = 50 CROP_WIDTH_STEP = 50 RENDER_DPI = 200 NARROW_PAGE_RATIO = 0.8 +AUTO_STRIP_WIDTH_FACTOR = 1.10 pdf_cache_lock = threading.Lock() # The requested spelling is kept as the public switch for this alternate layout. @@ -116,6 +118,9 @@ def process_single_pdf( shift_offset: int = 0, max_per_file: int = 5, width_offset: int = 0, + *, + automatic_margin: bool = True, + margin_analysis: dict | None = None, ) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None: """Convert one PDF into a preview, full-resolution splits and metadata.""" try: @@ -124,7 +129,10 @@ def process_single_pdf( if not images: return None reference_width = normal_page_width(pdf_path, images) + if automatic_margin and max_per_file != 1 and margin_analysis is None: + margin_analysis = analyse_margins(images, RENDER_DPI, reference_width) full_width_pages = [] + page_crops = [] for page_index, image in enumerate(images): width, height = image.size if max_per_file == 1: @@ -140,8 +148,17 @@ def process_single_pdf( width, width // 3 + 100 + shift_offset + max(0, width_offset), ) + if automatic_margin and max_per_file != 1: + left = min(width - 1, max(0, margin_analysis['starts_px'][page_index] + shift_offset)) + strip_width = round( + ((reference_width if width < NARROW_PAGE_RATIO * reference_width else width) // 3) + * AUTO_STRIP_WIDTH_FACTOR + ) + right = min(width, left + strip_width + max(0, width_offset)) if right > left: cropped_images.append(image.crop((left, 0, right, height))) + page_crops.append({'page': page_index + 1, 'left_px': left, 'right_px': right, + 'width_px': width, 'height_px': height}) if left == 0 and right == width: full_width_pages.append(page_index + 1) if not cropped_images: @@ -165,6 +182,8 @@ def process_single_pdf( "number_of_files": len(split_images), "columns_per_file": distribution, "full_width_pages": full_width_pages, + "page_crops": page_crops, + "auto_margin": margin_analysis if automatic_margin and max_per_file != 1 else None, } return preview, split_images, schema except Exception as exc: # noqa: BLE001 - interactive item failure @@ -208,6 +227,7 @@ class ImageReviewer: files: list[Path], output_dir: Path, default_max_per_file: int = 5, + automatic_margin: bool = True, ) -> None: self.files = files self.output_dir = output_dir @@ -216,6 +236,8 @@ class ImageReviewer: self.had_errors = False self.stop_prefetch = threading.Event() self.current_result = None + self.automatic_margin = automatic_margin + self.margin_analysis = None self.index = 0 self.current_shift = 0 self.current_width_offset = 0 @@ -283,6 +305,7 @@ class ImageReviewer: self.current_shift = 0 self.current_width_offset = 0 self.current_result = None + self.margin_analysis = None self.trigger_processing(self.files[self.index], self.current_shift) def trigger_processing(self, pdf_path: Path, shift: int) -> None: @@ -299,6 +322,8 @@ class ImageReviewer: shift, self.current_max_per_file, self.current_width_offset, + automatic_margin=self.automatic_margin, + margin_analysis=self.margin_analysis, ) ) @@ -325,6 +350,8 @@ class ImageReviewer: ) -> None: self.current_preview = result[0] self.current_result = result + if result[2].get('auto_margin') is not None: + self.margin_analysis = result[2]['auto_margin'] self.update_display(pdf_path.name, result[2]) def update_display(self, filename: str, schema: dict[str, object]) -> None: @@ -338,6 +365,17 @@ class ImageReviewer: if personnal_binding else "" ) + analysis = schema.get('auto_margin') + margin_info = '' + if analysis: + offsets = sorted({round(crop['left_px'] * 25.4 / RENDER_DPI, 1) + for crop in schema['page_crops']}) + needs_review = any(group['status'] in {'inconsistent', 'uncertain', 'insufficient-ink'} + for group in analysis['groups']) + margin_info = (f"Auto margin: {offsets} mm from left" + + (' | Check uncertain pages' if needs_review else '') + '\n') + if analysis.get('no_margin_from_page') is not None: + margin_info += f"No margin from page {analysis['no_margin_from_page']} onwards\n" self.label_info.configure( text=( f"[{self.index + 1}/{len(self.files)}] {filename} | " @@ -345,6 +383,7 @@ class ImageReviewer: f"Extra width: {self.current_width_offset}px\n" f"Files: {schema['number_of_files']} | " f"Cols: {schema['columns_per_file']}\n" + f"{margin_info}" "Arrows: left/right move by 50px | up/down widen/narrow by 50px\n" "Enter: Save and next | !: flag error and skip | 1: use full pages" f"{personal_instructions}" @@ -428,6 +467,7 @@ def run( fullpage: bool = False, no_visual_check: bool = False, marked: bool = False, + automatic_margin: bool = True, ) -> ExitCode: files = marked_copy_paths(workspace) if marked else _selected_files(workspace, target) if not files: @@ -463,6 +503,7 @@ def run( files, workspace.cutleft_dir, default_max_per_file=1 if fullpage else 5, + automatic_margin=automatic_margin, ) if not reviewer.completed: return ExitCode.INTERRUPTED @@ -472,6 +513,10 @@ def run( def build_parser() -> argparse.ArgumentParser: parser = target_parser("Interactively crop the label margin from PDF copies") parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear only margin-cropping flags after saving with Enter") + parser.add_argument( + '--manual-margin', action='store_true', + help='Use the previous fixed margin instead of automatically estimating body-text positions', + ) parser.add_argument( "--fullpage", action="store_true", @@ -496,6 +541,7 @@ def main(argv: Sequence[str] | None = None) -> int: fullpage=args.fullpage, no_visual_check=args.no_visual_check, marked=args.marked, + automatic_margin=not args.manual_margin, ) return execute(parser, argv, handle) diff --git a/copienator/left_margin.py b/copienator/left_margin.py new file mode 100644 index 0000000..009e665 --- /dev/null +++ b/copienator/left_margin.py @@ -0,0 +1,151 @@ +"""Conservative body-text margin suggestions for the cutleft reviewer.""" +from __future__ import annotations + +import cv2 +import numpy as np +from PIL import Image + +from copienator.ink_detection import detect_bounds + +ANALYSIS_DPI = 100 +WIDTH_TOLERANCE = .08 +# Copie01's name/class block reaches about 27% of the full page height. +# Start below it with clearance, including on later pages with repeated names. +BODY_TOP = .30 +BODY_BOTTOM = .82 +DISCARD_LEFT_FRACTION = .10 +STANDARD_MARGIN_MM = 40 +MIN_MARGIN_MM = STANDARD_MARGIN_MM / 2 + + +def trimmed_left_edge(starts: list[float]) -> float | None: + """Discard the leftmost 10% of occupied bands; retain the next minimum.""" + if not starts: + return None + ordered = sorted(starts) + return round(ordered[int(len(ordered) * DISCARD_LEFT_FRACTION)], 1) + + +def handwriting_mask(rgb: np.ndarray) -> np.ndarray: + """Intersect rognage's foreground with a directional ruling filter.""" + px = ANALYSIS_DPI / 25.4 + gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) + darkness = 255 - rgb.min(axis=2) + length = round(5.2 * px) | 1 + horizontal = cv2.morphologyEx(darkness, cv2.MORPH_OPEN, np.ones((1, length), np.uint8)) + vertical = cv2.morphologyEx(darkness, cv2.MORPH_OPEN, np.ones((length, 1), np.uint8)) + residual = cv2.subtract(darkness, np.maximum(horizontal, vertical)) + binary = ((residual > 32) & (gray < 200)).astype(np.uint8) + count, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8) + keep = np.zeros(count, bool) + for index, (_x, _y, width, height, area) in enumerate(stats[1:], 1): + keep[index] = (area >= max(5, .35 * px * px) and height >= .5 * px + and width >= .25 * px and height <= 6 * width and width <= 15 * height) + diagnostics = {} + detect_bounds(rgb, ANALYSIS_DPI, diagnostics=diagnostics) + return keep[labels] & diagnostics['masks']['foreground'] + + +def body_edge(mask: np.ndarray) -> dict: + """Estimate a left edge from occupied 4 mm bands within 30–82% height.""" + height = mask.shape[0] + band_height = max(1, round(4 * ANALYSIS_DPI / 25.4)) + bottom = round(BODY_BOTTOM * height) + starts = [] + for y in range(round(BODY_TOP * height), bottom, band_height): + band = mask[y:min(y + band_height, bottom)] + xs = np.flatnonzero(band.sum(axis=0) >= 2) + if band.sum() >= 40 and len(xs) >= 12: + starts.append(float(xs[0]) * 25.4 / ANALYSIS_DPI) + return { + 'edge_mm': trimmed_left_edge(starts) if len(starts) >= 6 else None, + 'bands': len(starts), + 'band_starts_mm': starts, + } + + +def estimate_page(image: Image.Image, render_dpi: int) -> dict: + scale = ANALYSIS_DPI / render_dpi + small = image.convert('RGB').resize( + (max(1, round(image.width * scale)), max(1, round(image.height * scale))), + Image.Resampling.LANCZOS, + ) + return body_edge(handwriting_mask(np.asarray(small))) + + +def format_groups(images: list[Image.Image], reference_width: float) -> list[list[int]]: + """Group similar widths; keep secondary narrow subpages in separate groups.""" + groups: list[list[int]] = [] + for index, image in enumerate(images): + narrow = image.width < .8 * reference_width + for group in groups: + width = float(np.median([images[i].width for i in group])) + if (images[group[0]].width < .8 * reference_width) == narrow and abs(image.width - width) <= width * WIDTH_TOLERANCE: + group.append(index) + break + else: + groups.append([index]) + return groups + + +def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: float) -> dict: + """Pool occupied bands per format, trim outliers, and crop at half the edge.""" + starts = [0] * len(images) + groups = [] + page_estimates = {} + + def page_estimate(index): + if index not in page_estimates: + page_estimates[index] = estimate_page(images[index], render_dpi) + return page_estimates[index] + + for indices in format_groups(images, reference_width): + # Spread samples through the copy rather than relying on the header page. + sample = sorted({indices[0], indices[min(1, len(indices) - 1)], + indices[len(indices) // 2], indices[-1]}) + estimates = {index: page_estimate(index) for index in sample} + values = [item['edge_mm'] for item in estimates.values() if item['edge_mm'] is not None] + # Trim across the copy's format group, not separately on each page: + # one marginal comment must not veto the other pages' shared margin. + band_starts = [start for item in estimates.values() for start in item['band_starts_mm']] + status = 'insufficient-ink' + edge = trimmed_left_edge(band_starts) + left_mm = 0.0 + if len(values) >= min(2, len(indices)): + status = 'near-edge' if edge < MIN_MARGIN_MM else 'margin' + left_mm = 0.0 if edge < MIN_MARGIN_MM else edge / 2 + # Secondary subpages can start in the middle of an answer. Preserve + # their left edge even when the only visible writing is indented. + if images[indices[0]].width < .8 * reference_width: + status = 'narrow-page' + left_mm = 0.0 + start = round(left_mm * render_dpi / 25.4) + for index in indices: + starts[index] = start + groups.append({ + 'pages': [index + 1 for index in indices], + 'status': status, + 'edge_mm': edge, + 'left_mm': round(left_mm, 1), + 'bands': len(band_starts), + 'discarded_bands': int(len(band_starts) * DISCARD_LEFT_FRACTION), + 'samples': [{'page': index + 1, **item} for index, item in estimates.items()], + }) + # Inspect actual neighbours, including pages outside the format samples. + # Blank/uncertain pages do not establish or extend a no-margin run. + no_margin_run = 0 + no_margin_from_page = None + for index in range(len(images)): + edge = page_estimate(index)['edge_mm'] + no_margin_run = no_margin_run + 1 if edge is not None and edge < MIN_MARGIN_MM else 0 + if no_margin_run >= 2: + run_start = index - 1 + no_margin_from_page = run_start + 1 + starts[run_start:] = [0] * (len(images) - run_start) + break + + return {'version': 5, 'starts_px': starts, 'groups': groups, + 'no_margin_from_page': no_margin_from_page, + 'discard_left_fraction': DISCARD_LEFT_FRACTION, 'cut_fraction_of_margin': .5, + 'standard_margin_mm': STANDARD_MARGIN_MM, 'minimum_margin_mm': MIN_MARGIN_MM, + 'body_height_fraction': [BODY_TOP, BODY_BOTTOM]} diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 5b8f9bb..8addbe1 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -289,6 +289,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Prétraitement des copies", "Découper une partie à gauche pour détection des labels", "Produit les images de la partie gauche des copies. " + "La position est estimée automatiquement sur plusieurs pages de même largeur ; " + "Les 10 % de bandes d’écriture les plus à gauche sont ignorées ; " + "la découpe commence à la moitié de la marge restante. " + "Si la marge est inférieure à 20 mm, elle commence au bord gauche. " + "Deux pages consécutives sans marge imposent le bord gauche pour la suite. " + "La bande est élargie de 10 %. " "Pour les sous-pages étroites, la marge commence au bord gauche et est limitée à la largeur disponible. " "Dans la fenêtre de découpe, utilisez les flèches : ←/→ déplacent la zone " "de 50 px vers la gauche/droite, ↑ l’élargit de 50 px et ↓ la réduit de 50 px. " @@ -299,6 +305,10 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: (python("default", "Découpe", "crop-labels"),), arguments=( arg_target(), + ArgumentSpec( + "manual_margin", "Utiliser la marge fixe", "bool", "--manual-margin", + help="Désactive l’estimation automatique et reprend la position fixe habituelle, ajustable avec les flèches.", + ), ArgumentSpec( "fullpage", "Toujours utiliser la page entière", diff --git a/docs/guide_utilisateur_gui.md b/docs/guide_utilisateur_gui.md index 3f1af69..97f924b 100644 --- a/docs/guide_utilisateur_gui.md +++ b/docs/guide_utilisateur_gui.md @@ -94,11 +94,32 @@ cette étape non interactive, cocher aussi `et ne pas vérifier visuellement`. Ce mode est plus cher : sans avoir vérifié, Astra estime que le coût passerait de 0,50€ à 2€. -Sans quoi, pour chaque copie, il faut cadrer la bande pour qu'elle -contienne bien tous les labels : +La position de la bande est proposée automatiquement à partir de plusieurs +pages de largeur comparable. L’estimation cherche le début de l’écriture +dans la partie centrale des pages (30–82 % de la hauteur), en ignorant le haut +et le bas. Les positions de début d’écriture des bandes sont regroupées pour +les pages de même format. Les 10 % de bandes les plus à gauche sont ignorées ; +le début le plus à gauche restant définit la marge. La découpe commence à la +moitié de cette distance. Une marge inférieure à 20 mm (la moitié d’une marge +de référence de 40 mm) commence au bord gauche. Les pages étroites et les groupes sans assez +d’écriture restent au bord gauche. La même règle s’applique à toutes les pages, +y compris la première. + +Deux pages consécutives dont la marge détectée est inférieure à 20 mm imposent +le bord gauche pour ces deux pages et toutes les suivantes, même si les pages +suivantes semblent plus indentées. Les pages sans assez d’écriture ne comptent +pas comme des pages sans marge. La largeur automatique de la bande est augmentée +de 10 %, puis les ajustements manuels de largeur sont ajoutés. + +La fenêtre indique les positions proposées et les cas à vérifier. Vérifiez +pour chaque copie que la bande contient bien tous les labels : **←/→** la déplacent, **↑/↓** l’élargissent ou la réduisent ; **Entrée** valide. +Ces réglages ajustent la proposition automatique sans relancer l’analyse. +Pour reprendre l’ancienne position fixe, cochez **Utiliser la marge fixe** +(option `--manual-margin` en ligne de commande). + Si les labels sont dispersés, utilisez **1** pour utiliser les pages entières pour cette copie. Cette vérification visuelle permet toutefois de repérer des copies à diff --git a/tests/test_cutleft_narrow_pages.py b/tests/test_cutleft_narrow_pages.py index 8a23f47..dde0dcc 100644 --- a/tests/test_cutleft_narrow_pages.py +++ b/tests/test_cutleft_narrow_pages.py @@ -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) diff --git a/tests/test_left_margin.py b/tests/test_left_margin.py new file mode 100644 index 0000000..30053df --- /dev/null +++ b/tests/test_left_margin.py @@ -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']) diff --git a/tests/test_marked_copies.py b/tests/test_marked_copies.py index c879d26..5dbb540 100644 --- a/tests/test_marked_copies.py +++ b/tests/test_marked_copies.py @@ -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 )