Spaces:
Running
Running
Download pdf_utils.py from gauravmeena0708/epfo-circulars: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/spaces/gauravmeena0708/epfo-circulars/resolve/main/pdf_utils.py
- Command line
-
hf download hf://spaces/gauravmeena0708/epfo-circulars/pdf_utils.py
-
curl -L -o pdf_utils.py https://huggingface.co/spaces/gauravmeena0708/epfo-circulars/resolve/main/pdf_utils.py
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() | |