pdf-rtldoc 1.0.6__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.
Potentially problematic release.
This version of pdf-rtldoc might be problematic. Click here for more details.
- pdf_rtldoc-1.0.6.dist-info/METADATA +163 -0
- pdf_rtldoc-1.0.6.dist-info/RECORD +16 -0
- pdf_rtldoc-1.0.6.dist-info/WHEEL +5 -0
- pdf_rtldoc-1.0.6.dist-info/entry_points.txt +2 -0
- pdf_rtldoc-1.0.6.dist-info/licenses/LICENSE +21 -0
- pdf_rtldoc-1.0.6.dist-info/top_level.txt +1 -0
- rtldoc/__init__.py +1 -0
- rtldoc/arabic.py +204 -0
- rtldoc/cli.py +75 -0
- rtldoc/cvfallback.py +111 -0
- rtldoc/geobidi.py +262 -0
- rtldoc/layout.py +2026 -0
- rtldoc/ocr.py +115 -0
- rtldoc/pipeline.py +843 -0
- rtldoc/primitives.py +631 -0
- rtldoc/visual.py +508 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdf-rtldoc
|
|
3
|
+
Version: 1.0.6
|
|
4
|
+
Summary: Geometry-first PDF parser for RTL and complex layouts (Arabic textbooks, financial tables, multi-column docs)
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/umerjavaidkh/rtldoc
|
|
7
|
+
Project-URL: Repository, https://github.com/umerjavaidkh/rtldoc
|
|
8
|
+
Project-URL: Issues, https://github.com/umerjavaidkh/rtldoc/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: pymupdf>=1.24
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Provides-Extra: cv
|
|
15
|
+
Requires-Dist: opencv-python-headless; extra == "cv"
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: arabic-reshaper; extra == "dev"
|
|
18
|
+
Requires-Dist: python-bidi; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# rtldoc
|
|
22
|
+
|
|
23
|
+
**A geometry-first PDF parser for RTL & complex layouts.** It extracts text,
|
|
24
|
+
reading order, and tables from born-digital PDFs using the glyph coordinates
|
|
25
|
+
already in the file — **no OCR, no GPU, no API**. Built for the pages that
|
|
26
|
+
quietly break Docling, Marker, and VLM parsers: Arabic/RTL, multi-column,
|
|
27
|
+
and vector tables.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Why it's better where it matters
|
|
32
|
+
|
|
33
|
+
- **RTL / Arabic done right.** Reading order and bidi are rebuilt from glyph
|
|
34
|
+
*positions*, not from a reading-order model trained on English. It fixes the
|
|
35
|
+
presentation-form and lam-alef bugs that silently corrupt ~⅓ of Arabic words
|
|
36
|
+
in every general parser — encoding bugs no layout model can fine-tune away.
|
|
37
|
+
- **Tables without a model.** Reads table structure from the PDF's own vector
|
|
38
|
+
rules at 100% (vs ~85–95% from a CNN), and recovers *borderless* tables by
|
|
39
|
+
column alignment.
|
|
40
|
+
- **Deterministic & auditable.** Every output traces to a rule you can point
|
|
41
|
+
at. CPU-only, **6–60 pages/sec**, $0/page — a VLM is 100–1000× the cost and
|
|
42
|
+
can't be audited.
|
|
43
|
+
- **Page visual summary, no vision model.** Opt-in (`--visual`) geometry pass
|
|
44
|
+
that turns a page's own vector drawings into a structured summary: image
|
|
45
|
+
size/color stats, table dimensions, and — for vector flowcharts/diagrams —
|
|
46
|
+
the actual boxes, their text labels, and which ones a connecting line joins.
|
|
47
|
+
Built entirely from the PDF's own drawing commands (PyMuPDF `get_drawings()`),
|
|
48
|
+
not a screenshot or a guess. HTML output renders any detected diagram as a
|
|
49
|
+
real chart via Mermaid.js, fed the extracted nodes/edges directly.
|
|
50
|
+
|
|
51
|
+
## Proven at scale
|
|
52
|
+
|
|
53
|
+
Tested on **119 PDFs / 13,557 pages** it never saw during development — an
|
|
54
|
+
Arabic teacher's guide, two SEC 10-Ks, 96+ arXiv papers (15 fields), 5 OpenStax
|
|
55
|
+
physics/chemistry/calculus textbooks (figures, geometry, exercises), the
|
|
56
|
+
3,130-page PostgreSQL 18 manual (deeply-nested reference tables, code blocks),
|
|
57
|
+
and a growing set of real-world forms, reports, and scanned documents.
|
|
58
|
+
The checks are *property-based and label-free*, so they scale to any corpus:
|
|
59
|
+
|
|
60
|
+
| Property (must hold on every page) | Result |
|
|
61
|
+
|---|---:|
|
|
62
|
+
| crashes | **0** |
|
|
63
|
+
| encoding leaks (presentation forms in output) | **0** |
|
|
64
|
+
| malformed tables | **0** |
|
|
65
|
+
| non-deterministic pages | **0** |
|
|
66
|
+
| text coverage vs the PDF's own glyph stream | **~99%** |
|
|
67
|
+
|
|
68
|
+
**Table quality, scored with TEDS** (the PubTabNet/OmniDocBench standard) on a
|
|
69
|
+
borderless financial statement — where the whole point is a hard table:
|
|
70
|
+
|
|
71
|
+
| rtldoc | pdfplumber | naive `get_text` |
|
|
72
|
+
|---:|---:|---:|
|
|
73
|
+
| **0.942** | 0.061 | 0.000 |
|
|
74
|
+
|
|
75
|
+
Everything is reproducible in [`eval/`](eval/) (harnesses, arXiv manifest,
|
|
76
|
+
saved reports).
|
|
77
|
+
|
|
78
|
+
## Use it
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install pdf-rtldoc
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
(Published on PyPI as `pdf-rtldoc` -- the plain `rtldoc` name was already
|
|
85
|
+
taken by an unrelated project. The installed command, and the module you
|
|
86
|
+
import in Python, are both still `rtldoc`.)
|
|
87
|
+
|
|
88
|
+
Or install straight from a specific release without going through PyPI:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install "git+https://github.com/umerjavaidkh/rtldoc.git@v1.0.6"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Then:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
rtldoc parse book.pdf --md out/ --json out.json
|
|
98
|
+
rtldoc parse book.pdf --html out_html/ # real <table>/<figure>, RTL-aware dir=
|
|
99
|
+
rtldoc parse book.pdf --html out_html/ --visual # + diagram/image/table visual summary
|
|
100
|
+
rtldoc audit book.pdf # flags low-confidence pages for review
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Runtime deps are just PyMuPDF + numpy. (From a clone: `pip install -e .`.)
|
|
104
|
+
|
|
105
|
+
Zero-setup via Docker (399 MB, no compiler/GPU):
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
docker build -t rtldoc . && docker run --rm -v "$PWD:/d" rtldoc parse /d/book.pdf --md /d/out
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Output: per-page Markdown (tables as GFM, images extracted + auto-captioned)
|
|
112
|
+
plus structured JSON, or a self-contained HTML page per PDF page.
|
|
113
|
+
|
|
114
|
+
## Honest limits
|
|
115
|
+
|
|
116
|
+
- Borderless-table *grid geometry* is approximate on the hardest wide,
|
|
117
|
+
multi-level-header tables (occasional row/column structure mismatches
|
|
118
|
+
— that's the 0.942 TEDS on our graded case, not 1.0).
|
|
119
|
+
- A page dominated by a figure/table spanning the full content width can
|
|
120
|
+
still be under-counted as fewer columns than it visually has — the
|
|
121
|
+
whitespace-gutter detector requires the gap to stay empty across most
|
|
122
|
+
of the page's height, and a full-width element defeats that locally.
|
|
123
|
+
- A table cell whose own text wraps onto a later line, where that line's
|
|
124
|
+
content coincidentally re-aligns with an earlier row's columns, can
|
|
125
|
+
occasionally attach to the wrong row (`_merge_wrapped_label_rows` — a
|
|
126
|
+
narrow, tracked edge case, not a general table-detection failure).
|
|
127
|
+
- Scanned / no-text-layer pages now OCR via Tesseract (needs the `tesseract`
|
|
128
|
+
binary on PATH — `brew install tesseract` / `apt install tesseract-ocr`;
|
|
129
|
+
no Python package required). Word-level positioning, not this repo's
|
|
130
|
+
glyph-exact reading order; a page with no tesseract installed just gets
|
|
131
|
+
no blocks, as before.
|
|
132
|
+
- Best semantic typing needs a one-time per-publisher style map (~20 min).
|
|
133
|
+
- Diagram detection reconstructs simple box-and-arrow flowcharts reliably;
|
|
134
|
+
dense multi-level diagrams (deep tree/org-chart hierarchies with many
|
|
135
|
+
branches) get correct node/box detection but not yet reliable connection
|
|
136
|
+
tracing — a harder, separate problem noted for future work.
|
|
137
|
+
|
|
138
|
+
## Roadmap
|
|
139
|
+
|
|
140
|
+
- Reliable connection tracing for dense/branching diagrams (see above).
|
|
141
|
+
- A page-level chart/figure classification pass, so bar charts, legends,
|
|
142
|
+
and gridlines are recognized and set aside before table/diagram
|
|
143
|
+
detection runs, rather than relying on those detectors' own guards to
|
|
144
|
+
reject them case by case.
|
|
145
|
+
- A cell-level golden regression corpus (`eval/golden/` + `eval/regression.py`)
|
|
146
|
+
now exists and grows with each table-detection fix; still short of full
|
|
147
|
+
coverage across document types.
|
|
148
|
+
|
|
149
|
+
## Publishing a release (maintainers)
|
|
150
|
+
|
|
151
|
+
Bump `version` in `pyproject.toml`, commit, tag (`git tag -a vX.Y.Z`), push
|
|
152
|
+
the tag, then publish a GitHub Release from it. `.github/workflows/publish.yml`
|
|
153
|
+
builds and uploads to PyPI automatically when the release is published, via
|
|
154
|
+
PyPI's Trusted Publisher (OIDC) mechanism — no API token stored anywhere.
|
|
155
|
+
|
|
156
|
+
One-time setup (already done for `pdf-rtldoc`): on pypi.org, under the
|
|
157
|
+
project's *Publishing* settings, add a trusted publisher with owner
|
|
158
|
+
`umerjavaidkh`, repository `rtldoc`, workflow filename `publish.yml`, and
|
|
159
|
+
environment name `pypi`.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
*Design rationale, the full bug log, and methodology: **[DESIGN.md](DESIGN.md)**.*
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pdf_rtldoc-1.0.6.dist-info/licenses/LICENSE,sha256=xNgR7dC-qNVH0g0hABLrZoCct0E67zBtkG8eMVnFTF8,1068
|
|
2
|
+
rtldoc/__init__.py,sha256=AhTCif3PKa_5RBv1CwqDaXpStwMXEVGMcgYrrkyBQwc,70
|
|
3
|
+
rtldoc/arabic.py,sha256=qKDQ_2qDMyeFSF5bWQHqrJ_2l1V3E6pxfpVBbZNHe9I,8401
|
|
4
|
+
rtldoc/cli.py,sha256=xhxORmoaaS3tw-_HmakUz2Zt6Qp6uHXQJbj-hn6AoP0,3575
|
|
5
|
+
rtldoc/cvfallback.py,sha256=DT4Vu5zrd16Mzl1enLeGWHbapSJOQOEKX6iwTL4hGkE,4781
|
|
6
|
+
rtldoc/geobidi.py,sha256=QlFM8Ej-k9AM0ugXx2uPSbWNwSQLDmG_jF73KtVcT9I,10921
|
|
7
|
+
rtldoc/layout.py,sha256=khLpBDaVkpHj8cVFHDw7VdjuU0LINhjqGu-3CQ3PUoU,109664
|
|
8
|
+
rtldoc/ocr.py,sha256=8XmmDz9i1vgvRCJ-m0tzZZPAnyxxMYSFG_xiVqdv59g,4398
|
|
9
|
+
rtldoc/pipeline.py,sha256=7Ty4C16_vIVbWRO34bgiqKkQZq6ENKphS_6w_NV81Is,39087
|
|
10
|
+
rtldoc/primitives.py,sha256=ETs6GK-Pa_5a_wxG0cAhs9ZoDzpTKRSTbeRItEzv4eg,28233
|
|
11
|
+
rtldoc/visual.py,sha256=KmyxdWHGf0NwCkJiFB0YFm5u2TCcinBkUP6M5KgSgoA,24006
|
|
12
|
+
pdf_rtldoc-1.0.6.dist-info/METADATA,sha256=xaYxzIXdea4D-mBx57J1YxhgUdgjvEcKeins5nIBrkA,7143
|
|
13
|
+
pdf_rtldoc-1.0.6.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
14
|
+
pdf_rtldoc-1.0.6.dist-info/entry_points.txt,sha256=pMfV5CHW0E6pF5b_m_p-KhE6w_gAj-8acLyLiujgVYM,43
|
|
15
|
+
pdf_rtldoc-1.0.6.dist-info/top_level.txt,sha256=6mnTflvaxkcMy5mrITEFB6zrlPgz-eFjnh9M_P8CIUs,7
|
|
16
|
+
pdf_rtldoc-1.0.6.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Umer Javaid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rtldoc
|
rtldoc/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""rtldoc — a geometry-first parser for RTL complex-layout PDFs."""
|
rtldoc/arabic.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Arabic text repair for PDF-extracted strings.
|
|
3
|
+
|
|
4
|
+
Three failure modes are handled, in this order:
|
|
5
|
+
1. Presentation forms (U+FB50-FDFF, U+FE70-FEFF) leaking into the text layer
|
|
6
|
+
because the PDF's ToUnicode map points at shaped glyphs, not base letters.
|
|
7
|
+
2. Visual-order strings (logically reversed) produced by extractors that do no
|
|
8
|
+
bidi reordering, or partially-reversed strings caused by ligature glyphs
|
|
9
|
+
(lam-alef) that survive reversal as a unit.
|
|
10
|
+
3. Cosmetic noise: kashida/tatweel, non-breaking marks, digit systems.
|
|
11
|
+
|
|
12
|
+
Nothing here is lossy by default. Diacritics (harakat) are PRESERVED because
|
|
13
|
+
vowelled text is the whole point of a primary-school Arabic corpus.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import unicodedata
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------
|
|
23
|
+
# character classes
|
|
24
|
+
# --------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
PRESENTATION_RANGES = ((0xFB50, 0xFDFF), (0xFE70, 0xFEFF))
|
|
27
|
+
ARABIC_BLOCK = re.compile(r"[\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]")
|
|
28
|
+
HARAKAT = re.compile(r"[\u064B-\u0652\u0670\u0640]") # incl. tatweel
|
|
29
|
+
TATWEEL = "\u0640"
|
|
30
|
+
INVISIBLES = dict.fromkeys(map(ord, "\u200b\u200c\u200d\u200e\u200f\u202a\u202b\u202c\u2066\u2067\u2068\u2069"), None)
|
|
31
|
+
|
|
32
|
+
ARABIC_INDIC = {ord(c): ord(d) for c, d in zip("٠١٢٣٤٥٦٧٨٩", "0123456789")}
|
|
33
|
+
EASTERN_INDIC = {ord(c): ord(d) for c, d in zip("۰۱۲۳۴۵۶۷۸۹", "0123456789")}
|
|
34
|
+
|
|
35
|
+
# High-frequency Arabic function words. Used as a language-model-free
|
|
36
|
+
# orientation test: a correctly-ordered string contains many of these,
|
|
37
|
+
# a reversed one contains almost none.
|
|
38
|
+
STOPWORDS = frozenset(
|
|
39
|
+
(
|
|
40
|
+
"في من على إلى عن أن إن التي الذي هذا هذه ذلك مع كان قد هو هي هم "
|
|
41
|
+
"بعد قبل بين كل عند ثم أو حتى لكن كما حيث منذ نحو لدى وفي ومن والتي "
|
|
42
|
+
"الهدف إجابات النص التلاميذ القراءة السؤال إجابة الصف الدرس"
|
|
43
|
+
).split()
|
|
44
|
+
)
|
|
45
|
+
_WORD = re.compile(r"[\u0600-\u06FF\u0750-\u077F]+")
|
|
46
|
+
_STRIP_HARAKAT = re.compile(r"[\u064B-\u0652\u0670\u0640]")
|
|
47
|
+
AL = "\u0627\u0644" # definite article, word-INITIAL in logical order
|
|
48
|
+
AL_REV = "\u0644\u0627" # what it looks like at word-END when reversed
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --------------------------------------------------------------------------
|
|
52
|
+
# primitives
|
|
53
|
+
# --------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def is_arabic(text: str, threshold: float = 0.25) -> bool:
|
|
56
|
+
"""True if a meaningful fraction of the letters are Arabic script."""
|
|
57
|
+
letters = [c for c in text if c.isalpha()]
|
|
58
|
+
if not letters:
|
|
59
|
+
return False
|
|
60
|
+
hits = sum(1 for c in letters if ARABIC_BLOCK.match(c))
|
|
61
|
+
return hits / len(letters) >= threshold
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def deshape(text: str) -> str:
|
|
65
|
+
"""Map Arabic presentation forms back to base letters, char by char.
|
|
66
|
+
|
|
67
|
+
We deliberately do NOT run NFKC over the whole string: NFKC would also
|
|
68
|
+
fold full-width Latin, superscripts and some math symbols that carry
|
|
69
|
+
meaning in a textbook. We only touch the two presentation blocks.
|
|
70
|
+
"""
|
|
71
|
+
out = []
|
|
72
|
+
for ch in text:
|
|
73
|
+
cp = ord(ch)
|
|
74
|
+
if any(lo <= cp <= hi for lo, hi in PRESENTATION_RANGES):
|
|
75
|
+
decomposed = unicodedata.normalize("NFKC", ch)
|
|
76
|
+
out.append(decomposed)
|
|
77
|
+
else:
|
|
78
|
+
out.append(ch)
|
|
79
|
+
return "".join(out)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _score_orientation(text: str) -> int:
|
|
83
|
+
"""How 'logically-ordered' is this Arabic string?
|
|
84
|
+
|
|
85
|
+
Two independent signals, both computed on whole tokens (substring matching
|
|
86
|
+
is worthless here -- 'لا' and 'ما' occur inside half the words in the
|
|
87
|
+
language and fire constantly on reversed text):
|
|
88
|
+
|
|
89
|
+
* exact function-word hits, weighted x3
|
|
90
|
+
* definite-article position: 'ال' opens roughly a third of tokens in
|
|
91
|
+
written Arabic. Reversed, it closes them instead. The difference
|
|
92
|
+
between prefix-count and suffix-count is a very strong, very cheap
|
|
93
|
+
orientation signal that needs no dictionary.
|
|
94
|
+
"""
|
|
95
|
+
tokens = [_STRIP_HARAKAT.sub("", t) for t in _WORD.findall(text)]
|
|
96
|
+
if not tokens:
|
|
97
|
+
return 0
|
|
98
|
+
stops = sum(1 for t in tokens if t in STOPWORDS)
|
|
99
|
+
prefix = sum(1 for t in tokens if len(t) > 3 and t.startswith(AL))
|
|
100
|
+
suffix = sum(1 for t in tokens if len(t) > 3 and t.endswith(AL_REV))
|
|
101
|
+
return 3 * stops + (prefix - suffix)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
MIRROR = str.maketrans("()[]{}<>«»", ")(][}{><»«")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _reverse_preserving_runs(text: str) -> str:
|
|
108
|
+
"""Reverse the string, but keep Latin/digit runs internally intact and
|
|
109
|
+
mirror directional punctuation.
|
|
110
|
+
|
|
111
|
+
Two things a naive `text[::-1]` gets wrong:
|
|
112
|
+
* an embedded '2021' or 'PDF' was already laid out LTR inside the RTL
|
|
113
|
+
flow, so reversing char-by-char corrupts it into '1202'
|
|
114
|
+
* '(' in visual order is the *closing* paren in logical order
|
|
115
|
+
"""
|
|
116
|
+
tokens = re.findall(r"[A-Za-z0-9][A-Za-z0-9.,:/\-]*|.", text, re.S)
|
|
117
|
+
return "".join(reversed(tokens)).translate(MIRROR)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def fix_order(text: str) -> tuple[str, bool]:
|
|
121
|
+
"""Return (text_in_logical_order, was_reversed).
|
|
122
|
+
|
|
123
|
+
Uses a stopword-frequency vote rather than trusting the extractor. This
|
|
124
|
+
catches the nasty middle case: strings that are *mostly* reordered but
|
|
125
|
+
where lam-alef ligatures stayed put, which no bidi library will detect
|
|
126
|
+
for you because the string is already 'valid' Unicode.
|
|
127
|
+
"""
|
|
128
|
+
if not is_arabic(text):
|
|
129
|
+
return text, False
|
|
130
|
+
forward = _score_orientation(text)
|
|
131
|
+
flipped = _reverse_preserving_runs(text)
|
|
132
|
+
backward = _score_orientation(flipped)
|
|
133
|
+
if backward > forward:
|
|
134
|
+
return flipped, True
|
|
135
|
+
return text, False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_LEAD_PUNCT = re.compile(r"^([.،؛:!؟?]+)\s*(.+)$", re.S)
|
|
139
|
+
_LEAD_ORDINAL = re.compile(r"^([.\u0660-\u06690-9]{1,3})\s*[.\u061B]\s*", re.S)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _repair_punctuation(text: str) -> str:
|
|
143
|
+
"""Move sentence-final punctuation that the reversal stranded at the head.
|
|
144
|
+
|
|
145
|
+
Full stops, commas and question marks are Unicode-neutral characters: they
|
|
146
|
+
take direction from context, so in a visual-order stream they sit at the
|
|
147
|
+
physical left edge and land at string-start after reversal.
|
|
148
|
+
"""
|
|
149
|
+
t = text.strip()
|
|
150
|
+
m = _LEAD_PUNCT.match(t)
|
|
151
|
+
if m and is_arabic(m.group(2)):
|
|
152
|
+
t = f"{m.group(2).strip()}{m.group(1)}"
|
|
153
|
+
# list ordinals: '.. في العبارة1' -> '1. في العبارة'
|
|
154
|
+
m2 = re.match(r"^\.\s*(.*?)([\u0660-\u06690-9]{1,2})\.?$", t, re.S)
|
|
155
|
+
if m2 and is_arabic(m2.group(1)):
|
|
156
|
+
t = f"{m2.group(2)}. {m2.group(1).strip()}."
|
|
157
|
+
return t
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class NormalizeOptions:
|
|
162
|
+
strip_tatweel: bool = True
|
|
163
|
+
strip_harakat: bool = False # keep vowels: this is a teaching corpus
|
|
164
|
+
western_digits: bool = False # keep ٢٢ as ٢٢ unless asked
|
|
165
|
+
collapse_space: bool = True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def normalize(text: str, opts: NormalizeOptions | None = None) -> tuple[str, dict]:
|
|
169
|
+
"""Full repair pipeline. Returns (clean_text, diagnostics)."""
|
|
170
|
+
opts = opts or NormalizeOptions()
|
|
171
|
+
diag = {"had_presentation_forms": False, "was_reversed": False}
|
|
172
|
+
text = text.translate(INVISIBLES)
|
|
173
|
+
|
|
174
|
+
has_pf = any(any(lo <= ord(c) <= hi for lo, hi in PRESENTATION_RANGES) for c in text)
|
|
175
|
+
diag["had_presentation_forms"] = has_pf
|
|
176
|
+
|
|
177
|
+
# ORDER MATTERS. Reverse BEFORE deshaping.
|
|
178
|
+
#
|
|
179
|
+
# A lam-alef ligature is a single codepoint (U+FEFB..U+FEFC) in the
|
|
180
|
+
# content stream. If we deshape first it becomes two codepoints, and the
|
|
181
|
+
# subsequent reversal flips them: لا -> ال. Every word containing 'لا'
|
|
182
|
+
# silently corrupts, which is the failure behind PyMuPDF #2199 and the
|
|
183
|
+
# reason so much Arabic PDF text "looks almost right". Reversing while
|
|
184
|
+
# the ligature is still atomic keeps it intact.
|
|
185
|
+
probe = deshape(text) if has_pf else text # score on readable text
|
|
186
|
+
_, reversed_ = fix_order(probe)
|
|
187
|
+
if reversed_:
|
|
188
|
+
text = _reverse_preserving_runs(text)
|
|
189
|
+
diag["was_reversed"] = reversed_
|
|
190
|
+
|
|
191
|
+
if has_pf:
|
|
192
|
+
text = deshape(text)
|
|
193
|
+
text = _repair_punctuation(text)
|
|
194
|
+
|
|
195
|
+
if opts.strip_tatweel:
|
|
196
|
+
text = text.replace(TATWEEL, "")
|
|
197
|
+
if opts.strip_harakat:
|
|
198
|
+
text = HARAKAT.sub("", text)
|
|
199
|
+
if opts.western_digits:
|
|
200
|
+
text = text.translate(ARABIC_INDIC).translate(EASTERN_INDIC)
|
|
201
|
+
if opts.collapse_space:
|
|
202
|
+
text = re.sub(r"[ \t\u00a0]+", " ", text).strip()
|
|
203
|
+
|
|
204
|
+
return text, diag
|
rtldoc/cli.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""rtldoc CLI: python -m rtldoc.cli parse book.pdf --pages 1-5 --md out/"""
|
|
2
|
+
import argparse, json, os, sys
|
|
3
|
+
import fitz
|
|
4
|
+
from . import pipeline, primitives, arabic
|
|
5
|
+
|
|
6
|
+
def _pages(spec, n):
|
|
7
|
+
if not spec: return list(range(n))
|
|
8
|
+
out=[]
|
|
9
|
+
for part in spec.split(","):
|
|
10
|
+
if "-" in part:
|
|
11
|
+
a,b=part.split("-"); out += list(range(int(a)-1,int(b)))
|
|
12
|
+
else: out.append(int(part)-1)
|
|
13
|
+
return [p for p in out if 0 <= p < n]
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
ap = argparse.ArgumentParser(prog="rtldoc")
|
|
17
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
18
|
+
|
|
19
|
+
p = sub.add_parser("parse"); p.add_argument("pdf"); p.add_argument("--pages")
|
|
20
|
+
p.add_argument("--style-map"); p.add_argument("--json"); p.add_argument("--md")
|
|
21
|
+
p.add_argument("--html", help="write one HTML page per PDF page, plus styles.css and index.html")
|
|
22
|
+
p.add_argument("--no-geo", action="store_true")
|
|
23
|
+
p.add_argument("--strip-harakat", action="store_true")
|
|
24
|
+
p.add_argument("--visual", action="store_true",
|
|
25
|
+
help="add a geometry-only visual summary (images/diagrams/tables/layout) per page, no vision model")
|
|
26
|
+
|
|
27
|
+
s = sub.add_parser("styles", help="style census -> label once per book series")
|
|
28
|
+
s.add_argument("pdf"); s.add_argument("--pages"); s.add_argument("--out")
|
|
29
|
+
|
|
30
|
+
a = sub.add_parser("audit"); a.add_argument("pdf"); a.add_argument("--pages")
|
|
31
|
+
|
|
32
|
+
args = ap.parse_args(argv)
|
|
33
|
+
doc = fitz.open(args.pdf); idx = _pages(getattr(args,"pages",None), doc.page_count)
|
|
34
|
+
|
|
35
|
+
if args.cmd == "styles":
|
|
36
|
+
prof = primitives.style_profile([primitives.extract_page(doc[i]) for i in idx])
|
|
37
|
+
payload = {e["style"]: "" for e in prof}
|
|
38
|
+
print(json.dumps(prof, ensure_ascii=False, indent=2)[:4000])
|
|
39
|
+
if args.out:
|
|
40
|
+
open(args.out,"w").write(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
41
|
+
print(f"\n-> blank style map written to {args.out}; fill in roles and pass with --style-map", file=sys.stderr)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
style_map = json.load(open(args.style_map)) if getattr(args,"style_map",None) else None
|
|
45
|
+
opts = arabic.NormalizeOptions(strip_harakat=getattr(args,"strip_harakat",False))
|
|
46
|
+
results = [pipeline.parse_page(doc[i], style_map, opts,
|
|
47
|
+
geometry_bidi=not getattr(args,"no_geo",False),
|
|
48
|
+
visual_summary=getattr(args,"visual",False)) for i in idx]
|
|
49
|
+
|
|
50
|
+
if args.cmd == "audit":
|
|
51
|
+
print(json.dumps(pipeline.audit(results), ensure_ascii=False, indent=2)); return
|
|
52
|
+
|
|
53
|
+
img_dir = args.html or args.md or (os.path.dirname(args.json) if args.json else None)
|
|
54
|
+
if img_dir:
|
|
55
|
+
n = pipeline.save_images(doc, results, os.path.join(img_dir, "images"))
|
|
56
|
+
if n:
|
|
57
|
+
print(f"images -> {os.path.join(img_dir, 'images')}/ ({n})", file=sys.stderr)
|
|
58
|
+
|
|
59
|
+
if args.json:
|
|
60
|
+
os.makedirs(os.path.dirname(args.json) or ".", exist_ok=True)
|
|
61
|
+
open(args.json,"w").write(pipeline.to_json(results)); print(f"json -> {args.json}")
|
|
62
|
+
if args.md:
|
|
63
|
+
os.makedirs(args.md, exist_ok=True)
|
|
64
|
+
for r in results:
|
|
65
|
+
open(os.path.join(args.md, f"page_{r.page:04d}.md"),"w").write(pipeline.to_markdown(r))
|
|
66
|
+
print(f"markdown -> {args.md}/")
|
|
67
|
+
if args.html:
|
|
68
|
+
title = os.path.splitext(os.path.basename(args.pdf))[0]
|
|
69
|
+
pipeline.save_html(results, args.html, title_prefix=title)
|
|
70
|
+
print(f"html -> {args.html}/ ({len(results)} pages, index.html)")
|
|
71
|
+
if not args.json and not args.md and not args.html:
|
|
72
|
+
for r in results: print(pipeline.to_markdown(r))
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
rtldoc/cvfallback.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Layer 2b -- pixel fallback.
|
|
3
|
+
|
|
4
|
+
Used only when a page has no usable vector fills (flattened PDF, scanned page,
|
|
5
|
+
or a designer who used placed images instead of frames). Classical CV is
|
|
6
|
+
enough here and costs ~15 ms/page against ~400 ms for a layout transformer:
|
|
7
|
+
|
|
8
|
+
* colour-plane segmentation finds tinted panels
|
|
9
|
+
* morphological run-length smearing finds text blocks
|
|
10
|
+
* Hough/morphology finds rules and table lines
|
|
11
|
+
|
|
12
|
+
Coordinates are returned in PDF points so they drop straight into the same
|
|
13
|
+
Region list as the vector path. That interchangeability is the point: the
|
|
14
|
+
downstream reading-order and typing stages never learn which branch ran.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import cv2
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .primitives import Rect
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render(page, dpi: int = 200) -> tuple[np.ndarray, float]:
|
|
26
|
+
"""Rasterise a PyMuPDF page. Returns (BGR image, points-per-pixel scale)."""
|
|
27
|
+
zoom = dpi / 72.0
|
|
28
|
+
import fitz
|
|
29
|
+
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
|
|
30
|
+
img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, 3)
|
|
31
|
+
return cv2.cvtColor(img, cv2.COLOR_RGB2BGR), 1.0 / zoom
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tinted_panels(img: np.ndarray, scale: float, min_area_pt: float = 2000.0,
|
|
35
|
+
sat_min: int = 18, val_min: int = 120) -> list[Rect]:
|
|
36
|
+
"""Find flat, low-saturation colour blocks -- the classic textbook callout.
|
|
37
|
+
|
|
38
|
+
Deliberately excludes near-white (val high + sat ~0) and photographs
|
|
39
|
+
(high local variance) so we get containers, not content.
|
|
40
|
+
"""
|
|
41
|
+
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
|
42
|
+
mask = ((hsv[..., 1] > sat_min) & (hsv[..., 2] > val_min)).astype(np.uint8) * 255
|
|
43
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
|
|
44
|
+
|
|
45
|
+
boxes = []
|
|
46
|
+
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
47
|
+
for c in cnts:
|
|
48
|
+
x, y, w, h = cv2.boundingRect(c)
|
|
49
|
+
if w * h * scale * scale < min_area_pt:
|
|
50
|
+
continue
|
|
51
|
+
# flatness test: a tint is uniform, a photo is not
|
|
52
|
+
patch = img[y:y + h, x:x + w]
|
|
53
|
+
if patch.size == 0 or float(patch.std()) > 34:
|
|
54
|
+
continue
|
|
55
|
+
boxes.append((x * scale, y * scale, (x + w) * scale, (y + h) * scale))
|
|
56
|
+
return boxes
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def text_blocks(img: np.ndarray, scale: float, smear: tuple[int, int] = (25, 5)) -> list[Rect]:
|
|
60
|
+
"""Run-length smearing: dilate horizontally to fuse glyphs into lines,
|
|
61
|
+
then vertically to fuse lines into paragraphs."""
|
|
62
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
63
|
+
bin_ = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
64
|
+
cv2.THRESH_BINARY_INV, 35, 15)
|
|
65
|
+
horiz = cv2.dilate(bin_, np.ones((1, smear[0]), np.uint8), iterations=1)
|
|
66
|
+
block = cv2.dilate(horiz, np.ones((smear[1], 1), np.uint8), iterations=2)
|
|
67
|
+
block = cv2.morphologyEx(block, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
|
68
|
+
|
|
69
|
+
out = []
|
|
70
|
+
cnts, _ = cv2.findContours(block, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
71
|
+
for c in cnts:
|
|
72
|
+
x, y, w, h = cv2.boundingRect(c)
|
|
73
|
+
if w < 20 or h < 8:
|
|
74
|
+
continue
|
|
75
|
+
out.append((x * scale, y * scale, (x + w) * scale, (y + h) * scale))
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def rules(img: np.ndarray, scale: float, min_len_frac: float = 0.15) -> list[Rect]:
|
|
80
|
+
"""Long thin horizontal/vertical strokes: separators and table grids."""
|
|
81
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
82
|
+
bin_ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
|
|
83
|
+
h, w = bin_.shape
|
|
84
|
+
found = []
|
|
85
|
+
for kernel, minlen in (
|
|
86
|
+
(np.ones((1, max(int(w * min_len_frac), 10)), np.uint8), None),
|
|
87
|
+
(np.ones((max(int(h * min_len_frac), 10), 1), np.uint8), None),
|
|
88
|
+
):
|
|
89
|
+
det = cv2.morphologyEx(bin_, cv2.MORPH_OPEN, kernel, iterations=1)
|
|
90
|
+
cnts, _ = cv2.findContours(det, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
91
|
+
for c in cnts:
|
|
92
|
+
x, y, cw, ch = cv2.boundingRect(c)
|
|
93
|
+
found.append((x * scale, y * scale, (x + cw) * scale, (y + ch) * scale))
|
|
94
|
+
return found
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def deskew(img: np.ndarray, max_angle: float = 6.0) -> tuple[np.ndarray, float]:
|
|
98
|
+
"""Estimate and correct small rotations. Only relevant for scans."""
|
|
99
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
100
|
+
bin_ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
|
|
101
|
+
coords = np.column_stack(np.where(bin_ > 0))
|
|
102
|
+
if coords.shape[0] < 100:
|
|
103
|
+
return img, 0.0
|
|
104
|
+
angle = cv2.minAreaRect(coords.astype(np.float32))[-1]
|
|
105
|
+
angle = angle + 90 if angle < -45 else angle
|
|
106
|
+
if abs(angle) > max_angle:
|
|
107
|
+
return img, 0.0
|
|
108
|
+
h, w = img.shape[:2]
|
|
109
|
+
m = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
|
|
110
|
+
return cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_CUBIC,
|
|
111
|
+
borderMode=cv2.BORDER_REPLICATE), float(angle)
|