مقاله فنی

ساخت نمایشگرهای PDF دسترس‌پذیر با تبدیل متن به گفتار (Text-to-Speech) در Delphi

یک دکمه خواندن با صدای بلند (read-aloud) در یک بعدازظهر به صورت دمو ساخته می‌شود اما سپس یک هفته وقت می‌گیرد. نسخه بعدازظهر متن صفحه را استخراج می‌کند، آن را به SAPI می‌دهد و صدا دریافت می‌کند. اما آن یک هفته صرف چیزهایی می‌شود که این ویژگی را قابل استفاده می‌کنند: صدا نباید پنجره را منجمد کند، کلمه‌ای که خوانده می‌شود باید همزمان با صدا روی صفحه روشن شود و کلید Space باید کل این فرآیند را متوقف کند. این مقاله این پایپ‌لاین را در Delphi در مقابل API متن خام PDFium و Windows Speech API می‌سازد، همراه با کدهای کارآمد برای سه بخشی که نسخه سریع از آن‌ها می‌گذرد: انجام چرخه حیات COM برای یک بار به جای هر بار صحبت، رویدادهای واقعی مرز کلمات و ریاضیات مختصاتی که یک کادر کلمه در فضای PDF را به یک مستطیل قابل نقاشی تبدیل می‌کند

محتوای نظارتی در یک جمله خلاصه می‌شود: خواندن همزمان با صدای بلند، نیمه سمت نمایشگر از چیزی است که WCAG 2.1 از نرم‌افزار سند می‌خواهد و ISO 14289-1 (PDF/UA) نیمه فایل تگ‌دار را تعریف می‌کند که این ویژگی به بهترین شکل با آن کار می‌کند. اگر روی PDFium Component می‌سازید، ممکن است اصلاً به این پایپ‌لاین نیاز نداشته باشید: این نمایشگر دارای یک مکان‌نمای ردیابی داخلی است که یک آفست کاراکتر را در یک فراخوانی به یک هایلایت کلمه رنگ‌آمیزی شده نگاشت می‌کند، که در مقاله هایلایت کردن کلمه به کلمه TTS پوشش داده شده است. آنچه در ادامه می‌آید برای زمانی است که شما کل برنامه نمایشگر را در اختیار دارید و می‌خواهید پایپ‌لاین را خودتان بسازید

یک نخ (thread) رندر می‌کند، یک نخ صحبت می‌کند

معماری شامل دو نخ و یک قرارداد است. نخ رابط کاربری (UI) بیت‌مپ صفحه را رندر می‌کند، وضعیت زوم و اسکرول را در اختیار دارد و روکش هایلایت را رسم می‌کند. یک نخ اختصاصی گفتار، صدای SAPI را در اختیار دارد و هیچ چیز دیگری به آن دست نمی‌زند. قرارداد ساده است: نخ گفتار پیشرفت را به صورت آفست‌های کاراکتر گزارش می‌دهد و نخ UI آفست‌ها را به مستطیل تبدیل می‌کند

بیشتر نمونه‌های SAPI هر گفتار را در CoInitialize و CoUninitialize قرار می‌دهند، و یک نمایشگر فوراً نشان می‌دهد که چرا این کار اشتباه است. متد Speak با پرچم SVSFlagsAsync به محض اینکه متن در صف قرار گرفت باز می‌گردد، بنابراین یک CoUninitialize در بلوک finally همان روال در حالی که صدا هنوز در حال صحبت است اجرا می‌شود و آپارتمان COM که آن را در اختیار دارد، از بین می‌برد. بسته به زمان‌بندی، شما با سکوت، یک گفتار ناقص، یا یک خطای نقض دسترسی (access violation) چند دقیقه بعد مواجه می‌شوید. چرخه حیات صحیح خسته‌کننده است: CoInitialize یک بار وقتی نخ گفتار شروع می‌شود، ایجاد صدا در داخل آن آپارتمان، و CoUninitialize یک بار وقتی نخ خارج می‌شود، پس از اینکه صدا آزاد شد. هرگز این کار را برای هر گفتار انجام ندهید

