harfix 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- harfix/__init__.py +30 -0
- harfix/cli.py +137 -0
- harfix/data/arabic_char_lm.json.gz +0 -0
- harfix/data/arabic_char_lm_unspaced.json.gz +0 -0
- harfix/data/thresholds.json +33 -0
- harfix/detect.py +365 -0
- harfix/model.py +126 -0
- harfix/normalize.py +282 -0
- harfix/pdf.py +181 -0
- harfix-0.1.0.dist-info/METADATA +212 -0
- harfix-0.1.0.dist-info/RECORD +15 -0
- harfix-0.1.0.dist-info/WHEEL +4 -0
- harfix-0.1.0.dist-info/entry_points.txt +2 -0
- harfix-0.1.0.dist-info/licenses/LICENSE +202 -0
- harfix-0.1.0.dist-info/licenses/NOTICE +13 -0
harfix/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""harfix: find Arabic PDF text that will silently poison a search index."""
|
|
2
|
+
|
|
3
|
+
from .detect import (
|
|
4
|
+
ROUTES,
|
|
5
|
+
Assessment,
|
|
6
|
+
Segment,
|
|
7
|
+
TextReport,
|
|
8
|
+
Thresholds,
|
|
9
|
+
assess_document,
|
|
10
|
+
assess_text,
|
|
11
|
+
)
|
|
12
|
+
from .model import CharLM
|
|
13
|
+
from .pdf import DocumentReport, FontReport, HarfixError, PageReport, scan_pdf
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ROUTES",
|
|
17
|
+
"Assessment",
|
|
18
|
+
"CharLM",
|
|
19
|
+
"DocumentReport",
|
|
20
|
+
"FontReport",
|
|
21
|
+
"HarfixError",
|
|
22
|
+
"PageReport",
|
|
23
|
+
"Segment",
|
|
24
|
+
"TextReport",
|
|
25
|
+
"Thresholds",
|
|
26
|
+
"assess_document",
|
|
27
|
+
"assess_text",
|
|
28
|
+
"scan_pdf",
|
|
29
|
+
]
|
|
30
|
+
__version__ = "0.1.0"
|
harfix/cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""`harfix scan` for PDFs and `harfix check` for text produced by any extractor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .detect import ROUTES, assess_document
|
|
11
|
+
from .pdf import HarfixError, scan_pdf
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_pages(spec: str) -> list[int]:
|
|
15
|
+
pages: set[int] = set()
|
|
16
|
+
for part in spec.split(","):
|
|
17
|
+
start, _, end = part.strip().partition("-")
|
|
18
|
+
first = int(start)
|
|
19
|
+
last = int(end) if end else first
|
|
20
|
+
if first < 1 or last < first:
|
|
21
|
+
raise argparse.ArgumentTypeError(f"invalid page range: {part!r}")
|
|
22
|
+
pages.update(range(first, last + 1))
|
|
23
|
+
return sorted(pages)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fails(route: str, fail_on: str | None) -> bool:
|
|
27
|
+
return fail_on is not None and ROUTES.index(route) >= ROUTES.index(fail_on)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _scan(args: argparse.Namespace) -> int:
|
|
31
|
+
worst = "keep"
|
|
32
|
+
reports = []
|
|
33
|
+
for path in args.pdf:
|
|
34
|
+
try:
|
|
35
|
+
report = scan_pdf(path, pages=args.pages)
|
|
36
|
+
except (HarfixError, FileNotFoundError) as error:
|
|
37
|
+
print(f"harfix: {error}", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
reports.append(report)
|
|
40
|
+
for page in report.pages:
|
|
41
|
+
worst = max(worst, page.route, key=ROUTES.index)
|
|
42
|
+
|
|
43
|
+
if args.json:
|
|
44
|
+
payload = [r.to_dict() for r in reports]
|
|
45
|
+
json.dump(
|
|
46
|
+
payload[0] if len(payload) == 1 else payload, sys.stdout, ensure_ascii=False, indent=2
|
|
47
|
+
)
|
|
48
|
+
print()
|
|
49
|
+
else:
|
|
50
|
+
for report in reports:
|
|
51
|
+
print(f"{report.path}")
|
|
52
|
+
counts = ", ".join(f"{route}={n}" for route, n in report.route_counts.items() if n)
|
|
53
|
+
print(f" pages: {counts}")
|
|
54
|
+
for page in report.pages:
|
|
55
|
+
if page.route == "keep" and not args.verbose:
|
|
56
|
+
continue
|
|
57
|
+
reasons = ", ".join(page.reasons) or "-"
|
|
58
|
+
print(f" p{page.page:<5} {page.route:<9} {reasons}")
|
|
59
|
+
if args.verbose:
|
|
60
|
+
for font in page.fonts:
|
|
61
|
+
a = font.assessment
|
|
62
|
+
bits = a.metrics.get("bits", "-")
|
|
63
|
+
print(
|
|
64
|
+
f" {font.font[:40]:<40} {a.route:<9} "
|
|
65
|
+
f"letters={a.metrics.get('arabic_letters')} bits={bits} "
|
|
66
|
+
f"{','.join(a.reasons)}"
|
|
67
|
+
)
|
|
68
|
+
return 1 if _fails(worst, args.fail_on) else 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check(args: argparse.Namespace) -> int:
|
|
72
|
+
text = sys.stdin.read() if args.file == "-" else Path(args.file).read_text(encoding="utf-8")
|
|
73
|
+
report = assess_document(text)
|
|
74
|
+
if args.json:
|
|
75
|
+
json.dump(report.to_dict(), sys.stdout, ensure_ascii=False, indent=2)
|
|
76
|
+
print()
|
|
77
|
+
else:
|
|
78
|
+
print(f"{report.route}: {', '.join(report.reasons) or 'no issues found'}")
|
|
79
|
+
counts = {route: 0 for route in ROUTES}
|
|
80
|
+
for segment in report.segments:
|
|
81
|
+
counts[segment.assessment.route] += 1
|
|
82
|
+
unit = "pages" if any(s.page is not None for s in report.segments) else "segments"
|
|
83
|
+
print(f" {unit}: " + ", ".join(f"{route}={n}" for route, n in counts.items() if n))
|
|
84
|
+
for segment in report.segments:
|
|
85
|
+
a = segment.assessment
|
|
86
|
+
if a.route == "keep" and not args.verbose:
|
|
87
|
+
continue
|
|
88
|
+
where = (
|
|
89
|
+
f"p{segment.page}"
|
|
90
|
+
if segment.page is not None
|
|
91
|
+
else f"lines {segment.start_line}-{segment.end_line}"
|
|
92
|
+
)
|
|
93
|
+
print(f" {where:<16} {a.route:<9} {', '.join(a.reasons) or '-'}")
|
|
94
|
+
if args.verbose:
|
|
95
|
+
for key, value in a.metrics.items():
|
|
96
|
+
print(f" {key}: {value}")
|
|
97
|
+
return 1 if _fails(report.route, args.fail_on) else 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
101
|
+
parser = argparse.ArgumentParser(
|
|
102
|
+
prog="harfix", description="Quality gate for Arabic text extracted from PDFs."
|
|
103
|
+
)
|
|
104
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
105
|
+
|
|
106
|
+
scan = sub.add_parser("scan", help="audit the text layer of PDFs, per page and per font")
|
|
107
|
+
scan.add_argument("pdf", nargs="+")
|
|
108
|
+
scan.add_argument("--pages", type=_parse_pages, help="1-based pages, e.g. 1-5,9")
|
|
109
|
+
scan.add_argument("--json", action="store_true", help="print the full report as JSON")
|
|
110
|
+
scan.add_argument("-v", "--verbose", action="store_true", help="show every page and font")
|
|
111
|
+
scan.add_argument(
|
|
112
|
+
"--fail-on", choices=ROUTES[1:], help="exit 1 if any page needs this route or worse"
|
|
113
|
+
)
|
|
114
|
+
scan.set_defaults(func=_scan)
|
|
115
|
+
|
|
116
|
+
check = sub.add_parser(
|
|
117
|
+
"check", help="assess text produced by any extractor, page by page (form feeds)"
|
|
118
|
+
)
|
|
119
|
+
check.add_argument("file", nargs="?", default="-", help="UTF-8 text file, or - for stdin")
|
|
120
|
+
check.add_argument("--json", action="store_true")
|
|
121
|
+
check.add_argument(
|
|
122
|
+
"-v", "--verbose", action="store_true", help="show every segment and its metrics"
|
|
123
|
+
)
|
|
124
|
+
check.add_argument(
|
|
125
|
+
"--fail-on", choices=ROUTES[1:], help="exit 1 if any segment needs this route or worse"
|
|
126
|
+
)
|
|
127
|
+
check.set_defaults(func=_check)
|
|
128
|
+
return parser
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv: list[str] | None = None) -> int:
|
|
132
|
+
args = build_parser().parse_args(argv)
|
|
133
|
+
return args.func(args)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
raise SystemExit(main())
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"thresholds": {
|
|
3
|
+
"min_letters": 80,
|
|
4
|
+
"substitution_bits": 5.0,
|
|
5
|
+
"spike_bits": 18.0,
|
|
6
|
+
"spike_rate": 0.03,
|
|
7
|
+
"spike_min_letters": 200,
|
|
8
|
+
"unspaced_bits": 5.7,
|
|
9
|
+
"short_token_ratio": 0.46,
|
|
10
|
+
"orientation_margin": 0.5,
|
|
11
|
+
"presentation_form_ratio": 0.05,
|
|
12
|
+
"unmapped_ratio": 0.02,
|
|
13
|
+
"unmapped_min_glyphs": 5,
|
|
14
|
+
"legacy_ratio": 0.15,
|
|
15
|
+
"legacy_min_visible": 30,
|
|
16
|
+
"latin_min_words": 15,
|
|
17
|
+
"latin_vowel_ratio": 0.25,
|
|
18
|
+
"latin_irregular_ratio": 0.3,
|
|
19
|
+
"quran_min_forms": 8,
|
|
20
|
+
"quran_ligature_share": 0.5,
|
|
21
|
+
"quran_short_run_share": 0.6,
|
|
22
|
+
"quran_consecutive_share": 0.5,
|
|
23
|
+
"transposition_ratio": 0.27,
|
|
24
|
+
"mixed_order_min_share": 0.2
|
|
25
|
+
},
|
|
26
|
+
"calibration": {
|
|
27
|
+
"date": "2026-10-17",
|
|
28
|
+
"model_sha256": "3704fbc01115f6e6bc6bcfde0aba8881ebb1f2b658fce2904210fda0949e0be8",
|
|
29
|
+
"calib_pages": 73,
|
|
30
|
+
"test_pages": 73,
|
|
31
|
+
"target_false_flag": 0.005
|
|
32
|
+
}
|
|
33
|
+
}
|
harfix/detect.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Turn character statistics and model scores into a keep / repair / reextract / ocr verdict."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from importlib import resources
|
|
9
|
+
|
|
10
|
+
from .model import ModelPair, default_models
|
|
11
|
+
from .normalize import BOUNDARY, drop_ligature_forms, letters_only, profile, strip_decorations
|
|
12
|
+
|
|
13
|
+
# Ordered from least to most severe; a page takes the most severe route of its parts.
|
|
14
|
+
ROUTES = ("keep", "repair", "reextract", "ocr")
|
|
15
|
+
_MIN_LINE_LETTERS = 20
|
|
16
|
+
# `assess_document` splits text without page breaks into pieces of about this many letters.
|
|
17
|
+
SEGMENT_LETTERS = 500
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Thresholds:
|
|
22
|
+
"""Decision thresholds. Packaged values come from `scripts/calibrate.py`."""
|
|
23
|
+
|
|
24
|
+
min_letters: int = 80
|
|
25
|
+
# Mean surprisal (bits/character) of the reading-order-corrected text.
|
|
26
|
+
substitution_bits: float = 4.7
|
|
27
|
+
# A character at least this surprising is a spike; too many spikes means swapped letters.
|
|
28
|
+
spike_bits: float = 18.0
|
|
29
|
+
spike_rate: float = 0.02
|
|
30
|
+
# Spike rates on short runs (headings, captions) are too noisy to act on.
|
|
31
|
+
spike_min_letters: int = 200
|
|
32
|
+
# Implausible text whose letters are fine once spaces are removed has broken word spacing.
|
|
33
|
+
unspaced_bits: float = 5.4
|
|
34
|
+
short_token_ratio: float = 0.46
|
|
35
|
+
orientation_margin: float = 0.5
|
|
36
|
+
presentation_form_ratio: float = 0.05
|
|
37
|
+
unmapped_ratio: float = 0.02
|
|
38
|
+
unmapped_min_glyphs: int = 5
|
|
39
|
+
# Pre-Unicode Arabic fonts: mostly Latin-1/math symbols and almost no Arabic letters.
|
|
40
|
+
legacy_ratio: float = 0.15
|
|
41
|
+
legacy_min_visible: int = 30
|
|
42
|
+
# Pre-Unicode Arabic fonts mapped onto ASCII: Latin "words" with almost no vowels, or
|
|
43
|
+
# with digits, symbols and case flips inside them.
|
|
44
|
+
latin_min_words: int = 15
|
|
45
|
+
latin_vowel_ratio: float = 0.25
|
|
46
|
+
latin_irregular_ratio: float = 0.3
|
|
47
|
+
# Quran glyph fonts: one Presentation Forms-A code point per word, numbered in order.
|
|
48
|
+
quran_min_forms: int = 8
|
|
49
|
+
quran_ligature_share: float = 0.5
|
|
50
|
+
quran_short_run_share: float = 0.6
|
|
51
|
+
quran_consecutive_share: float = 0.5
|
|
52
|
+
transposition_ratio: float = 0.27
|
|
53
|
+
mixed_order_min_share: float = 0.2
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, data: dict) -> Thresholds:
|
|
57
|
+
known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
|
|
58
|
+
return cls(**known)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@lru_cache(maxsize=1)
|
|
62
|
+
def default_thresholds() -> Thresholds:
|
|
63
|
+
path = resources.files("harfix") / "data" / "thresholds.json"
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
return Thresholds()
|
|
66
|
+
return Thresholds.from_dict(json.loads(path.read_text(encoding="utf-8"))["thresholds"])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Measurements:
|
|
71
|
+
letters: int
|
|
72
|
+
orientation: str # "logical", "visual" or "unknown"
|
|
73
|
+
visual_line_share: float
|
|
74
|
+
bits: float
|
|
75
|
+
spike_rate: float
|
|
76
|
+
bits_unspaced: float
|
|
77
|
+
short_token_ratio: float
|
|
78
|
+
transposition_ratio: float
|
|
79
|
+
decoration_runs: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def measure(text: str, models: ModelPair, thresholds: Thresholds) -> Measurements:
|
|
83
|
+
"""Model-based measurements of `text` after correcting each line's reading direction."""
|
|
84
|
+
model = models.spaced
|
|
85
|
+
margin = thresholds.orientation_margin
|
|
86
|
+
lines, decoration_runs = [], 0
|
|
87
|
+
for raw in text.splitlines():
|
|
88
|
+
line, runs = strip_decorations(letters_only(raw))
|
|
89
|
+
decoration_runs += runs
|
|
90
|
+
if line:
|
|
91
|
+
lines.append(line)
|
|
92
|
+
decisions: list[bool | None] = [] # True = visual (reversed) line
|
|
93
|
+
visual_letters = logical_letters = 0
|
|
94
|
+
for line in lines:
|
|
95
|
+
n = len(line.replace(BOUNDARY, ""))
|
|
96
|
+
if n < _MIN_LINE_LETTERS:
|
|
97
|
+
decisions.append(None)
|
|
98
|
+
continue
|
|
99
|
+
forward, _ = model.bits_per_char(line)
|
|
100
|
+
backward, _ = model.bits_per_char(line[::-1])
|
|
101
|
+
if forward - backward > margin:
|
|
102
|
+
decisions.append(True)
|
|
103
|
+
visual_letters += n
|
|
104
|
+
elif backward - forward > margin:
|
|
105
|
+
decisions.append(False)
|
|
106
|
+
logical_letters += n
|
|
107
|
+
else:
|
|
108
|
+
decisions.append(None)
|
|
109
|
+
|
|
110
|
+
if visual_letters or logical_letters:
|
|
111
|
+
document_visual = visual_letters > logical_letters
|
|
112
|
+
orientation = "visual" if document_visual else "logical"
|
|
113
|
+
else:
|
|
114
|
+
joined = BOUNDARY.join(lines)
|
|
115
|
+
forward, _ = model.bits_per_char(joined)
|
|
116
|
+
backward, _ = model.bits_per_char(joined[::-1])
|
|
117
|
+
document_visual = forward - backward > margin
|
|
118
|
+
if document_visual:
|
|
119
|
+
orientation = "visual"
|
|
120
|
+
else:
|
|
121
|
+
orientation = "logical" if backward - forward > margin else "unknown"
|
|
122
|
+
decided = visual_letters + logical_letters
|
|
123
|
+
|
|
124
|
+
oriented = BOUNDARY.join(
|
|
125
|
+
line[::-1] if (document_visual if visual is None else visual) else line
|
|
126
|
+
for line, visual in zip(lines, decisions, strict=True)
|
|
127
|
+
)
|
|
128
|
+
surprisals = model.surprisals(oriented)
|
|
129
|
+
unspaced = oriented.replace(BOUNDARY, "")
|
|
130
|
+
bits_unspaced, _ = models.unspaced.bits_per_char(unspaced)
|
|
131
|
+
|
|
132
|
+
words = oriented.split(BOUNDARY) if oriented else []
|
|
133
|
+
al_words = sum(1 for w in words if len(w) >= 3 and w.startswith("ال"))
|
|
134
|
+
swapped = sum(1 for w in words if len(w) >= 4 and w[0] == "ا" and w[2] == "ل" and w[1] != "ل")
|
|
135
|
+
|
|
136
|
+
return Measurements(
|
|
137
|
+
letters=len(unspaced),
|
|
138
|
+
orientation=orientation,
|
|
139
|
+
visual_line_share=visual_letters / decided if decided else 0.0,
|
|
140
|
+
bits=sum(surprisals) / len(surprisals) if surprisals else 0.0,
|
|
141
|
+
spike_rate=(
|
|
142
|
+
sum(1 for s in surprisals if s >= thresholds.spike_bits) / len(surprisals)
|
|
143
|
+
if surprisals
|
|
144
|
+
else 0.0
|
|
145
|
+
),
|
|
146
|
+
bits_unspaced=bits_unspaced,
|
|
147
|
+
short_token_ratio=sum(1 for w in words if len(w) <= 2) / len(words) if words else 0.0,
|
|
148
|
+
transposition_ratio=swapped / (al_words + swapped) if al_words + swapped else 0.0,
|
|
149
|
+
decoration_runs=decoration_runs,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class Assessment:
|
|
155
|
+
route: str
|
|
156
|
+
reasons: list[str]
|
|
157
|
+
sufficient: bool
|
|
158
|
+
metrics: dict[str, float | int | str] = field(default_factory=dict)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def decisive(self) -> bool:
|
|
162
|
+
"""Judged on enough text, or flagged by a check that needs no minimum length."""
|
|
163
|
+
return self.sufficient or self.route != "keep"
|
|
164
|
+
|
|
165
|
+
def to_dict(self) -> dict:
|
|
166
|
+
return asdict(self)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def assess_text(
|
|
170
|
+
text: str,
|
|
171
|
+
*,
|
|
172
|
+
order_known: bool = True,
|
|
173
|
+
models: ModelPair | None = None,
|
|
174
|
+
thresholds: Thresholds | None = None,
|
|
175
|
+
) -> Assessment:
|
|
176
|
+
"""Assess one piece of extracted Arabic text.
|
|
177
|
+
|
|
178
|
+
`order_known=True` is for an extractor's final output, where reading order is part of
|
|
179
|
+
what is being checked. `order_known=False` is for raw PDF text-layer runs whose order
|
|
180
|
+
depends on the extractor; reading-order faults are not reported then.
|
|
181
|
+
"""
|
|
182
|
+
models = models or default_models()
|
|
183
|
+
t = thresholds or default_thresholds()
|
|
184
|
+
prof = profile(text)
|
|
185
|
+
metrics: dict[str, float | int | str] = {
|
|
186
|
+
"arabic_letters": prof.arabic_letters,
|
|
187
|
+
"arabic_ratio": round(prof.arabic_ratio, 4),
|
|
188
|
+
"presentation_form_ratio": round(prof.presentation_form_ratio, 4),
|
|
189
|
+
"unmapped_ratio": round(prof.unmapped_ratio, 4),
|
|
190
|
+
"legacy_ratio": round(prof.legacy_ratio, 4),
|
|
191
|
+
"latin_vowel_ratio": round(prof.latin_vowel_ratio, 4),
|
|
192
|
+
"irregular_latin_word_ratio": round(prof.irregular_latin_word_ratio, 4),
|
|
193
|
+
"bidi_controls": prof.bidi_controls,
|
|
194
|
+
}
|
|
195
|
+
reasons: list[str] = []
|
|
196
|
+
route = "keep"
|
|
197
|
+
|
|
198
|
+
def flag(reason: str, new_route: str) -> None:
|
|
199
|
+
nonlocal route
|
|
200
|
+
reasons.append(reason)
|
|
201
|
+
if ROUTES.index(new_route) > ROUTES.index(route):
|
|
202
|
+
route = new_route
|
|
203
|
+
|
|
204
|
+
# All three are visible even when too few Arabic letters survive to score.
|
|
205
|
+
if prof.unmapped >= t.unmapped_min_glyphs and prof.unmapped_ratio > t.unmapped_ratio:
|
|
206
|
+
flag("unmapped_glyphs", "ocr")
|
|
207
|
+
symbol_legacy = prof.visible >= t.legacy_min_visible and prof.legacy_ratio > t.legacy_ratio
|
|
208
|
+
ascii_legacy = prof.latin_words >= t.latin_min_words and (
|
|
209
|
+
prof.latin_vowel_ratio < t.latin_vowel_ratio
|
|
210
|
+
or prof.irregular_latin_word_ratio > t.latin_irregular_ratio
|
|
211
|
+
)
|
|
212
|
+
if prof.arabic_ratio < 0.2 and (symbol_legacy or ascii_legacy):
|
|
213
|
+
flag("legacy_encoding", "ocr")
|
|
214
|
+
if (
|
|
215
|
+
prof.presentation_forms >= t.quran_min_forms
|
|
216
|
+
and prof.ligature_form_share > t.quran_ligature_share
|
|
217
|
+
and prof.short_presentation_run_share > t.quran_short_run_share
|
|
218
|
+
and prof.consecutive_ligature_share > t.quran_consecutive_share
|
|
219
|
+
):
|
|
220
|
+
# Glyph IDs, not letters: NFKC would turn them into wrong words. Judge the rest.
|
|
221
|
+
flag("quran_glyph_codes", "ocr")
|
|
222
|
+
text = drop_ligature_forms(text)
|
|
223
|
+
prof = profile(text)
|
|
224
|
+
|
|
225
|
+
m = measure(text, models, t)
|
|
226
|
+
metrics["arabic_letters"] = m.letters
|
|
227
|
+
if m.letters < t.min_letters:
|
|
228
|
+
if not reasons:
|
|
229
|
+
reasons.append("insufficient_arabic_text")
|
|
230
|
+
return Assessment(route, reasons, False, metrics)
|
|
231
|
+
|
|
232
|
+
metrics.update(
|
|
233
|
+
orientation=m.orientation,
|
|
234
|
+
bits=round(m.bits, 4),
|
|
235
|
+
spike_rate=round(m.spike_rate, 4),
|
|
236
|
+
bits_unspaced=round(m.bits_unspaced, 4),
|
|
237
|
+
short_token_ratio=round(m.short_token_ratio, 4),
|
|
238
|
+
decoration_runs=m.decoration_runs,
|
|
239
|
+
)
|
|
240
|
+
spiky = m.letters >= t.spike_min_letters and m.spike_rate > t.spike_rate
|
|
241
|
+
implausible = m.bits > t.substitution_bits or spiky
|
|
242
|
+
# Only meaningful for an extractor's final output. In a raw text-layer scan the spacing
|
|
243
|
+
# comes from harfix's own extraction, so implausible text is treated as wrong letters.
|
|
244
|
+
spacing_only = (
|
|
245
|
+
order_known
|
|
246
|
+
and m.short_token_ratio > t.short_token_ratio
|
|
247
|
+
and m.bits_unspaced <= t.unspaced_bits
|
|
248
|
+
)
|
|
249
|
+
if spacing_only:
|
|
250
|
+
# The letters are right; the extractor split words apart.
|
|
251
|
+
flag("lost_word_boundaries", "reextract")
|
|
252
|
+
elif implausible:
|
|
253
|
+
# Even after fixing the reading direction the letters do not form Arabic words:
|
|
254
|
+
# the font's glyph-to-letter mapping is wrong.
|
|
255
|
+
flag("same_script_substitution", "ocr")
|
|
256
|
+
if prof.presentation_form_ratio > t.presentation_form_ratio:
|
|
257
|
+
flag("presentation_forms", "repair")
|
|
258
|
+
|
|
259
|
+
if order_known and not implausible:
|
|
260
|
+
metrics.update(
|
|
261
|
+
visual_line_share=round(m.visual_line_share, 4),
|
|
262
|
+
transposition_ratio=round(m.transposition_ratio, 4),
|
|
263
|
+
)
|
|
264
|
+
if t.mixed_order_min_share <= m.visual_line_share <= 1 - t.mixed_order_min_share:
|
|
265
|
+
flag("mixed_reading_order", "reextract")
|
|
266
|
+
elif m.orientation == "visual":
|
|
267
|
+
flag("visual_order", "repair")
|
|
268
|
+
if m.transposition_ratio > t.transposition_ratio:
|
|
269
|
+
flag("lam_alef_transposition", "reextract")
|
|
270
|
+
|
|
271
|
+
return Assessment(route, reasons, True, metrics)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass
|
|
275
|
+
class Segment:
|
|
276
|
+
"""One page (text split at form feeds) or one run of lines of an extractor's output."""
|
|
277
|
+
|
|
278
|
+
index: int
|
|
279
|
+
page: int | None
|
|
280
|
+
start_line: int
|
|
281
|
+
end_line: int
|
|
282
|
+
assessment: Assessment
|
|
283
|
+
|
|
284
|
+
def to_dict(self) -> dict:
|
|
285
|
+
return {
|
|
286
|
+
"index": self.index,
|
|
287
|
+
"page": self.page,
|
|
288
|
+
"start_line": self.start_line,
|
|
289
|
+
"end_line": self.end_line,
|
|
290
|
+
**self.assessment.to_dict(),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass
|
|
295
|
+
class TextReport:
|
|
296
|
+
route: str
|
|
297
|
+
reasons: list[str]
|
|
298
|
+
segments: list[Segment]
|
|
299
|
+
|
|
300
|
+
def to_dict(self) -> dict:
|
|
301
|
+
return {
|
|
302
|
+
"route": self.route,
|
|
303
|
+
"reasons": self.reasons,
|
|
304
|
+
"segments": [s.to_dict() for s in self.segments],
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def worst(assessments: list[Assessment]) -> tuple[str, list[str]]:
|
|
309
|
+
"""Most severe route, with the reasons behind it (or every reason if all are keep)."""
|
|
310
|
+
route = max((a.route for a in assessments), default="keep", key=ROUTES.index)
|
|
311
|
+
if route == "keep":
|
|
312
|
+
return route, sorted({r for a in assessments for r in a.reasons})
|
|
313
|
+
return route, sorted({r for a in assessments if a.route != "keep" for r in a.reasons})
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _split(text: str, segment_letters: int, min_letters: int) -> list[tuple[int | None, int, str]]:
|
|
317
|
+
"""(page, first line number, text) pieces. Pages come from form feeds (pdftotext)."""
|
|
318
|
+
if "\f" in text:
|
|
319
|
+
pieces, line = [], 1
|
|
320
|
+
for number, page in enumerate(text.split("\f"), start=1):
|
|
321
|
+
if page.strip():
|
|
322
|
+
pieces.append((number, line, page))
|
|
323
|
+
line += page.count("\n")
|
|
324
|
+
return pieces
|
|
325
|
+
|
|
326
|
+
pieces: list[tuple[int | None, int, str]] = []
|
|
327
|
+
lines = text.split("\n")
|
|
328
|
+
start, letters = 0, 0
|
|
329
|
+
for i, line in enumerate(lines):
|
|
330
|
+
letters += len(letters_only(line).replace(BOUNDARY, ""))
|
|
331
|
+
if letters >= segment_letters:
|
|
332
|
+
pieces.append((None, start + 1, "\n".join(lines[start : i + 1])))
|
|
333
|
+
start, letters = i + 1, 0
|
|
334
|
+
rest = "\n".join(lines[start:])
|
|
335
|
+
if rest.strip():
|
|
336
|
+
if pieces and letters < min_letters:
|
|
337
|
+
# Too short to judge alone: fold the tail into the previous piece.
|
|
338
|
+
_, first, previous = pieces.pop()
|
|
339
|
+
pieces.append((None, first, previous + "\n" + rest))
|
|
340
|
+
else:
|
|
341
|
+
pieces.append((None, start + 1, rest))
|
|
342
|
+
return pieces
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def assess_document(
|
|
346
|
+
text: str,
|
|
347
|
+
*,
|
|
348
|
+
order_known: bool = True,
|
|
349
|
+
segment_letters: int = SEGMENT_LETTERS,
|
|
350
|
+
models: ModelPair | None = None,
|
|
351
|
+
thresholds: Thresholds | None = None,
|
|
352
|
+
) -> TextReport:
|
|
353
|
+
"""Assess a whole extracted document page by page, so a few bad pages are not averaged
|
|
354
|
+
away. Text with form feeds is split into pages; other text into runs of whole lines
|
|
355
|
+
holding at least `segment_letters` Arabic letters."""
|
|
356
|
+
models = models or default_models()
|
|
357
|
+
t = thresholds or default_thresholds()
|
|
358
|
+
segments = []
|
|
359
|
+
for index, (page, first, piece) in enumerate(_split(text, segment_letters, t.min_letters)):
|
|
360
|
+
assessment = assess_text(piece, order_known=order_known, models=models, thresholds=t)
|
|
361
|
+
last = first + piece.rstrip("\n").count("\n")
|
|
362
|
+
segments.append(Segment(index, page, first, last, assessment))
|
|
363
|
+
decisive = [s.assessment for s in segments if s.assessment.decisive]
|
|
364
|
+
route, reasons = worst(decisive or [s.assessment for s in segments])
|
|
365
|
+
return TextReport(route, reasons, segments)
|