"""Bounded English and Chinese OCR for image-only PDF pages, shared by both converters. Recognition runs on the existing server. No document is sent to another API. """ from contextlib import contextmanager import csv import io import math import os import re from pathlib import Path import shutil import subprocess import tempfile import time import pdfplumber import PyPDF2 OCR_LANGUAGES = { 'eng': ('eng', 'English'), 'chi_sim': ('chi_sim+eng', 'Simplified Chinese and English'), 'chi_tra': ('chi_tra+eng', 'Traditional Chinese and English'), } CJK = r'[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]' def validate_ocr_language(language): if language not in OCR_LANGUAGES: raise OcrError('Choose English, Simplified Chinese + English, or Traditional Chinese + English for OCR.') return language def normalise_cjk(text): # Remove single OCR-inserted spaces between CJK glyphs; retain line breaks. return re.sub(r'(?<=' + CJK + r') (?=' + CJK + r')', '', str(text)) MAX_OCR_PAGES = 3 MAX_RENDER_EDGE = 3600 OCR_BUDGET_SECONDS = 32 class OcrError(ValueError): def __init__(self, message, code=None): super().__init__(message) self.code = code def needs_ocr(page): text = (page.extract_text() or '').strip() images = {(image['x0'], image['top'], image['x1'], image['bottom']) for image in page.images} image_area = sum(max(0, box[2] - box[0]) * max(0, box[3] - box[1]) for box in images) # A short selectable footer must not hide an otherwise scanned page. return len(text) < 200 and image_area > page.width * page.height * .55 def centers(indices): groups = [] for value in indices: if not groups or value - groups[-1][-1] > 2: groups.append([]) groups[-1].append(int(value)) return [int(sum(group) / len(group)) for group in groups] def preprocess(image): import cv2 import numpy as np gray = np.array(image.convert('L')) _, ink = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # Correct a small skew only when long ruling lines provide a clear angle. # Edge pixels prevent a solid chart or photo from outvoting real rules. edges = cv2.Canny(gray, 50, 150) detected = cv2.HoughLinesP(edges, 1, np.pi / 1800, 100, minLineLength=gray.shape[1] // 4, maxLineGap=15) if detected is not None: angles = [math.degrees(math.atan2(y2 - y1, x2 - x1)) for x1, y1, x2, y2 in detected[:, 0] if x2 > x1 and abs(y2 - y1) < (x2 - x1) * .12] angle = float(np.median(angles)) if len(angles) >= 3 else 0 if .2 < abs(angle) < 5: transform = cv2.getRotationMatrix2D((gray.shape[1] / 2, gray.shape[0] / 2), angle, 1) gray = cv2.warpAffine(gray, transform, (gray.shape[1], gray.shape[0]), flags=cv2.INTER_CUBIC, borderValue=255) _, ink = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) horizontal = cv2.morphologyEx(ink, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (max(40, gray.shape[1] // 30), 1))) vertical = cv2.morphologyEx(ink, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(40, gray.shape[0] // 40)))) # Keep real rules, not the long strokes inside Chinese glyphs. for mask, axis, minimum in [(horizontal, 2, max(120, gray.shape[1] // 8)), (vertical, 3, max(120, gray.shape[0] // 25))]: contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) retained = np.zeros_like(mask) for contour in contours: box = cv2.boundingRect(contour) thickness = box[3 if axis == 2 else 2] if box[axis] >= minimum and thickness <= max(15, gray.shape[0 if axis == 2 else 1] // 150): cv2.drawContours(retained, [contour], -1, 255, -1) mask[:] = retained rules = cv2.bitwise_or(horizontal, vertical) clean = gray.copy() clean[cv2.dilate(rules, np.ones((7, 7), np.uint8)) > 0] = 255 return clean, horizontal, vertical, ink def read_words(path, page_number, language='eng'): with open(path, encoding='utf-8') as stream: words = [row for row in csv.DictReader(stream, delimiter='\t') if row['level'] == '5' and row.get('text', '').strip() and float(row['conf']) >= 0] length = sum(len(word['text']) for word in words) score = sum(float(word['conf']) * len(word['text']) for word in words) / max(length, 1) uncertain = sum(len(word['text']) for word in words if float(word['conf']) < 40) / max(length, 1) chinese = sum(len(re.findall(CJK, word['text'])) for word in words) minimum = 20 if language != 'eng' and chinese >= 6 else 40 if length < minimum or score < 70 or uncertain > .2: raise OcrError(f'Page {page_number}: OCR could not read this scan reliably. Use a clearer, upright printed scan and select its OCR language. Handwriting and vertical Chinese are not supported.', code='unreadable') return words def table_grids(horizontal, vertical, ink, words, dpi): """Keep real row separators; infer columns only from clear full-height gutters.""" import cv2 import numpy as np contours, _ = cv2.findContours(horizontal, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) rules = sorted([cv2.boundingRect(item) for item in contours], key=lambda box: box[1]) groups = [] for x, y, width, height in rules: if width < ink.shape[1] * .35: continue group = next((group for group in groups if abs(x - group['x']) < dpi / 24 and abs(width - group['width']) < dpi / 16), None) if group is None: group = {'x': x, 'width': width, 'ys': []} groups.append(group) group['ys'].append(y + height // 2) tables = [] for group in groups: ys = centers(sorted(group['ys'])) if len(ys) < 3: continue spacing = float(np.median(np.diff(ys))) if not dpi * 6 / 72 <= spacing <= dpi * 100 / 72: continue x0, x1 = group['x'], group['x'] + group['width'] - 1 top = ys[0] # Header rows often have only a bottom rule. Include an aligned header # immediately above that rule, while excluding a preceding paragraph. above = [word for word in words if top - spacing * 1.2 < int(word['top']) + int(word['height']) / 2 < top and x0 <= int(word['left']) <= x1] if above: last_baseline = max(int(word['top']) for word in above) above = sorted([word for word in above if abs(int(word['top']) - last_baseline) < dpi * 5 / 72], key=lambda word: int(word['left'])) gaps = sum(int(right['left']) - int(left['left']) - int(left['width']) > dpi * 12 / 72 for left, right in zip(above, above[1:])) if gaps >= 2: top = max(0, min(int(word['top']) for word in above) - int(dpi * 4 / 72)) ys.insert(0, top) bottom = ys[-1] if bottom <= top: continue region = ink[top:bottom + 1, x0:x1 + 1].copy() region[horizontal[top:bottom + 1, x0:x1 + 1] > 0] = 0 vertical_strength = np.count_nonzero(vertical[top:bottom + 1, x0:x1 + 1], axis=0) xs = [x0 + value for value in centers(np.where(vertical_strength > (bottom - top) * .8)[0])] if len(xs) < 3: occupied = np.count_nonzero(region, axis=0) blank = np.where(occupied <= 1)[0] runs = [] for value in blank: if not runs or value > runs[-1][-1] + 1: runs.append([]) runs[-1].append(int(value)) gutters = [x0 + int((run[0] + run[-1]) / 2) for run in runs if len(run) >= dpi * 8 / 72 and run[0] > dpi * 6 / 72 and run[-1] < x1 - x0 - dpi * 6 / 72] xs = [x0, *gutters, x1] if not 3 <= len(xs) <= 13: continue # Every proposed column must contain recognised text, including a header. if not all(any(left < int(word['left']) + int(word['width']) / 2 < right and top < int(word['top']) + int(word['height']) / 2 < bottom for word in words) for left, right in zip(xs, xs[1:])): continue tables.append({'xs': xs, 'ys': ys}) return tables def financial_table_grids(words, dpi, existing): """Recover common borderless invoices only with a clear header and numeric rows.""" rows = [] for word in sorted(words, key=lambda item: (int(item['top']) + int(item['height']) / 2, int(item['left']))): center = int(word['top']) + int(word['height']) / 2 if not rows or abs(center - rows[-1]['center']) > dpi * 4 / 72: rows.append({'center': center, 'words': []}) rows[-1]['words'].append(word) def groups(row): result = [] for word in sorted(row['words'], key=lambda item: int(item['left'])): left, right = int(word['left']), int(word['left']) + int(word['width']) if not result or left - result[-1]['right'] > dpi * 12 / 72: result.append({'left': left, 'right': right, 'text': word['text']}) else: result[-1]['right'] = max(right, result[-1]['right']) result[-1]['text'] += ' ' + word['text'] return result def bounds(row): return min(int(word['top']) for word in row['words']), max(int(word['top']) + int(word['height']) for word in row['words']) result = [] for index, row in enumerate(rows): header = groups(row) if not 3 <= len(header) <= 8: continue keywords = re.findall(r'\b(?:quantity|qty|amount|price|total|balance|debit|credit|rate|hours)\b', normalise_cjk(' '.join(group['text'] for group in header)), re.I) keywords += re.findall(r'数量|數量|金额|金額|单价|單價|合计|合計|总计|總計|余额|餘額|税额|稅額|工时|工時', normalise_cjk(' '.join(group['text'] for group in header))) if len(keywords) < 2: continue top, bottom = bounds(row) if any(grid['ys'][0] <= row['center'] <= grid['ys'][-1] for grid in existing): continue selected = [(row, header)] for candidate in rows[index + 1:]: candidate_top, candidate_bottom = bounds(candidate) if candidate_top - bottom > dpi * 36 / 72: break cells = groups(candidate) numeric_end = bool(cells and re.fullmatch(r'(?:[$£€¥¥]|[A-Z]{3})?\s*[+-]?\(?\d[\d,.]*\)?%?', cells[-1]['text'].strip())) if len(cells) < 2 or not numeric_end: break selected.append((candidate, cells)) bottom = candidate_bottom if len(selected) < 2: continue full_rows = [cells for _, cells in selected if len(cells) == len(header)] if len(full_rows) < 2: continue xs = [min(cells[0]['left'] for cells in full_rows) - dpi * 3 / 72] for column in range(len(header) - 1): right = max(cells[column]['right'] for cells in full_rows) left = min(cells[column + 1]['left'] for cells in full_rows) if left - right < dpi * 3 / 72: break xs.append((right + left) / 2) if len(xs) != len(header): continue xs.append(max(cells[-1]['right'] for _, cells in selected) + dpi * 3 / 72) ys = [top - dpi * 3 / 72] for (previous, _), (following, _) in zip(selected, selected[1:]): ys.append((bounds(previous)[1] + bounds(following)[0]) / 2) ys.append(bottom + dpi * 3 / 72) result.append({'xs': xs, 'ys': ys, 'row_groups': [cells for _, cells in selected]}) return result def add_table_rules(pdf_path, grids, image_size, output): if not grids: return pdf_path from reportlab.pdfgen import canvas reader = PyPDF2.PdfReader(pdf_path) page = reader.pages[0] width, height = float(page.mediabox.width), float(page.mediabox.height) scale_x, scale_y = width / image_size[0], height / image_size[1] overlay = io.BytesIO() drawing = canvas.Canvas(overlay, pagesize=(width, height)) drawing.setLineWidth(.3) for grid in grids: xs, ys = grid['xs'], grid['ys'] for y in ys: drawing.line(xs[0] * scale_x, height - y * scale_y, xs[-1] * scale_x, height - y * scale_y) for column, x in enumerate(xs): for row, (top, bottom) in enumerate(zip(ys, ys[1:])): merged = 0 < column < len(xs) - 1 and any(group['left'] < x < group['right'] for group in grid.get('row_groups', [[] for _ in ys])[row]) if not merged: drawing.line(x * scale_x, height - top * scale_y, x * scale_x, height - bottom * scale_y) drawing.save() overlay.seek(0) page.merge_page(PyPDF2.PdfReader(overlay).pages[0]) writer = PyPDF2.PdfWriter() writer.add_page(page) with open(output, 'wb') as stream: writer.write(stream) return output def recognise_page(page, page_number, workdir, deadline, require_table, language='eng'): from PIL import Image executable = shutil.which('tesseract') if not executable: raise OcrError('OCR is temporarily unavailable. Try a PDF with selectable text or return later.') dpi = min(300, MAX_RENDER_EDGE * 72 / max(page.width, page.height)) image = page.to_image(resolution=dpi).original clean, horizontal, vertical, ink = preprocess(image) image.close() prefix = Path(workdir).resolve() / f'ocr-{page_number}' Image.fromarray(clean).save(str(prefix) + '.png', dpi=(dpi, dpi)) remaining = deadline - time.monotonic() if remaining <= 1: raise OcrError('OCR reached the processing time limit. Split this PDF into fewer scanned pages and try again.') try: process = subprocess.run([executable, str(prefix) + '.png', str(prefix), '-l', OCR_LANGUAGES[language][0], '--dpi', str(round(dpi)), '--psm', '3', 'pdf', 'tsv'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=min(15, remaining), env={**os.environ, 'OMP_THREAD_LIMIT': '2'}) except subprocess.TimeoutExpired as error: raise OcrError(f'Page {page_number}: OCR timed out. Try a clearer scan or split the PDF into fewer pages.') from error if process.returncode != 0: raise OcrError(f'Page {page_number}: OCR could not process this scan. Try a clearer, upright printed scan and check the OCR language.') words = read_words(str(prefix) + '.tsv', page_number, language) grids = table_grids(horizontal, vertical, ink, words, dpi) grids.extend(financial_table_grids(words, dpi, grids)) if require_table and not grids: raise OcrError(f'Page {page_number}: no clear scanned table was found. Use visible row separators or clear quantity/amount headers. Complex or merged scan layouts need manual conversion.') return add_table_rules(str(prefix) + '.pdf', grids, (clean.shape[1], clean.shape[0]), str(prefix) + '-tables.pdf') @contextmanager def prepare_for_extraction(pdf_path, require_table=False, enabled=True, ocr_language='eng', fallback_pages=None): validate_ocr_language(ocr_language) deadline = time.monotonic() + OCR_BUDGET_SECONDS with pdfplumber.open(pdf_path) as pdf: scanned = [index for index, page in enumerate(pdf.pages) if needs_ocr(page)] if not scanned: yield pdf_path, 0 return if not enabled: raise OcrError('This PDF needs OCR. Reload the converter to use scan recognition, then try again.') if len(scanned) > MAX_OCR_PAGES: raise OcrError('OCR supports up to 3 scanned pages per PDF. Use Split PDF first; text-based PDFs can still have up to 10 pages.') with tempfile.TemporaryDirectory(prefix='pdf-ocr-') as workdir: reader = PyPDF2.PdfReader(pdf_path) writer = PyPDF2.PdfWriter() for index, page in enumerate(pdf.pages): if index in scanned: try: recognised = recognise_page(page, index + 1, workdir, deadline, require_table, ocr_language) except OcrError as error: if fallback_pages is None or error.code != 'unreadable': raise fallback_pages.append(index) writer.add_page(reader.pages[index]) else: writer.add_page(PyPDF2.PdfReader(recognised).pages[0]) else: writer.add_page(reader.pages[index]) output = str(Path(workdir) / 'recognised.pdf') with open(output, 'wb') as stream: writer.write(stream) yield output, len(scanned) - len(fallback_pages or [])