Техническа статия

Извличане на PDF текст в Delphi: интервали и нови редове

HotPDF Delphi Component възстановява интервалите между думи и новите редове в THotPDF.ExtractLoadedPageText от глифна геометрия, не от символи за интервал. Интервал влиза, когато разстоянието след собствената ширина на глифа надхвърли 0.15 от височината на текста, а нов ред започва само когато началото на текста се премести срещу посоката на писане с повече от половината височина на текста. От v2.768.3 текстът на страницата включва и текст, рисуван през Form XObjects, и пропуска глифи извън видимата crop зона. Останалата част от статията обяснява защо всяко правило изглежда така, защото всяко от тях замени по-просто правило, даващо правдоподобен, но грешен изход върху реални документи

Симптомите са познати на всеки, който е пускал PDF текст в търсачески индекс. Корична страница се извлича като PDFReferenceManualNovember4,1998, данъчен бланк се разпада на 156 реда, диагонален watermark пристига по един символ на ред, а отрязано корицарско доказателство започва със slug реда на принтера, който никакъв viewer не показва. Нито един от тези файлове не е повреден. Всеки ползва съвършено легален начин за поставяне на текст, който наивен extractor чете погрешно

Защо извлеченият PDF текст губи интервалите си?

Извлеченият текст губи интервалите, защото от PDF никога не се изисква да ги съдържа. Производител може да разделя думи, като покаже символ интервал, но също толкова добре може да премести писалката с число вътре в TJ масив (ISO 32000-1 §9.4.3) или със свеж Td (§9.4.2), а TeX изходът, много Distiller файлове и повечето подравнени оформления правят точно това. Преди v2.766.76 HPDFAssemblePageText гледаше само вертикално движение, така че разбивка на дума, направена с позициониране, просто изчезваше. Асамбльорът вече мери, по посоката на писане на предишния глиф, разстоянието от края на собствената му ширина до началото на текущия глиф и вмъква един интервал, когато разстоянието надхвърли 0.15 от височината на кутията на текущия глиф, мерена от ascent до descent в user space. Не се добавя интервал, когато някоя от страните вече е празна, и нищо между два CJK символа, защото подравняването раздалечава идеографи без това раздалечаване да значи граница на дума. Глифните записи излагат същата геометрия, така че можете да възпроизведете решението, когато конкретен файл ви озадачи

uses
  SysUtils, HPDFDoc, HPDFContentStream;

procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
  Glyphs: THPDFGlyphArray;
  I: Integer;
  Height, Gap: Double;
begin
  if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
    Exit;
  for I := 1 to High(Glyphs) do
  begin
    // височина на glyph кутията от ascent до descent, в user space
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // хоризонтален текст: разстояние от края на собствената ширина на предишния глиф
    Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
    if (Height > 0) and (Gap > 0.15 * Height) then
      Writeln(Format('U+%.4x gap %.2f height %.2f: space',
        [Glyphs[I].Unicode, Gap, Height]));
  end;
end;

Защо да мериш от собствената ширина на глифа, а не от позицията на писалката?

HotPDF мери интервалите между думи от GlyphEndX / GlyphEndY, защото позицията на писалката след глиф вече съдържа разстояния, които не са празнота. ISO 32000-1 §9.4.4 дефинира хоризонталното отместване като ширина на глифа по размера на шрифта, плюс character spacing Tc, плюс word spacing Tw, всичко мащабирано с Tz. BaselineEndX / BaselineEndY държат цялото отместване, докато GlyphEndX / GlyphEndY държат само шрифтовия advance и Tz. Разликата има значение за производители, които стягат tracking с отрицателен Tc и после връщат разстоянието чрез TJ корекция след всеки глиф: мерено от позицията на писалката, връщането изглежда като празнота, а китайският израз “95后” се извличаше като “9 5 后”. Прагът е вързан за височината на glyph кутията, а не за размера Tf по подобна причина. Word експорти често записват 1 Tf и носят истинския размер в мащабиран Tm, така че Tfs казва 1, докато текстът е висок 10 пункта, а правило, ключовано на Tfs, би третирало двата изписвания на същата страница различно

Правилото за интервал между думи на HotPDF за ExtractLoadedPageText в Delphi: интервал се вмъква само когато разстоянието от GlyphEndX на предишния глиф до BaselineStartX на следващия надхвърли 0.15 от височината на кутията от ascent до descent, защото позицията на писалката в BaselineEndX вече съдържа Tc, Tw и Tz и превръща връщанията на подравнен tracking в фалшиви празноти като 9 5 后
Геометрия, не символи интервал, решава къде се чупят думите — глифните записи излагат същите измервания, така че можете да възпроизведете решението за всеки озадачаващ файл

Правилото има честни ръбове. Заглавие, набрано с много разхлабен tracking, при който Tc сам отваря повече от 0.15 от височината на текста между буквите, се извлича с интервал между всяка буква — така изглежда страницата, но вероятно не това искате да индексирате. Парчета, нарисувани извън ред на една базова линия, дават отрицателна празнота и се слепват без интервал. Нито единият, нито другият случай е чест в основния текст, а върху тестов корпус промяната повиши съвпаденията на думи срещу reference extractor на 28 страници, без да понии нито едно

Кога HotPDF започва нов ред в извлечения текст?

