기술 문서

Delphi에서 텍스트 음성 변환(TTS)을 사용한 접근성 높은 PDF 뷰어 구축

소리내어 읽기 버튼은 오후 한나절이면 데모를 만들 수 있지만 완성하려면 일주일이 걸립니다. 오후에 만든 버전은 페이지 텍스트를 추출하여 SAPI에 넘겨주고 오디오를 얻습니다. 나머지 일주일은 이 기능을 유용하게 만드는 데 사용됩니다. 음성이 창을 멈추게 해서는 안 되고, 말하는 단어가 오디오에 맞춰 페이지에서 강조 표시되어야 하며, 스페이스 키로 전체를 일시 중지할 수 있어야 합니다. 이 기사에서는 발화마다 COM 수명 주기를 처리하는 대신 한 번만 처리하기, 실제 단어 경계 이벤트, PDF 공간의 단어 상자를 칠할 수 있는 사각형으로 변환하는 좌표 수학 등 빠른 버전에서 건너뛰는 세 가지 요소에 대한 작동 코드와 함께 원시 PDFium 텍스트 API 및 Windows Speech API를 사용하여 Delphi에서 해당 파이프라인을 구축합니다

규제 상황은 한 문장으로 요약할 수 있습니다. 동기화된 소리내어 읽기는 WCAG 2.1이 문서 소프트웨어에 요구하는 뷰어 측 절반이며, ISO 14289-1(PDF/UA)은 이것이 가장 잘 작동하는 태그된 파일의 나머지 절반을 정의합니다. PDFium Component를 기반으로 구축하는 경우 이 파이프라인이 전혀 필요하지 않을 수 있습니다. 뷰어에는 단일 호출로 문자 오프셋을 칠해진 단어 강조 표시에 매핑하는 내장 추적 커서가 제공되며, 이는 단어 단위 TTS 강조 표시 기사에서 다룹니다. 다음 내용은 뷰어 애플리케이션 전체를 소유하고 있으며 파이프라인 자체가 필요할 때를 위한 것입니다

한 스레드는 렌더링하고, 한 스레드는 말합니다

아키텍처는 두 개의 스레드와 하나의 계약으로 구성됩니다. UI 스레드는 페이지 비트맵을 렌더링하고, 확대/축소 및 스크롤 상태를 소유하며, 강조 표시 오버레이를 칠합니다. 전용 음성 스레드는 SAPI 음성을 소유하며, 다른 어떤 것도 이를 건드리지 않습니다. 계약은 간단합니다. 음성 스레드는 진행 상황을 문자 오프셋으로 보고하고, UI 스레드는 오프셋을 사각형으로 변환합니다

대부분의 SAPI 예제는 모든 발화를 CoInitializeCoUninitialize로 감싸는데, 뷰어는 왜 그것이 잘못되었는지 즉시 보여줍니다. SVSFlagsAsync를 사용한 Speak는 텍스트가 대기열에 추가되는 즉시 반환되므로, 같은 프로시저의 finally 블록에 있는 CoUninitialize는 음성이 계속 말하는 동안 실행되어 이를 소유한 COM 아파트먼트를 분해합니다. 타이밍에 따라 침묵, 잘린 발화 또는 몇 분 후의 액세스 위반이 발생할 수 있습니다. 올바른 수명 주기는 지루합니다. 음성 스레드가 시작될 때 CoInitialize를 한 번 호출하고, 해당 아파트먼트 내에 음성을 생성한 다음, 음성이 해제된 후 스레드가 종료될 때 CoUninitialize를 한 번 호출합니다. 절대 발화마다 호출하지 마십시오

