Artikel Teknis

Membangun Penampil PDF Bacakan Keras di Delphi dengan SAPI TTS

Tombol bacakan keras dapat didemokan dalam suatu sore dan kemudian memakan waktu seminggu. Versi sore mengekstrak teks halaman, menyerahkannya ke SAPI, dan mendapatkan audio. Seminggu berikutnya dihabiskan untuk apa yang membuat fitur ini dapat digunakan: suara tidak boleh membekukan jendela, kata yang diucapkan harus menyala di halaman tepat waktu dengan audio, dan tombol Spasi harus menjeda semuanya. Artikel ini membangun saluran tersebut di Delphi dengan API teks PDFium mentah dan Windows Speech API, dengan kode yang berfungsi untuk tiga bagian yang dilewati versi cepat: masa pakai COM yang dilakukan sekali, bukan per ucapan, peristiwa batas kata yang nyata, dan matematika koordinat yang mengubah kotak kata ruang-PDF menjadi persegi panjang yang dapat Anda lukis

Konteks peraturannya cocok dalam satu kalimat: bacakan keras tersinkronisasi adalah bagian sisi-penampil dari apa yang diminta WCAG 2.1 untuk perangkat lunak dokumen, dan ISO 14289-1 (PDF/UA) mendefinisikan bagian berkas-berlabel agar ini bekerja paling baik. Jika Anda membangun di atas Komponen PDFium, Anda mungkin tidak memerlukan saluran ini sama sekali: penampil menyertakan kursor pelacakan bawaan yang memetakan offset karakter ke sorotan kata yang dicat dalam satu panggilan, yang dibahas dalam artikel penyorotan TTS kata demi kata. Apa yang berikut ini adalah saat Anda memiliki seluruh aplikasi penampil dan menginginkan salurannya sendiri

Satu utas merender, satu utas berbicara

Arsitekturnya adalah dua utas dan satu kontrak. Utas UI merender bitmap halaman, memiliki status pembesaran dan gulir, dan melukis hamparan sorotan. Utas ucapan khusus memiliki suara SAPI, dan tidak ada yang lain yang menyentuhnya. Kontraknya tipis: utas ucapan melaporkan kemajuan sebagai offset karakter, dan utas UI mengubah offset menjadi persegi panjang

Sebagian besar sampel SAPI membungkus setiap ucapan di CoInitialize dan CoUninitialize, dan penampil menunjukkan mengapa itu langsung salah. Speak dengan SVSFlagsAsync kembali segera setelah teks diantrekan, sehingga CoUninitialize di blok finally prosedur yang sama berjalan saat suara masih berbicara, meruntuhkan apartemen COM yang memilikinya. Bergantung pada waktu, Anda akan mendapatkan keheningan, ucapan yang terpotong, atau pelanggaran akses beberapa menit kemudian. Masa pakai yang benar itu membosankan: CoInitialize sekali saat utas ucapan dimulai, buat suara di dalam apartemen itu, dan CoUninitialize sekali saat utas keluar, setelah suara dibebaskan. Jangan pernah per ucapan

Suara itu juga membutuhkan pompa pesan, yang memutuskan di mana ia bisa hidup. Objek otomatisasi SpVoice mengirimkan peristiwanya melalui antrean pesan dari utas yang membuatnya. Buat itu di utas UI dan peristiwa memang tiba, karena VCL memompa pesan, tetapi setiap catan lambat kemudian menunda batas kata Anda; buat itu di utas pekerja tanpa pompa dan peristiwa tidak pernah tiba sama sekali. Utas khusus dengan loop GetMessage-nya sendiri menjaga latensi batas tetap datar tidak peduli apa yang sedang dilakukan 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 memposting WM_QUIT sehingga pompa terbuka saat penampil ditutup. SpeakPage, yang dipanggil dari utas UI, menyimpan teks dalam bidang yang dijaga dengan kunci dan memposting WM_SPEAK_PAGE, karena memanggil metode di FVoice secara langsung dari utas lain akan menjadi panggilan COM lintas-apartemen pada antarmuka yang tidak dibatalkan. Baris tunggal PeekMessage sebelum loop memaksa Windows untuk membuat antrean pesan utas, menutup pacuan pemulaian di mana posting awal dari utas UI akan gagal

