기술 문서

PDFium을 사용하여 Delphi에서 접근 가능한 PDF 리더 빌드하기

시각 장애인 사용자가 멋진 새 Delphi 뷰어에서 분기별 보고서를 열고, NVDA를 켠 다음 페이지 바닥글, 숫자 열을 듣고 나서 시력을 가진 독자가 제일 먼저 읽었을 제목을 듣게 됩니다. 아니면 아무 소리도 듣지 못할 수도 있습니다. 화면상으로 페이지가 완벽해 보이지만, 바로 그것이 함정입니다. 렌더링과 읽기는 서로 다른 코드로 해결되는 다른 문제입니다. PDF가 글리프를 그리는 순서와 사람이 그 글리프를 들어야 하는 순서가 일치할 의무는 없으므로, 렌더링 호출로만 만들어진 뷰어는 흠결 없는 그림과 사용할 수 없는 내레이션을 생성합니다. Delphi, C++Builder 및 Lazarus용 PDFium 엔진을 감싸는 VCL/LCL 래퍼인 PDFium Component가 이를 위한 별도의 읽기 API 세트를 제공하는 이유입니다. 그리기 API는 처음부터 주어지지 않은 읽기 순서를 복구할 수 없습니다

접근성 있는 리더의 성패는 세 가지에 달려 있습니다. 화면 판독기가 말할 수 있는 순서를 추출해야 하고, 음성이 말하는 내용에 시각적인 단어 커서를 고정시켜야 하며, 문서가 전혀 태그 지정되지 않았을 때 추측하거나 척하는 대신 솔직하게 인정해야 합니다. 각각에 대해 사용해야 할 명확한 API가 있으며, 세부 사항을 건너뛰면 치명적인 오류가 발생합니다

읽기 순서는 그리기 순서가 아니라 구조 트리에 있습니다

ISO 32000-1 §14.8은 논리적 구조를 페이지 내용 위에 계층화된 요소의 트리로 정의합니다. PDF/UA (ISO 14289-1)는 더 나아가 해당 트리를 필수로 만듭니다. 모든 실제 콘텐츠 조각은 읽기 순서대로 트리를 통해 도달할 수 있어야 하며, 페이지 인공물은 그에 맞게 표시되고 건너뛰어져야 합니다. 올바르게 태그가 지정된 보고서는 "Quarterly Results"가 레벨 2의 제목이고 합계 그리드가 헤더 셀을 가진 표임을 알고 있습니다. 태그가 없는 보고서는 문서처럼 보이도록 우연히 배치된 글리프 실행의 더미입니다

ReadablePageContent는 구조 트리가 존재하는 경우 이 구조 트리를 탐색하고 cfHeadingcfParagraph와 같은 값인 의미론적 Kind 태그가 지정된 조각을 반환하여, 굵은 선을 일반 본문 텍스트로 읽는 대신 단어 앞에 UI가 "제목"이라고 말할 수 있도록 합니다. 사용할 수 있는 트리가 없는 경우 동일한 호출이 휴리스틱 레이아웃 분석(열 감지, 기준선 군집화, 왼쪽에서 오른쪽으로 및 위에서 아래로 정렬)으로 대체됩니다. 이러한 대체는 단일 열 메모에는 적합하지만 뉴스레터, 다중 열 양식, 사이드바나 인용구가 있는 경우에는 불안정합니다. 중요한 것은 어떤 결과를 얻었는지 아는 것이며, API는 이를 직접적으로 알려줍니다. TPdfReadableContent 레코드에는 읽기 순서가 태그 지정된 트리에서 온 경우 rosStructure로 설정되고 기하학적 구조에서 추론된 경우 rosHeuristic로 설정되는 Source 필드가 포함되어 있습니다. 추측한 순서를 마치 확인된 것처럼 표시한다면, 아무도 실행하지 않은 빌드에서 통과 배지의 접근성 버전을 배포하는 것과 같습니다

문서를 열 때 취할 수 있는 손쉬운 방법은 IsTagged를 읽고 ValidatePdfUa를 한 번 호출한 후 그 답을 캐시하는 것입니다. PDF/UA 확인 실패가 파일 거부의 근거가 되지는 않습니다. 이는 상태 표시줄에 "예상 읽기 순서"를 표시하는 근거가 되며, 그래야 고객이 왜곡된 내레이션에 대해 불만 메일을 보낼 때 고객 지원팀이 파일의 태그 문제인지 코드의 버그인지 이미 알고 대처할 수 있습니다

