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

Залитые прямоугольники как линейки таблиц в PDFium Delphi

Таблица, экспортированная из Word или Google Docs, может нести сотни кусков рамок на одной странице и ни единой обведённой линии. Разожмите страницу через qpdf --qdf, и поток содержимого читается как колонка пар re f, каждая описывает бокс высотой в полпункта. Детектор таблиц, который ищет только обведённые сегменты, не находит ничего, откатывается к выстраиванию слов по пробелам между ними и отдаёт россыпь фрагментов из двух-трёх строк там, где автор нарисовал одну чистую сетку. Рамки все на месте. Они нарисованы заливкой, поэтому детектор обязан читать залитые прямоугольники как линейки, а затем стягивать почти соприкасающиеся края на общие линии сетки

Почему у таблицы Word нет обведённых линий

Текстовый процессор трактует рамку ячейки как бокс с шириной и рисует тот бокс оператором заливки. ISO 32000-1 §8.5.2.1 определяет re как добавление замкнутого подпути-прямоугольника, а §8.5.3 делит операторы рисования: S обводит путь текущей шириной линии, f заливает его внутренность. Рамка в 0,5 пункта выходит как x y w 0.5 re f, и обводочная машинерия (ширина линии, стыки, штриховка) не запускается вовсе. Заливка ячейки — та же конструкция с боксом побольше. Обведённая форма, которой ждёт линейно-ориентированный детектор, — m, l, S — та, что офисные экспорты редко используют:

% одна рамка ячейки из офисного экспорта: залитый бокс высотой 0,5 pt
72 700 468 0.5 re f
% заливка ячейки: залитый бокс размером с ячейку
72 676 117 24 re f
% обведённая линия сетки, которую ищет детектор, знающий только обводку
72 700 m 540 700 l S

Пиши ты собственный детектор на сыром API PDFium, первый черновик обычно зовёт FPDFPath_GetDrawMode, оставляет путь при выставленном флаге обводки и идёт дальше. Оба залитых бокса выше этот тест проваливают. Слова внутри ячеек доезжают до детекции пробелов, и на компактном экспорте колонки сидят может быть в 6 пунктах друг от друга. В PDFium Component умолчание MinColumnGap — 12 пунктов, так что те колонки не регистрируются. Опустишь MinColumnGap, пока не зарегистрируются, — зарегистрируется и растянутый межсловный интервал выключенных абзацев, а сетка, которую нарисовал автор, всё равно не вернётся: информация, её определяющая, живёт в залитых боксах

Как PDFium Component читает залитый бокс как линейку

PDFium Component читает табличную геометрию из каждого объекта-пути на странице, по одному подпути за раз, и поскольку TPdfTableExtractionOptions.DetectFilledRulings по умолчанию True, путь с режимом заливки иным, чем none, осматривается на линейки наряду с обведёнными путями. Следующий фильтр строг нарочно. Залитый подпуть квалифицируется, только когда у него от четырёх до восьми точек, нет сегмента-кривой и каждая точка сидит на углу его охватывающего бокса с точностью до 0,05 пункта. Прямоугольник, нарисованный re, проходит, и нарисованный пятью явными точками с повторённым углом тоже. Треугольник, шеврон, Г-образный контур или скруглённый ярлык тест проваливают и не дают ничего

Диаграмма того, как PDFium Component в Delphi классифицирует замкнутый залитый подпуть: криволинейные контуры и многоугольники с точками вне углов охватывающего бокса отбрасываются, бокс не толще MaxRulingThickness даёт одну линейку вдоль длинной оси, залитая ячейка даёт четыре краевые линейки, а крошечный квадрат не даёт ничего
Замкнутый подпуть выживает, только будучи выровненным по осям, а дальше охватывающий бокс решает, линейка ли это, четыре края залитой ячейки или вовсе ничего

