Spaces:
Paused
Paused
File size: 8,034 Bytes
f66643d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | """PDF type detection for routing to appropriate pipeline.
This module provides PDFTypeDetector to classify PDFs as:
- "scanned": Image-based PDFs requiring OCR
- "digital": Text-based PDFs with extractable text
- "mixed": PDFs with both scanned and digital pages
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Literal
import fitz # PyMuPDF
logger = logging.getLogger(__name__)
PDFType = Literal["scanned", "digital", "mixed"]
class PDFTypeDetector:
"""Detect whether a PDF is scanned, digital, or mixed.
Detection is based on analyzing text extraction vs image coverage
on a sample of pages.
Attributes:
text_threshold: Minimum characters per page to consider it digital
image_coverage_threshold: Minimum image area ratio to consider scanned
sample_pages: Maximum pages to sample for detection
"""
def __init__(
self,
text_threshold: int = 100,
image_coverage_threshold: float = 0.5,
sample_pages: int = 5,
text_block_threshold: int = 3,
) -> None:
"""Initialize detector with thresholds.
Args:
text_threshold: Min chars per page for digital classification
image_coverage_threshold: Min image/page area ratio for scanned
sample_pages: Max pages to analyze (evenly sampled)
text_block_threshold: Min text blocks for digital fallback when
font encoding fails (e.g. font.unknown PDFs)
"""
self.text_threshold = text_threshold
self.image_coverage_threshold = image_coverage_threshold
self.sample_pages = sample_pages
self.text_block_threshold = text_block_threshold
def detect(self, pdf_path: str | Path) -> PDFType:
"""Detect PDF type.
Args:
pdf_path: Path to PDF file
Returns:
"scanned", "digital", or "mixed"
Raises:
FileNotFoundError: If PDF doesn't exist
fitz.FileDataError: If file is not a valid PDF
"""
pdf_path = Path(pdf_path)
if not pdf_path.exists():
raise FileNotFoundError(f"PDF not found: {pdf_path}")
doc = fitz.open(pdf_path)
try:
return self._analyze_document(doc)
finally:
doc.close()
def detect_from_bytes(self, pdf_bytes: bytes) -> PDFType:
"""Detect PDF type from bytes.
Args:
pdf_bytes: PDF file contents as bytes
Returns:
"scanned", "digital", or "mixed"
"""
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
try:
return self._analyze_document(doc)
finally:
doc.close()
def _analyze_document(self, doc: fitz.Document) -> PDFType:
"""Sample pages from the document and classify the overall PDF type.
Pages are sampled evenly up to ``self.sample_pages``. Each sampled
page is classified independently by :meth:`_analyze_page`. The
overall type is determined by majority vote with thresholds:
- 100 % scanned β ``"scanned"``
- 100 % digital β ``"digital"``
- β₯ 80 % scanned β ``"scanned"``
- β€ 20 % scanned β ``"digital"``
- otherwise β ``"mixed"``
Args:
doc: Open fitz Document to analyse.
Returns:
``"scanned"``, ``"digital"``, or ``"mixed"``.
"""
page_count = len(doc)
if page_count == 0:
logger.warning("Empty PDF, defaulting to digital")
return "digital"
# Sample pages evenly
if page_count <= self.sample_pages:
sample_indices = list(range(page_count))
else:
step = page_count / self.sample_pages
sample_indices = [int(i * step) for i in range(self.sample_pages)]
scanned_count = 0
digital_count = 0
for page_idx in sample_indices:
page = doc[page_idx]
page_type = self._analyze_page(page)
if page_type == "scanned":
scanned_count += 1
else:
digital_count += 1
# Classify based on majority
total_sampled = len(sample_indices)
if scanned_count == total_sampled:
return "scanned"
elif digital_count == total_sampled:
return "digital"
else:
# Mixed detection
scanned_ratio = scanned_count / total_sampled
if scanned_ratio >= 0.8:
return "scanned"
elif scanned_ratio <= 0.2:
return "digital"
else:
return "mixed"
def _analyze_page(self, page: fitz.Page) -> Literal["scanned", "digital"]:
"""Classify a single page as scanned or digital.
The classification uses a three-tier heuristic:
1. **Raw text length** β if extracted text has β₯ ``text_threshold``
characters, the page is ``digital``.
2. **Text block count fallback** β if font encoding prevents raw text
extraction (e.g. ``font.unknown`` PDFs), count structural text blocks
from ``get_text("blocks")``. β₯ ``text_block_threshold`` blocks
signals ``digital``.
3. **Image coverage** β if images cover β₯ ``image_coverage_threshold``
of the page area, the page is ``scanned``.
4. Otherwise defaults to ``digital``.
Args:
page: fitz Page object to classify.
Returns:
``"scanned"`` or ``"digital"``.
"""
# Extract text
text = page.get_text("text")
text_length = len(text.strip())
# Check for sufficient extractable text
if text_length >= self.text_threshold:
return "digital"
# Fallback: count text block objects even when font encoding is unknown.
# PDFs with non-standard fonts (e.g. font.unknown) return empty raw text
# but still have text block structures detectable via get_text("blocks").
blocks = page.get_text("blocks")
text_block_count = sum(1 for b in blocks if b[6] == 0) # type 0 = text
if text_block_count >= self.text_block_threshold:
logger.debug(
f"Font-encoding fallback: {text_block_count} text blocks found "
f"despite {text_length} raw chars β classifying as digital"
)
return "digital"
# Zero text by any measure β image-based page (scanned or screenshot PDF).
# image_coverage detection below can miss inline images and PDFs produced
# by screenshot tools that embed images outside the XObject registry.
if text_length == 0 and text_block_count == 0:
logger.debug("No text or text blocks found β classifying as scanned")
return "scanned"
# Check image coverage
page_rect = page.rect
page_area = page_rect.width * page_rect.height
if page_area == 0:
return "digital"
image_area = 0.0
image_list = page.get_images(full=True)
for img_info in image_list:
xref = img_info[0]
try:
# Get image bbox on page
for img_rect in page.get_image_rects(xref):
image_area += img_rect.width * img_rect.height
except Exception:
# If we can't get rect, estimate from image size
try:
pix = fitz.Pixmap(page.parent, xref)
# Rough estimate: image covers significant portion
image_area += pix.width * pix.height * 0.5
pix = None
except Exception:
pass
image_coverage = image_area / page_area
if image_coverage >= self.image_coverage_threshold:
return "scanned"
# Default to digital if unclear
return "digital"
|