مقاله فنی

ساخت یک پی‌دی‌اف خوان دسترس‌پذیر در دلفی با PDFium

یک کاربر نابینا یک گزارش سه‌ماهه را در نمایشگر دلفی جدید و درخشان شما باز می‌کند، NVDA را روشن می‌کند، و فوتر صفحه را می‌شنود، سپس یک ستون از اعداد، سپس عنوانی که هر خواننده بینا ابتدا آن را می‌خواند. یا اصلاً چیزی نمی‌شنود. صفحه روی مانیتور کامل به نظر می‌رسد، و این دقیقاً همان تله است: رندر کردن و خواندن دو مشکل متفاوت هستند که با کدهای متفاوتی حل می‌شوند. ترتیبی که در آن یک PDF حروف خود (glyphs) را رسم می‌کند هیچ الزامی برای مطابقت با ترتیبی که شخص باید آن‌ها را بشنود ندارد، بنابراین نمایشگری که فقط بر اساس فراخوانی‌های رندر ساخته شده، یک تصویر بی‌نقص و یک روایت غیرقابل استفاده تولید می‌کند. کامپوننت PDFium، پوشاننده VCL/LCL در اطراف موتور PDFium برای دلفی، C++Builder و لازاروس، به همین دلیل مجموعه جداگانه‌ای از APIهای خواندن را به همراه دارد. APIهای رسم نمی‌توانند ترتیب خواندنی را که هرگز به آن‌ها داده نشده است بازیابی کنند

موفقیت یا شکست یک پی‌دی‌اف خوان دسترس‌پذیر (accessible reader) به سه چیز بستگی دارد. این برنامه باید ترتیبی را استخراج کند که یک صفحه‌خوان بتواند آن را بخواند، یک نشانگر کلمه قابل مشاهده را روی هر چیزی که صدا می‌گوید نگه دارد، و در صورتی که سندی هرگز تگ‌گذاری نشده باشد، به جای حدس زدن و تظاهر کردن، آن را بپذیرد. هر کدام از این‌ها دارای یک API واضح برای دسترسی و شکستی گزنده در صورت نادیده گرفتن جزئیات هستند

ترتیب خواندن در درخت ساختار قرار دارد، نه در ترتیب رسم

استاندارد ISO 32000-1 §14.8 ساختار منطقی را به عنوان درختی از عناصر که بر روی محتوای صفحه قرار گرفته‌اند تعریف می‌کند. استاندارد PDF/UA (ISO 14289-1) فراتر می‌رود و آن درخت را اجباری می‌کند: هر قطعه از محتوای واقعی باید از طریق آن در ترتیب خواندن قابل دسترسی باشد، در حالی که آرتیفکت‌های صفحه به این صورت علامت‌گذاری شده و نادیده گرفته می‌شوند. یک گزارش که به درستی تگ‌گذاری شده باشد می‌داند که "نتایج سه‌ماهه" یک عنوان سطح دو است و جدول مجموع یک جدول با سلول‌های سرصفحه است. یک گزارش بدون تگ مجموعه‌ای از حروف موقعیت‌یابی شده است که تصادفاً شبیه یک سند به نظر می‌رسد

فراخوانی ReadablePageContent زمانی که آن ساختار وجود داشته باشد، آن را پیمایش می‌کند و قطعاتی را برمی‌گرداند که با یک Kind معنایی، مقادیری مانند cfHeading و cfParagraph تگ شده‌اند، بنابراین رابط کاربری به جای خواندن یک خط پررنگ به عنوان متن بدنه معمولی، می‌تواند کلمه "عنوان" را قبل از کلمات بیان کند. در صورت نبود هیچ درخت قابل استفاده‌ای، همین فراخوانی به تجزیه و تحلیل ابتکاری (heuristic) طرح‌بندی بازمی‌گردد: تشخیص ستون‌ها، خوشه‌بندی خطوط پایه، ترتیب از چپ به راست و از بالا به پایین. این بازگشت برای یک یادداشت تک ستونی خوب است و برای یک خبرنامه، یک فرم چند ستونی، یا هر چیزی که دارای نوار کناری یا نقل قول برجسته باشد متزلزل است. آنچه اهمیت دارد این است که بدانید کدام نتیجه را دریافت کرده‌اید، و API این را به طور صریح به شما می‌گوید. رکورد TPdfReadableContent دارای یک فیلد Source است که زمانی که ترتیب از درخت تگ‌گذاری شده به دست آمده باشد، روی rosStructure و زمانی که از هندسه استنتاج شده باشد، روی rosHeuristic تنظیم می‌شود. اگر ترتیب حدس‌زده شده را به گونه‌ای نشان دهید که انگار تأیید شده است، در واقع شما نسخه دسترس‌پذیری از یک نشان قبولی را در بیلد یا نسخه‌ای که هیچ‌کس اجرا نکرده است، ارائه داده‌اید