ReadingUnits를 사용하여 페이지에서 음성 대기열로

텍스트 음성 변환의 경우 ReadingUnits가 가장 힘든 작업을 수행합니다. 활성 페이지에 대한 TPdfReadingUnit 레코드 배열을 반환하며, 각 레코드는 말할 텍스트, 의미론적 역할, 그리고 페이지에서 이를 위치시키는 사각형을 담고 있습니다. 페이지 간의 연속 읽기가 필요할 때 사용할 수 있는 문서 차원의 동반자인 DocumentReadingUnits가 있습니다. 하나의 유닛은 음성 대기열의 한 슬롯에 바로 들어갑니다:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits는 활성 페이지에서 작동합니다
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // 텍스트 + 의미론 + 강조 표시 사각형
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

이 루프에서 두 가지를 잘못하기 쉽습니다. 대기열은 페이지별로 유지하고 사용자가 이동할 때마다 대기열을 다시 빌드해야 합니다. 왜냐하면 읽기 유닛은 페이지 공간의 사각형을 가지고 있으며 3페이지에서 남은 대기열은 강조 표시를 4페이지에 그리기 때문입니다. 빈 Units 배열은 분명히 내용이 있는 페이지에서 이미지 전용 감지기로 취급하세요. 스캔한 페이지는 아래에 텍스트 계층이 없는 픽셀이며, 정답은 청취자가 프로그램이 멈춘 상태와 구별할 수 없는 침묵을 지키는 대신 경고("이 페이지에는 추출 가능한 텍스트가 없습니다")를 말하는 것입니다

음성을 따라가는 단어 커서

문단을 한 번에 전체를 강조 표시하는 것은 글이 소리내어 읽혀질 때 눈으로 글을 따라가는 시각 장애 사용자에게 느리게 느껴집니다. 단어 수준의 강조 표시, 즉 가라오케 효과는 각 단어의 기하학적 구조와 TTS 엔진의 진행 보고를 해당 기하학적 구조에 매핑하는 두 가지 요소가 필요합니다. PageWordBoxes는 각 기하학적 구조를 단어 텍스트, 그 문자 오프셋, 그 문자 수 및 페이지 공간의 사각형과 함께 TPdfWordBox 레코드로 제공합니다. TrackReadingWordAt은 매핑을 제공합니다. SAPI의 단어 경계 이벤트가 이미 보고한 문자 위치를 입력하면 해당 오프셋을 단어 상자 배열의 인덱스로 확인하고 단일 호출에서 일치하는 단어에 커서를 그립니다

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // 뷰의 단어 상자는 뷰가 표시하는 페이지에서 나옵니다.
  // Pdf.PageNumber만 설정하면 뷰가 이동하지 않습니다
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt은 오프셋을 매핑하고 단어 커서를 그립니다
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // 경계가 페이지 텍스트를 지났습니다
end;

계약은 한 가지 측면에서는 관대하고 다른 측면에서는 용서가 없습니다. 관대한 부분: TrackReadingWordAt은 추적하는 페이지에 대해 자체적으로 단어 상자 캐시를 유지하므로 사전 로드할 내용이 없고 렌더링도 발생하지 않습니다. 왜냐하면 단어 상자는 텍스트 계층에서 오기 때문입니다. 보이는 창이 없는 헤드리스 음성 서비스도 여전히 위치를 추적할 수 있습니다. 용서가 없는 부분: 문자 인덱스는 사용자가 직접 만든 정리된 문자열이 아니라 구성 요소가 추출한 텍스트 안을 가리켜야 합니다. CharIndex가 페이지 텍스트의 끝을 지나갈 때 이 함수는 예외를 발생시키는 대신 -1을 반환하며, 이는 TTS 엔진이 뒤따르는 구두점에 대해 하나의 마지막 경계 이벤트를 발생시킬 때 항상 발생합니다. -1은 "커서 지우기"로 읽고 오류로 취급하지 마세요

