Technical Article

Създаване на достъпен PDF четец в Delphi с PDFium

Сляп потребител отваря тримесечен отчет във вашия нов Delphi четец, включва NVDA и чува долния колонтитул на страницата, след това колона от цифри и накрая заглавието, което всеки зрящ читател би прочел първо. Или не чува абсолютно нищо. Страницата изглежда перфектно на екрана и точно това е капанът: изобразяването (rendering) и четенето са различни проблеми, решавани от различен код. Редът, в който PDF чертае своите глифове, няма никакво задължение да съвпада с реда, в който човек трябва да ги чуе. Поради тази причина четец, изграден само на база извиквания за изобразяване, създава безупречно изображение, но неизползваемо озвучаване. PDFium Component, VCL/LCL обвивката около ядрото PDFium за Delphi, C++Builder и Lazarus, включва отделен набор от програмни интерфейси (APIs) за четене по тази причина. Интерфейсите за чертане не могат да възстановят ред на четене, който никога не им е бил предоставен.

Всеки достъпен четец зависи от три основни неща. Той трябва да извлече ред за четене, който екранният четец може да изговори, да поддържа видим курсор върху думите, съгласуван с гласа, и да разпознава кога даден документ не е маркиран (tagged), вместо да гадае и да се преструва. Всяко от тези неща има ясен API интерфейс, както и възможни проблеми при пропускане на детайлите.

Редът на четене е в дървото на структурата, а не в реда на чертане

ISO 32000-1 §14.8 дефинира логическата структура като дърво от елементи, разположени над съдържанието на страницата. PDF/UA (ISO 14289-1) отива по-далеч и прави това дърво задължително: всяка част от реалното съдържание трябва да бъде достъпна чрез него в реда на четене, като артефактите на страницата са маркирани като такива и се пропускат. Правилно маркираният отчет знае, че „Quarterly Resultsâ€?е заглавие от второ ниво, а таблицата с резултатите е таблица с клетки за заглавки. Немаркираният отчет е просто купчина позиционирани глифове, които случайно изглеждат като документ.

ReadablePageContent обхожда тази структура, когато тя присъства, и връща фрагменти, маркирани със семантичен тип Kind (стойности като cfHeading и cfParagraph), така че потребителският интерфейс да може да каже „headingâ€?(заглавие) преди думите, вместо да чете удебелен ред като обикновен текст. При липса на използваемо дърво същото извикване преминава към евристичен анализ на оформлението: разпознаване на колони, групиране на базови линии, подреждане от ляво на дясно и от горе на долу. Този резервен вариант е подходящ за меморандуми с една колона, но е ненадежден за бюлетини, многоколонни формуляри или всичко, което има странична лента или цитат в каре. Важното е да знаете какъв резултат сте получили, а API ви казва това директно. Записът TPdfReadableContent съдържа поле Source със стойност rosStructure, когато редът идва от маркираното дърво, или rosHeuristic, когато е изведен от геометрията на страницата. Показването на предполагаем ред за четене като потвърден е еквивалент на публикуване на фалшив статус за успешно тестване на приложение.

Най-лесният подход при отваряне е да прочетете IsTagged и да извикате ValidatePdfUa веднъж, след което да окачите отговора. Неуспешната проверка за PDF/UA не е основание за отказ от отваряне на файла. Тя е повод да поставите надпис „приблизителен ред на четенеâ€?в лентата на състоянието (status bar), така че когато клиент изпрати оплакване за объркано гласово възпроизвеждане, техническата поддръжка вече да знае дали става въпрос за липса на маркиране във файла или за грешка във вашия код.

От страница към опашка за говор с ReadingUnits

За преобразуване на текст в реч (text-to-speech), ReadingUnits извършва основната работа. Този метод връща масив от записи TPdfReadingUnit за активната страница, като всеки съдържа текста за изговаряне, неговата семантична роля и правоъгълниците, които го локализират на страницата. Съществува и съответен метод за целия документ, DocumentReadingUnits, когато искате непрекъснато четене между страниците. Всяка единица отговаря директно на една позиция в опашката за говор:

procedure TReaderForm.QueuePageSpeech(PageNumber: Integer);
var
  Units: TPdfReadingUnits;
  i: Integer;
begin
  Pdf.PageNumber := PageNumber;   // ReadingUnits works on the active page
  Units := Pdf.ReadingUnits;
  FSpeechQueue.Clear;
  for i := Low(Units) to High(Units) do
    FSpeechQueue.Add(Units[i]);  // text + semantics + highlight rects
  FCurrentPage := PageNumber;
  SpeakNextUnit;
end;