صدا همچنین به یک پمپ پیام (message pump) نیاز دارد، که تعیین می‌کند کجا می‌تواند زندگی کند. شیء اتوماسیون SpVoice رویدادهای خود را از طریق صف پیام نخی که آن را ایجاد کرده است ارائه می‌دهد. اگر آن را در نخ UI ایجاد کنید رویدادها می‌رسند، زیرا VCL پیام‌ها را پمپ می‌کند، اما هر نقاشی کند باعث تاخیر در مرز کلمات شما می‌شود؛ اگر آن را در یک نخ کارگر بدون پمپ ایجاد کنید، رویدادها هرگز نخواهند رسید. یک نخ اختصاصی با حلقه GetMessage خودش، تاخیر مرزها را بدون توجه به کاری که UI انجام می‌دهد، ثابت نگه می‌دارد

uses
  System.Classes, System.SyncObjs, Winapi.Windows, Winapi.Messages,
  Winapi.ActiveX, SpeechLib_TLB;

const
  WM_SPEAK_PAGE = WM_APP + 1;

type
  TSpeechThread = class(TThread)
  private
    FVoice: TSpVoice;
    FLock: TCriticalSection;
    FText: string;
    function NextUtterance: string;   // reads FText under FLock
    procedure VoiceWord(ASender: TObject; StreamNumber: Integer;
      StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
  protected
    procedure Execute; override;
    procedure TerminatedSet; override;
  public
    procedure SpeakPage(const AText: string);   // safe from the UI thread
  end;

procedure TSpeechThread.Execute;
var
  Msg: TMsg;
begin
  CoInitialize(nil);                       // once, when the thread starts
  try
    FVoice := TSpVoice.Create(nil);
    try
      FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
      FVoice.OnWord := VoiceWord;
      // Force creation of this thread's message queue before anyone posts to it
      PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
      while GetMessage(Msg, 0, 0, 0) do    // exits when WM_QUIT arrives
        if Msg.message = WM_SPEAK_PAGE then
          FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
        else
          DispatchMessage(Msg);            // delivers the SAPI event callbacks
    finally
      FVoice.Free;
    end;
  finally
    CoUninitialize;                        // once, when the thread exits
  end;
end;

procedure TSpeechThread.TerminatedSet;
begin
  inherited;
  PostThreadMessage(ThreadID, WM_QUIT, 0, 0);   // unblock GetMessage
end;

متد TerminatedSet پیام WM_QUIT را ارسال می‌کند تا پمپ هنگام خاموش شدن نمایشگر از حالت انسداد خارج شود. متد SpeakPage که از نخ UI فراخوانی می‌شود، متن را در یک فیلد محافظت شده با قفل (lock) ذخیره می‌کند و WM_SPEAK_PAGE را ارسال می‌کند، زیرا فراخوانی یک متد روی FVoice به طور مستقیم از یک نخ دیگر، یک فراخوانی COM بین آپارتمانی روی یک رابط مارشال نشده خواهد بود. کد یک خطی PeekMessage قبل از حلقه، ویندوز را مجبور می‌کند تا صف پیام نخ را ایجاد کند و از رقابت زمان راه‌اندازی (startup race) که در آن یک ارسال زودهنگام از نخ UI شکست می‌خورد، جلوگیری می‌کند

مرزهای کلمات به عنوان آفست‌های کاراکتر می‌رسند

یک بار کتابخانه Microsoft Speech Object Library را از طریق واردکننده کتابخانه نوع (type library) IDE وارد کنید و SpeechLib_TLB را همراه با پوشش TSpVoice و رویدادهای تایپ‌شده آن دریافت خواهید کرد. دو تنظیم اهمیت دارند. EventInterests باید به رویدادهایی که واقعاً مصرف می‌کنید محدود شود، زیرا هر علاقه‌ای که روشن بماند، ترافیک رویداد بین نخی برای هر کلمه از هر صفحه ایجاد می‌کند؛ SVEWordBoundary هایلایت را هدایت می‌کند و SVEEndInputStream به شما می‌گوید که گفتار به پایان رسیده است. و مدیریت کننده OnWord پارامترهای CharacterPosition و طول را دریافت می‌کند، که در رشته دقیقی که به Speak ارسال کرده‌اید اندیس‌گذاری می‌کنند — این یک آفست به بافر گفتار است، نه هیچ چیز دیگر

آن جمله آخر، اصلی است که این ویژگی به آن وابسته است: آفست‌ها فقط در برابر رشته‌ای که صدا در حال خواندن آن است معنی دارند، بنابراین دقیقاً همان متنی را که استخراج کرده‌اید، کاراکتر به کاراکتر صحبت کنید. حذف فاصله‌ها (Trim)، ادغام شکستگی‌های خط، یا بسط یک مخفف برای تلفظ بهتر، باعث می‌شود هر هایلایت بعد از اولین ویرایش، یک کلمه جابجا شود. اگر UI باید محتوای گفتاری تزریق کند — اعلان‌های صفحه، پیشوندهای عنوان — موقعیت و طول هر درج را ثبت کنید و شیفت انباشته شده را از هر آفست قبل از نگاشت آن کم کنید

procedure TSpeechThread.SpeakPage(const AText: string);
begin
  FLock.Enter;
  try
    FText := AText;
  finally
    FLock.Leave;
  end;
  PostThreadMessage(ThreadID, WM_SPEAK_PAGE, 0, 0);
end;

procedure TSpeechThread.VoiceWord(ASender: TObject; StreamNumber: Integer;
  StreamPosition: OleVariant; CharacterPosition, WordLength: Integer);
begin
  // Runs on the speech thread; hand the offsets to the UI without blocking
  TThread.Queue(nil,
    procedure
    begin
      ViewerForm.HighlightWordAt(CharacterPosition, WordLength);
    end);
end;

استفاده از TThread.Queue در اینجا مارشال مناسبی است، نه Synchronize: مدیریت کننده نباید نخ گفتار را در حالی که UI در حال نقاشی مجدد است متوقف کند، و اگر رویدادهای مرزی سریع‌تر از رسم صفحه برسند، یک به‌روزرسانی هایلایت قدیمی بی‌ضرر است زیرا به‌روزرسانی بعدی روی آن بازنویسی می‌شود. رویداد OnEndStream را به همین ترتیب سیم‌کشی کنید تا هایلایت را پاک کنید و در حالت خواندن پیوسته، متن صفحه بعدی را بارگیری کرده و گفتار بعدی را ارسال کنید

از آفست‌های کاراکتر تا پیکسل‌های روی صفحه

کتابخانه PDFium هندسه را به ازای هر کاراکتر گزارش می‌دهد. متد FPDFText_GetCharBox چهار متغیر نوع double را با ترتیبی پر می‌کند که باعث باگ‌های پنهان بیشتری نسبت به هر چیز دیگری در API متن شده است — چپ، راست، پایین، بالا، نه ترتیب چپ، بالا، راست، پایین در ویندوز — و آن‌ها را در فضای صفحه گزارش می‌دهد: نقاط PDF، شامل 72 نقطه در اینچ، با مبدأ در گوشه پایین سمت چپ که در آن محور Y به سمت بالا رشد می‌کند. کادر یک کلمه، اجتماع کادرهای کاراکترهای آن است و تبدیل به پیکسل‌های دستگاه سه مرحله دارد: انتقال به وسیله مبدأ صفحه، مقیاس‌گذاری با ضرب زوم در DPI صفحه تقسیم بر 72، و قرینه کردن محور Y

uses
  System.Math;

type
  TPdfRectF = record
    Left, Top, Right, Bottom: Double;    // PDF points, origin bottom-left
  end;

function TViewerForm.WordBox(CharIndex, CharCount: Integer): TPdfRectF;
var
  i, LastChar: Integer;
  L, T, R, B: Double;
begin
  Result.Left := MaxDouble;   Result.Bottom := MaxDouble;
  Result.Right := -MaxDouble; Result.Top := -MaxDouble;
  LastChar := Min(CharIndex + CharCount, FPDFText_CountChars(FTextPage)) - 1;
  for i := CharIndex to LastChar do
  begin
    // Parameter order is left, right, bottom, top - not the Windows order
    FPDFText_GetCharBox(FTextPage, i, @L, @R, @B, @T);
    Result.Left   := Min(Result.Left, L);
    Result.Right  := Max(Result.Right, R);
    Result.Bottom := Min(Result.Bottom, B);
    Result.Top    := Max(Result.Top, T);
  end;
end;

function TViewerForm.PdfToDevice(const W: TPdfRectF): TRect;
var
  Scale: Double;
begin
  // 72 PDF points per inch; FZoom is the viewer scale factor
  Scale := FZoom * FScreenDpi / 72.0;
  Result.Left   := Round((W.Left  - FPageLeft) * Scale) - FScrollX;
  Result.Right  := Round((W.Right - FPageLeft) * Scale) - FScrollX;
  // PDF Y grows upward from the bottom edge; device Y grows downward
  Result.Top    := Round((FPageTop - W.Top)    * Scale) - FScrollY;
  Result.Bottom := Round((FPageTop - W.Bottom) * Scale) - FScrollY;
end;

متغیر FPageTop ارتفاع صفحه به نقاط (points) است که از FPDF_GetPageHeight به دست می‌آید و FPageLeft برای اکثر اسناد صفر است اما وقتی صفحه یکی را تعریف می‌کند از کراپ‌باکس (crop box) می‌آید، بنابراین هر دو را از FPDF_GetPageBoundingBox بخوانید به جای اینکه آن‌ها را فرض کنید. قرینه کردن Y جایی است که نسخه‌های دستی خراب می‌شوند: بالای مستطیل دستگاه از بالای کادر PDF که از بالای صفحه به سمت پایین اندازه‌گیری می‌شود، به دست می‌آید. اگر آن را برعکس انجام دهید، هر هایلایت به صورت آینه‌ای در نیمه اشتباه صفحه نقاشی می‌شود

procedure TViewerForm.HighlightWordAt(CharIndex, CharCount: Integer);
var
  Old: TRect;
begin
  if CharCount <= 0 then Exit;
  Old := FHighlightRect;
  FHighlightRect := PdfToDevice(WordBox(CharIndex, CharCount));
  InvalidateRect(PageBox.Handle, @Old, False);             // erase the old word
  InvalidateRect(PageBox.Handle, @FHighlightRect, False);  // draw the new one
end;

procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
  Blend: TBlendFunction;
begin
  PageBox.Canvas.Draw(0, 0, FPageBitmap);      // rendered page first, always
  if FHighlightRect.IsEmpty then Exit;

  Blend.BlendOp := AC_SRC_OVER;
  Blend.BlendFlags := 0;
  Blend.SourceConstantAlpha := 96;             // about 38 percent opacity
  Blend.AlphaFormat := 0;                      // constant alpha, no per-pixel data
  Winapi.Windows.AlphaBlend(PageBox.Canvas.Handle,
    FHighlightRect.Left, FHighlightRect.Top,
    FHighlightRect.Width, FHighlightRect.Height,
    FHighlightBrush.Canvas.Handle, 0, 0, 1, 1, Blend);
end;

مدیریت کننده نقاشی (paint handler) ابتدا بیت‌مپ صفحه و پس از آن هایلایت را رسم می‌کند، هر بار، بنابراین نیازی نیست که روکش خودش را پاک کند؛ باطل کردن مستطیل‌های قدیم و جدید، ناحیه نقاشی مجدد را حتی در نرخ‌های گفتار بالا کوچک نگه می‌دارد. متغیر FHighlightBrush یک TBitmap یک در یک است که یک بار هنگام راه‌اندازی با رنگ هایلایت پر می‌شود — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF برای رنگ کهربایی — که AlphaBlend آن را روی مستطیل هدف می‌کشد، بنابراین چیزی به ازای هر فریم تخصیص داده نمی‌شود و تنظیم SourceConstantAlpha روی 96 باعث می‌شود کلمه از طریق سایه رنگ خوانا بماند. رنگ را تحت حالت‌های نمایش معکوس و کنتراست بالا آزمایش کنید؛ روکشی که یک کاربر کم‌بینا نتواند ببیند، برای شخصی که این ویژگی دقیقاً برای او ساخته شده است، وجود ندارد

ترتیب خواندن بخشی است که API متن آن را حل نمی‌کند

متد FPDFText_GetText کاراکترها را در ترتیبی که از استریم محتوا با برخی پاک‌سازی‌های فضایی به دست آمده است، برمی‌گرداند و این ترتیب برای یک گزارش تک‌ستونی خوب است. این متد هیچ الزامی ندارد که در جاهای دیگر درست عمل کند. یک خبرنامه دو ستونی می‌تواند مستقیماً از روی هر دو ستون خوانده شود، یک نوار کناری (sidebar) می‌تواند یک جمله را در وسط عبارت قطع کند و یک پاورقی (footer) می‌تواند در وسط صفحه ظاهر شود. اطلاعاتی که این مشکل را برطرف می‌کند — درخت ساختار منطقی ISO 32000-1 §14.8، که PDFهای تگ‌دار به همراه دارند و PDF/UA آن را اجباری می‌کند — به هیچ وجه در فراخوانی‌های صفحه متن خام بررسی نمی‌شوند. اگر به ترتیبی آگاه از ساختار با یک سیگنال صریح از منشأ آن نیاز دارید، این مشکل یک سطح بالاتر حل شده است: API خواندن PDFium Component محتوا را با فیلد Source شامل مقادیر rosStructure یا rosHeuristic برمی‌گرداند و مقاله خواننده PDF دسترس‌پذیر آن را بررسی می‌کند. در سطح API خام، رویکرد قابل دفاع این است که ترتیب استخراج را به عنوان یک تخمین در نظر بگیرید، این موضوع را در UI اعلام کنید و یک سند چند ستونی و یک اسکن فقط شامل تصویر را در مجموعه تست‌های رگرسیون نگه دارید تا هر دو حالت خرابی قابل مشاهده باقی بمانند

خود نمایشگر باید با صفحه‌کلید قابل استفاده باشد

خروجی گفتار به این معنا نیست که نمایشگر از دسترسی به صفحه‌کلید معاف است؛ افرادی که بیشتر احتمال دارد از قابلیت خواندن با صدای بلند استفاده کنند، کمتر احتمال دارد به سراغ ماوس بروند. به پنل صفحه TabStop := True و یک مستطیل فوکوس قابل مشاهده بدهید، سپس سه کلید را مدیریت کنید: کلید Space برای جابجایی بین FVoice.Pause و FVoice.Resume، و کلیدهای چپ و راست برای پرش (skip) از طریق FVoice.Skip('Sentence', 1) با یک شمارش منفی برای بازگشت به عقب استفاده می‌شود. متد Skip در SAPI فقط سطح جمله را درک می‌کند، بنابراین پرش در سطح کلمه به معنای پاک کردن پخش با SVSFPurgeBeforeSpeak و صحبت مجدد از آفست کلمه‌ای است که آخرین بار ردیابی کرده‌اید — که ارزان است، زیرا کد هایلایت از قبل دقیقاً همان آفست را ذخیره می‌کند. هر کنترل انتقال را یک TButton واقعی با یک عنوان نگه دارید تا صفحه‌خوان‌ها آن را اعلام کنند

این کل پایپ‌لاین است، تمام آن در برابر API متن خام PDFium: یک نخ گفتار که COM و صدا را برای تمام طول عمر برنامه در اختیار دارد، رویدادهای مرزی که به عنوان آفست‌های کاراکتر به UI مارشال می‌شوند و کادرهای فضای صفحه به ازای هر کاراکتر که به یک مستطیل ترکیب‌شده روی صفحه تبدیل می‌شوند. اگر ترجیح می‌دهید خودتان درگیر هندسه و ردیابی نشوید، PDFium Component کادرهای کلمه به کلمه، مکان‌نمای ردیابی، دنبال کردن خودکار اسکرول و واحدهای خواندن در سطح جمله را به عنوان ویژگی‌های کامپوننت ارائه می‌دهد، و دموی خواندن با صدای بلند آن، همین پایپ‌لاین مقاله است که به تعداد انگشت‌شماری از فراخوانی‌ها کاهش یافته است