"""Propose conservative top/bottom crops for scanned, optionally ruled PDFs. Run with ``python -m copienator.crop_blank_margins INPUT_DIR OUTPUT_DIR``. Only PDFs directly in INPUT_DIR are processed. Originals are never modified. Analysis is deskewed; output keeps the original scan and changes its CropBox. This is a heuristic review utility, not a guarantee that a scan contains no ink. """ from __future__ import annotations import argparse import csv import hashlib import html import json from pathlib import Path import cv2 import numpy as np import pymupdf from PIL import Image, ImageDraw from copienator.ink_detection import detect_bounds def apply_bounds(page: pymupdf.Page, top: float, bottom: float) -> None: """Apply fractional bounds in displayed orientation, respecting old CropBox.""" rect = page.rect visible = pymupdf.Rect(0, top*rect.height, rect.width, bottom*rect.height) box = visible * page.derotation_matrix box += (page.cropbox_position.x, page.cropbox_position.y, page.cropbox_position.x, page.cropbox_position.y) page.set_cropbox(box) def process_pdf(source: Path, destination: Path, review: Path | None, dpi: float, padding_mm: float, min_crop_mm: float, progress=None) -> list[dict]: digest = hashlib.sha256(source.read_bytes()).hexdigest() rows = [] with pymupdf.open(source) as doc: for index, page in enumerate(doc): pix = page.get_pixmap(dpi=round(dpi), colorspace=pymupdf.csRGB, alpha=False) rgb = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3) result = detect_bounds(rgb, dpi, padding_mm, min_crop_mm) top, bottom = result.pop('top_px'), result.pop('bottom_px') row = dict(file=source.name, page=index+1, **result, top_removed_mm=round(top/pix.height*page.rect.height*25.4/72, 2), bottom_removed_mm=round((pix.height-bottom)/pix.height*page.rect.height*25.4/72, 2), original_cropbox=list(page.cropbox), rotation=page.rotation, source_sha256=digest) if review is not None: preview = Image.fromarray(rgb) preview.thumbnail((500, 700)) overlay = Image.new('RGBA', preview.size) draw = ImageDraw.Draw(overlay) y0, y1 = top/pix.height*preview.height, bottom/pix.height*preview.height if top: draw.rectangle((0, 0, preview.width, y0), fill=(255, 40, 40, 85)) draw.line((0, y0, preview.width, y0), fill=(230, 0, 0, 255), width=2) if bottom < pix.height: draw.rectangle((0, y1, preview.width, preview.height), fill=(255, 40, 40, 85)) draw.line((0, y1, preview.width, y1), fill=(230, 0, 0, 255), width=2) preview = Image.alpha_composite(preview.convert('RGBA'), overlay).convert('RGB') preview.save(review/f'{source.stem}-{index+1:03}.jpg', quality=85) if top or bottom < pix.height: apply_bounds(page, top/pix.height, bottom/pix.height) row['output_cropbox'] = list(page.cropbox) rows.append(row) if progress is not None: progress(index+1, len(doc), row) doc.save(destination, garbage=3, deflate=True) with pymupdf.open(destination) as check: if len(check) != len(rows): raise RuntimeError(f'Page count changed: {source}') for page in check: if page.rect.is_empty: raise RuntimeError(f'Empty output page: {destination}') if hashlib.sha256(source.read_bytes()).hexdigest() != digest: raise RuntimeError(f'Source changed during processing: {source}') return rows def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('input', type=Path) parser.add_argument('output', type=Path) parser.add_argument('--dpi', type=int, default=200) parser.add_argument('--padding-mm', type=float, default=6) parser.add_argument('--min-crop-mm', type=float, default=5) args = parser.parse_args() if args.dpi < 100 or args.padding_mm < 0 or args.min_crop_mm < 0: parser.error('Use dpi >= 100 and nonnegative margins.') sources = sorted(args.input.glob('*.pdf')) if args.input.is_dir() else [args.input] if not sources or any(not p.is_file() for p in sources): parser.error('No input PDFs found.') if any(p.resolve() == (args.output/p.name).resolve() for p in sources): parser.error('Output must not overwrite input PDFs.') args.output.mkdir(parents=True, exist_ok=True) review = args.output/'review' review.mkdir(exist_ok=True) cv2.setNumThreads(2) rows = [] for i, source in enumerate(sources): batch = process_pdf(source, args.output/source.name, review, args.dpi, args.padding_mm, args.min_crop_mm) rows.extend(batch) print(f'[{i+1}/{len(sources)}] {source.name}: {len(batch)} pages, ' f'{sum(r["top_removed_mm"] > 0 or r["bottom_removed_mm"] > 0 for r in batch)} cropped', flush=True) (args.output/'report.json').write_text(json.dumps(rows, indent=2)+'\n') with (args.output/'report.csv').open('w') as stream: fieldnames = list(dict.fromkeys(key for row in rows for key in row)) writer = csv.DictWriter(stream, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) cards = [] for row in rows: stem = Path(row['file']).stem cards.append(f'
' f'{html.escape(stem)} / {row["page"]}' f'

Top: {row["top_removed_mm"]} mm · Bottom: {row["bottom_removed_mm"]} mm' f' · {row["status"]}

' f'
') (args.output/'index.html').write_text( 'Crop review' '' '

Crop review

Red shading shows the removed areas on the original scan. ' 'Click a page title to open the processed PDF. Review statuses flag uncertain detections.

' '
'+''.join(cards)+'
') changed = sum(r['top_removed_mm'] > 0 or r['bottom_removed_mm'] > 0 for r in rows) print(f'Done: {len(sources)} PDFs, {len(rows)} pages, {changed} cropped. Review: {args.output / "index.html"}') if __name__ == '__main__': main()