Две неща в този цикъл лесно могат да се объркат. Поддържайте опашката за всяка страница отделно и я изграждайте наново при всяко преминаване на потребителя към друга страница, тъй като единиците за четене съдържат правоъгълници в координатите на конкретната страница; опашка, останала от трета страница, ще начертае своите маркирания върху четвърта страница. Освен това, празен масив Units на страница, която очевидно има съдържание, трябва да се третира като откриване на сканиран документ (само изображение). Сканираната страница се състои от пиксели без текстов слой под тях и правилното решение е да се съобщи предупреждение („тазÐ?страница няма текст за извличанеâ€?, вместо да се запази мълчание, което слушателят може да сбърка със забиване на програмата.

Курсор за думи, който следва гласа

Маркирането на цял абзац наведнъж изглежда мудно за потребители с лошо зрение, които проследяват думите с очи, докато те се четат на глас. Маркирането на ниво дума (караоке ефект) изисква две части: геометрията на всяка дума и начин за съпоставяне на отчетите за прогрес от TTS ядрото към тази геометрия. PageWordBoxes предоставя геометрията под формата на записи TPdfWordBox, всеки с текста на думата, нейния отместващ символ (character offset), броя на символите и съответния правоъгълник на страницата. TrackReadingWordAt осигурява съпоставянето. Подайте му позицията на символа, която събитието за граница на дума на SAPI вече съобщава, и то ще определи това отместване до индекс в масива от думи и ще оцвети курсора върху съответната дума с едно извикване.

procedure TReaderForm.PrepareKaraoke(PageNumber: Integer);
begin
  // The view's word boxes come from the page the view displays.
  // Setting Pdf.PageNumber alone would not move the view
  PdfView.PageNumber := PageNumber;
  FWordBoxes := PdfView.PageWordBoxes;
end;

procedure TReaderForm.OnTtsWordBoundary(Sender: TObject; CharIndex: Integer);
var
  WordIdx: Integer;
begin
  // TrackReadingWordAt maps the offset AND paints the word cursor
  WordIdx := PdfView.TrackReadingWordAt(FCurrentPage, CharIndex);
  if WordIdx < 0 then
    PdfView.ClearReadingWord;  // boundary ran past the page text
end;

Условията са щедри в едно отношение и безкомпромисни в друго. Щедрата част: TrackReadingWordAt поддържа собствен кеш с думи за страницата, която следи, така че няма нужда от предварително зареждане и не се извършва никакво рендиране, тъй като рамките на думите идват директно от текстовия слой. Фонова услуга за говор без видим прозорец все пак може да следи позициите. Безкомпромисната част: индексът на символа трябва да сочи към текста, извлечен от компонента, а не към някакъв пречистен низ, който сте генерирали сами. Когато CharIndex премине края на текста на страницата, функцията връща -1 вместо да предизвика изключение. Това се случва често, когато TTS ядрото задейства едно последно събитие за граница за препинателни знаци в края. Тълкувайте -1 като „изчистванÐ?на курсораâ€? а не като грешка.

От страна на дисплея, ReadingWordColor задава цвета на курсора. Кехлибареният цвят по подразбиране е видим върху повечето фонове на страници, но го тествайте под всеки филтър за дисплей, който вашият четец предлага. Кехлибареният курсор може да изчезне напълно при инверсия на цветовете, а инверсията, съчетана с говор, е именно начинът, по който работят потребителите с лошо зрение. Поради това комбинацията, която най-много трябва да тествате, често се пропуска в кратки демо демонстрации. Задайте ReadingWordFollow на True и изгледът автоматично ще превърти изговаряната дума в полето на видимост, което е жизненоважно при мащабирана страница, излизаща извън екрана. Внимавайте с едно правило за обхват: SetReadingWord чертае само върху активната страница на TPdfView. Решете предварително дали ръчното превъртане временно спира говора или функцията за следване го отменя, тъй като липсата на избор оставя гласа да чете нататък, докато курсорът стои някъде извън екрана.

Документите, които нарушават работата на вашия четец

Няколко вида входящи файлове провалят стандартните имплементации достатъчно надеждно, че да заслужават място като постоянни тестове в регресионния ви пакет, а не като единични грешки, които поправяте и забравяте.

  • Немаркирани файлове с много текст. Евристичният ред обикновено е правилен за линеен отчет, но греши в момента, в който се появи странична лента или цитат в каре. Маркирайте реда като приблизителен както в интерфейса, така и в диагностичния си дневник, за да може проблемът да бъде лесно идентифициран по-късно.
  • Сканирани документи само с изображения. Няма никакъв текстов слой. Уловете ги чрез празни единици за четене и насочете потребителя към OCR стъпка по веригата, вместо да позволявате на четеца да озвучава празна страница.
  • Комбиниращи знаци и смесени скриптове. Комбиниращите знаци в Unicode невинаги се сриват едно към едно във визуални думи, така че броят на кутиите с думи може да се различава от очакванията на вашия собствен анализатор. Не индексирайте масива с кутии от думи с отмествания, които сте изчислили чрез самостоятелно разделяне на текста; използвайте само индексите, които TrackReadingWordAt връща.

Тествайте като одитор, а не като за презентация

„Програмата прочете моя примерен файл на гласâ€?не доказва нищо. Истинският успешен тест изисква пускането на три файла през завършения билд с прикачен NVDA: един потвърдено маркиран файл, където заглавията се съобщават като такива, а таблицата се чете по реда на редовете; един потвърдено немаркиран файл, където индикаторът за приблизителен ред е видим; и едно сканирано изображение, при което предупреждението за липса на текст действително се изговаря. Всеки от тях тества път, който идеалният случай пропуска.

След това потвърдете, че курсорът на думите остава заключен при двойна и наполовина скорост на речта, и че превъртането с ReadingWordFollow не се конфликтира с ръчното превъртане от потребителя. След това пуснете говора, докато преминавате през всеки цветен филтър, и се уверете, че курсорът никога не изчезва. Статията за филтри за лошо зрение описва този път на изобразяване в детайли, а статията за курсора за говор на думи анализира времето на TTS.

API интерфейсите за единици за четене и кутии от думи, използвани по-горе, се доставят с PDFium Component за Delphi и C++Builder (VCL) и Lazarus/FPC (LCL). Страницата на продукта съдържа връзки към пълната документация на API, включително структурата на записите за единици за четене и кутии от думи, представени в тези примери.