Batas kata tiba sebagai offset karakter

Impor Microsoft Speech Object Library sekali melalui pengimpor pustaka jenis IDE dan Anda mendapatkan SpeechLib_TLB dengan pembungkus TSpVoice dan peristiwa berjenisnya. Dua pengaturan penting. EventInterests harus dipersempit ke peristiwa yang benar-benar Anda konsumsi, karena setiap minat yang dibiarkan menyala adalah lalu lintas peristiwa lintas-utas untuk setiap kata dari setiap halaman; SVEWordBoundary menggerakkan sorotan dan SVEEndInputStream memberi tahu Anda ucapan selesai. Dan penangan OnWord menerima CharacterPosition dan panjang, yang menunjuk ke untai persis yang Anda teruskan ke Speak — offset ke dalam penyangga ucapan, bukan ke apa pun lainnya

Klausa terakhir itu adalah invarian yang digantung oleh fitur: offset hanya bermakna terhadap untai yang sedang dibaca suara, jadi bicarakan dengan tepat teks yang Anda ekstrak, karakter demi karakter. Potong spasi, runtuhkan jeda baris, atau perluas singkatan untuk pengucapan yang lebih baik, dan setiap sorotan setelah pengeditan pertama mendarat satu kata di luar target. Jika UI harus memasukkan materi yang diucapkan — pengumuman halaman, awalan tajuk — rekam posisi dan panjang setiap sisipan, dan kurangi pergeseran terakumulasi dari setiap offset sebelum memetakannya

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 adalah marshal yang tepat di sini, bukan Synchronize: penangan tidak boleh memarkir utas ucapan saat UI melukis ulang, dan jika peristiwa batas tiba lebih cepat dari layar menggambar, pembaruan sorotan yang basi tidak berbahaya karena yang berikutnya akan menimpanya. Hubungkan OnEndStream dengan cara yang sama untuk membersihkan sorotan, dan dalam mode baca berkelanjutan, untuk memuat teks halaman berikutnya dan memposting ucapan berikutnya

Dari offset karakter ke piksel di layar

PDFium melaporkan geometri per karakter. FPDFText_GetCharBox mengisi empat ganda dengan urutan yang menyebabkan bug lebih senyap daripada apa pun di API teks — kiri, kanan, bawah, atas, bukan urutan Windows kiri, atas, kanan, bawah — dan itu melaporkannya dalam ruang halaman: Titik PDF, 72 ke inci, asal di sudut kiri bawah dengan Y tumbuh ke atas. Kotak kata adalah penyatuan dari kotak-kotak karakternya, dan transformasi ke piksel perangkat adalah tiga langkah: terjemahkan berdasarkan asal halaman, skala dengan pembesaran kali DPI layar di atas 72, dan balik sumbu 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 adalah tinggi halaman dalam titik dari FPDF_GetPageHeight, dan FPageLeft adalah nol untuk sebagian besar dokumen tetapi berasal dari kotak potong jika halaman mendefinisikannya, jadi baca keduanya dari FPDF_GetPageBoundingBox daripada berasumsi. Balik Y adalah di mana versi yang digulung tangan pecah: bagian atas persegi panjang perangkat berasal dari atas kotak PDF yang diukur turun dari atas halaman. Lakukan secara terbalik dan setiap sorotan akan melukis secara bercermin ke separuh halaman yang salah

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;

