File size: 12,648 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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
"""OCR text cleaning and extraction utilities.

This module provides functions to clean and process OCR output from Surya,
including handling common OCR artifacts and extracting text for specific regions.
"""

from __future__ import annotations

import logging
import re
import unicodedata
from typing import Any

from pdf2zh.parser.utils.bbox import bbox_area, bbox_intersection, polygon_to_bbox

logger = logging.getLogger(__name__)


def adjust_cell_bbox(
    matching_cell_lines: list[Any],
    cell_bbox_pdf: list[float],
    cell_bbox_image: list[float],
    padding: float = 0.0,
) -> list[float]:
    """
    Co nhỏ cell_bbox_pdf lại để ôm sát vào phân vùng chứa textlines thực tế,
    sau đó bổ sung thêm một lượng padding.

    Args:
        matching_cell_lines: Danh sách các dòng OCR tìm thấy trong ô
        cell_bbox_pdf: Bounding box của ô ở hệ PDF [x0, y0, x1, y1]
        cell_bbox_image: Bounding box của ô ở hệ ảnh [x0, y0, x1, y1]
        padding: Khoảng cách đệm thêm vào các cạnh (đơn vị: points)

    Returns:
        Bounding box mới hệ PDF [x0, y0, x1, y1] đã được điều chỉnh ôm sát text
    """
    if not matching_cell_lines:
        return cell_bbox_pdf

    text_x0 = float("inf")
    text_y0 = float("inf")
    text_x1 = float("-inf")
    text_y1 = float("-inf")

    for line in matching_cell_lines:
        line_bbox = _get_ocr_bbox(line)
        if line_bbox is None:
            continue
        text_x0 = min(text_x0, line_bbox[0])
        text_y0 = min(text_y0, line_bbox[1])
        text_x1 = max(text_x1, line_bbox[2])
        text_y1 = max(text_y1, line_bbox[3])

    if text_x0 == float("inf"):
        return cell_bbox_pdf

    img_w = cell_bbox_image[2] - cell_bbox_image[0]
    img_h = cell_bbox_image[3] - cell_bbox_image[1]
    pdf_w = cell_bbox_pdf[2] - cell_bbox_pdf[0]
    pdf_h = cell_bbox_pdf[3] - cell_bbox_pdf[1]

    scale_x = pdf_w / img_w if img_w > 0 else 1.0
    scale_y = pdf_h / img_h if img_h > 0 else 1.0

    dx0 = max(0.0, text_x0 - cell_bbox_image[0])
    dy0 = max(0.0, text_y0 - cell_bbox_image[1])
    dx1 = max(0.0, cell_bbox_image[2] - text_x1)
    dy1 = max(0.0, cell_bbox_image[3] - text_y1)

    new_pdf_x0 = cell_bbox_pdf[0] + max(0.0, dx0 * scale_x - padding)
    new_pdf_y0 = cell_bbox_pdf[1] + max(0.0, dy0 * scale_y - padding)
    new_pdf_x1 = cell_bbox_pdf[2] - max(0.0, dx1 * scale_x - padding)
    new_pdf_y1 = cell_bbox_pdf[3] - max(0.0, dy1 * scale_y - padding)

    final_x0 = max(cell_bbox_pdf[0], min(new_pdf_x0, cell_bbox_pdf[2]))
    final_y0 = max(cell_bbox_pdf[1], min(new_pdf_y0, cell_bbox_pdf[3]))
    final_x1 = max(final_x0, min(new_pdf_x1, cell_bbox_pdf[2]))
    final_y1 = max(final_y0, min(new_pdf_y1, cell_bbox_pdf[3]))

    return [final_x0, final_y0, final_x1, final_y1]