음성에는 메시지 펌프도 필요하며, 이는 음성이 존재할 위치를 결정합니다. 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을 게시합니다. UI 스레드에서 호출되는 SpeakPage는 텍스트를 잠금으로 보호된 필드에 저장하고 WM_SPEAK_PAGE를 게시합니다. 왜냐하면 다른 스레드에서 FVoice의 메서드를 직접 호출하는 것은 마샬링되지 않은 인터페이스에 대한 교차 아파트먼트 COM 호출이 되기 때문입니다. 루프 앞의 한 줄짜리 PeekMessage는 Windows가 스레드의 메시지 큐를 생성하도록 강제하여, UI 스레드에서 초기에 게시한 메시지가 실패할 수 있는 시작 시의 경쟁 조건을 닫습니다

단어 경계는 문자 오프셋으로 도착합니다

IDE의 형식 라이브러리 가져오기를 통해 Microsoft Speech Object Library를 한 번 가져오면 TSpVoice 래퍼 및 형식화된 이벤트가 있는 SpeechLib_TLB를 얻습니다. 두 가지 설정이 중요합니다. 활성화된 모든 관심사는 모든 페이지의 모든 단어에 대한 교차 스레드 이벤트 트래픽이므로 EventInterests는 실제로 소비하는 이벤트로 좁혀야 합니다. SVEWordBoundary는 강조 표시를 구동하고 SVEEndInputStream은 발화가 끝났음을 알려줍니다. 그리고 OnWord 처리기는 Speak에 전달한 정확한 문자열에 대한 인덱스인 CharacterPosition과 길이를 수신합니다. 이는 다른 어떤 것도 아닌 음성 버퍼로의 오프셋입니다

마지막 구문은 이 기능이 의존하는 불변 조건입니다. 오프셋은 음성이 읽고 있는 문자열에 대해서만 의미가 있으므로 추출한 텍스트를 글자 하나하나 정확히 읽어줍니다. 더 나은 발음을 위해 공백을 다듬거나, 줄 바꿈을 축소하거나, 약어를 확장하면 첫 번째 편집 이후의 모든 강조 표시가 한 단어씩 어긋나게 됩니다. 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;

여기서 올바른 마샬링 메서드는 Synchronize가 아니라 TThread.Queue입니다. 처리기는 UI가 다시 그려지는 동안 음성 스레드를 멈춰서는 안 되며, 경계 이벤트가 화면이 그려지는 것보다 빨리 도착하는 경우 오래된 강조 표시 업데이트는 다음 업데이트가 덮어쓰기 때문에 무해합니다. 같은 방식으로 OnEndStream을 연결하여 강조 표시를 지우고, 연속 읽기 모드에서는 다음 페이지의 텍스트를 로드하고 다음 발화를 게시합니다

문자 오프셋에서 화면의 픽셀로

