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

+46
View File
@@ -26,6 +26,7 @@ from copienator import (
from copienator.filesystem import staged_files from copienator.filesystem import staged_files
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
from copienator.configuration import SHOW_PERSONAL_STEPS from copienator.configuration import SHOW_PERSONAL_STEPS
from copienator.left_margin import analyse_margins
DELIMITER_WIDTH = 5 DELIMITER_WIDTH = 5
DELIMITER_COLOR = (0, 0, 0) DELIMITER_COLOR = (0, 0, 0)
@@ -34,6 +35,7 @@ CROP_SHIFT_STEP = 50
CROP_WIDTH_STEP = 50 CROP_WIDTH_STEP = 50
RENDER_DPI = 200 RENDER_DPI = 200
NARROW_PAGE_RATIO = 0.8 NARROW_PAGE_RATIO = 0.8
AUTO_STRIP_WIDTH_FACTOR = 1.10
pdf_cache_lock = threading.Lock() pdf_cache_lock = threading.Lock()
# The requested spelling is kept as the public switch for this alternate layout. # 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, shift_offset: int = 0,
max_per_file: int = 5, max_per_file: int = 5,
width_offset: int = 0, width_offset: int = 0,
*,
automatic_margin: bool = True,
margin_analysis: dict | None = None,
) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None: ) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None:
"""Convert one PDF into a preview, full-resolution splits and metadata.""" """Convert one PDF into a preview, full-resolution splits and metadata."""
try: try:
@@ -124,7 +129,10 @@ def process_single_pdf(
if not images: if not images:
return None return None
reference_width = normal_page_width(pdf_path, images) 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 = [] full_width_pages = []
page_crops = []
for page_index, image in enumerate(images): for page_index, image in enumerate(images):
width, height = image.size width, height = image.size
if max_per_file == 1: if max_per_file == 1:
@@ -140,8 +148,17 @@ def process_single_pdf(
width, width,
width // 3 + 100 + shift_offset + max(0, width_offset), 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: if right > left:
cropped_images.append(image.crop((left, 0, right, height))) 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: if left == 0 and right == width:
full_width_pages.append(page_index + 1) full_width_pages.append(page_index + 1)
if not cropped_images: if not cropped_images:
@@ -165,6 +182,8 @@ def process_single_pdf(
"number_of_files": len(split_images), "number_of_files": len(split_images),
"columns_per_file": distribution, "columns_per_file": distribution,
"full_width_pages": full_width_pages, "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 return preview, split_images, schema
except Exception as exc: # noqa: BLE001 - interactive item failure except Exception as exc: # noqa: BLE001 - interactive item failure
@@ -208,6 +227,7 @@ class ImageReviewer:
files: list[Path], files: list[Path],
output_dir: Path, output_dir: Path,
default_max_per_file: int = 5, default_max_per_file: int = 5,
automatic_margin: bool = True,
) -> None: ) -> None:
self.files = files self.files = files
self.output_dir = output_dir self.output_dir = output_dir
@@ -216,6 +236,8 @@ class ImageReviewer:
self.had_errors = False self.had_errors = False
self.stop_prefetch = threading.Event() self.stop_prefetch = threading.Event()
self.current_result = None self.current_result = None
self.automatic_margin = automatic_margin
self.margin_analysis = None
self.index = 0 self.index = 0
self.current_shift = 0 self.current_shift = 0
self.current_width_offset = 0 self.current_width_offset = 0
@@ -283,6 +305,7 @@ class ImageReviewer:
self.current_shift = 0 self.current_shift = 0
self.current_width_offset = 0 self.current_width_offset = 0
self.current_result = None self.current_result = None
self.margin_analysis = None
self.trigger_processing(self.files[self.index], self.current_shift) self.trigger_processing(self.files[self.index], self.current_shift)
def trigger_processing(self, pdf_path: Path, shift: int) -> None: def trigger_processing(self, pdf_path: Path, shift: int) -> None:
@@ -299,6 +322,8 @@ class ImageReviewer:
shift, shift,
self.current_max_per_file, self.current_max_per_file,
self.current_width_offset, self.current_width_offset,
automatic_margin=self.automatic_margin,
margin_analysis=self.margin_analysis,
) )
) )
@@ -325,6 +350,8 @@ class ImageReviewer:
) -> None: ) -> None:
self.current_preview = result[0] self.current_preview = result[0]
self.current_result = result 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]) self.update_display(pdf_path.name, result[2])
def update_display(self, filename: str, schema: dict[str, object]) -> None: def update_display(self, filename: str, schema: dict[str, object]) -> None:
@@ -338,6 +365,17 @@ class ImageReviewer:
if personnal_binding if personnal_binding
else "" 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( self.label_info.configure(
text=( text=(
f"[{self.index + 1}/{len(self.files)}] {filename} | " f"[{self.index + 1}/{len(self.files)}] {filename} | "
@@ -345,6 +383,7 @@ class ImageReviewer:
f"Extra width: {self.current_width_offset}px\n" f"Extra width: {self.current_width_offset}px\n"
f"Files: {schema['number_of_files']} | " f"Files: {schema['number_of_files']} | "
f"Cols: {schema['columns_per_file']}\n" f"Cols: {schema['columns_per_file']}\n"
f"{margin_info}"
"Arrows: left/right move by 50px | up/down widen/narrow by 50px\n" "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" "Enter: Save and next | !: flag error and skip | 1: use full pages"
f"{personal_instructions}" f"{personal_instructions}"
@@ -428,6 +467,7 @@ def run(
fullpage: bool = False, fullpage: bool = False,
no_visual_check: bool = False, no_visual_check: bool = False,
marked: bool = False, marked: bool = False,
automatic_margin: bool = True,
) -> ExitCode: ) -> ExitCode:
files = marked_copy_paths(workspace) if marked else _selected_files(workspace, target) files = marked_copy_paths(workspace) if marked else _selected_files(workspace, target)
if not files: if not files:
@@ -463,6 +503,7 @@ def run(
files, files,
workspace.cutleft_dir, workspace.cutleft_dir,
default_max_per_file=1 if fullpage else 5, default_max_per_file=1 if fullpage else 5,
automatic_margin=automatic_margin,
) )
if not reviewer.completed: if not reviewer.completed:
return ExitCode.INTERRUPTED return ExitCode.INTERRUPTED
@@ -472,6 +513,10 @@ def run(
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively crop the label margin from PDF copies") 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("--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( parser.add_argument(
"--fullpage", "--fullpage",
action="store_true", action="store_true",
@@ -496,6 +541,7 @@ def main(argv: Sequence[str] | None = None) -> int:
fullpage=args.fullpage, fullpage=args.fullpage,
no_visual_check=args.no_visual_check, no_visual_check=args.no_visual_check,
marked=args.marked, marked=args.marked,
automatic_margin=not args.manual_margin,
) )
return execute(parser, argv, handle) return execute(parser, argv, handle)
+151
View File
@@ -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]}
+10
View File
@@ -289,6 +289,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement des copies", "Prétraitement des copies",
"Découper une partie à gauche pour détection des labels", "Découper une partie à gauche pour détection des labels",
"Produit les images de la partie gauche des copies. " "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. " "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 " "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. " "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"),), (python("default", "Découpe", "crop-labels"),),
arguments=( arguments=(
arg_target(), 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( ArgumentSpec(
"fullpage", "fullpage",
"Toujours utiliser la page entière", "Toujours utiliser la page entière",
+23 -2
View File
@@ -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 Ce mode est plus cher : sans avoir vérifié, Astra estime que le coût
passerait de 0,50€ à 2€. passerait de 0,50€ à 2€.
Sans quoi, pour chaque copie, il faut cadrer la bande pour qu'elle La position de la bande est proposée automatiquement à partir de plusieurs
contienne bien tous les labels : 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. **←/→** 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. 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 à Cette vérification visuelle permet toutefois de repérer des copies à
+4 -4
View File
@@ -17,7 +17,7 @@ class NarrowPageCropTests(unittest.TestCase):
narrow.paste('blue', (0, 0, 300, 600)) narrow.paste('blue', (0, 0, 300, 600))
thin = Image.new('RGB', (200, 600), 'yellow') thin = Image.new('RGB', (200, 600), 'yellow')
with patch.object(cutleft, 'get_pdf_pages', return_value=[normal, narrow, thin]): 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 _, images, schema = result
image = images[0] image = images[0]
self.assertEqual(schema['full_width_pages'], [3]) self.assertEqual(schema['full_width_pages'], [3])
@@ -39,7 +39,7 @@ class NarrowPageCropTests(unittest.TestCase):
narrow.paste('blue', (50, 0, 450, 600)) narrow.paste('blue', (50, 0, 450, 600))
with patch.object(cutleft, 'get_pdf_pages', return_value=[normal, narrow]): with patch.object(cutleft, 'get_pdf_pages', return_value=[normal, narrow]):
result = cutleft.process_single_pdf( 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].size, (805, 600))
self.assertEqual(result[1][0].getpixel((405, 0)), (0, 0, 255)) self.assertEqual(result[1][0].getpixel((405, 0)), (0, 0, 255))
self.assertEqual(result[1][0].getpixel((804, 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') document.save(originals / 'Copy.pdf')
images = [Image.new('RGB', (900, 2339)), Image.new('RGB', (750, 2339))] images = [Image.new('RGB', (900, 2339)), Image.new('RGB', (750, 2339))]
with patch.object(cutleft, 'get_pdf_pages', return_value=images): 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[2]['full_width_pages'], [])
self.assertEqual(result[1][0].width, 2 * int((1191 / 2 * 200 / 72) // 3) + 5) self.assertEqual(result[1][0].width, 2 * int((1191 / 2 * 200 / 72) // 3) + 5)
def test_small_main_split_adjustments_keep_margin_cropping(self): def test_small_main_split_adjustments_keep_margin_cropping(self):
images = [Image.new('RGB', (1600, 2339)), Image.new('RGB', (1694, 2339))] images = [Image.new('RGB', (1600, 2339)), Image.new('RGB', (1694, 2339))]
with patch.object(cutleft, 'get_pdf_pages', return_value=images): 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[2]['full_width_pages'], [])
self.assertEqual(result[1][0].width, 2 * (1694 // 3) + 5) 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): def test_process_single_pdf_enlarges_crop_and_caps_it_at_page_edge(self):
page = Image.new("RGB", (900, 300), "white") page = Image.new("RGB", (900, 300), "white")
with patch.object(cutleft, "get_pdf_pages", return_value=[page]): with patch.object(cutleft, "get_pdf_pages", return_value=[page]):
regular = cutleft.process_single_pdf(self.files[0]) regular = cutleft.process_single_pdf(self.files[0], automatic_margin=False)
enlarged = cutleft.process_single_pdf(self.files[0], width_offset=200) 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) capped = cutleft.process_single_pdf(self.files[0], width_offset=1000, automatic_margin=False)
fullpage = cutleft.process_single_pdf( fullpage = cutleft.process_single_pdf(
self.files[0], max_per_file=1, width_offset=200 self.files[0], max_per_file=1, width_offset=200
) )