def clean_ocr_text(text: str) -> str:
    """Clean OCR text by removing artifacts and normalizing whitespace.

    Processing steps:
    1. Normalize Unicode (NFC form)
    2. Remove control characters except newlines and tabs
    3. Fix common OCR artifacts (ligatures, smart quotes, etc.)
    4. Normalize whitespace (collapse multiple spaces, trim lines)
    5. Remove empty lines at start/end

    Args:
        text: Raw OCR text from Surya

    Returns:
        Cleaned text string
    """
    if not text:
        return ""

    # Step 1: Unicode normalization
    text = unicodedata.normalize("NFC", text)

    # Step 2: Remove control characters except newlines and tabs
    cleaned_chars = []
    for char in text:
        if char in ("\n", "\t"):
            cleaned_chars.append(char)
        elif unicodedata.category(char)[0] != "C":
            cleaned_chars.append(char)
    text = "".join(cleaned_chars)

    # # Step 3: Fix common OCR artifacts
    # # Ligatures
    # text = text.replace("\ufb01", "fi")
    # text = text.replace("\ufb02", "fl")
    # text = text.replace("\ufb00", "ff")
    # text = text.replace("\ufb03", "ffi")
    # text = text.replace("\ufb04", "ffl")

    # # Smart quotes to straight quotes
    # text = text.replace("\u2018", "'")  # Left single quote
    # text = text.replace("\u2019", "'")  # Right single quote
    # text = text.replace("\u201c", '"')  # Left double quote
    # text = text.replace("\u201d", '"')  # Right double quote

    # # Dashes
    # text = text.replace("\u2013", "-")  # En dash
    # text = text.replace("\u2014", "-")  # Em dash
    # text = text.replace("\u2212", "-")  # Minus sign

    # # Other common artifacts
    # text = text.replace("\u00a0", " ")  # Non-breaking space
    # text = text.replace("\u2026", "...")  # Ellipsis

    text = text.replace("<br>", "\n")  # Line break tags

    # Step 4: Normalize whitespace

    # Collapse multiple spaces into one
    text = re.sub(r" +", " ", text)

    # Trim each line
    lines = text.split("\n")
    lines = [line.strip() for line in lines]

    # Step 5: Remove empty lines at start and end
    while lines and not lines[0]:
        lines.pop(0)
    while lines and not lines[-1]:
        lines.pop()

    return "\n".join(lines)


def collect_ocr_text(ocr_result: Any) -> str:
    """Collect all text lines from an OCR result into a single string.

    Used after crop-then-OCR: the entire OCR result belongs to one layout
    region, so we simply concatenate all detected text lines.

    Args:
        ocr_result: Surya OCR result with ``text_lines`` attribute

    Returns:
        Cleaned concatenated text
    """
    if not hasattr(ocr_result, "text_lines"):
        return ""

    lines = []
    for line in ocr_result.text_lines:
        if hasattr(line, "text") and line.text:
            lines.append(line.text)

    return clean_ocr_text(" ".join(lines))


def smart_join_text_lines(lines: list[Any]) -> str:
    if not lines:
        return ""

    result = []
    last_valid_text = ""  # Lưu lại văn bản của dòng có chữ gần nhất

    for line in lines:
        current_text = getattr(line, "text", "").strip()

        if not current_text:
            continue

        # Nếu là dòng chứa chữ đầu tiên, chỉ cần thêm vào kết quả
        if not result:
            result.append(current_text)
            last_valid_text = current_text
            continue

        ends_with_punctuation = last_valid_text[-1] in {".", "!", "?"}
        starts_with_upper = current_text[0].isupper()
        ends_with_hyphen = last_valid_text.endswith("-")

        if ends_with_hyphen:
            result.append(current_text)
        elif not ends_with_punctuation and starts_with_upper:
            result.append("\n" + current_text)
        else:
            result.append(" " + current_text)

        last_valid_text = current_text

    return clean_ocr_text("".join(result))


def sort_text_lines(lines: list[Any]) -> list[Any]:
    """
    Sort OCR text lines in reading order (top-to-bottom, left-to-right).
    """
    if not lines:
        return []

    first_line = lines[0]
    if hasattr(first_line, "bbox") and first_line.bbox:

        def get_full_bbox(line):
            b = line.bbox
            return b[0], b[1], b[2], b[3]

    elif hasattr(first_line, "polygon"):

        def get_full_bbox(line):
            poly = line.polygon
            xs = [p[0] for p in poly]
            ys = [p[1] for p in poly]
            return min(xs), min(ys), max(xs), max(ys)

    else:
        return lines

    boxes = []
    for line in lines:
        x_min, y_min, x_max, y_max = get_full_bbox(line)
        y_center = (y_min + y_max) / 2.0

        boxes.append((y_min, y_center, x_min, y_max, line))

    boxes.sort()

    rows = []
    current_row = []
    anchor_y_center = None

    for box in boxes:
        y_min, y_center, x_min, y_max, line = box

        if not current_row:
            current_row.append((x_min, line))
            anchor_y_center = y_center
        else:
            if y_min <= anchor_y_center <= y_max:
                current_row.append((x_min, line))
            else:
                rows.append(current_row)
                current_row = [(x_min, line)]
                anchor_y_center = y_center

    if current_row:
        rows.append(current_row)

    sorted_lines = []
    for row in rows:
        row.sort()
        for _, line in row:
            sorted_lines.append(line)

    return sorted_lines


