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 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)