epfo-circulars / pdf_utils.py
gauravmeena0708's picture
Upload folder using huggingface_hub
7caa7d0 verified
Raw History Blame Contribute Delete
10.7 kB
"""Core in-memory PDF manipulation utilities for merging, splitting, rotating,
compressing, format conversion, and OCR processing.
"""
from __future__ import annotations
import io
import os
import re
from typing import Sequence
import numpy as np
from PIL import Image
try:
import pymupdf as fitz
except ImportError: # pragma: no cover
import fitz
class PDFProcessingError(ValueError):
"""Raised when PDF processing fails due to invalid input, encryption, or corruption."""
def _open_pdf(pdf_data: bytes) -> fitz.Document:
"""Safely opens an in-memory PDF, checking for corruption and encryption."""
if not pdf_data:
raise PDFProcessingError("The provided PDF data is empty.")
try:
doc = fitz.open(stream=pdf_data, filetype="pdf")
except Exception as exc:
raise PDFProcessingError(f"Could not open or parse the PDF document: {exc}") from exc
if doc.needs_pass:
doc.close()
raise PDFProcessingError("The PDF document is password-protected. Please upload an unlocked PDF.")
return doc
def parse_page_ranges(range_str: str, max_pages: int) -> list[int]:
"""
Parses a page range string (1-indexed) into a list of 0-indexed page indices.
Supports formats: "1", "1-3", "1, 3, 5", "2-4, 1", "all".
"""
if not range_str or not range_str.strip():
raise PDFProcessingError("Page range cannot be empty.")
clean_str = range_str.strip().lower()
if clean_str == "all":
return list(range(max_pages))
indices: list[int] = []
tokens = [t.strip() for t in clean_str.split(",") if t.strip()]
if not tokens:
raise PDFProcessingError("Invalid page range format.")
for token in tokens:
if "-" in token:
parts = token.split("-")
if len(parts) != 2:
raise PDFProcessingError(f"Invalid range format: '{token}'")
try:
start = int(parts[0].strip())
end = int(parts[1].strip())
except ValueError:
raise PDFProcessingError(f"Invalid numbers in range: '{token}'")
if start < 1 or end > max_pages or start > end:
raise PDFProcessingError(
f"Range '{start}-{end}' is invalid. Document has {max_pages} pages (allowed: 1-{max_pages})."
)
indices.extend(range(start - 1, end))
else:
try:
page_num = int(token)
except ValueError:
raise PDFProcessingError(f"Invalid page number: '{token}'")
if page_num < 1 or page_num > max_pages:
raise PDFProcessingError(
f"Page number {page_num} is out of bounds (1-{max_pages})."
)
indices.append(page_num - 1)
return indices
def merge_pdfs(pdf_bytes_list: Sequence[bytes]) -> bytes:
"""
Merges multiple PDF files in sequential order.
Returns the merged PDF as bytes.
"""
if not pdf_bytes_list:
raise PDFProcessingError("At least one PDF file is required to merge.")
merged_doc = fitz.open()
try:
for index, pdf_data in enumerate(pdf_bytes_list):
sub_doc = _open_pdf(pdf_data)
try:
merged_doc.insert_pdf(sub_doc)
finally:
sub_doc.close()
if len(merged_doc) == 0:
raise PDFProcessingError("The resulting merged PDF has 0 pages.")
return merged_doc.tobytes(deflate=True, garbage=3)
finally:
merged_doc.close()
def split_pdf(pdf_bytes: bytes, page_range_str: str) -> bytes:
"""
Extracts specified pages from a PDF and returns a new PDF containing only those pages.
"""
src_doc = _open_pdf(pdf_bytes)
try:
target_indices = parse_page_ranges(page_range_str, len(src_doc))
out_doc = fitz.open()
try:
for page_idx in target_indices:
out_doc.insert_pdf(src_doc, from_page=page_idx, to_page=page_idx)
return out_doc.tobytes(deflate=True, garbage=3)
finally:
out_doc.close()
finally:
src_doc.close()
def split_pdf_to_individual_pages(pdf_bytes: bytes) -> list[tuple[str, bytes]]:
"""
Splits a multi-page PDF into a list of single-page PDFs: [('page_1.pdf', bytes), ...].
"""
src_doc = _open_pdf(pdf_bytes)
try:
pages_list: list[tuple[str, bytes]] = []
for i in range(len(src_doc)):
single_doc = fitz.open()
try:
single_doc.insert_pdf(src_doc, from_page=i, to_page=i)
page_bytes = single_doc.tobytes(deflate=True, garbage=3)
pages_list.append((f"page_{i + 1}.pdf", page_bytes))
finally:
single_doc.close()
return pages_list
finally:
src_doc.close()
def rotate_pdf_pages(
pdf_bytes: bytes,
rotation_degrees: int,
page_range_str: str | None = None,
) -> bytes:
"""
Rotates pages by the specified degrees (90, 180, 270).
If page_range_str is provided, only specified pages are rotated.
"""
if rotation_degrees not in (90, 180, 270):
raise PDFProcessingError(
f"Invalid rotation degrees: {rotation_degrees}. Allowed values: 90, 180, 270."
)
doc = _open_pdf(pdf_bytes)
try:
if page_range_str and page_range_str.strip():
target_indices = set(parse_page_ranges(page_range_str, len(doc)))
else:
target_indices = set(range(len(doc)))
for idx in target_indices:
page = doc[idx]
new_rotation = (page.rotation + rotation_degrees) % 360
page.set_rotation(new_rotation)
return doc.tobytes(deflate=True, garbage=3)
finally:
doc.close()
def compress_pdf(
pdf_bytes: bytes,
deflate: bool = True,
clean: bool = True,
) -> tuple[bytes, int, int]:
"""
Compresses and optimizes a PDF by deflating streams and cleaning redundant objects.
Returns: (compressed_bytes, original_size_bytes, new_size_bytes)
"""
doc = _open_pdf(pdf_bytes)
try:
orig_size = len(pdf_bytes)
compressed_bytes = doc.tobytes(
garbage=4,
deflate=deflate,
clean=clean,
)
return compressed_bytes, orig_size, len(compressed_bytes)
finally:
doc.close()
def images_to_pdf(images_data: Sequence[tuple[str, bytes]]) -> bytes:
"""
Combines multiple images into a single PDF document.
images_data: list of (filename, image_bytes)
"""
if not images_data:
raise PDFProcessingError("At least one image is required to build a PDF.")
pil_images: list[Image.Image] = []
try:
for name, img_bytes in images_data:
if not img_bytes:
continue
try:
img = Image.open(io.BytesIO(img_bytes))
# Convert RGBA/P/LA to RGB for clean PDF output
if img.mode in ("RGBA", "P", "LA"):
rgb_img = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "RGBA":
rgb_img.paste(img, mask=img.split()[3])
else:
rgb_img.paste(img.convert("RGBA"), mask=img.convert("RGBA").split()[3])
pil_images.append(rgb_img)
elif img.mode != "RGB":
pil_images.append(img.convert("RGB"))
else:
pil_images.append(img)
except Exception as exc:
raise PDFProcessingError(f"Could not load image '{name}': {exc}") from exc
if not pil_images:
raise PDFProcessingError("No valid images could be decoded.")
out_buf = io.BytesIO()
pil_images[0].save(
out_buf,
format="PDF",
save_all=True,
append_images=pil_images[1:] if len(pil_images) > 1 else [],
resolution=150.0,
)
return out_buf.getvalue()
finally:
for img in pil_images:
img.close()
def pdf_to_images(
pdf_bytes: bytes,
dpi: int = 200,
image_format: str = "PNG",
) -> list[tuple[str, bytes]]:
"""
Renders each page of a PDF as a high-resolution image.
Returns: [('page_1.png', image_bytes), ...]
"""
doc = _open_pdf(pdf_bytes)
ext = "jpg" if image_format.upper() in ("JPG", "JPEG") else "png"
fmt = "jpeg" if ext == "jpg" else "png"
try:
rendered: list[tuple[str, bytes]] = []
scale = max(dpi, 72) / 72
matrix = fitz.Matrix(scale, scale)
for i, page in enumerate(doc):
pix = page.get_pixmap(matrix=matrix, alpha=False)
img_bytes = pix.tobytes(output=fmt)
rendered.append((f"page_{i + 1}.{ext}", img_bytes))
return rendered
finally:
doc.close()
def ocr_scanned_pdf(
pdf_bytes: bytes,
ocr_reader,
dpi: int = 200,
) -> tuple[str, bytes]:
"""
Runs OCR on each page of a scanned PDF.
Returns: (extracted_text, searchable_pdf_bytes)
"""
doc = _open_pdf(pdf_bytes)
extracted_pages: list[str] = []
scale = max(dpi, 72) / 72
matrix = fitz.Matrix(scale, scale)
try:
out_doc = fitz.open()
for i, page in enumerate(doc):
pix = page.get_pixmap(matrix=matrix, alpha=False)
image_np = np.frombuffer(pix.samples, dtype=np.uint8).reshape(
pix.height,
pix.width,
pix.n,
)
# Read text with EasyOCR
results = ocr_reader.readtext(image_np, detail=0, paragraph=True)
page_text = "\n".join(str(r).strip() for r in results if str(r).strip())
extracted_pages.append(f"--- [Page {i + 1} / {len(doc)}] ---\n{page_text}")
# Recreate page in output PDF with OCR text
new_page = out_doc.new_page(width=page.rect.width, height=page.rect.height)
new_page.insert_image(page.rect, stream=pix.tobytes(output="png"))
if page_text:
# Insert hidden/overlay text to make it searchable
new_page.insert_text(
(36, 36),
page_text,
fontsize=8,
color=(0, 0, 0),
render_mode=3, # Invisible text render mode in PDF
)
full_extracted_text = "\n\n".join(extracted_pages)
searchable_pdf_bytes = out_doc.tobytes(deflate=True, garbage=3)
out_doc.close()
return full_extracted_text, searchable_pdf_bytes
finally:
doc.close()