پرش به محتویات

درس ۰۳ — بارگذاری اسناد فارسی (Loading documents)

ماژول: parsrag.loaders · کد شروع: starter/loaders.py · تصحیح: python -m grader run 03 (۳۶ بررسی) · پیش‌نیاز: درس ۰۲ — نرمال‌سازی متن فارسی

تا این‌جا اسناد ما فایل‌های تمیز .md بودند. در دنیای واقعی، دانش یک سازمان در PDF، Word (DOCX)، صفحه‌های وب (HTML) و فایل‌های متنی قدیمی پراکنده است. گام بارگذاری (Loading) باید از همهٔ این‌ها متنی تمیز و قابل‌جست‌وجو بیرون بکشد — و برای فارسی، این گام پر از دام است.

یک جمله را به خاطر بسپارید: اگر متن در گام بارگذاری خراب شود، هیچ مدل برداری و هیچ LLMای نمی‌تواند نجاتش بدهد. (Garbage in, garbage out.)

در این درس parsrag.loaders را می‌سازیم و چهار مشکل واقعی فارسی را — با عدد — حل می‌کنیم.

خروجی یک بارگذار

هر بارگذار (Loader) یک Document برمی‌گرداند. متن آن یک Markdown سبک است (سرتیترها به خط‌های # تبدیل می‌شوند) و در metadata ثبت می‌شود که هر بخش از متن از کجا آمده است:

from parsrag.loaders import load_document, locate

doc = load_document("data/fixtures/documents/kahroba-installation-guide.docx")
doc.metadata["sections"][:2]
# [{'title': 'راهنمای نصب کولر گازی کهربا', 'level': 1, 'start': 0,   'end': 703},
#  {'title': 'پیش از نصب',                  'level': 2, 'start': 109, 'end': 268}]

locate(doc, doc.text.index("فیوز"))
# {'page': None, 'section': 'پیش از نصب', 'section_path': ['راهنمای نصب کولر گازی کهربا', 'پیش از نصب']}

start و end آفست نویسه‌ای (Character offset) در doc.text هستند. برای PDF فهرست pages هم داریم. در درس ۰۴ هر قطعه با کمک locate می‌فهمد از کدام صفحه و کدام بخش آمده، تا پاسخ نهایی بتواند بگوید «صفحهٔ ۲، بخش گارانتی».

flowchart LR
    F[📁 فایل] --> D{پسوند؟}
    D -->|.txt / .md| T[read_text_file<br/>حدس رمزگذاری]
    D -->|.html| H[BeautifulSoup<br/>حذف منو و فوتر]
    D -->|.docx| W[python-docx<br/>سبک‌ها → سرتیتر]
    D -->|.pdf| P[pypdf<br/>+ تعمیر متن فارسی]
    T --> M[Markdown سبک<br/>+ متادیتا]
    H --> M
    W --> M
    P --> M
    M --> N[نرمال‌سازی<br/>درس ۰۲] --> C[قطعه‌بندی<br/>درس ۰۴]

TXT و Markdown به هیچ بستهٔ اضافه‌ای نیاز ندارند؛ برای بقیه:

pip install -e ".[loaders]"     # pypdf, python-docx, beautifulsoup4

این بسته‌ها به‌صورت تنبل (Lazy) وارد (import) می‌شوند: import parsrag.loaders همیشه کار می‌کند و فقط وقتی PDF باز می‌کنید pypdf لازم است.

فایل‌های نمونه

همهٔ فایل‌های data/fixtures/documents/ برای همین مخزن نوشته شده‌اند. فایل‌های دودویی (PDF و DOCX) با اسکریپت data/fixtures/build_fixtures.py از متن‌های sources/ ساخته شده‌اند تا هر کسی بتواند ببیند دقیقاً چه چیزی داخلشان است:

فایل چه مشکلی را شبیه‌سازی می‌کند
kahroba-notice.txt رمزگذاری قدیمی ویندوز (Windows-1256) با ي/ك عربی
mehregan-events.html منو، مسیر راهنما (Breadcrumb)، بنر کوکی، نوار کناری و فوتر؛ موجودیت &zwnj; و نشانهٔ LRM
kahroba-installation-guide.docx سرتیتر، فهرست، جدول، و نشانه‌های نامرئی RLM دور شماره‌مدل‌های لاتین
kahroba-catalog.pdf حروف ذخیره‌شده به‌صورت شکل‌های نمایشی (Presentation forms) و نیم‌فاصله‌های گم‌شده
mehregan-newsletter.pdf همان مشکل‌ها، به‌علاوهٔ ترتیب دیداری (Visual order) — متن برعکس استخراج می‌شود

هر دو PDF روی صفحه کاملاً درست دیده می‌شوند. مشکل فقط وقتی آشکار می‌شود که متنشان را بیرون بکشید — دقیقاً مثل PDFهای واقعی.

مشکل ۱ — رمزگذاری قدیمی (TXT)

فایل‌های متنی قدیمی فارسی اغلب با Windows-1256 ذخیره شده‌اند، نه UTF-8. اگر فرض کنید همه‌چیز UTF-8 است، یا با خطا روبه‌رو می‌شوید یا با متن به‌هم‌ریخته. read_text_file به این ترتیب امتحان می‌کند:

  1. اگر BOM داشت (UTF-8 یا UTF-16)، به آن اعتماد کن؛
  2. UTF-8 سخت‌گیرانه؛
  3. در آخر cp1256.

چرا cp1256 آخر؟ چون هر دنباله‌ای از بایت‌ها را بدون خطا رمزگشایی می‌کند — حتی اگر حدسمان غلط باشد. پس فقط وقتی سراغش می‌رویم که UTF-8 شکست خورده باشد.

read_text_file("data/fixtures/documents/kahroba-notice.txt")
# ('اطلاعيه فروشگاه كهربا\nبه اطلاع مشتريان گرامي ...', 'cp1256')

دقت کنید: متن هنوز ي و ك عربی دارد (آن نرم‌افزارهای قدیمی همین را ذخیره می‌کردند) و «اطلاعیهٔ» بدون همزه نوشته شده است، چون cp1256 همزهٔ روی «ه» را ندارد. بخش اول را نرمال‌سازی درس ۰۲ درست می‌کند؛ بخش دوم برای همیشه از دست رفته است.

مشکل ۲ — اسکلت صفحهٔ وب (HTML)

در یک صفحهٔ وب، متن اصلی معمولاً کمتر از نیمی از کل متن است. منو، فوتر و بنر کوکی در همهٔ صفحه‌های یک سایت تکرار می‌شوند و اگر نمایه شوند، جست‌وجو را آلوده می‌کنند (پرسش «عضویت» به‌جای صفحهٔ عضویت، با منوی همهٔ صفحه‌ها جور می‌شود!).

remove_boilerplate سه نشانه را بررسی می‌کند: نام برچسب (nav، header، footer، aside، script...)، ویژگی role (مثل navigation)، و نام‌های class/id شبیه cookie، breadcrumb و sidebar. بعد سرتیترها به # و ردیف‌های جدول به a | b تبدیل می‌شوند:

# رویدادهای پاییز کتابخانهٔ مهرگان

در فصل پاییز، کتابخانه برنامه‌های تازه‌ای ... برگزار می‌کند. ...

## هفتهٔ کتاب
...
کارگاه داستان‌نویسی | ۲۰۰ هزار تومان | ۴۰۰ هزار تومان

این قاعده‌های ساده برای بیشتر سایت‌ها کافی‌اند؛ برای سایت‌های پیچیده ابزارهای تخصصی‌تری مثل trafilatura هم وجود دارد.

مشکل ۳ — شکل‌های نمایشی (Presentation Forms) در PDF

حروف خط فارسی و عربی بسته به جایشان در کلمه شکل عوض می‌کنند: «ب» در بابا آغازی (Initial) است، در صبر میانی (Medial)، در کتاب پایانی (Final) و در آب تنها (Isolated). PDF شکلِ رسم‌شده (Glyph) را ذخیره می‌کند، نه حرف را. خیلی از نرم‌افزارهای تولید PDF به‌جای حرف ب (U+0628)، کد شکلِ آن را می‌نویسند — مثلاً ﺑ (U+FE91، BEH INITIAL FORM). نتیجه:

>>> raw = load_pdf("data/fixtures/documents/kahroba-catalog.pdf", repair=False).text
>>> raw.splitlines()[0]
'ﮐﺎﺗﺎﻟﻮﮒ ﻟﻮﺍﺯﻡ ﺑﺮﻗﯽ ﮐﻬﺮﺑﺎ - ﭘﺎﯾﯿﺰ 1404'          # روی صفحه درست است...
>>> [hex(ord(c)) for c in raw[:4]]
['0xfb90', '0xfe8e', '0xfe97', '0xfe8e']           # ...ولی هیچ‌کدام حرف معمولی نیست

برای کامپیوتر ﮐﺎﺗﺎﻟﻮﮒ و کاتالوگ هیچ نویسهٔ مشترکی ندارند. راه‌حل، NFKC یونیکد است که هر شکل نمایشی را به حرف پایه برمی‌گرداند — همان fix_presentation_forms درس ۰۲. (یادتان هست که گفتیم باید قبل از یکسان‌سازی حروف اجرا شود؟ چون ﻙ با NFKC به ك عربی تبدیل می‌شود.)

مشکل ۴ — ترتیب دیداری (Visual Order)

متن فارسی در حافظه به ترتیب منطقی (Logical order) ذخیره می‌شود؛ یعنی حروف به همان ترتیبی که خوانده می‌شوند. نمایش راست‌به‌چپ کار نرم‌افزار نمایش است. ولی ابزارهای قدیمی‌تر PDF هر خط را به ترتیب دیداری — از چپ به راست، همان‌طور که روی کاغذ رسم می‌شود — می‌نویسند. نتیجه، متن وارونه است:

خروجی خام:        .ﺪﻨﮐﯽﻣ ﺐﺼﻧ ﻪﻧﺎﺒﺷ ﺖﻧﺎﻣﺍ ﯼﺍﺮﺑ ﺪﻨﻤﺷﻮﻫ ٔﻪﺴﻔﻗ ﮏﯾ ﻩﺎﻣ ﻦﯾﺍ ﺯﺍ ﺭﺍﺪﯿﭙﺳ ٔﻪﻧﺎﺨﺑﺎﺘﮐ
فقط با NFKC:      .دنکیم بصن هنابش تناما یارب دنمشوه ٔهسفق کی هام نیا زا رادیپس ٔهناخباتک

تشخیص: از کجا بفهمیم متن برعکس است؟

visual_order_votes دو سرنخ مستقل را می‌شمارد:

flowchart TB
    T[متن استخراج‌شده] --> G{شکل‌های نمایشی دارد؟}
    G -->|بله| S["سرنخ ۱ — شکل حروف<br/>منطقی: کلمه با شکل آغازی شروع و با پایانی تمام می‌شود<br/>دیداری: برعکس"]
    T --> W["سرنخ ۲ — کلمه‌های پرتکرار<br/>«است»، «این»، «برای» → منطقی<br/>«تسا»، «نیا»، «یارب» → دیداری"]
    S --> V{رأی دیداری بیشتر<br/>و دست‌کم ۳ رأی؟}
    W --> V
    V -->|بله| F[visual_to_logical<br/>روی هر خط]
    V -->|نه| K[دست نزن]

روی خبرنامهٔ نمونه، شمارش آرا (منطقی ۰، دیداری ۱۰۲) است. کلمه‌هایی مثل «در/رد» یا «هم/مه» را عمداً کنار گذاشته‌ایم چون وارونه‌شان هم کلمهٔ واقعی است. آستانهٔ «دست‌کم ۳ رأی» هم جلوی تصمیم‌گیری روی تکه‌متن‌های خیلی کوتاه را می‌گیرد: تغییر اشتباه بدتر از تغییر ندادن است.

اصلاح: وارونه کردن، ولی نه همه‌چیز

اعداد و کلمه‌های لاتین در یک خط راست‌به‌چپ همچنان چپ‌به‌راست نمایش داده می‌شوند، پس در متن دیداری برعکس نشده‌اند. visual_to_logical خط را به توکن‌ها تقسیم می‌کند — هر عدد یا عبارت لاتین یک توکن، هر نویسهٔ دیگر یک توکن — و ترتیب توکن‌ها را برعکس می‌کند:

visual_to_logical("تسا 45 تمیق")           # 'قیمت 45 است'
visual_to_logical("تسا PDF file هخسن")     # 'نسخه PDF file است'
visual_to_logical("(مالس)")                # '(سلام)'   ← پرانتزها آینه‌ای می‌شوند

محدودیت‌ها را صادقانه بگوییم: این یک روش ابتکاری (Heuristic) است، نه الگوریتم کامل دوجهتهٔ یونیکد (Unicode Bidirectional Algorithm). صفحه‌های چندستونی، خط‌هایی که قبل از وارونه شدن شکسته شده‌اند، و ترکیب‌های پیچیدهٔ عدد و علامت ممکن است هنوز اشتباه دربیایند. همیشه چند صفحه را با چشم بررسی کنید.

مشکل ۵ — نیم‌فاصلهٔ گم‌شده

نیم‌فاصله (ZWNJ) شکل ندارد؛ فقط جلوی چسبیدن دو حرف را می‌گیرد. پس در PDF ذخیره نمی‌شود و می‌شود به میشود تبدیل می‌شود. نرمال‌ساز درس ۰۲ عمداً به میشود دست نمی‌زند (بدون فرهنگ لغت نمی‌شود آن را از «میز» تشخیص داد).

ولی تا وقتی شکل‌های نمایشی را داریم، یک سرنخ طلایی باقی می‌ماند: در ﻣﯽﺷﻮﺩ، حرف «ی» در شکل پایانی است و «ش» بعد از آن در شکل آغازی. «ی» حرفی دوطرفه‌چسبان (Dual-joining) است و می‌توانست به «ش» بچسبد؛ نچسبیده، پس آن‌جا نیم‌فاصله بوده! recover_zwnj دقیقاً همین را بررسی می‌کند. برای حرف‌هایی مثل «ا»، «د» و «و» که هیچ‌وقت به حرف بعد نمی‌چسبند (کتابخانه، دارد) نیم‌فاصله اضافه نمی‌شود.

ترتیب حیاتی است: اول ترتیب دیداری را درست کنید، بعد نیم‌فاصله را از روی شکل‌ها بازگردانید، و فقط بعد از آن NFKC را اعمال کنید — NFKC اطلاعات شکل را برای همیشه پاک می‌کند.

مشکل ۶ — خط‌های ترکیبی و نشانه‌های نامرئی

وقتی در یک پاراگراف فارسی کلمهٔ لاتین می‌آید («مدل KX-2400»)، Word و مرورگرها برای درست نمایش دادن آن، نویسه‌های نامرئی‌ای به نام کنترل‌های جهت (Bidi controls)، مثل RLM (U+200F) و LRM (U+200E)، اضافه می‌کنند. چشم آن‌ها را نمی‌بیند، ولی "KX-2400" in text جواب نادرست می‌دهد و توکن‌ها به‌هم می‌ریزند. strip_bidi_controls همه را حذف می‌کند — به‌جز نیم‌فاصله، که بخشی از املای فارسی است.

کنار هم: repair_pdf_text

flowchart LR
    R[متن خام pypdf] --> B[حذف کنترل‌های<br/>دوجهته] --> V{ترتیب دیداری؟} -->|بله| L[visual_to_logical<br/>هر خط]
    V -->|نه| Z
    L --> Z[recover_zwnj<br/>از روی شکل حروف] --> K[NFKC<br/>شکل‌های نمایشی] --> O[متن منطقی]
from parsrag.loaders import load_pdf

doc = load_pdf("data/fixtures/documents/mehregan-newsletter.pdf")
doc.metadata["pages"]   # [{'page': 1, 'start': 0, 'end': 451, 'visual_order': True}]
doc.text.splitlines()[1]
# 'کتابخانهٔ مهرگان از این ماه یک قفسهٔ هوشمند برای امانت شبانه نصب می‌کند.'

اندازه‌گیری: هر گام چقدر متن را برمی‌گرداند؟

python examples/pdf_repair_impact.py

متن استخراج‌شده را با متن اصلی (sources/) مقایسه می‌کنیم. بازیابی کلمه (Word recall) یعنی چند درصد از کلمه‌های متن اصلی دقیقاً به همان شکل در خروجی پیدا می‌شوند (کلمه‌ها با فاصله جدا می‌شوند، پس نیم‌فاصلهٔ گم‌شده هم خطا حساب می‌شود). ستون عبارت‌ها نشان می‌دهد چند عبارت کلیدی با جست‌وجوی سادهٔ زیررشته پیدا می‌شوند:

kahroba-catalog.pdf
step                           word recall  phrases  exact
raw pypdf text                       0.076      0/3  False
+ NFKC presentation forms            0.938      2/3  False
+ visual-order fix                   0.938      2/3  False
+ ZWNJ recovery (= load_pdf)         1.000      3/3   True

mehregan-newsletter.pdf
step                           word recall  phrases  exact
raw pypdf text                       0.067      0/3  False
+ NFKC presentation forms            0.080      0/3  False
+ visual-order fix                   0.893      2/3  False
+ ZWNJ recovery (= load_pdf)         1.000      3/3   True
  • متن خام PDF عملاً غیرقابل‌جست‌وجو است: فقط ۷٪ از کلمه‌ها (همان عددها!) پیدا می‌شوند.
  • برای کاتالوگ، NFKC به‌تنهایی ۹۴٪ را برمی‌گرداند؛ ۶٪ باقی‌مانده کلمه‌های نیم‌فاصله‌دار هستند.
  • برای خبرنامهٔ وارونه، NFKC تقریباً هیچ کمکی نمی‌کند (۸٪)؛ اصلاح ترتیب، عدد را به ۸۹٪ می‌رساند.
  • بازگرداندن نیم‌فاصله، هر دو را به ۱۰۰٪ و متنِ دقیقاً یکسان می‌رساند.

هشدار صادقانه: این دو PDF را خودمان با شبیه‌ساز parsrag ساخته‌ایم، پس دقیقاً از همان مدلی پیروی می‌کنند که کد تعمیر فرض می‌کند. «۱۰۰٪» یعنی کد درست کار می‌کند، نه این‌که روی هر PDF واقعی ۱۰۰٪ می‌گیرید. PDFهای واقعی ستون‌بندی، سرصفحه و پاصفحه، فونت‌های بدون جدول ToUnicode و گاهی متنی دارند که به‌صورت تصویر است. ابزارهای مختلف استخراج (pypdf، pdfminer.six، PyMuPDF) هم نتیجه‌های متفاوتی می‌دهند. PDFهای اسکن‌شده اصلاً متن ندارند و به نویسه‌خوانی نوری (OCR) نیاز دارند (مثلاً Tesseract با مدل زبان fas) که خارج از موضوع این درس است.

تمرین

فایل lessons/03-loading-documents/starter/loaders.py را باز کنید. جدول شکل حروف، شبیه‌ساز شکل‌دهی (shape) و روند اصلی خواندن فایل‌ها آماده‌اند؛ تعمیرهای فارسی با شماست:

تابع راهنمایی
strip_bidi_controls re.sub با کلاس نویسهٔ BIDI_CONTROLS
read_text_file BOM → UTF-8 → cp1256؛ \r\n → \n
markdown_sections _HEADING_RE.finditer؛ پایان بخش = سرتیتر بعدی با سطح ≤
visual_to_logical توکن‌سازی با _LTR_RUN_RE، برعکس کردن، آینه کردن پرانتزها
visual_order_votes GLYPH_INFO[glyph] → (base, form) و فهرست کلمه‌های پرتکرار
recover_zwnj joins_both_sides، joins_right و شکل حروف دو طرف
repair_pdf_text ترتیب: کنترل‌ها → ترتیب دیداری → نیم‌فاصله → NFKC
remove_boilerplate soup.find_all(...) و tag.decompose()
heading_level Title → ۱، Heading N → N+1
load_pdf آفست صفحه‌ها با جداکنندهٔ \n\n
pip install -e ".[loaders]"
python -m grader run 03          # ۳۶ بررسی
python -m grader run 03 -v

اگر بستهٔ loaders نصب نباشد، بررسی‌های HTML/DOCX/PDF با پیام راهنما نادیده گرفته (Skip) می‌شوند و شکست نمی‌خورند. آخرین بررسی، کار شما را روی جست‌وجوی واقعی امتحان می‌کند: بعد از تعمیر، پرسش «قفسهٔ هوشمند برای امانت شبانه» باید خبرنامه را پیدا کند. 🎉

برای کنجکاوها

  • یک PDF فارسی واقعی از کامپیوتر خودتان را با load_pdf(path, repair=False) و load_pdf(path) باز کنید. کدام‌یک از مشکل‌های این درس را دارد؟ visual_order_votes چه می‌گوید؟
  • همان PDF را با pdfplumber یا PyMuPDF هم استخراج کنید. خروجی‌ها چه فرقی دارند؟
  • چرا recover_zwnj باید قبل از NFKC اجرا شود؟ ترتیب را عوض کنید و تست‌ها را اجرا کنید.
  • visual_to_logical("فیفخت 50%") را اجرا کنید. نمایش‌دهنده‌ها «50%» را یک واحد چپ‌به‌راست می‌دانند، ولی خروجی ما %50 تخفیف است. چرا؟ _LTR_RUN_RE را طوری درست کنید که علامت درصد (% و ٪) بعد از عدد را هم بگیرد.

مطالعهٔ بیشتر

  • Unicode Standard Annex #9، Unicode Bidirectional Algorithm — الگوریتم کاملی که visual_to_logical فقط تقریبی از آن است: unicode.org.
  • مستندات pypdf — استخراج متن و محدودیت‌هایش.
  • برای PDF اسکن‌شده: Tesseract با مدل زبان فارسی (fas) در tessdata.

چالش‌ها (اختیاری)

تمرین‌هایی باز، بدون پاسخ مرجع و بدون بررسی تصحیح‌کننده، برای وقتی که می‌خواهید جلوتر بروید:

  1. PDF واقعی. یک PDF فارسی واقعی (مثلاً یک بخش‌نامه یا گزارش عمومی) را با load_pdf(path, repair=False) و با repair=True بخوانید. برای چند جمله، متن درست را دستی بنویسید و بازیابی کلمه را حساب کنید. کدام مشکل‌ها در شبیه‌ساز ما نبودند؟
  2. جدول‌ها. بارگذار DOCX جدول‌ها را چطور به متن تبدیل می‌کند؟ راهی پیشنهاد و پیاده‌سازی کنید که هر سطر جدول با سرستون‌هایش یک جملهٔ قابل‌جست‌وجو شود، و اثرش را روی چند پرسش دربارهٔ جدول بسنجید.

درس بعد

درس ۰۴ — قطعه‌بندی متن فارسی: حالا که متن تمیز و ساختار (سرتیتر، صفحه) را داریم، از آن برای ساختن قطعه‌های بهتر استفاده می‌کنیم.