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"