Переживший фильтр прямоугольник классифицируется охватывающим боксом против MaxRulingThickness, по умолчанию 3 пункта. Ширина в пороге или ниже при высоте выше даёт одну вертикальную линейку по горизонтальному центру, на всю высоту бокса; зеркальный случай — одну горизонтальную. Когда оба измерения сверх порога, бокс трактуется как залитая ячейка и даёт четыре линейки, по одной на край. Когда оба измерения в пороге или ниже, бокс не даёт ничего, поэтому квадратная маркер-точка в 2 пункта в списке никогда не станет обрубком табличной сетки. Цвет заливки ни в чём из этого не участвует. Белый бокс на белой странице даёт те же линейки, что чёрный, отчего фоновая панель размером с таблицу может вылезти внешней рамкой

Обведённые пути держат свой маршрут. Каждый прямой сегмент, чьи концы различаются не более чем на RulingTolerance (по умолчанию 1 пункт) по одной оси, становится линейкой вдоль другой, сегменты-кривые пропускаются, и замыкающий сегмент замкнутого подпути тоже считается. Путь, нарисованный и заливкой, и обводкой, вносит вклад обоими маршрутами, а перекрывающиеся куски схлопывает позже шаг слияния. Обведённый скруглённый прямоугольник всё ещё даёт линейки для прямых сторон; выпадают только дуги по углам. Сам вызов извлечения не требует особой подготовки:

uses
  System.SysUtils, PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I, J, WithText: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // с единицы

    Options := TPdfTableExtractionOptions.Default;
    // поля залитых линеек, выписанные в значениях по умолчанию
    Options.DetectFilledRulings := True;
    Options.MaxRulingThickness := 3.0;       // пункты; должно быть больше 0
    Options.RulingSnapTolerance := 4.0;      // пункты; 0 выключает прилипание
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      WithText := 0;
      for J := 0 to High(Tables[I].Cells) do
        if Tables[I].Cells[J].Text <> '' then
          Inc(WithText);
      Writeln(Format('%dx%d %s, confidence %.2f, %d of %d cells hold text',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence, WithText, Length(Tables[I].Cells)]));
    end;
  finally
    Pdf.Free;
  end;
end;

Установка MaxRulingThickness в 0 фичу не выключает. ExtractTables валидирует геометрические опции заранее и поднимает EPdfError с сообщением "Table extraction geometry options are invalid" при пороге ноль или ниже. Чтобы получить поведение «только обводка», поставьте DetectFilledRulings в False. Для всего, что вышло из офисного пакета или диалога печати браузера, оставляйте включённым: строгий прямоугольный фильтр как раз держит цену низкой, ведь декоративные заливки почти никогда его не проходят

Что делает RulingSnapTolerance для таблиц из залитых ячеек?

RulingSnapTolerance — то, что позволяет таблице, построенной из одной заливки, соединиться в одну сетку. Некоторые экспорты не рисуют рамок вовсе: каждая ячейка — залитый бокс, отделённый от соседей белой средой в 1-3 пункта. Каждый бокс даёт свои четыре краевые линейки, но правый край одной ячейки и левый край следующей сидят, скажем, в 2 пунктах друг от друга, а тест, решающий, принадлежат ли две линейки одной сетке, допускает лишь RulingTolerance, 1 пункт. Без прилипания каждая ячейка — остров из четырёх линеек, описывающий сетку 1x1, до MinColumns или MinRows (обе по 2 по умолчанию) ничего не дотягивает, и ruled-проход не сообщает таблицу на странице, где она очевидно есть

Диаграмма RulingSnapTolerance в PDFium Component для Delphi: соседние залитые ячейки оставляют среду в 2 pt, их краевые линейки на X 230.0 и 232.0 сидят за пределами 1 pt RulingTolerance, и прилипание собирает оба значения в 231.0, так что обе ячейки делят одну линию сетки
Прилипание идёт до слияния и до ruled-детектора, поэтому две стороны белой среды становятся одной линией, и каждая ячейка перестаёт быть островом из четырёх линеек

