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

Спри фалшиви таблици от двустранно подравнен текст в PDF

PDFium Component версия 3.117.0 спира да докладва двустранно подравнени параграфи като whitespace-подравнени таблици, като изисква всяка колоночна граница да бъде вертикален коридор без текст на нито един ред, който разделя, прескача думи, вече претендирани от линирана мрежа, и сглобява клетъчния текст по вертикално застъпване вместо по разстояние между центровете на glyph боксовете. И трите промени живеят вътре в ExtractTables и ExtractDocumentTables и не се нуждаят от опция

Докладът, който стартира това, беше неярък. Страница с прессъобщение без никаква таблица се върна от ExtractTables с 5x4 whitespace таблица, confidence удобно над подразбиращия се MinConfidence от 0.5, а клетките държаха фрагменти от обикновен основен текст. Форма за прием направи същото със своите есетни параграфи и произведе 3x4 и 5x3. И двата документа бяха набрани двустранно подравнени. Очевидният отговор е да настроиш праговете, а полезният урок от това издание е, че настройката не може да го оправи, защото правилото, което се настройваше, задаваше грешния въпрос

uses
  PDFium;

// Регресионна проверка: изброй всяка whitespace таблица в документ, така че
// страница, която знаете, че е само проза, да може да се потвърди чиста
procedure ReportWhitespaceTables(Pdf: TPdf);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;   // MinColumnGap 12pt
  Tables := Pdf.ExtractDocumentTables(Options);
  for I := 0 to High(Tables) do
    if Tables[I].DetectionMode = ptdmWhitespace then
      Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
        'first cell "%s"',
        [Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;

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

Двустранно подравнен параграф изглежда като таблица, защото подравнен ред е редица от думи, разделени с пролуки, които layout engine-ът е разпънал, и щом разпъната пролука стигне MinColumnGap, детекторът няма начин на ниво ред да я различи от колоночен разделител. Whitespace стратегията в PDFium Component групира word боксове във визуални редове, разрязва всеки ред на словни групи навсякъде, където хоризонталното разстояние до предишната дума е поне MinColumnGap (12 пункта по подразбиране), и приема таблица, когато поне два последователни реда повторят поне MinColumns ляво-подравнени групови котви в рамките на AlignmentTolerance, който е 3 пункта. Това е правилото, описано в прегледа на откриването на таблици, и за истинска подравнена таблица е точно вярно

Сега приложете го към двадесет реда двустранно подравнена проза от 10 пункта. Всеки ред е разпънат до един и същ десен маргин, така че ред, завършващ с дълга дума, разпъва вътрешните си интервали, а в параграф с няколко къси реда някои от тези интервали пресичат 12 пункта. Два последователни реда се нуждаят само от по една разпъната пролука, кацнала в рамките на 3 пункта от една и съща X позиция, за да образуват кандидат от два реда и две колони. През достатъчно редове това не е лош късмет; това е вероятност, приближаваща сигурност, а 5x4 на прессъобщението беше просто пробегът, в който четири такива пролуки се подредиха на пет реда

Диаграма на PDFium Component защо подравнена проза точкува като таблица: всеки ред е разпънат до един и същ маргин, така че единични пролуки пресичат MinColumnGap на различна X на всеки ред, а два последователни празни места в рамките на AlignmentTolerance построиха фалшивите кандидати, които коридорният тест вече отхвърля
Истинска таблица повтаря колоночните си котви на всеки ред, докато двустранно подравнен параграф разпъва различно интервал на всеки ред — затова настройката само на ниво ред не можеше да ги раздели

Всеки праг разменя една класа документи срещу друга. Вдигането на MinColumnGap на 20 пункта губи компакните колони на гъсти финансови доклади — точният случай, заради който подразбиращата се вече беше смалена. Вдигането на MinRows на 3 изхвърля истински таблици от два реда и просто смалява шансовете за дълги параграфи. Затягането на AlignmentTolerance под 3 пункта чупи OCR-произведени word боксове, чиито леви ръбове треперят с повече от това. Сигналът на ниво ред наистина е двусмислен, така че поправката трябва да дойде от сигнал, който редовете сами не носят

Какво прави колоночна граница истинска?

Истинска колоночна граница е вертикална лента от страницата, която остава празна през всеки ред, който разделя. Таблица има една между всяка двойка колони по конструкция, защото клетките са били подредени спрямо споделени X позиции. Двустранно подравнен параграф разпъва словните си интервали на различни хоризонтални позиции на всеки ред, така че никаква лента не оцелява през сечението на повече от ред-два. PDFium Component вече тества точно това: след като кандидатните словни групи са разпределени към котвени колони, за всяка двойка съседни колони той взема, на всеки ред, който има съдържание и в двете клетки, интервала от най-десния ръб на думите на лявата клетка до най-левия ръб на думите на дясната клетка, сече тези интервали през редовете и отхвърля целия кандидат, ако сечението е по-тясно от MinColumnGap по 0.5 — 6 пункта при подразбиращите се

Диаграма на PDFium Component за теста за текстово-свободен коридор зад ExtractTables: всеки ред дарява интервала от десния ръб на лявата си клетка до левия ръб на дясната си клетка, сечението остава по-широко от половината MinColumnGap в истинска таблица и се свива до нищо в подравнен текст
Истинска колоночна граница е празна на всеки ред, който разделя, така че сечението на пролуките по ред оставя споделена лента за таблица и никаква лента за разпъната проза

Два детайла имат значение. Редове, при които някоя клетка е празна, не гласуват, така че таблица с празна клетка, или header, обхващащ по-малко колони от тялото, все още минава. И ширината на коридора се извежда от MinColumnGap, вместо да се излага като отделна опция, защото двете описват едно и също физическо нещо: пролуката, която дизайнер оставя между колоните. Логиката е достатъчно малка, за да я възпроизведете, ако градите върху сурови word боксове, а не върху table API, а примерът по-долу огледалва проверката вътре в компонента:

uses
  Math, PDFium;

type
  TIndexList = array of Integer;
  TCellIndexes = array of TIndexList;   // Row * ColumnCount + Column

// Връща False, когато някоя съседна двойка колони липсва текстово-свободен
// вертикален коридор широк поне MinColumnGap / 2 през редовете, които го ползват
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
  const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
  MinColumnGap: Double): Boolean;