یک حرکت کم‌هزینه در زمان باز کردن فایل این است که IsTagged را بخوانید و ValidatePdfUa را یک بار فراخوانی کنید، سپس پاسخ را کش کنید. یک بررسی ناموفق PDF/UA دلیلی برای رد کردن فایل نیست. این دلیلی است که عبارت "ترتیب خواندن تخمینی" را در نوار وضعیت قرار دهید، تا زمانی که مشتری شکایتی درباره روایت مخدوش ارسال می‌کند، تیم پشتیبانی از قبل بداند که آیا با یک مشکل تگ‌گذاری در فایل مواجه است یا یک باگ در کد شما

از صفحه تا صف گفتار با ReadingUnits

برای تبدیل متن به گفتار، ReadingUnits بخش سخت کار را انجام می‌دهد. این متد آرایه‌ای از رکوردهای TPdfReadingUnit را برای صفحه فعال برمی‌گرداند، که هر کدام حاوی متنی برای خواندن، نقش معنایی آن و مستطیل‌هایی هستند که جایگاه آن را در صفحه مشخص می‌کنند. یک متد همراه در سطح سند به نام DocumentReadingUnits وجود دارد، زمانی که خواندن پیوسته در بین صفحات را می‌خواهید. یک یونیت مستقیماً در یک اسلات از صف گفتار قرار می‌گیرد:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits works on the active page
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // text + semantics + highlight rects
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

در این حلقه احتمال انجام دو اشتباه بسیار زیاد است. صف را به ازای هر صفحه نگه‌دارید و هر بار که کاربر در صفحات جابجا می‌شود آن را از نو بسازید، زیرا واحدهای خواندن (reading units) دارای مستطیل‌های فضای صفحه هستند؛ یک صف باقی‌مانده از صفحه سه، برجسته‌سازی‌های خود را روی صفحه چهار رسم می‌کند. و یک آرایه Units خالی در صفحه‌ای که به وضوح دارای محتوا است را به عنوان تشخیص‌دهنده تصویرِ تنها در نظر بگیرید. یک صفحه اسکن‌شده دارای پیکسل‌هایی است که هیچ لایه متنی در زیر آن‌ها وجود ندارد، و پاسخ مناسب پخش یک هشدار صوتی ("این صفحه فاقد متن قابل استخراج است") به جای سکوتی است که شنونده نمی‌تواند آن را از هنگ کردن سیستم تشخیص دهد

نشانگر کلمه‌ای که صدا را دنبال می‌کند

برجسته‌سازی هم‌زمان کل یک پاراگراف برای کاربر کم‌بینایی که کلمات را در حین خوانده شدن با چشم دنبال می‌کند، کند به نظر می‌رسد. برجسته‌سازی در سطح کلمه، اثر کارائوکه، به دو بخش نیاز دارد: هندسه هر کلمه، و روشی برای نگاشت گزارش‌های پیشرفت موتور TTS به آن هندسه. PageWordBoxes هندسه را به عنوان رکوردهای TPdfWordBox به شما می‌دهد، که هر کدام شامل متن کلمه، آفست کاراکتر آن، تعداد کاراکترهای آن و یک مستطیل فضای صفحه است. TrackReadingWordAt این نگاشت را به شما می‌دهد. موقعیت کاراکتری که رویداد مرز-کلمه (word-boundary) مربوط به SAPI از قبل گزارش می‌دهد را به آن بدهید، و آن این آفست را به یک ایندکس در آرایه کادر کلمه پردازش می‌کند و نشانگر را در یک فراخوانی روی کلمه منطبق رسم می‌کند

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // The view's word boxes come from the page the view displays.
  // Setting Pdf.PageNumber alone would not move the view
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt maps the offset AND paints the word cursor
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // boundary ran past the page text
end;

این قرارداد در یک مورد سخاوتمندانه و در مورد دیگر نابخشودنی است. بخش سخاوتمندانه: TrackReadingWordAt کش کادر کلمه خود را برای صفحه‌ای که ردیابی می‌کند نگه می‌دارد، بنابراین چیزی برای پیش‌بارگذاری (pre-load) وجود ندارد، و اصلاً هیچ رندری اتفاق نمی‌افتد زیرا کادرهای کلمه از لایه متنی می‌آیند. یک سرویس گفتار بدون هد (headless) و بدون پنجره قابل مشاهده همچنان می‌تواند موقعیت‌ها را ردیابی کند. بخش نابخشودنی: ایندکس کاراکتر باید به متنی که کامپوننت استخراج کرده است اشاره کند، نه به یک رشته پاک‌سازی شده که خودتان ساخته‌اید. زمانی که CharIndex از انتهای متن صفحه عبور می‌کند، تابع به جای ایجاد خطا مقدار 1- برمی‌گرداند، که همیشه زمانی اتفاق می‌افتد که یک موتور TTS آخرین رویداد مرزی را برای نقطه‌گذاری انتهایی اجرا می‌کند. مقدار 1- را به عنوان "پاک کردن نشانگر" در نظر بگیرید، هرگز آن را به عنوان یک خطا تلقی نکنید

