139 lines
6.8 KiB
Python
139 lines
6.8 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:
|
|
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'<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()
|