Penangan cat menggambar bitmap halaman terlebih dahulu dan menyorotinya setelah itu, setiap kali, sehingga hamparan tidak pernah harus menghapus dirinya sendiri; membatalkan persegi panjang lama dan baru menjaga wilayah lukis ulang tetap kecil bahkan pada tingkat ucapan cepat. FHighlightBrush adalah TBitmap satu-kali-satu yang diisi sekali pada saat mulai dengan warna sorotan — FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF untuk sebuah amber — bahwa AlphaBlend membentang di atas persegi panjang target, jadi tidak ada yang dialokasikan per bingkai, dan SourceConstantAlpha di 96 menjaga kata tetap dapat dibaca melalui pewarnaan. Uji warna di bawah mode tampilan terbalik dan kontras-tinggi; hamparan yang tidak dapat dilihat oleh pengguna dengan penglihatan rendah tidak ada untuk orang yang tepat untuk siapa fitur itu dibangun

Urutan bacaan adalah bagian yang API teks tidak akan pecahkan

FPDFText_GetText mengembalikan karakter dalam urutan yang diturunkan dari aliran konten dengan beberapa pembersihan spasial, dan untuk laporan kolom tunggal urutan itu bagus. Ia tidak memiliki kewajiban untuk benar di tempat lain. Buletin dua-kolom dapat membaca lurus melintasi kedua kolom, bilah sisi dapat mengganggu klausa pertengahan kalimat, dan catatan kaki dapat tiba di tengah halaman. Informasi yang memperbaiki ini — pohon struktur logis dari ISO 32000-1 §14.8, yang dibawa PDF berlabel dan PDF/UA membuatnya wajib — sama sekali tidak dikonsultasikan oleh panggilan halaman teks mentah. Jika Anda memerlukan urutan yang sadar-struktur dengan sinyal eksplisit tentang asalnya, itu adalah masalah yang terpecahkan satu tingkat ke atas: API bacaan Komponen PDFium mengembalikan konten dengan bidang Source dari rosStructure atau rosHeuristic, dan artikel pembaca PDF yang dapat diakses memandunya. Di tingkat API mentah, posisi yang dapat dipertahankan adalah memperlakukan urutan ekstraksi sebagai perkiraan, katakan demikian dalam UI, dan simpan satu dokumen multi-kolom dan satu pindaian gambar saja di set regresi sehingga kedua mode kegagalan tetap terlihat

Penampil itu sendiri harus dapat dioperasikan dengan papan ketik

Output ucapan tidak memaafkan penampil dari akses papan ketik; orang-orang yang paling mungkin menggunakan bacakan keras adalah yang paling tidak mungkin meraih tetikus. Berikan panel halaman TabStop := True dan persegi panjang fokus yang terlihat, kemudian tangani tiga tuts: Spasi mengaktifkan FVoice.Pause dan FVoice.Resume, dan Kiri dan Kanan melewati FVoice.Skip('Sentence', 1) dengan hitungan negatif untuk kembali. Skip dari SAPI hanya memahami butiran kalimat, jadi melewati tingkat-kata berarti membersihkan pemutaran dengan SVSFPurgeBeforeSpeak dan mengucapkan kembali dari offset dari kata terakhir yang Anda lacak — murah, karena kode sorotan sudah menyimpan tepat offset itu. Pertahankan setiap kendali transpor TButton nyata dengan sebuah judul sehingga pembaca layar mengumumkannya

Itu adalah keseluruhan saluran, semuanya terhadap API teks PDFium mentah: utas ucapan yang memiliki COM dan suara untuk seumur hidup aplikasi, peristiwa batas berbaris ke UI sebagai offset karakter, dan kotak ruang-halaman per-karakter diubah menjadi satu persegi panjang campuran di layar. Jika Anda lebih suka tidak memiliki geometri dan pelacakan sendiri, Komponen PDFium mengirimkan kotak per-kata, kursor pelacakan, ikuti gulir-otomatis, dan unit bacaan tingkat-kalimat sebagai properti komponen, dan demo bacakan kerasnya adalah saluran artikel ini dikurangi menjadi segenggam panggilan