یک دکمه خواندن با صدای بلند (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; // FText را تحت 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); // ایمن از نخ UI
end;
procedure TSpeechThread.Execute;
var
Msg: TMsg;
begin
CoInitialize(nil); // یک بار، هنگام شروع نخ
try
FVoice := TSpVoice.Create(nil);
try
FVoice.EventInterests := SVEWordBoundary or SVEEndInputStream;
FVoice.OnWord := VoiceWord;
// ایجاد صف پیام این نخ را پیش از آنکه کسی به آن پیام ارسال کند اجبار میکند
PeekMessage(Msg, 0, WM_USER, WM_USER, PM_NOREMOVE);
while GetMessage(Msg, 0, 0, 0) do // وقتی WM_QUIT برسد خارج میشود
if Msg.message = WM_SPEAK_PAGE then
FVoice.Speak(NextUtterance, SVSFlagsAsync or SVSFPurgeBeforeSpeak)
else
DispatchMessage(Msg); // callbackهای رویداد SAPI را تحویل میدهد
finally
FVoice.Free;
end;
finally
CoUninitialize; // یک بار، هنگام خروج نخ
end;
end;
procedure TSpeechThread.TerminatedSet;
begin
inherited;
PostThreadMessage(ThreadID, WM_QUIT, 0, 0); // 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
// روی نخ گفتار اجرا میشود؛ آفستها را بدون انسداد به UI تحویل میدهد
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، مبدأ در گوشه پایین چپ
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
// ترتیب پارامترها چپ، راست، پایین، بالا است - نه ترتیب ویندوز
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 در هر اینچ؛ FZoom عامل مقیاس نمایشگر است
Scale := FZoom * FScreenDpi / 72.0;
Result.Left := Round((W.Left - FPageLeft) * Scale) - FScrollX;
Result.Right := Round((W.Right - FPageLeft) * Scale) - FScrollX;
// محور Y در PDF از لبه پایین به سمت بالا رشد میکند؛ محور Y دستگاه به سمت پایین
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); // کلمه قدیمی را پاک کن
InvalidateRect(PageBox.Handle, @FHighlightRect, False); // کلمه جدید را رسم کن
end;
procedure TViewerForm.PageBoxPaint(Sender: TObject);
var
Blend: TBlendFunction;
begin
PageBox.Canvas.Draw(0, 0, FPageBitmap); // همیشه ابتدا صفحه رندرشده
if FHighlightRect.IsEmpty then Exit;
Blend.BlendOp := AC_SRC_OVER;
Blend.BlendFlags := 0;
Blend.SourceConstantAlpha := 96; // حدود ۳۸ درصد کدری (opacity)
Blend.AlphaFormat := 0; // آلفای ثابت، بدون داده به ازای هر پیکسل
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 کادرهای کلمه به کلمه، مکاننمای ردیابی، دنبال کردن خودکار اسکرول و واحدهای خواندن در سطح جمله را به عنوان ویژگیهای کامپوننت ارائه میدهد، و دموی خواندن با صدای بلند آن، همین پایپلاین مقاله است که به تعداد انگشتشماری از فراخوانیها کاهش یافته است