Шаг прилипания собирает каждую координату X в игре, то есть позицию каждой вертикальной линейки плюс начало и конец каждой горизонтальной, и каждую координату Y так же. Каждый список сортируется и режется на кластеры, где два соседа различаются больше допуска; каждый кластер заменяется своим средним, и затем каждая позиция, начало и конец сдвигаются к ближайшему центру. Края на 230.0 и 232.0 оба становятся 231.0, две ячейки делят линию, и горизонтальные линейки вытягиваются вместе с ними, чтобы углы сошлись

Коллинеарные куски, соприкасающиеся или перекрывающиеся в пределах RulingTolerance, сливаются в одиночные линии. Только затем запускается детектор сетки, сравнивая линейки попарно; таблица на 30 строк, приехавшая как 720 кусков рамок, доезжает до того сравнения в виде пары десятков линий

На обведённой сетке шаг ничего не меняет: совпадающие координаты прилипают к самим себе. Подловка в том, что у сцепления нет собственной границы ширины. Серия значений по 3 пункта — 100, 103, 106 и 109 — схлопнется в один центр на 104.5, хотя крайние разнесены на 9 пунктов. С умолчанием в 4 пункта это кусает только колонки уже символа, так что офисному выводу можно держать 4 пункта. Опускайте к 1 или 2, когда у формы нарочно узкие колонки (чек-боксы, поля номера счёта по цифре на ячейку), и ставьте 0, чтобы выключить прилипание. Прогон ruled-стратегии в одиночку под тремя настройками показывает, от какой механики зависит страница:

// Только ruled-стратегия, чтобы детекция по пробелам не замаскировала результат
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// Ожидание для таблицы, нарисованной только залитыми ячейками со средами 2 pt:
//   только обводка          -> 0  (обведённой геометрии нет вовсе)
//   залитые боксы, snap 4   -> 1  (края среды делят одну линию)
//   залитые боксы, snap 0   -> 0  (каждая ячейка остаётся островом 1x1)
Writeln('stroke only:          ', CountRuledTables(Pdf, False, 4.0));
Writeln('filled boxes, snap 4: ', CountRuledTables(Pdf, True, 4.0));
Writeln('filled boxes, snap 0: ', CountRuledTables(Pdf, True, 0.0));

Когда третий счёт падает ниже второго, страница держится на прилипании, и это та страница, которую стоит проверить глазами, прежде чем опускать допуск

Линейки внутри form XObject

Инструменты вёрстки часто заворачивают таблицу или всё тело страницы в form XObject и рисуют его через Do. ISO 32000-1 §8.10.1 говорит, что матрица формы конкатенируется с текущей матрицей преобразования при рисовании формы, поэтому прямоугольник внутри формы записан в пространстве формы и попадает в пространство страницы лишь после двух или более преобразований. С выставленным IncludeFormXObjects — это умолчание — PDFium Component заходит в каждый объект-форму, совмещает матрицу объекта с матрицей родителя и передаёт совмещённую матрицу каждому потомку, поэтому рамка, нарисованная как 72 700 468 0.5 re f внутри смещённой формы, ложится туда, куда её показывает вьюер. Обход останавливается на восьми уровнях вложенных форм

Диаграмма линеек внутри form XObject в PDFium Component для Delphi: тонкий прямоугольник, записанный как 72 700 468 0.5 re f, живёт в пространстве формы и попадает на верх страницы только после совмещения матрицы формы и родительского преобразования в правильном порядке
Прямоугольник записан в пространстве формы и попадает на верх страницы только после перемножения матриц в порядке, который держит член переноса там, где ему место

Порядок того совмещения важен. Точка внутри формы проходит матрицу объекта первой и матрицу родителя второй; поменяешь операнды — член переноса применится не в том пространстве, и линейка, принадлежащая верху страницы, окажется у начала координат или за страницей. Та же ловушка всплывает всякий раз, когда строишь матрицы сам, и статья о prepend против append для матриц разбирает её на точке вращения