От v2.766.79 нов ред започва, когато преместването от началото на предишния глиф към текущия, проектирано върху нормалата на предишната посока на писане, надхвърли половината от по-голямата кутия височина на двата глифа. По-старото правило сравняваше суровото Y преместване с половин Tfs, което се проваляше в две посоки. С 1 Tf и мащабиран Tm прагът се свиваше до половин единица, така че горен индекс, вдигнат с text rise 0.4, или обикновено трептене на базовата линия чупеше реда. Правилото също игнорираше X изцяло, така че текст под обърнат Tm стъпваше надолу по страницата с всеки глиф и излизаше по един глиф на ред. Проектирането върху нормалата на посоката кара обърнатите пасажи да се държат като хоризонталните, а вземането на по-голямата от двете височини пази голяма пробна дума и малкият ѝ надпис на един ред, когато споделят базова линия. На споменатия данъчен бланк броят редове падна от 156 на 97. Вертикален текст в писмен режим 1 (§9.7.4.3) върви по отделен път: тези глифи се групират в колони, четат се отдясно наляво и отгоре надолу, с нов ред при всяка смяна на колоната

Как ExtractLoadedPageText на HotPDF решава новите редове в Delphi: преместването между началата на глифите се проектира върху нормалата на посоката на писане и се сравнява с половината от по-голямата кутия височина, така че горен индекс, вдигнат с малко text rise под шрифт 1 Tf, и текст, стъпващ надолу по страница под обърнат Tm, вече не се разпадат на по един глиф на ред
Проектирането кара обърнатите пасажи да се държат като хоризонталните, а вземането на по-голямата от двете кутия височини пази голяма пробна дума и малкият ѝ надпис на един ред

Кой текст ExtractLoadedPageText включва, а кой пропуска?

ExtractLoadedPageText връща текста, който viewer показва. От v2.766.80 работи само от видимите глифи, изхвърляйки всеки глиф, чийто център на кутията пада извън GetLoadedPageVisibleBox — CropBox, отрязан до MediaBox (§14.11.2). Това маха slug редове и други принтерски марки, набрани като текст извън областта за рязане. ExtractLoadedPageGlyphs нарочно продължава да връща всеки глиф на content stream-а на страницата, така че пак можете да намерите този материал, когато ви потрябва. Филтърът е тест за кутия, не тест за видимост: текст, скрит от clipping път, рисуван на бяло или покрит от изображение, пак се извлича

var
  Pdf: THotPDF;
  Glyphs: THPDFGlyphArray;
  PageText: UnicodeString;
  L, B, R, T: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('trimmed-proof.pdf');
    if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
      Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
    // всеки глиф на page content stream-а, включително slug редът
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // само това, което страницата показва, с вплетен Form XObject текст
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Текст, рисуван през Form XObjects, е част от текста на страницата от v2.768.3 насам. Хедъри, щампи и watermark-и много често живеят във форми, а някои стандартни документи губеха 30 до 35 процента от символите си преди промяната. THotPDF.InterpretContentWithForms записва всеки Do заедно с действащия CTM, интерпретира формата при неговия /Matrix по този CTM (§8.10.1) и вплита глифите на формата на позицията на Do, рекурсирайки във вложени форми. Форма без собствен /Resources назъмва тези на stream-а, който я рисува, както позволява §7.8.3. Form глифите носят TokenIndex = -1, а ExtractLoadedPageGlyphs и досега връща само глифи от page stream, защото търсенето, замяната и redaction записват промените обратно през TokenIndex и биха редактирали грешни байтове, ако form глиф се промъкне. Две опростявания си струват да се знаят: form текст не се отрязва до /BBox на формата, а рекурсията спира на 12 нива вместо чрез откриване на цикли, така че зле оформена форма, рисуваща себе си, повтаря текста си, докато стигне тази таван

Кои глифи включва HotPDF при извличане на PDF текст от страница в Delphi: ExtractLoadedPageText пази само глифи, чийто център на кутията пада вътре в GetLoadedPageVisibleBox — CropBox, отрязан до MediaBox, така че принтерските slug редове изчезват, докато InterpretContentWithForms вплита Form XObject глифи на всяка Do позиция с TokenIndex = -1, а glyph нивото API и досега връща всичко
Тест за кутия върху центъра на глифа не е тест за видимост — бял текст, отрязан текст и покрит текст пак излизат, а form текстът се брои от v2.768.3 насам

Защо текст след оператор Q се декодираше като глупости?

Текст след Q можеше да се декодира погрешно преди v2.766.73, защото extractor-ът пазеше само CTM на q. Параметрите на текстовото състояние — шрифт, размер, Tc, Tw, Tz, TL, режим на рендиране и rise — принадлежат на graphics state (§9.3.1), така че Q трябва да ги възстанови заедно с всичко друго на стека (§8.4.2). Един индустриален доклад избираше двубайтов Identity-H шрифт вътре в q … Q и после показваше еднобайтов WinAnsi текст без собствен Tf. Extractor-ът държеше вътрешния шрифт, четеше водещите точки и думата “Adobe” в съдържанието като двубайтови кодове и изпускаше 15% от символите на страницата. Стекът q/Q на интерпретатора вече държи пълното текстово състояние. Правилата за извличане, описани тук, важат за всяка страница, така че целият документ може да отиде във файл с едно извикване

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // празен диапазон = всички страници; form feed между страниците; UTF-8 BOM
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

Кой HotPDF текстов API да ползвате?

ExtractLoadedPageText остава в реда на content stream, което е правилното подразбиране за търсене и индексиране; декодиращата верига под него е покрита в извличането на текст от заредени PDF-и с HotPDF. За tagged документи, при които редът на авторство има значение, извличането на текст в структурен ред минава структурното дърво, вместо да отгатва от геометрия, а за дана, заклещена в таблици, типизираното извличане на таблици през прехвърляния на страници връща клетки, а не редове. Пълната API референция и trial изтегляне са на продуктовата страница HotPDF Delphi PDF Component