Files
Copies/copienator/crop_blank_margins.py
T
2026-09-09 14:53:04 +02:00

138 lines
6.7 KiB
Python

"""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:
writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
cards = []
for row in rows:
stem = Path(row['file']).stem
cards.append(f'<article><a href="{html.escape(row["file"])}#page={row["page"]}">'
f'{html.escape(stem)} / {row["page"]}</a>'
f'<p>Top: {row["top_removed_mm"]} mm · Bottom: {row["bottom_removed_mm"]} mm'
f' · {row["status"]}</p>'
f'<img loading="lazy" src="review/{html.escape(stem)}-{row["page"]:03}.jpg"></article>')
(args.output/'index.html').write_text(
'<!doctype html><meta charset="utf-8"><title>Crop review</title>'
'<style>body{font:15px system-ui;background:#eee;margin:24px}'
'main{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px}'
'article{background:white;padding:12px}img{width:100%}p{font-size:12px}</style>'
'<h1>Crop review</h1><p>Red shading shows the removed areas on the original scan. '
'Click a page title to open the processed PDF. Review statuses flag uncertain detections.</p>'
'<main>'+''.join(cards)+'</main>')
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()