var
  Col, Row, I, LeftCell, RightCell, Supported: Integer;
  CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
  for Col := 0 to ColumnCount - 2 do
  begin
    CorridorLeft := -MaxDouble;
    CorridorRight := MaxDouble;
    Supported := 0;
    for Row := 0 to RowCount - 1 do
    begin
      LeftCell := Row * ColumnCount + Col;
      RightCell := LeftCell + 1;
      if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
        Continue;                             // празни клетки не гласуват
      RowLeft := -MaxDouble;
      RowRight := MaxDouble;
      for I in Cells[LeftCell] do
        RowLeft := Max(RowLeft, Words[I].Rect.Right);
      for I in Cells[RightCell] do
        RowRight := Min(RowRight, Words[I].Rect.Left);
      CorridorLeft := Max(CorridorLeft, RowLeft);
      CorridorRight := Min(CorridorRight, RowRight);
      Inc(Supported);
    end;
    if (Supported > 0) and
       (CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
      Exit(False);
  end;
  Result := True;
end;

Защо линираните таблици се извличаха два пъти?

Линираните таблици се извличаха два пъти, защото whitespace проходът виждаше всяка дума на страницата, включително думите, които линеаният проход вече беше поставил в мрежа, а чиста линирана таблица е по конструкция също и перфектно подравнена whitespace таблица. Проверка за застъпване вече отхвърляше whitespace кандидат, чиито граници покриваха повече от половината на съществуваща таблица, но кандидат, комбинирал долните редове на таблицата с няколко подравнени реда текст под нея, можеше да падне под това съотношение и да оцелее като втора, малко по-голяма таблица, просмукваща се в съседната си. ExtractTables вече премахва тези думи, преди whitespace проходът да тръгне. Дума се изпуска, когато централната ѝ точка лежи вътре в границите на някоя таблица, произведена от линеания проход; центърът се ползва вместо пълно съдържание, така че дума, застъпваща рамка с част от точка, следва таблицата, към която визуално принадлежи. Whitespace стратегията после работи само върху свободните думи, което също означава, че малка нелинирана таблица, седяща направо под линирана, се открива по собствени заслуги, вместо да се слее с мрежата над нея

Защо "Purpose of Request:" излезе като "of Purpose Request:"?

Думите излязоха подредени наново, защото word боксовете, които PDFium Component постройва, са обединения на bounding боксовете на глифовете, а „of“ няма десцендер, докато „Purpose“ и „Request:“ имат. FPDFText_GetCharBox връща тесния бокс на мастилото на глифа в пространството на страницата, не бокс, подплатен до ascender и descender на шрифта, а word боксът е обединението на боксовете на неговите знаци. Дума без десцендери е следователно по-къса и вертикалният ѝ център седи по-високо — с 2 до 3 пункта на въпросната форма. Старата клетъчно-текстова рутина сортираше думите първо по централно Y, с толеранс от 1 пункт за „същият ред“, после по ляв ръб; „of“ мина толеранса, сортира се като собствен ред над останалите и беше излъчена първа

Това не е причуда на PDFium толкова, колкото следствие от начина, по който PDF позиционира текст. ISO 32000-1 §9.2.2 и §9.4.4 дефинират поставянето на глифи като хоризонтално отместване по базовата линия в текстовото пространство, а единствените вертикални метрики, които файлът носи, са по шрифт: записите Ascent, Descent и FontBBox на шрифтовия дескриптор в §9.8.1. Нищо във файла не казва, че два глифа споделят ред; това трябва да се изведе от геометрията, а тесните glyph боксове, които карат маркирането на текст да изглежда правилно, описани в избор на текстови редове с PDFium char боксове, са грешният вход за сравнение по централно разстояние

Поправката в версия 3.117.0 сменя въпроса от „колко далеч са центровете“ на „колко се застъпват боксовете вертикално“. Клетъчният текст се сглобява като първо групира думите на клетката във визуални редове, където дума се присъединява към ред, когато вертикалното ѝ застъпване с текущите граници на реда е поне 25 процента от по-малкината от двете височини, после сортира всеки ред чрез insertion sort по ляв ръб, после съединява редовете със знак за нов ред. „Purpose“ и „of“ се застъпват през целия x-height, което е далеч повече от 25 процента от по-късия бокс, така че кацат на същия ред и се сортират по X, както е предвидено

Диаграма на PDFium Component за поправката на размененото Purpose of Request: тесните glyph боксове от FPDFText_GetCharBox дават на десцендер-свободното of по-висок център, който старият 1 pt централно-Y толеранс сортира като собствен ред, докато правило за 25 процента вертикално застъпване го държи на базовата линия и възстановява словния ред
Централното Y се движи с каквито и да е ascenders и descenders мастилото случайно носи, докато два бокса на една базова линия се застъпват през споделения x-height независимо какво правят височините им

Групирай текстови редове по застъпване, не по централно разстояние

Правилото, което си заслужава да се отнесе от този бъг, е общо: всеки PDF текстово-оформящ код, който решава „същият ред“, като сравнява вертикални центрове срещу фиксиран толеранс, ще се провали на истински шрифтове, а провалът е тих: нищо не гърми, думите просто излизат в грешен ред. Смесени десцендери са най-лекият задействащ фактор. Bold етикет от 12 пункта до стойности от 10 пункта, горен индекс на бележка под линия, валутен символ, начертан от fallback шрифт, и OCR word боксове с шум във височината на всяка дума всички местят центровете с повече от всеки толеранс, който все още разделя съседни редове от 10-пунктова проза при 12-пунктова ведуща. Съотношението на застъпване е инвариантно към размера: два бокса на една базова линия се застъпват през споделения си x-height независимо какво правят ascenders и descenders им, а два бокса на съседни редове не се застъпват с нищо

Същото правило лесно се прилага извън извличането на таблици. TPdf.PageWordBoxes връща всяка дума на активната страница с нейния правоъгълник в пространството на страницата, така че групирането на страница във визуални редове е кратък цикъл:

uses
  Math, PDFium;

function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
  Overlap, MinHeight: Double;
begin
  Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
  MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
  Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;

procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
  Words: TPdfWordBoxes;
  Bounds: TArray<TPdfRectangle>;   // текущо обединение на ред
  I, J, Found: Integer;
begin
  Words := Pdf.PageWordBoxes;
  Lines := nil;
  Bounds := nil;
  for I := 0 to High(Words) do
  begin
    Found := -1;
    for J := High(Lines) downto 0 do
      if SameVisualLine(Bounds[J], Words[I].Rect) then
      begin
        Found := J;
        Break;
      end;
    if Found < 0 then
    begin
      SetLength(Lines, Length(Lines) + 1);
      SetLength(Bounds, Length(Bounds) + 1);
      Found := High(Lines);
      Bounds[Found] := Words[I].Rect;
    end;
    SetLength(Lines[Found], Length(Lines[Found]) + 1);
    Lines[Found][High(Lines[Found])] := Words[I];
    Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
    Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
    Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
    Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
  end;
  // сортирай всеки ред по Rect.Left, преди да го прочетеш; PageWordBoxes връща
  // думи в content-stream ред, който не е гарантирано визуален
end;

Какво се променя за съществуващите caller-и и къде са лимитите

Точката на тази скица е предикатът, не цикълът; за каквото и да е отвъд бърз дъмп, тръгнете от структурирания текстов модел, който вече носи блокове, редове и източник на ред на четене, както е покрито в структурирано извличане на PDF текст с ред на четене. Съществуващите table caller-и получават и трите корекции, без да пипат опциите си. Коридорният праг е фиксиран на половината от MinColumnGap, whitespace стратегията пази своя двуредов под дори когато MinRows е зададен на 1 (което ruled стратегията вече приема), а филтрирането на думи ruled-първо е безусловно, винаги когато и двете стратегии са включени. Върху примерния набор от 13 документа, ползван за изданието, whitespace проходът преди беше връщал 34 фрагмента и фалшиви положителни заедно с 9 линирани таблици; след изданието не връща нито едно, а броят на линираните таблици скочи на 41, макар голямата част от този ръст да идва от същото издание, научило линеания детектор да чете рамки, начертани като запълнени правоъгълници — което е отделна история

Честните лимити: коридорният тест се нуждае от поне един ред със съдържание от двете страни на граница, за да отхвърли каквото и да е, така че кандидат от два реда, чиито две разпънати пролуки случайно падат в рамките на 6 пункта една от друга, все още минава. Това е тясно съвпадение, а не близката сигурност, която беше преди, но документи, претоварени с проза и без истински таблици от два реда, могат да го затворят, като зададат MinRows на 3. Ляво-подравненият неравен текст никога не беше проблемът и не се засяга. И PDF все още няма таблицен обект; ISO 32000-1 §14.8.4.3 дефинира структурен елемент Table, но само Tagged PDF го носи, така че за всичко останало мрежата остава извод от геометрията, а стойността confidence на всяка TPdfTable е там, защото изводът заслужава оценка

Извличането на таблици, структурираният текст и word боксовете четат от един и същ модел на страницата в Delphi, C++Builder и Lazarus; пълното API, включително TPdfTableExtractionOptions и демото TableExtractionLab, което се доставя заедно с него, е описано на страницата на PDFium Component за Delphi