File size: 8,473 Bytes
540600d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import os
try:
    # Ensure local .env values (OLLAMA_*) are loaded when this module is imported
    from dotenv import load_dotenv
    load_dotenv()
except Exception:
    pass
import uuid
import re
import base64
from typing import Dict, Any, Optional

def _mock_result(received: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    out = {"doc_id": str(uuid.uuid4()), "text": "(mock OCR output)", "metadata": {"pages": 1, "confidence": 0.0}}
    if received:
        out["received_args"] = received
    return out


def clean_ocr_text(raw: Optional[str]) -> str:
    """Lightweight post-processing for OCR output to improve readability.



    - turns literal "\\n" sequences into real newlines

    - unescapes common unicode/HTML escapes when safe

    - collapses repeated spaces and blank lines

    - trims and merges lines that were broken mid-sentence

    """
    if not raw:
        return ""
    s = raw
    try:
        # Convert literal backslash-n to actual newline
        s = s.replace('\\n', '\n')
    except Exception:
        pass

    # Try to interpret common escape sequences (conservative)
    try:
        s_decoded = s.encode('utf-8', errors='surrogatepass').decode('unicode_escape')
        # Accept decoded result if it reduces backslash artifacts
        if '\\n' not in s_decoded:
            s = s_decoded
    except Exception:
        pass

    # Normalize line endings and whitespace
    s = s.replace('\r\n', '\n').replace('\r', '\n')
    s = re.sub(r'[ \t]{2,}', ' ', s)
    # Collapse 3+ newlines into max 2
    s = re.sub(r'\n{3,}', '\n\n', s)

    # Trim whitespace on each line
    lines = [ln.strip() for ln in s.splitlines()]

    # Merge lines that likely were broken mid-sentence: if a line doesn't end with
    # sentence punctuation and the next line begins with a lowercase letter or digit,
    # join them with a space.
    merged = []
    i = 0
    while i < len(lines):
        line = lines[i]
        if i < len(lines) - 1:
            nxt = lines[i + 1]
            if line and nxt and not re.search(r'[\.\!\?\:;]$', line) and re.match(r'^[a-z0-9]', nxt):
                line = line + ' ' + nxt
                i += 2
                # Continue merging subsequent similar lines
                while i < len(lines) and lines[i] and not re.search(r'[\.\!\?\:;]$', line) and re.match(r'^[a-z0-9]', lines[i]):
                    line = line + ' ' + lines[i]
                    i += 1
                merged.append(line)
                continue
        merged.append(line)
        i += 1

    s = '\n'.join([ln for ln in merged if ln])

    # Remove long runs of underscores or pipes
    s = re.sub(r'[_\|]{2,}', '', s)

    # Strip control chars except common whitespace
    s = ''.join(ch for ch in s if ord(ch) >= 9 and ord(ch) != 11 and ord(ch) != 12)

    try:
        import html

        s = html.unescape(s)
    except Exception:
        pass

    # Decode literal unicode escapes like \u0027 -> ' and \xA4 -> currency symbol
    try:
        def _u_decode(m):
            code = m.group(1)
            try:
                return chr(int(code, 16))
            except Exception:
                return m.group(0)

        s = re.sub(r'\\u([0-9A-Fa-f]{4})', _u_decode, s)
        s = re.sub(r'\\x([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), s)
    except Exception:
        pass

    # Remove stray leading 'n' characters that appear before uppercase words (common OCR/newline artifact)
    try:
        s = re.sub(r'(?<=\s|^)[nN](?=[A-Z])', '', s)
    except Exception:
        pass

    # Ensure space after commas when missing
    try:
        s = re.sub(r',(?=[^\s])', ', ', s)
    except Exception:
        pass

    # Remove spaces before punctuation
    try:
        s = re.sub(r'\s+([,\.\:\;\%])', r'\1', s)
    except Exception:
        pass

    # Attempt to fix common mojibake (double-encoded UTF-8) conservatively.
    # Only apply if there are visible 'Ã' sequences suggesting encoding issues.
    try:
        if 'Ã' in s or 'Â' in s:
            try:
                candidate = s.encode('latin-1').decode('utf-8')
                # Accept candidate if it reduces suspicious sequences
                if candidate.count('Ã') < s.count('Ã'):
                    s = candidate
            except Exception:
                pass
    except Exception:
        pass

    return s.strip()


def process_image(image_url: Optional[str] = None, image_bytes: Optional[bytes] = None, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    """Process an image using local OCR first (pytesseract), with a light transformers fallback.



    Accepts either `image_url` or `image_bytes` (bytes or base64 string). Returns dict: {doc_id, text, metadata}

    """

    # Lightweight imports that may be missing in minimal environments
    try:
        from PIL import Image
        from io import BytesIO
        import requests
    except Exception:
        # Missing imaging/network deps -> return mock with echo of args
        return _mock_result(received={"image_url": image_url, "has_bytes": bool(image_bytes)})

    # Normalize image_bytes if base64 string was passed
    if isinstance(image_bytes, str):
        try:
            image_bytes = base64.b64decode(image_bytes)
        except Exception:
            image_bytes = None

    # Acquire PIL image
    img = None
    try:
        if image_bytes:
            img = Image.open(BytesIO(image_bytes)).convert("RGB")
        elif image_url:
            resp = requests.get(image_url, timeout=15)
            resp.raise_for_status()
            img = Image.open(BytesIO(resp.content)).convert("RGB")
        else:
            return _mock_result(received={"image_url": image_url, "has_bytes": False})
    except Exception:
        return _mock_result(received={"image_url": image_url, "has_bytes": bool(image_bytes)})

    # PRIORITY: Try local Tesseract (pytesseract) first — lightweight and deterministic on Windows

    try:
        import pytesseract
        # Robustly find tesseract executable: prefer PATH, then common Windows install locations
        try:
            import shutil
            found = shutil.which('tesseract')
        except Exception:
            found = None
        if not found:
            # common install locations on Windows
            for candidate in (r"C:\Program Files\Tesseract-OCR\tesseract.exe", r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"):
                try:
                    if os.path.exists(candidate):
                        found = candidate
                        break
                except Exception:
                    continue
        try:
            if found:
                pytesseract.pytesseract.tesseract_cmd = found
                print(f"[document_scanner] using tesseract executable: {found}")
            else:
                # leave default; pytesseract will raise a helpful error which we catch below
                print("[document_scanner] tesseract executable not found in PATH or common locations")
        except Exception:
            # ignore assignment errors and let pytesseract raise on use
            pass

        try:
            if img is not None:
                print("[document_scanner] attempting PRIMARY pytesseract OCR")
                ttxt = pytesseract.image_to_string(img)
                if ttxt and ttxt.strip():
                    print(f"[document_scanner] pytesseract returned text (len={len(ttxt.strip())})")
                    return {"doc_id": str(uuid.uuid4()), "text": clean_ocr_text(ttxt), "metadata": {"pages": 1, "confidence": None}}
                else:
                    print("[document_scanner] pytesseract returned no text; falling through to model-based fallbacks")
        except Exception as e_pyt_first:
            print(f"[document_scanner] primary pytesseract attempt failed: {e_pyt_first}")
    except Exception:
        # pytesseract not installed in environment — continue to available backends
        pass

    # Last resort: return a safe mock that echoes inputs
    # (Tesseract failed or is not installed — Q&A will still work via Granite Vision if image is saved)
    return _mock_result(received={"image_url": image_url, "has_bytes": bool(image_bytes)})