표시 측면에서는 ReadingWordColor가 커서 색상을 설정합니다. 기본 호박색(amber)은 대부분의 페이지 배경색 위에서 눈에 띄지만 뷰어가 제공하는 모든 디스플레이 필터에서 테스트해야 합니다. 호박색 커서는 색상 반전 하에서는 완전히 사라질 수 있으며, 반전이 음성과 나란히 실행되는 것은 정확히 저시력 사용자가 작업하는 방식이므로, 올바르게 처리해야 하는 한 가지 조합이 바로 빠른 데모에서는 결코 테스트되지 않는 조합입니다. ReadingWordFollow를 True로 설정하면 뷰가 소리 내어 읽히는 단어가 시야에 들어오도록 자체 스크롤하며, 화면에 꽉 찬 확대 페이지에서는 이 기능이 필수적입니다. 하나의 범위 규칙에 유의하세요: SetReadingWord는 활성 TPdfView 페이지에만 그립니다. 수동 스크롤이 말하기를 일시 중지할지 또는 따르기(follow) 동작이 그것을 재정의할지 미리 결정하세요. 어느 것도 선택하지 않으면 커서가 화면 밖 어딘가에 있는 동안 목소리는 계속 말하기 때문입니다

리더를 중단시키는 문서들

소수의 입력 모양은 일회성 버그로 고치고 잊어버리는 것이 아니라 회귀(regression) 도구 모음에 영구 샘플로 포함될 정도로 순진한 구현을 확실하게 패배시킵니다

  • 태그가 지정되지 않았지만 텍스트가 많은 파일. 휴리스틱 순서는 선형 보고서에는 옳은 경향이 있지만 사이드바나 인용구가 들어가는 순간 틀립니다. 예상 순서로 플래그를 UI와 진단 로그에 모두 표시하여 나중에 실패 원인을 읽을 수 있도록 하세요
  • 이미지 전용 스캔. 아래에 텍스트 계층이 전혀 없습니다. 빈 읽기 유닛을 통해 이를 포착하고 사용자가 빈 페이지를 내레이션하게 내버려두는 대신 업스트림에 있는 OCR 단계로 사용자를 안내하세요
  • 결합 문자와 혼합 스크립트. 유니코드 결합 마크가 항상 일대일로 시각적인 단어로 붕괴되는 것은 아니므로 단어 상자 개수는 사용자의 자체 토크나이저가 예상하는 것과 차이가 날 수 있습니다. 직접 텍스트를 분할하여 계산한 오프셋으로 단어 상자 배열을 인덱싱하지 마세요. TrackReadingWordAt이 반환하는 인덱스만 사용하세요

데모가 아니라 감사원처럼 테스트하세요

"샘플을 소리 내어 읽었습니다"는 아무것도 증명하지 못합니다. 여러분이 옹호할 수 있는 통과 테스트는 NVDA가 연결된 완성된 빌드를 통해 세 개의 파일을 실행하는 것입니다: 제목이 제목으로 통지되고 표가 행 순서로 읽히는 알려진 태그가 지정된 파일, 예상 순서 표시기가 표시되는 알려진 태그가 없는 파일, 그리고 텍스트가 없다는 경고가 실제로 말해지는 스캔입니다. 각각은 정상적인 경우가 건너뛰는 경로를 테스트합니다

그런 다음 단어 커서가 두 배의 말하기 속도와 절반의 속도에서 고정되어 있는지 확인하고 ReadingWordFollow 스크롤이 사용자의 자체 스크롤과 충돌하지 않는지 확인하세요. 그런 다음 모든 색상 필터를 차례로 통과하면서 커서가 절대 사라지지 않는 것을 관찰하며 음성을 실행하세요. 저시력 색상 필터 기사는 해당 렌더링 경로를 자세히 다루고, 단어 음성 커서 심층 분석은 TTS 타이밍을 하나하나 분석합니다

위에서 사용된 읽기 유닛과 단어 상자 API는 Delphi 및 C++Builder(VCL) 및 Lazarus/FPC(LCL)용 PDFium Component와 함께 제공됩니다. 제품 페이지에는 위의 예제 뒤에 숨겨진 읽기 유닛과 단어 상자의 레코드 레이아웃을 포함하여 전체 API 참조가 링크되어 있습니다