def extract_text_for_region(
    ocr_result: Any,
    region_bbox: list[float],
    overlap_threshold: float = 0.5,
) -> list[Any]:
    """Extract OCR text that falls within a region.

    Finds all text lines from the OCR result that overlap significantly
    with the given region and concatenates them.

    Args:
        ocr_result: Surya OCR result object with text_lines attribute
        region_bbox: [x0, y0, x1, y1] in image pixels
        image_width: Image width for coordinate validation
        image_height: Image height for coordinate validation
        overlap_threshold: Minimum overlap ratio to include a line

    Returns:
        Concatenated text from overlapping lines and estimated font size
    """
    if not hasattr(ocr_result, "text_lines"):
        return []

    matching_lines = _collect_region_matches(
        getattr(ocr_result, "text_lines", []),
        region_bbox,
        overlap_threshold,
    )
    return sort_text_lines(matching_lines)


def _collect_region_matches(
    items: list[Any],
    region_bbox: list[float],
    overlap_threshold: float,
) -> list[Any]:
    matching_items: list[Any] = []

    for item in items:
        if not hasattr(item, "text"):
            continue

        item_bbox = _get_ocr_bbox(item)
        if item_bbox is None:
            continue

        intersection = bbox_intersection(region_bbox, item_bbox)
        if intersection is None:
            continue

        item_area = max(1.0, bbox_area(item_bbox))
        overlap_ratio = bbox_area(intersection) / item_area
        if overlap_ratio >= overlap_threshold:
            matching_items.append(item)

    return matching_items


def _get_ocr_bbox(item: Any) -> list[float] | None:
    item_bbox = getattr(item, "bbox", None)
    if item_bbox is not None:
        return list(item_bbox)

    if hasattr(item, "polygon"):
        return polygon_to_bbox(item.polygon)

    return None


def log_toc_hints(elements: list[Any], page_index: int) -> None:
    """Log potential Table of Contents entries for debugging.

    Looks for Section-header elements that might indicate chapter structure
    and logs them for manual review during development.

    Args:
        elements: List of ElementData objects from the page
        page_index: 0-based page number for logging context
    """
    toc_hints = []

    for elem in elements:
        label = getattr(elem, "label", "")

        # Look for section headers and TOC elements
        if label in ("Section-header", "Table-of-contents"):
            text = getattr(elem, "source_text", "")
            if text:
                # Truncate long text
                display_text = text[:80] + "..." if len(text) > 80 else text
                toc_hints.append(f"  [{label}] {display_text}")

    if toc_hints:
        logger.debug(f"Page {page_index} TOC hints:\n" + "\n".join(toc_hints))


def join_raw_text(elements: list[Any]) -> str:
    """Concatenate ``source_text`` from translatable layout elements.

    Collects source text from every :class:`~pdf2zh.scanned.enums.ElementCategory`
    that carries translatable content (``FLOWING_TEXT`` and ``IN_PLACE``) and
    joins them with newlines to form the ``raw_text`` field of
    :class:`~pdf2zh.scanned.models.PageData`.

    BYPASS, TABLE, and EQUATION categories are intentionally excluded:
    BYPASS has no text; TABLE text is stored per-cell; EQUATION text is a
    placeholder handled separately.

    Args:
        elements: Ordered list of :class:`~pdf2zh.scanned.models.ElementData`
                  objects (or any object with ``category`` and ``source_text``
                  attributes) for a single page.

    Returns:
        Single string with element texts joined by ``"\n"``,
        or an empty string if no translatable elements are present.
    """
    from pdf2zh.parser.enums import ElementCategory

    text_parts = []

    for elem in elements:
        category = getattr(elem, "category", None)

        # Only include FLOWING_TEXT and IN_PLACE categories
        if category in (ElementCategory.FLOWING_TEXT, ElementCategory.IN_PLACE):
            source_text = getattr(elem, "source_text", "")
            if source_text:
                text_parts.append(source_text)

    return "\n".join(text_parts)