File size: 8,981 Bytes
b68816f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Quality checks on the parse β€” catching the damage that makes NO noise.

The basis is a finding in `Hasil-Uji-MinerU.md`: the EOQ formula came out of the
CPU pipeline with a digit changed, no error and no warning. Damage like that will
never show up in a "finished" status β€” it has to be looked for.

Everything here is a WARNING, never a failure: the document is still processed,
but the warning is recorded in the manifest so quality cannot quietly degrade
across hundreds of documents.
"""

from __future__ import annotations

import re
import unicodedata
from collections import Counter
from pathlib import Path
from typing import Any

# Digits inside latex. A technical formula almost always has a number or a divisor.
_HAS_DIGIT = re.compile(r"\d")

# Minimum length of a digit run before it is matched against the source.
#
# Measured on a real document: at a 2-digit threshold the genuine bug (55600) is
# caught, but six false warnings come with it from short numbers β€” the PDF's text
# layer is partly broken, so short numbers often fail to match despite being
# correct. At 3 digits the bug is still caught and the noise disappears.
MIN_DIGITS = 3

_ALL_DIGITS = re.compile(r"\d+")


def _digit_runs(text: str, min_digits: int) -> set[str]:
    return {n for n in _ALL_DIGITS.findall(text) if len(n) >= min_digits}


def _digits_from_latex(latex: str, min_digits: int = MIN_DIGITS) -> set[str]:
    """MinerU writes latex character-spaced ('5 5 6 0 0'), so spaces go first."""
    return _digit_runs(re.sub(r"\s+", "", latex), min_digits)


def _digits_per_page(pdf: Path, min_digits: int = MIN_DIGITS) -> dict[int, set[str]] | None:
    """Digit runs in the source PDF's own text layer, per page.

    Used as the reference. If the PDF cannot be read, return None and skip this
    check silently β€” not checking is better than warning falsely.
    """
    try:
        from pypdf import PdfReader
        reader = PdfReader(str(pdf))
    except Exception:
        return None

    result: dict[int, set[str]] = {}
    for i, page in enumerate(reader.pages):
        try:
            text = page.extract_text() or ""
        except Exception:
            text = ""
        # drop thousands separators & spaces so "$5,600" and "5 600" -> "5600"
        cleaned = re.sub(r"[, \s]", "", text)
        result[i] = _digit_runs(cleaned, min_digits)
    return result


_WORD = re.compile(r"[A-Za-z][A-Za-z0-9]*")


def vocabulary_per_page(pdf: Path) -> dict[int, frozenset[str]] | None:
    """Words in the source PDF's own text layer, per page.

    Used by `render.restore_word_boundaries` as the arbiter: MinerU writes
    formulas one character at a time and the word boundaries are lost there,
    while the PDF still holds them intact (`MOHH x Qty x PA`, not
    `MOHHxQtyxPAxUAxPty`).

    NFKC is required because formulas use a mathematical font β€” this page
    literally contains `𝑀𝑂𝐻𝐻` (U+1D440…) rather than ASCII, and without
    normalisation not a single word would match.

    Returns None if the PDF cannot be read; the caller then skips recovery
    silently, exactly as the digit check below does.
    """
    try:
        from pypdf import PdfReader
        reader = PdfReader(str(pdf))
    except Exception:
        return None

    result: dict[int, frozenset[str]] = {}
    for i, page in enumerate(reader.pages):
        try:
            text = page.extract_text() or ""
        except Exception:
            text = ""
        result[i] = frozenset(_WORD.findall(unicodedata.normalize("NFKC", text)))
    return result


MIN_COVERAGE = 0.90     # share of MinerU's own words that must be found in the text layer
MIN_WORDS_TO_JUDGE = 20  # below this there is no basis for a judgement


def trustworthy_vocabulary(
    vocabulary: dict[int, frozenset[str]],
    items: list[dict[str, Any]],
    min_coverage: float = MIN_COVERAGE,
) -> dict[int, frozenset[str]]:
    """Drop pages whose text layer is not fit to arbitrate.

    `vocabulary_per_page` trusts the PDF text layer outright. That is safe when
    the layer is intact and dangerous when it is HALF there: a legitimate word
    starts looking unknown, and word-boundary recovery then splits it.

    The measure judges itself rather than relying on a constant tuned to one
    document: how many of MinerU's own prose words on that page are actually
    found in its text layer. A low score means the two read the same page
    differently β€” and a source that disagrees with the parser is not fit to
    arbitrate it.

    Measured on three real documents: 99.6% Β· 98.1% Β· 96.0%. Pages falling well
    below that lose their vocabulary, so recovery is skipped and the text is left
    as it is β€” damaged but honest, rather than repaired into something wrong.
    """
    trusted: dict[int, frozenset[str]] = {}
    for page, words in vocabulary.items():
        if not words:
            continue
        matched = total = 0
        for x in items:
            if x.get("type") != "text" or x.get("page_idx") != page:
                continue
            for w in re.findall(r"[A-Za-z][A-Za-z0-9]*", x.get("text") or ""):
                if len(w) < 4:
                    continue
                total += 1
                matched += w in words
        # Too little prose to judge -> no basis for trusting it.
        if total < MIN_WORDS_TO_JUDGE or matched / total < min_coverage:
            continue
        trusted[page] = words
    return trusted


def check_items(
    items: list[dict[str, Any]],
    min_latex_len: int = 8,
    source_pdf: Path | None = None,
    page_offset: int = 0,
) -> dict[str, Any]:
    """Return a summary plus the list of warnings for one document.

    `source_pdf` enables matching numbers against the original PDF β€” the only
    way to catch a number that changed silently (the EOQ bug: 5600 -> 55600).
    """
    per_type = Counter(x.get("type") for x in items)
    pages = {x.get("page_idx") for x in items if x.get("page_idx") is not None}

    warnings: list[dict[str, Any]] = []

    # 1. A page with no text at all -> it probably failed to read
    text_per_page = Counter(
        x.get("page_idx") for x in items
        if x.get("type") in {"text", "table", "equation"} and (x.get("text") or x.get("table_body"))
    )
    for p in sorted(pages):
        if text_per_page.get(p, 0) == 0:
            warnings.append({"kind": "empty_page", "page": p})

    # 2. Formula truncated / missing its numbers  <- the EOQ bug
    for i, x in enumerate(items):
        if x.get("type") != "equation":
            continue
        latex = (x.get("text") or "").strip()
        if len(latex) < min_latex_len:
            warnings.append({"kind": "latex_too_short", "item": i,
                             "page": x.get("page_idx"), "content": latex[:60]})
        elif not _HAS_DIGIT.search(latex) and "\\frac" not in latex:
            warnings.append({"kind": "latex_without_digits", "item": i,
                             "page": x.get("page_idx"), "content": latex[:60]})

    # 3. ⭐ A number in a formula that is absent from the source PDF -> it changed
    #    silently. This is the ONLY check that can catch the EOQ bug; every other
    #    check in this file only inspects our own output, and a wrong number still
    #    produces perfectly valid latex.
    source_digits = _digits_per_page(source_pdf) if source_pdf else None
    if source_digits is not None:
        for i, x in enumerate(items):
            if x.get("type") != "equation":
                continue
            p = x.get("page_idx", 0) + page_offset
            reference = source_digits.get(p)
            if not reference:      # page with no text layer -> cannot be judged
                continue
            for number in sorted(_digits_from_latex(x.get("text") or "")):
                if number not in reference:
                    warnings.append({
                        "kind": "digits_absent_from_source", "item": i, "page": p,
                        "digits": number,
                        "note": "a number in the formula was not found in the source PDF text",
                    })

    # 4. Table with no body
    for i, x in enumerate(items):
        if x.get("type") == "table" and not (x.get("table_body") or "").strip():
            warnings.append({"kind": "empty_table", "item": i, "page": x.get("page_idx")})

    # 5. Chart with no context whatsoever (the next stage can do nothing with it)
    for i, x in enumerate(items):
        if x.get("type") == "chart":
            has_any = (x.get("content") or "").strip() or (x.get("chart_caption") or [])
            if not has_any:
                warnings.append({"kind": "chart_without_context", "item": i,
                                 "page": x.get("page_idx")})

    return {
        "items": len(items),
        "pages_detected": len(pages),
        "per_type": dict(per_type),
        "warnings": warnings,
        "warning_count": len(warnings),
    }