| """ |
| Image Document Loading |
| |
| Handles single images and multi-page TIFF documents. |
| """ |
|
|
| import logging |
| from pathlib import Path |
| from typing import Iterator, List, Optional, Tuple, Union |
|
|
| import numpy as np |
| from PIL import Image |
|
|
| from .base import ( |
| DocumentFormat, |
| DocumentInfo, |
| DocumentLoader, |
| PageInfo, |
| PageRenderer, |
| RenderOptions, |
| ) |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class ImageLoader(DocumentLoader): |
| """ |
| Image document loader. |
| |
| Handles common image formats (JPEG, PNG, etc.) and multi-page TIFF. |
| """ |
|
|
| SUPPORTED_EXTENSIONS = { |
| ".jpg", ".jpeg", ".png", ".bmp", ".gif", |
| ".tif", ".tiff", ".webp" |
| } |
|
|
| def __init__(self): |
| self._images: List[Image.Image] = [] |
| self._info: Optional[DocumentInfo] = None |
| self._path: Optional[Path] = None |
|
|
| def load(self, path: Union[str, Path]) -> DocumentInfo: |
| """Load image(s) and extract metadata.""" |
| self._path = Path(path) |
| if not self._path.exists(): |
| raise FileNotFoundError(f"Image file not found: {self._path}") |
|
|
| suffix = self._path.suffix.lower() |
| if suffix not in self.SUPPORTED_EXTENSIONS: |
| raise ValueError(f"Unsupported image format: {suffix}") |
|
|
| |
| self.close() |
|
|
| |
| img = Image.open(self._path) |
|
|
| |
| if suffix in {".tif", ".tiff"}: |
| self._load_multipage_tiff(img) |
| else: |
| |
| self._images = [img.convert("RGB")] |
|
|
| |
| pages = [] |
| for i, page_img in enumerate(self._images): |
| dpi = page_img.info.get("dpi", (72, 72)) |
| if isinstance(dpi, tuple): |
| dpi = int(dpi[0]) |
| else: |
| dpi = int(dpi) |
|
|
| page_info = PageInfo( |
| page_number=i + 1, |
| width_pixels=page_img.width, |
| height_pixels=page_img.height, |
| dpi=dpi, |
| has_images=True |
| ) |
| pages.append(page_info) |
|
|
| |
| if suffix in {".tif", ".tiff"} and len(self._images) > 1: |
| doc_format = DocumentFormat.TIFF_MULTIPAGE |
| else: |
| doc_format = DocumentFormat.IMAGE |
|
|
| self._info = DocumentInfo( |
| path=self._path, |
| format=doc_format, |
| num_pages=len(self._images), |
| pages=pages, |
| file_size_bytes=self._path.stat().st_size, |
| is_scanned=True, |
| has_text_layer=False |
| ) |
|
|
| return self._info |
|
|
| def _load_multipage_tiff(self, img: Image.Image) -> None: |
| """Load all pages from a multi-page TIFF.""" |
| self._images = [] |
|
|
| try: |
| page_num = 0 |
| while True: |
| img.seek(page_num) |
| |
| self._images.append(img.copy().convert("RGB")) |
| page_num += 1 |
| except EOFError: |
| |
| pass |
|
|
| if not self._images: |
| raise ValueError("No pages found in TIFF file") |
|
|
| def close(self) -> None: |
| """Close all loaded images.""" |
| for img in self._images: |
| try: |
| img.close() |
| except Exception: |
| pass |
| self._images = [] |
|
|
| def is_loaded(self) -> bool: |
| """Check if images are loaded.""" |
| return len(self._images) > 0 |
|
|
| @property |
| def info(self) -> Optional[DocumentInfo]: |
| """Get document info.""" |
| return self._info |
|
|
| def get_image(self, page_number: int) -> Image.Image: |
| """Get PIL Image for a specific page (1-indexed).""" |
| if not self._images: |
| raise RuntimeError("No images loaded") |
| if page_number < 1 or page_number > len(self._images): |
| raise ValueError(f"Invalid page number: {page_number}") |
| return self._images[page_number - 1] |
|
|
|
|
| class ImageRenderer(PageRenderer): |
| """ |
| Image page renderer. |
| |
| Renders images with optional resizing and format conversion. |
| """ |
|
|
| def __init__(self, loader: ImageLoader): |
| self._loader = loader |
|
|
| def render_page( |
| self, |
| page_number: int, |
| options: Optional[RenderOptions] = None |
| ) -> np.ndarray: |
| """Render an image page.""" |
| if not self._loader.is_loaded(): |
| raise RuntimeError("No document loaded") |
|
|
| options = options or RenderOptions() |
| img = self._loader.get_image(page_number) |
|
|
| |
| original_dpi = img.info.get("dpi", (72, 72)) |
| if isinstance(original_dpi, tuple): |
| original_dpi = original_dpi[0] |
|
|
| |
| if options.dpi != original_dpi and original_dpi > 0: |
| scale = options.dpi / original_dpi |
| new_size = (int(img.width * scale), int(img.height * scale)) |
|
|
| resample = Image.LANCZOS if options.antialias else Image.NEAREST |
| img = img.resize(new_size, resample=resample) |
|
|
| |
| if options.color_mode == "L": |
| img = img.convert("L") |
| elif options.color_mode == "RGBA": |
| img = img.convert("RGBA") |
| else: |
| img = img.convert("RGB") |
|
|
| return np.array(img) |
|
|
| def render_pages( |
| self, |
| page_numbers: Optional[List[int]] = None, |
| options: Optional[RenderOptions] = None |
| ) -> Iterator[Tuple[int, np.ndarray]]: |
| """Render multiple pages.""" |
| if not self._loader.is_loaded(): |
| raise RuntimeError("No document loaded") |
|
|
| info = self._loader.info |
| if page_numbers is None: |
| page_numbers = list(range(1, info.num_pages + 1)) |
|
|
| for page_num in page_numbers: |
| yield page_num, self.render_page(page_num, options) |
|
|
|
|
| def load_image(path: Union[str, Path]) -> Tuple[ImageLoader, ImageRenderer]: |
| """ |
| Convenience function to load an image document. |
| |
| Returns: |
| Tuple of (loader, renderer) |
| """ |
| loader = ImageLoader() |
| loader.load(path) |
| renderer = ImageRenderer(loader) |
| return loader, renderer |
|
|