PDFium은 문자 단위로 형상을 보고합니다. FPDFText_GetCharBox는 텍스트 API에서 다른 어떤 것보다 더 많은 조용한 버그를 일으킨 순서인 왼쪽, 오른쪽, 아래쪽, 위쪽(Windows의 왼쪽, 위쪽, 오른쪽, 아래쪽 아님)으로 4개의 double을 채우며, 이를 페이지 공간(인치당 72 PDF 포인트, 원점은 왼쪽 아래 모서리, 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;

FPageTopFPDF_GetPageHeight에서 얻은 포인트 단위의 페이지 높이이고, FPageLeft는 대부분의 문서에서 0이지만 페이지에서 정의하는 경우 자르기 상자(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;

페인트 처리기는 항상 페이지 비트맵을 먼저 그린 다음 강조 표시를 그리기 때문에 오버레이가 스스로를 지울 필요가 없습니다. 이전 사각형과 새 사각형을 무효화하면 빠른 음성 속도에서도 다시 그리기 영역이 작게 유지됩니다. FHighlightBrush는 시작 시 강조 색상(호박색의 경우 FHighlightBrush.Canvas.Pixels[0, 0] := $0032C8FF)으로 한 번 채워진 1x1 TBitmap이며 AlphaBlend가 이를 대상 사각형에 걸쳐 늘리므로 프레임당 할당되는 것이 없고, 96의 SourceConstantAlpha는 틴트를 통해 단어를 읽을 수 있게 유지합니다. 색상 반전 및 고대비 디스플레이 모드에서 색상을 테스트하십시오. 저시력 사용자가 볼 수 없는 오버레이는 바로 그 오버레이가 만들어진 대상을 위해 존재하지 않는 것과 같습니다

읽기 순서는 텍스트 API가 해결해주지 않는 부분입니다

FPDFText_GetText는 약간의 공간적 정리와 함께 콘텐츠 스트림에서 파생된 순서로 문자를 반환하며, 단일 열 보고서의 경우 해당 순서가 괜찮습니다. 다른 곳에서 올바를 의무는 없습니다. 2단 뉴스레터는 두 단을 가로질러 똑바로 읽을 수 있고, 사이드바는 절 중간에 문장을 끊을 수 있으며, 바닥글이 페이지 중앙에 나타날 수도 있습니다. 이를 수정하는 정보, 즉 태그된 PDF가 포함하고 PDF/UA가 필수로 만드는 ISO 32000-1 §14.8의 논리적 구조 트리는 원시 텍스트 페이지 호출에서 전혀 참조되지 않습니다. 출처에 대한 명시적 신호가 있는 구조 인식 순서가 필요한 경우 한 단계 위에서 해결된 문제입니다. PDFium Component의 읽기 API는 rosStructure 또는 rosHeuristicSource 필드와 함께 콘텐츠를 반환하며, 접근성 높은 PDF 리더 기사에서 이를 살펴봅니다. 원시 API 수준에서 방어할 수 있는 입장은 추출 순서를 추정치로 취급하고 UI에 그렇게 표시하며, 회귀 테스트 세트에 다중 열 문서 1개와 이미지만 있는 스캔 1개를 유지하여 두 실패 모드가 계속 보이도록 하는 것입니다

뷰어 자체는 키보드로 조작할 수 있어야 합니다

음성 출력이 뷰어의 키보드 접근성을 면제해주지는 않습니다. 소리내어 읽기를 사용할 가능성이 가장 높은 사람들은 마우스를 사용할 가능성이 가장 적은 사람들입니다. 페이지 패널에 TabStop := True와 눈에 띄는 포커스 사각형을 지정한 다음, 세 개의 키를 처리합니다. 스페이스(Space) 키는 FVoice.PauseFVoice.Resume을 토글하고, 왼쪽(Left) 및 오른쪽(Right) 키는 FVoice.Skip('Sentence', 1)을 통해 건너뛰며, 음수 개수를 사용하여 뒤로 돌아갑니다. SAPI의 Skip은 문장 단위만 이해하므로, 단어 수준 건너뛰기는 SVSFPurgeBeforeSpeak로 재생을 지우고 마지막으로 추적한 단어의 오프셋부터 다시 말하는 것을 의미합니다. 강조 표시 코드가 이미 정확히 해당 오프셋을 저장하고 있으므로 이 작업은 비용이 적게 듭니다. 모든 트랜스포트 제어 요소는 화면 판독기가 읽을 수 있도록 캡션이 있는 실제 TButton으로 유지하십시오

앱의 수명 동안 COM과 음성을 소유하는 음성 스레드, 문자 오프셋으로 UI에 마샬링된 경계 이벤트, 화면에서 하나의 혼합된 사각형으로 변환되는 문자별 페이지 공간 상자 등 원시 PDFium 텍스트 API에 대해 작성된 이 파이프라인의 전부입니다. 형상과 추적을 직접 다루고 싶지 않다면, PDFium Component는 단어별 상자, 추적 커서, 자동 스크롤 팔로우, 문장 수준 읽기 단위를 구성 요소 속성으로 제공하며, 이 구성 요소의 소리내어 읽기 데모는 이 기사의 파이프라인을 단 몇 번의 호출로 축소한 것입니다