در بخش نمایش، ReadingWordColor رنگ نشانگر را تنظیم می‌کند. رنگ کهربایی پیش‌فرض در بیشتر پس‌زمینه‌های صفحه به خوبی عمل می‌کند، اما آن را با هر فیلتر نمایشی که نمایشگر شما ارائه می‌دهد تست کنید. یک نشانگر کهربایی می‌تواند به طور کامل در حالت وارونگی رنگ ناپدید شود، و وارونگی رنگ همزمان با گفتار دقیقاً همان روشی است که یک کاربر کم‌بینا استفاده می‌کند، بنابراین ترکیبی که بیش از همه باید به درستی پیاده‌سازی کنید همان ترکیبی است که یک دمو سریع هرگز آن را تست نمی‌کند. ReadingWordFollow را روی True تنظیم کنید و نما به طور خودکار کلمه در حال پخش را به محدوده دید اسکرول می‌کند، که در یک صفحه زوم‌شده که از صفحه‌نمایش بیرون می‌زند، بدون آن نمی‌توانید کار کنید. به یک قانون دامنه (scope) توجه داشته باشید: SetReadingWord فقط روی صفحه فعال TPdfView رسم می‌کند. از پیش تصمیم بگیرید که آیا اسکرول دستی باعث توقف گفتار می‌شود یا رفتار دنبال‌کردن (follow) آن را لغو می‌کند، زیرا در صورت انتخاب نکردن هیچ‌کدام، صدا به خواندن ادامه می‌دهد در حالی که نشانگر جایی بیرون از صفحه نمایشگر گیر کرده است

اسنادی که پی‌دی‌اف خوان شما را خراب می‌کنند

تعداد انگشت‌شماری از فرم‌های ورودی به قدری یک پیاده‌سازی مبتدیانه را با شکست مواجه می‌کنند که باید به عنوان نمونه‌های دائمی در مجموعه رگرسیون قرار گیرند، نه به عنوان باگ‌های موقتی که رفع می‌کنید و سپس فراموش می‌کنید

  • فایل‌های بدون تگ اما غنی از متن. ترتیب ابتکاری (Heuristic) معمولاً برای یک گزارش خطی درست است اما به محض ورود یک نوار کناری یا نقل قول برجسته اشتباه می‌شود. این ترتیب را هم در رابط کاربری و هم در گزارش تشخیص (diagnostics log) خود به عنوان تخمینی علامت‌گذاری کنید، تا شکست بعداً قابل خواندن باشد
  • اسکن‌های فقط-تصویر. هیچ لایه متنی وجود ندارد. از طریق واحدهای خواندن خالی، آن‌ها را شناسایی کنید و به جای اینکه اجازه دهید خواننده یک صفحه خالی را روایت کند، کاربر را به یک مرحله OCR در بالا دست هدایت کنید
  • ترکیب کاراکترها و اسکریپت‌های مختلط. علائم ترکیبی یونیکد همیشه به صورت یک‌به‌یک به کلمات بصری تبدیل نمی‌شوند، بنابراین شمارش کادر کلمه ممکن است از چیزی که توکنایزر (tokenizer) خودتان انتظار دارد، منحرف شود. آرایه کادر کلمه را با آفست‌هایی که با تقسیم دستی متن محاسبه کرده‌اید، ایندکس نکنید؛ فقط از ایندکس‌هایی استفاده کنید که TrackReadingWordAt برمی‌گرداند

آن را مانند یک بازرس تست کنید، نه یک نسخه دمو

اینکه بگویید "نمونه مرا با صدای بلند خواند" چیزی را ثابت نمی‌کند. یک آزمون قابل دفاع این است که سه فایل را در بیلد نهایی در حالی که NVDA متصل است اجرا کنید: یک فایل که مطمئن هستید تگ‌گذاری شده است، که در آن عنوان‌ها به عنوان عنوان اعلام می‌شوند و یک جدول به ترتیب سطرها خوانده می‌شود؛ یک فایل بدون تگِ شناخته‌شده، که در آن نشانگرِ ترتیب تخمینی قابل مشاهده است؛ و یک فایل اسکن‌شده، که در آن هشدارِ نبودِ متن واقعاً با صدای بلند بیان می‌شود. هر کدام از این‌ها مسیری را آزمایش می‌کند که در حالت ایده‌آل (happy case) نادیده گرفته می‌شود

از آنجا، تأیید کنید که نشانگر کلمه در سرعت گفتار دو برابر و نصف، همچنان قفل می‌ماند و اینکه اسکرول ReadingWordFollow با اسکرول دستی کاربر تداخلی ندارد. سپس در حالی که بین فیلترهای رنگی جابجا می‌شوید گفتار را اجرا کنید و بررسی کنید که نشانگر هرگز ناپدید نمی‌شود. مقاله فیلتر رنگی کم‌بینایی آن مسیر رندر را با جزئیات پوشش می‌دهد، و بررسی عمیق نشانگر کلمه گفتاری زمان‌بندی TTS را کالبدشکافی می‌کند

APIهای واحدهای خواندن و کادر کلمه که در بالا استفاده شدند همراه با کامپوننت PDFium برای دلفی و C++Builder (VCL) و Lazarus/FPC (LCL) عرضه می‌شوند. صفحه محصول به مرجع کامل API از جمله ساختار رکوردهای واحدهای خواندن و کادرهای کلمه که در پشت این مثال‌ها قرار دارند، لینک می‌دهد