arabicpdf 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.
- arabicpdf/__init__.py +25 -0
- arabicpdf/arabic_utils.py +326 -0
- arabicpdf/cli.py +168 -0
- arabicpdf/config.py +141 -0
- arabicpdf/consensus.py +267 -0
- arabicpdf/core.py +297 -0
- arabicpdf/corrector.py +348 -0
- arabicpdf/data/__init__.py +1 -0
- arabicpdf/data/arabic_words.txt.gz +0 -0
- arabicpdf/detector.py +267 -0
- arabicpdf/extractor.py +249 -0
- arabicpdf/ocr.py +376 -0
- arabicpdf/postprocessor.py +197 -0
- arabicpdf/preprocessor.py +357 -0
- arabicpdf-0.1.0.dist-info/METADATA +192 -0
- arabicpdf-0.1.0.dist-info/RECORD +19 -0
- arabicpdf-0.1.0.dist-info/WHEEL +4 -0
- arabicpdf-0.1.0.dist-info/entry_points.txt +2 -0
- arabicpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
arabicpdf/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""ArabicPDF — Convert any Arabic PDF to text. Lightweight. Local. Accurate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from arabicpdf.config import (
|
|
6
|
+
Config,
|
|
7
|
+
ConversionResult,
|
|
8
|
+
Mode,
|
|
9
|
+
PageResult,
|
|
10
|
+
PageType,
|
|
11
|
+
)
|
|
12
|
+
from arabicpdf.core import convert, to_text
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"to_text",
|
|
18
|
+
"convert",
|
|
19
|
+
"Config",
|
|
20
|
+
"Mode",
|
|
21
|
+
"PageType",
|
|
22
|
+
"PageResult",
|
|
23
|
+
"ConversionResult",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Arabic text utilities for ArabicPDF.
|
|
3
|
+
|
|
4
|
+
Provides Unicode-aware helpers for detecting, analysing, and tokenising
|
|
5
|
+
Arabic text. All functions are pure-Python with no external dependencies.
|
|
6
|
+
|
|
7
|
+
Key capabilities
|
|
8
|
+
─────────────────
|
|
9
|
+
• Unicode range checks (is_arabic_char, is_arabic_word, is_valid_arabic)
|
|
10
|
+
• Text analysis (arabic_char_ratio, count_arabic_words, has_diacritics, char_entropy)
|
|
11
|
+
• Dot-confusion data (DOT_GROUPS, CONFUSION_MAP, variant sets)
|
|
12
|
+
• Levenshtein edit-distance (no external deps)
|
|
13
|
+
• Arabic-aware tokenisation
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from typing import Final
|
|
22
|
+
|
|
23
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
24
|
+
# 1. Unicode range checks
|
|
25
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
#: Compiled pattern matching the main Arabic Unicode blocks:
|
|
28
|
+
#: U+0600–U+06FF Arabic
|
|
29
|
+
#: U+0750–U+077F Arabic Supplement
|
|
30
|
+
#: U+FB50–U+FDFF Arabic Presentation Forms-A
|
|
31
|
+
#: U+FE70–U+FEFF Arabic Presentation Forms-B
|
|
32
|
+
ARABIC_PATTERN: Final[re.Pattern[str]] = re.compile(
|
|
33
|
+
r"[\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_arabic_char(char: str) -> bool:
|
|
38
|
+
"""Check whether a single character falls within Arabic Unicode ranges.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
char:
|
|
43
|
+
A single character string.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
bool
|
|
48
|
+
``True`` if the character is in one of the Arabic Unicode blocks.
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
ValueError
|
|
53
|
+
If *char* is not exactly one character long.
|
|
54
|
+
"""
|
|
55
|
+
if len(char) != 1:
|
|
56
|
+
raise ValueError(f"Expected a single character, got {len(char)} characters")
|
|
57
|
+
return ARABIC_PATTERN.match(char) is not None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_arabic_word(word: str) -> bool:
|
|
61
|
+
"""Check whether a word is *predominantly* Arabic.
|
|
62
|
+
|
|
63
|
+
A word is considered Arabic when more than half of its non-whitespace
|
|
64
|
+
characters belong to Arabic Unicode ranges.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
word:
|
|
69
|
+
The word to test.
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
bool
|
|
74
|
+
``True`` if the word is predominantly Arabic.
|
|
75
|
+
"""
|
|
76
|
+
if not word:
|
|
77
|
+
return False
|
|
78
|
+
chars = [ch for ch in word if not ch.isspace()]
|
|
79
|
+
if not chars:
|
|
80
|
+
return False
|
|
81
|
+
arabic_count = sum(1 for ch in chars if ARABIC_PATTERN.match(ch))
|
|
82
|
+
return arabic_count > len(chars) / 2
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_valid_arabic(text: str, threshold: float = 0.6) -> bool:
|
|
86
|
+
"""Decide whether *text* is mostly valid Arabic.
|
|
87
|
+
|
|
88
|
+
Computes the ratio of Arabic characters to total non-space characters
|
|
89
|
+
and checks that it meets or exceeds *threshold*.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
text:
|
|
94
|
+
The text to evaluate.
|
|
95
|
+
threshold:
|
|
96
|
+
Minimum ratio of Arabic characters (default ``0.6``).
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
bool
|
|
101
|
+
``True`` if the Arabic-character ratio ≥ *threshold*.
|
|
102
|
+
"""
|
|
103
|
+
return arabic_char_ratio(text) >= threshold
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
107
|
+
# 2. Arabic text analysis
|
|
108
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def arabic_char_ratio(text: str) -> float:
|
|
111
|
+
"""Return the ratio of Arabic characters to total alphabetic letters.
|
|
112
|
+
|
|
113
|
+
Punctuation (e.g. leader dots in table of contents) and digits
|
|
114
|
+
do not penalise the ratio.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
text:
|
|
119
|
+
Input text.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
float
|
|
124
|
+
A value in [0.0, 1.0]. Returns ``0.0`` for empty / whitespace-only
|
|
125
|
+
text.
|
|
126
|
+
"""
|
|
127
|
+
letters = [ch for ch in text if ch.isalpha()]
|
|
128
|
+
if letters:
|
|
129
|
+
arabic_letters = sum(1 for ch in letters if ARABIC_PATTERN.match(ch))
|
|
130
|
+
return arabic_letters / len(letters)
|
|
131
|
+
|
|
132
|
+
non_space = [ch for ch in text if not ch.isspace()]
|
|
133
|
+
if not non_space:
|
|
134
|
+
return 0.0
|
|
135
|
+
arabic_count = sum(1 for ch in non_space if ARABIC_PATTERN.match(ch))
|
|
136
|
+
return arabic_count / len(non_space)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def count_arabic_words(text: str) -> int:
|
|
140
|
+
"""Count the number of words in *text* that are predominantly Arabic.
|
|
141
|
+
|
|
142
|
+
Words are split on whitespace via :func:`str.split`.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
text:
|
|
147
|
+
Input text.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
int
|
|
152
|
+
Number of Arabic words.
|
|
153
|
+
"""
|
|
154
|
+
return sum(1 for word in text.split() if is_arabic_word(word))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
#: Arabic diacritical marks (tashkeel), U+064B – U+0652.
|
|
158
|
+
_DIACRITICS_PATTERN: Final[re.Pattern[str]] = re.compile(r"[\u064B-\u0652]")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def has_diacritics(text: str) -> bool:
|
|
162
|
+
"""Check whether *text* contains any Arabic diacritics (tashkeel).
|
|
163
|
+
|
|
164
|
+
The diacritics checked are U+064B (fathatan) through U+0652 (sukun).
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
text:
|
|
169
|
+
Input text.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
bool
|
|
174
|
+
``True`` if at least one diacritical mark is present.
|
|
175
|
+
"""
|
|
176
|
+
return _DIACRITICS_PATTERN.search(text) is not None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def char_entropy(text: str) -> float:
|
|
180
|
+
"""Compute the Shannon entropy of the character distribution in *text*.
|
|
181
|
+
|
|
182
|
+
Garbled / incorrectly-decoded text tends to have abnormally high or
|
|
183
|
+
low entropy compared to natural Arabic.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
text:
|
|
188
|
+
Input text (whitespace is included in the distribution).
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
float
|
|
193
|
+
Entropy in bits. Returns ``0.0`` for empty text.
|
|
194
|
+
"""
|
|
195
|
+
if not text:
|
|
196
|
+
return 0.0
|
|
197
|
+
counts = Counter(text)
|
|
198
|
+
total = len(text)
|
|
199
|
+
entropy = 0.0
|
|
200
|
+
for count in counts.values():
|
|
201
|
+
prob = count / total
|
|
202
|
+
if prob > 0:
|
|
203
|
+
entropy -= prob * math.log2(prob)
|
|
204
|
+
return entropy
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
208
|
+
# 3. Arabic dot-confusion data
|
|
209
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
#: Groups of Arabic letters that share the same base shape and differ
|
|
212
|
+
#: only in the number / placement of dots.
|
|
213
|
+
DOT_GROUPS: Final[list[str]] = [
|
|
214
|
+
"بتثنی", # ba / ta / tha / nun / ya (Farsi)
|
|
215
|
+
"جحخ", # jim / ḥa / kha
|
|
216
|
+
"دذ", # dal / dhal
|
|
217
|
+
"رز", # ra / zay
|
|
218
|
+
"سش", # sin / shin
|
|
219
|
+
"صض", # ṣad / ḍad
|
|
220
|
+
"طظ", # ṭa / ẓa
|
|
221
|
+
"عغ", # ʿain / ghain
|
|
222
|
+
"فق", # fa / qaf
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
#: Map each Arabic letter to the set of letters it can be confused with
|
|
226
|
+
#: (same base shape, different dots). Built from :data:`DOT_GROUPS`.
|
|
227
|
+
CONFUSION_MAP: Final[dict[str, set[str]]] = {}
|
|
228
|
+
for _group in DOT_GROUPS:
|
|
229
|
+
for _char in _group:
|
|
230
|
+
CONFUSION_MAP[_char] = set(_group) - {_char}
|
|
231
|
+
|
|
232
|
+
#: Common alef variants that may need normalisation.
|
|
233
|
+
ALEF_VARIANTS: Final[str] = "اأإآٱ"
|
|
234
|
+
|
|
235
|
+
#: Ta marbuta and Ha — often confused in OCR output.
|
|
236
|
+
TA_MARBUTA_HA: Final[str] = "ةه"
|
|
237
|
+
|
|
238
|
+
#: Alef maqsura and Ya — often confused in OCR output.
|
|
239
|
+
ALEF_MAQSURA_YA: Final[str] = "ىي"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
243
|
+
# 4. Levenshtein distance
|
|
244
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
def levenshtein(s1: str, s2: str) -> int:
|
|
247
|
+
"""Compute the Levenshtein (edit) distance between two strings.
|
|
248
|
+
|
|
249
|
+
Uses the classic dynamic-programming algorithm with *O(min(m, n))*
|
|
250
|
+
space (single-row optimisation). No external dependencies.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
s1:
|
|
255
|
+
First string.
|
|
256
|
+
s2:
|
|
257
|
+
Second string.
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
int
|
|
262
|
+
Minimum number of single-character edits (insertions, deletions,
|
|
263
|
+
or substitutions) needed to transform *s1* into *s2*.
|
|
264
|
+
"""
|
|
265
|
+
# Ensure s1 is the shorter string for space optimisation.
|
|
266
|
+
if len(s1) > len(s2):
|
|
267
|
+
s1, s2 = s2, s1
|
|
268
|
+
|
|
269
|
+
n = len(s1)
|
|
270
|
+
m = len(s2)
|
|
271
|
+
|
|
272
|
+
# Previous row of distances.
|
|
273
|
+
previous: list[int] = list(range(n + 1))
|
|
274
|
+
|
|
275
|
+
for j in range(1, m + 1):
|
|
276
|
+
current: list[int] = [j] + [0] * n
|
|
277
|
+
for i in range(1, n + 1):
|
|
278
|
+
cost = 0 if s1[i - 1] == s2[j - 1] else 1
|
|
279
|
+
current[i] = min(
|
|
280
|
+
current[i - 1] + 1, # insertion
|
|
281
|
+
previous[i] + 1, # deletion
|
|
282
|
+
previous[i - 1] + cost, # substitution
|
|
283
|
+
)
|
|
284
|
+
previous = current
|
|
285
|
+
|
|
286
|
+
return previous[n]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
290
|
+
# 5. Text tokenisation
|
|
291
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
#: Arabic-specific punctuation characters that should act as token
|
|
294
|
+
#: boundaries in addition to whitespace.
|
|
295
|
+
_ARABIC_PUNCTUATION: Final[str] = (
|
|
296
|
+
"\u060C" # Arabic comma ،
|
|
297
|
+
"\u061B" # Arabic semicolon ؛
|
|
298
|
+
"\u061F" # Arabic question mark ؟
|
|
299
|
+
"\u06D4" # Arabic full stop ۔
|
|
300
|
+
"\u0640" # tatweel (kashida) ـ
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
#: Pattern that splits on whitespace and Arabic / Latin punctuation,
|
|
304
|
+
#: keeping only non-empty tokens.
|
|
305
|
+
_TOKENIZE_PATTERN: Final[re.Pattern[str]] = re.compile(
|
|
306
|
+
r"[" + re.escape(_ARABIC_PUNCTUATION) + r"\s\.\,\;\:\!\?\"\'\(\)\[\]\{\}<>«»/\\]+"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def tokenize_arabic(text: str) -> list[str]:
|
|
311
|
+
"""Split *text* into word tokens, handling Arabic punctuation.
|
|
312
|
+
|
|
313
|
+
Splits on whitespace as well as common Arabic and Latin punctuation.
|
|
314
|
+
Empty tokens are discarded.
|
|
315
|
+
|
|
316
|
+
Parameters
|
|
317
|
+
----------
|
|
318
|
+
text:
|
|
319
|
+
Input text (may contain mixed Arabic / Latin content).
|
|
320
|
+
|
|
321
|
+
Returns
|
|
322
|
+
-------
|
|
323
|
+
list[str]
|
|
324
|
+
Non-empty word tokens, in their original order.
|
|
325
|
+
"""
|
|
326
|
+
return [tok for tok in _TOKENIZE_PATTERN.split(text) if tok]
|
arabicpdf/cli.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Command-line interface for ArabicPDF."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from arabicpdf import __version__, convert, to_text, Config, Mode
|
|
10
|
+
from arabicpdf.detector import classify_document
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_pages(pages_str: str | None) -> list[int] | None:
|
|
14
|
+
"""Parse a page range string like '1,3,5-7' into a sorted list of ints."""
|
|
15
|
+
if not pages_str:
|
|
16
|
+
return None
|
|
17
|
+
pages: set[int] = set()
|
|
18
|
+
for part in pages_str.split(","):
|
|
19
|
+
part = part.strip()
|
|
20
|
+
if "-" in part:
|
|
21
|
+
start_s, end_s = part.split("-", 1)
|
|
22
|
+
start, end = int(start_s.strip()), int(end_s.strip())
|
|
23
|
+
pages.update(range(start, end + 1))
|
|
24
|
+
else:
|
|
25
|
+
pages.add(int(part))
|
|
26
|
+
return sorted(pages)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="arabicpdf",
|
|
32
|
+
description="Convert any Arabic PDF to clean text locally with high accuracy.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
39
|
+
|
|
40
|
+
# Command: convert
|
|
41
|
+
convert_parser = subparsers.add_parser(
|
|
42
|
+
"convert", help="Extract text from an Arabic PDF"
|
|
43
|
+
)
|
|
44
|
+
convert_parser.add_argument(
|
|
45
|
+
"pdf_path", type=str, help="Path to input Arabic PDF"
|
|
46
|
+
)
|
|
47
|
+
convert_parser.add_argument(
|
|
48
|
+
"-o", "--output", type=str, default=None, help="Output text file path (default: stdout)"
|
|
49
|
+
)
|
|
50
|
+
convert_parser.add_argument(
|
|
51
|
+
"-m",
|
|
52
|
+
"--mode",
|
|
53
|
+
choices=["fast", "balanced", "max_accuracy"],
|
|
54
|
+
default="balanced",
|
|
55
|
+
help="Extraction mode (default: balanced)",
|
|
56
|
+
)
|
|
57
|
+
convert_parser.add_argument(
|
|
58
|
+
"-p", "--pages", type=str, default=None, help="Page numbers or ranges (e.g. '1,3,5-8')"
|
|
59
|
+
)
|
|
60
|
+
convert_parser.add_argument(
|
|
61
|
+
"--dpi", type=int, default=300, help="Rendering DPI for OCR (default: 300)"
|
|
62
|
+
)
|
|
63
|
+
convert_parser.add_argument(
|
|
64
|
+
"--strip-diacritics",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="Remove Arabic vowels/harakat from output",
|
|
67
|
+
)
|
|
68
|
+
convert_parser.add_argument(
|
|
69
|
+
"--normalize-alef",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="Normalize all alef forms (أ/إ/آ) to bare alef (ا)",
|
|
72
|
+
)
|
|
73
|
+
convert_parser.add_argument(
|
|
74
|
+
"--no-spell-check",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="Disable dictionary-based spell and dot correction",
|
|
77
|
+
)
|
|
78
|
+
convert_parser.add_argument(
|
|
79
|
+
"--details",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Print detailed page-level extraction metadata",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Command: inspect / info
|
|
85
|
+
inspect_parser = subparsers.add_parser(
|
|
86
|
+
"inspect", help="Inspect PDF page types (digital vs scanned vs corrupted)"
|
|
87
|
+
)
|
|
88
|
+
inspect_parser.add_argument(
|
|
89
|
+
"pdf_path", type=str, help="Path to input Arabic PDF"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
args = parser.parse_args(argv)
|
|
93
|
+
|
|
94
|
+
if not args.command:
|
|
95
|
+
# Default behavior: if a single positional argument is passed without subcommand
|
|
96
|
+
if len(sys.argv) > 1 and not sys.argv[1].startswith("-"):
|
|
97
|
+
args.command = "convert"
|
|
98
|
+
args.pdf_path = sys.argv[1]
|
|
99
|
+
args.output = None
|
|
100
|
+
args.mode = "balanced"
|
|
101
|
+
args.pages = None
|
|
102
|
+
args.dpi = 300
|
|
103
|
+
args.strip_diacritics = False
|
|
104
|
+
args.normalize_alef = False
|
|
105
|
+
args.no_spell_check = False
|
|
106
|
+
args.details = False
|
|
107
|
+
else:
|
|
108
|
+
parser.print_help()
|
|
109
|
+
return 1
|
|
110
|
+
|
|
111
|
+
if args.command == "inspect":
|
|
112
|
+
pdf_file = Path(args.pdf_path)
|
|
113
|
+
if not pdf_file.is_file():
|
|
114
|
+
sys.stderr.write(f"Error: File not found: {args.pdf_path}\n")
|
|
115
|
+
return 1
|
|
116
|
+
print(f"Inspecting '{args.pdf_path}'...\n")
|
|
117
|
+
classifications = classify_document(str(pdf_file))
|
|
118
|
+
for c in classifications:
|
|
119
|
+
print(
|
|
120
|
+
f"Page {c.page_num:3d}: Type={c.page_type.value:18s} "
|
|
121
|
+
f"Confidence={c.confidence*100:5.1f}% HasImages={str(c.has_images):5s}"
|
|
122
|
+
)
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
if args.command == "convert":
|
|
126
|
+
pdf_file = Path(args.pdf_path)
|
|
127
|
+
if not pdf_file.is_file():
|
|
128
|
+
sys.stderr.write(f"Error: File not found: {args.pdf_path}\n")
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
pages_list = _parse_pages(args.pages)
|
|
132
|
+
cfg = Config(
|
|
133
|
+
mode=Mode(args.mode),
|
|
134
|
+
pages=pages_list,
|
|
135
|
+
preserve_diacritics=not args.strip_diacritics,
|
|
136
|
+
normalize_alef=args.normalize_alef,
|
|
137
|
+
fix_spelling=not args.no_spell_check,
|
|
138
|
+
fix_dot_confusion=not args.no_spell_check,
|
|
139
|
+
render_dpi=args.dpi,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
result = convert(str(pdf_file), config=cfg)
|
|
143
|
+
|
|
144
|
+
if args.details:
|
|
145
|
+
sys.stderr.write(f"Processed {result.page_count} page(s). Document confidence: {result.confidence*100:.1f}%\n")
|
|
146
|
+
for p in result.pages:
|
|
147
|
+
flags_msg = f" | Flags: {', '.join(p.flags)}" if p.flags else ""
|
|
148
|
+
sys.stderr.write(
|
|
149
|
+
f" - Page {p.number}: Method={p.method} Conf={p.confidence*100:.1f}%{flags_msg}\n"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if args.output:
|
|
153
|
+
out_path = Path(args.output)
|
|
154
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
out_path.write_text(result.text, encoding="utf-8")
|
|
156
|
+
sys.stderr.write(f"Saved extracted text to: {args.output}\n")
|
|
157
|
+
else:
|
|
158
|
+
sys.stdout.write(result.text)
|
|
159
|
+
if not result.text.endswith("\n"):
|
|
160
|
+
sys.stdout.write("\n")
|
|
161
|
+
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main())
|
arabicpdf/config.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Configuration for ArabicPDF pipeline.
|
|
2
|
+
|
|
3
|
+
Uses Pydantic when installed, with graceful fallback to standard library
|
|
4
|
+
dataclasses for ultra-lightweight environments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Optional, Any
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
HAS_PYDANTIC = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
HAS_PYDANTIC = False
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Mode(str, Enum):
|
|
21
|
+
"""Processing mode — trades speed for accuracy."""
|
|
22
|
+
|
|
23
|
+
FAST = "fast" # Qalam only (digital) or single OCR (scanned)
|
|
24
|
+
BALANCED = "balanced" # Dual OCR + consensus + correction
|
|
25
|
+
MAX_ACCURACY = "max_accuracy" # All available engines + full correction
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PageType(str, Enum):
|
|
29
|
+
"""Classification of a PDF page."""
|
|
30
|
+
|
|
31
|
+
DIGITAL_CLEAN = "digital_clean" # Valid extractable Arabic text
|
|
32
|
+
DIGITAL_CORRUPTED = "digital_corrupted" # Text layer exists but garbled
|
|
33
|
+
SCANNED = "scanned" # Image-only, no text layer
|
|
34
|
+
MIXED = "mixed" # Some regions digital, some scanned
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if HAS_PYDANTIC:
|
|
38
|
+
class Config(BaseModel):
|
|
39
|
+
"""Pipeline configuration."""
|
|
40
|
+
|
|
41
|
+
mode: Mode = Field(default=Mode.BALANCED, description="Processing mode")
|
|
42
|
+
pages: Optional[list[int]] = Field(
|
|
43
|
+
default=None, description="Pages to process (1-indexed). None = all."
|
|
44
|
+
)
|
|
45
|
+
preserve_diacritics: bool = Field(
|
|
46
|
+
default=True, description="Preserve tashkeel/harakat"
|
|
47
|
+
)
|
|
48
|
+
normalize_alef: bool = Field(
|
|
49
|
+
default=False, description="Normalize أ/إ/آ → ا"
|
|
50
|
+
)
|
|
51
|
+
fix_spelling: bool = Field(
|
|
52
|
+
default=True, description="Apply Hunspell spell correction"
|
|
53
|
+
)
|
|
54
|
+
fix_dot_confusion: bool = Field(
|
|
55
|
+
default=True, description="Apply Arabic dot-confusion resolver"
|
|
56
|
+
)
|
|
57
|
+
render_dpi: int = Field(
|
|
58
|
+
default=300,
|
|
59
|
+
ge=150,
|
|
60
|
+
le=1200,
|
|
61
|
+
description="DPI for rendering PDF pages to images",
|
|
62
|
+
)
|
|
63
|
+
arabic_threshold: float = Field(
|
|
64
|
+
default=0.6,
|
|
65
|
+
ge=0.0,
|
|
66
|
+
le=1.0,
|
|
67
|
+
description="Minimum ratio of Arabic chars to consider text valid",
|
|
68
|
+
)
|
|
69
|
+
correction_max_distance: int = Field(
|
|
70
|
+
default=2,
|
|
71
|
+
ge=1,
|
|
72
|
+
le=5,
|
|
73
|
+
description="Max Levenshtein distance for spell corrections",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
class PageResult(BaseModel):
|
|
77
|
+
"""Result for a single page."""
|
|
78
|
+
|
|
79
|
+
number: int
|
|
80
|
+
text: str
|
|
81
|
+
method: str # "direct", "ocr", "ocr+corrected"
|
|
82
|
+
confidence: float # 0.0 to 1.0
|
|
83
|
+
page_type: PageType
|
|
84
|
+
flags: list[str] = Field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
class ConversionResult(BaseModel):
|
|
87
|
+
"""Full conversion result."""
|
|
88
|
+
|
|
89
|
+
text: str
|
|
90
|
+
pages: list[PageResult]
|
|
91
|
+
confidence: float # Average across pages
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def page_count(self) -> int:
|
|
95
|
+
return len(self.pages)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def total_pages(self) -> int:
|
|
99
|
+
return len(self.pages)
|
|
100
|
+
|
|
101
|
+
else:
|
|
102
|
+
@dataclass
|
|
103
|
+
class Config:
|
|
104
|
+
"""Pipeline configuration (dataclass fallback)."""
|
|
105
|
+
|
|
106
|
+
mode: Mode = Mode.BALANCED
|
|
107
|
+
pages: Optional[list[int]] = None
|
|
108
|
+
preserve_diacritics: bool = True
|
|
109
|
+
normalize_alef: bool = False
|
|
110
|
+
fix_spelling: bool = True
|
|
111
|
+
fix_dot_confusion: bool = True
|
|
112
|
+
render_dpi: int = 300
|
|
113
|
+
arabic_threshold: float = 0.6
|
|
114
|
+
correction_max_distance: int = 2
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class PageResult:
|
|
118
|
+
"""Result for a single page (dataclass fallback)."""
|
|
119
|
+
|
|
120
|
+
number: int
|
|
121
|
+
text: str
|
|
122
|
+
method: str
|
|
123
|
+
confidence: float
|
|
124
|
+
page_type: PageType
|
|
125
|
+
flags: list[str] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class ConversionResult:
|
|
129
|
+
"""Full conversion result (dataclass fallback)."""
|
|
130
|
+
|
|
131
|
+
text: str
|
|
132
|
+
pages: list[PageResult]
|
|
133
|
+
confidence: float
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def page_count(self) -> int:
|
|
137
|
+
return len(self.pages)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def total_pages(self) -> int:
|
|
141
|
+
return len(self.pages)
|