Почему бюджет линеек по умолчанию 16384?

Умолчание MaxRulingSegments в 16384 отражает, сколько кусков производит экспорт с рамкой на каждую ячейку. Обведённая таблица на 30 строк и 6 колонок требует 38 сегментов линий: 31 горизонтальный и 7 вертикальных. Та же таблица, экспортированная залитыми боксами, может нести по четыре куска рамки на ячейку — 720 до слияния, — а заливка каждой ячейки добавляет ещё четыре краевые линейки на ячейку, 1440 в сумме. Две такие таблицы на одной странице уже требуют близко к 3000

Превышение бюджета — ошибка, и наружу она выходит как ошибка. ExtractTables поднимает EPdfError с сообщением "Table ruling-segment budget exceeded", не возвращает частичной сетки, и проход по пробелам для той страницы тоже не идёт. Тугой бюджет разумен для недоверенного входа, где ухищрённая страница может нести миллионы крошечных боксов, но тогда вызывающий обязан отличать «слишком сложно для этого бюджета» от «на странице нет таблиц»:

function TryExtractTables(Pdf: TPdf; Budget: Integer;
  out Tables: TPdfTables): Boolean;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.MaxRulingSegments := Budget;     // 16384 по умолчанию
  try
    Tables := Pdf.ExtractTables(Options);
    Result := True;
  except
    on E: EPdfError do
    begin
      // LogWarning — ваша собственная журнализация, не часть библиотеки
      LogWarning(Format('Page %d: %s', [Pdf.PageNumber, E.Message]));
      Tables := nil;
      Result := False;                     // перебор бюджета или неверные опции
    end;
  end;
end;

// False означает «решения нет», а это отличается от пустого массива
if not TryExtractTables(Pdf, 4096, Tables) then
  QueueForReview(Pdf.FileName, Pdf.PageNumber);  // тоже ваша собственная подпрограмма

Тот же тип исключения покрывает и словесный бюджет, и неверные опции, так что в очереди на разбор их различает записанное сообщение

Где ruled-путь передаёт эстафету

Ruled-детекция идёт первой, детекция по пробелам — второй, и два правила не дают им топтаться друг по другу. Слова, чьи центры попадают внутрь сетки, которую ruled-проход уже сообщил, убираются до старта детекции по пробелам, поэтому таблица не рапортуется дважды. Граница колонки по пробелам также обязана быть свободным от текста вертикальным коридором через каждую строку, которую она разделяет, что не даёт выключенным абзацам набирать очки как таблицам; правило разобрано в статье о ложных срабатываниях табличной детекции по пробелам. Чтение залитых прямоугольников выносит таблицы офисных экспортов из пробельного пути в ruled

Поскольку линейки приходят из объектов-путей, а текст — из текстовой страницы, страница без текстового слоя всё равно даёт скелет сетки с пустыми ячейками. Уверенность ruled-сетки — 0,88 плюс 0,12 на долю ячеек с текстом, поэтому пустой скелет рапортует ровно 0,88, а счётчик ячеек в первой распечатке покажет 0 ячеек с текстом. Та комбинация — сигнал сначала запустить OCR, как описано в статье о добавлении поискового текстового слоя через OCR. Залитые фигуры с кривыми или непрямоугольными контурами не дают ничего, поэтому таблица из залитых скруглённых прямоугольников падает в детекцию по пробелам. Таблица без рамок и без заливки принадлежит пробельной стратегии из статьи о детекции и извлечении таблиц, а когда и её мало, словесные боксы и блоки из структурированного текста и порядка чтения — сырьё для читателя, написанного под одно семейство документов

У демонстрации TableExtractionLab, идущей с компонентом, есть чекбокс для DetectFilledRulings, так что можно загрузить экспорт и увидеть, как сетка появляется и исчезает, без написания кода. Опции, типы записей и остальной API извлечения перечислены на странице продукта PDFium Component for Delphi