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

Продолжение таблиц по содержимому страниц PDF в Delphi

PDFium Component версии 3.117.0 связывает таблицу, разорванную границей страницы, когда либо оба фрагмента касаются краёв страницы, либо под первым фрагментом и над вторым нет основного текста, а колонтитулы игнорируются. ExtractDocumentTables применяет эту проверку по содержимому как альтернативу прежней проверке по полям страницы, отвергает фрагмент со следующей страницы, первая строка которого — одна ячейка подписи на всю ширину, и сохраняет единственную строку, выехавшую на следующую страницу, как часть её цепочки продолжения

Статья про обнаружение и извлечение таблиц подавала продолжение как четыре строгих ворот и считала «касается края страницы» одними из них. Для релиза, который она описывала, это было верно — и неверно для большинства таблиц, которые люди реально скармливают компоненту. Эта статья — поправка: какие документы проверка по полям не тянет, что пришло ей на замену и два побочных случая, которые фикс притащил за собой

Почему проверка по полям страницы не работает на экспорте из Word?

Проверка по полям страницы не работает потому, что текстовый процессор перестаёт раскладывать строки на нижнем поле, а не на краю бумаги. При значении ContinuationMargin по умолчанию в 36 пунктов исходное правило требовало, чтобы нижний край раннего фрагмента лежал в пределах 36 пунктов от низа страницы, а верхний край позднего — в пределах 36 пунктов от верха. Документ, экспортированный из Word с его полями по умолчанию в один дюйм, ставит последнюю строку минимум на 72 пункта выше низа страницы, а с колонтитулом — ещё выше, так что условие не выполнялось никогда. Каждая длинная таблица в таком документе возвращалась как независимые фрагменты с ContinuationGroup в нуле, и вызывающий снова сшивал всё руками. Проверка по-прежнему осмысленна для того, подо что её писали: отчётов, которые генерируют движки вёрстки, заполняющие страницу до фиксированного блока содержимого и начинающие следующую страницу вплотную к верху. Это не плохое правило, а неполное — поэтому версия 3.117.0 его сохранила и добавила второй путь вместо замены

Что проверяет проверка по содержимому вместо этого?

Проверка по содержимому смотрит, занимает ли пространство между двумя фрагментами что-нибудь кроме таблицы, используя рамки слов на каждой странице, а не геометрию страницы. Пока ExtractDocumentTables идёт по документу, он записывает по каждой странице самую низкую нижнюю границу среди слов, чей верх лежит выше полосы колонтитула, и самую высокую верхнюю границу среди слов, чей низ лежит ниже полосы верхнего колонтитула. Обе полосы глубиной в ContinuationMargin пунктов, так что одна и та же опция теперь работает и как запас у края страницы, и как высота зон колонтитулов. Пара фрагментов проходит, когда нижний край раннего лежит не выше самого низкого основного текста на своей странице, а верхний край позднего — не ниже самого высокого основного текста на следующей, каждый в пределах AlignmentTolerance. Простыми словами: таблица была последним на странице N и первым на странице N+1, а номер страницы или заголовок документа в полосе поля не в счёт. Это исключение не произвольно. ISO 32000-1 §14.8.2.2 относит колонтитулы к артефактам пагинации — содержимому, которое существует из-за разрыва страницы, а не вопреки ему, — и та же идея, что позволяет читателю тегов их пропускать, позволяет таблице тянуться через них. Статья про marked content рассказывает, как файлы с тегами объявляют эти артефакты явно; здесь классификация выводится из положения, потому что большинство экспортированных таблиц тегов не несёт вообще

Зачем PDFium Component две проверки продолжения таблиц: при полях Word в один дюйм проверка по полям страницы требует, чтобы края фрагментов попадали в окна по 36 пт, которых вёрстка никогда не достигает, а проверка по содержимому сравнивает рамки слов и связывает, когда таблица — последнее содержимое на странице N и первое на странице N+1, игнорируя полосы колонтитулов
Любая из двух проверок открывает ворота, и только тогда запускаются остальные: соседние страницы, отсутствие строки подписи на всю ширину у позднего фрагмента и совпадение границ столбцов в пределах удвоенного AlignmentTolerance

Две проверки объединяются через OR. Отчёт движка вёрстки, чьи таблицы доходят до края бумаги, проходит первую; экспорт из Word, чьи таблицы останавливаются на поле, проходит вторую; документ, делающий и то и другое, проходит дважды. Остальные ворота запускаются только после того, как одни из них пройдены, и идут в фиксированном порядке: номера страниц должны быть соседними, поздний фрагмент не должен открываться строкой подписи, а границы столбцов должны совпасть в пределах удвоенного AlignmentTolerance, что при значениях по умолчанию равно 6 пунктам. Перечисление — TPdfTableContinuation со значениями ptcNone, ptcStart, ptcMiddle и ptcEnd. Фрагмент, помеченный ptcEnd и затем связанный ещё с одной страницей, повышается до ptcMiddle, так что таблица на три страницы читается как start, middle, end в порядке страниц. Номера групп начинаются с 1, а 0 означает отсутствие связи; ToJson выдаёт ту же информацию членами continuation и continuationGroup, и эту форму стоит предпочесть, если сшиванием занимается нижестоящий сервис

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectContinuations := True;     // по умолчанию; показано для наглядности
    Options.ContinuationMargin := 54;        // колонтитул в две строки, глубиной около 50 пунктов

    Tables := Pdf.ExtractDocumentTables(Options);
    for I := 0 to High(Tables) do
      case Tables[I].Continuation of
        ptcStart:
          Writeln(Format('group %d starts on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
        ptcMiddle, ptcEnd:
          Writeln(Format('group %d continues on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
      else
        Writeln(Format('standalone table on page %d (%d rows)',
          [Tables[I].PageNumber, Tables[I].RowCount]));
      end;
  finally
    Pdf.Free;
  end;
end;

Как строка подписи мешает двум таблицам слиться?

Фрагмент со следующей страницы, первая строка которого — одна ячейка на все столбцы, считается новой таблицей и никогда продолжением предыдущей. Это правило существует потому, что проверка по содержимому сама по себе связывает слишком охотно. Случай, её вскрывший, был формой в стиле стенограммы: таблица заканчивается у низа страницы 1, вторая таблица с такими же ширинами столбцов начинается у верха страницы 2, между ними только колонтитул, и столбцы совпадают до точки. При проверке по полям эти двое не встречались, потому что ни одна не касалась края; при проверке по содержимому они связались сразу, и форма с разделами стала одной бессвязной сеткой. То, что их разделяет, видно в структуре ячеек. Вторая таблица открывается подписью раздела вроде «RECIPIENT INFORMATION», выложенной одной объединённой ячейкой на всю ширину, а настоящее продолжение так никогда не делает, потому что подпись принадлежит таблице, которая началась ещё на предыдущей странице. TableStartsWithCaptionRow кодирует ровно это: во фрагменте не меньше двух столбцов и есть ячейка с RowIndex = 0, ColumnIndex = 0 и ColumnSpan = ColumnCount. Проверка идёт только по позднему фрагменту, так что таблица, чья собственная строка подписи стоит на её первой странице, не затронута: подпись на странице N, а осматривается только фрагмент страницы N+1

Ворота строки подписи в PDFium Component: настоящее продолжение открывается ячейками данных и присоединяется к той же ContinuationGroup, а поздний фрагмент, чья нулевая строка содержит одну объединённую ячейку с RowIndex 0, ColumnIndex 0 и ColumnSpan, равным ColumnCount, отвергается как продолжение и сообщается как новая таблица
Осмотр касается только позднего фрагмента, поэтому таблица, чья собственная строка подписи стоит на её первой странице, не затронута, а ворота срабатывают после того, как одна из двух проверок краёв уже связала пару

Следующее за ним сравнение столбцов, TablesHaveMatchingColumns, строже, чем «одинаковое число столбцов». Оно восстанавливает позиции границ каждого фрагмента по прямоугольникам ячеек, интерполирует границы, скрытые объединёнными ячейками, и отвергает пару, когда хоть одна граница уезжает больше допуска. Две таблицы на четыре столбца с разными пропорциями поэтому остаются врозь, даже когда всё остальное сходится

Что происходит с единственной строкой, выехавшей на следующую страницу?

Сетка с линейками, переносящая одну строку на следующую страницу, теперь обнаруживается и связывается — при условии, что она попадает в цепочку продолжения; сама по себе она отбрасывается. Значение MinRows по умолчанию, равное 2, существует, чтобы случайная пара линий не выдавалась за таблицу, но последняя строка, выдавленная за разрыв, — настоящая строка, которую жёсткий порог в 2 молча ронял, и остальная таблица выглядела полной, хотя не была. Сканирование уровня документа разбирается с этим в три шага. Когда выставлены и DetectContinuations, и DetectRuledTables, постраничный проход запускает детектор сеток с временно опущенным до 1 порогом строк — поэтому ExtractTables теперь принимает MinRows равным 1 для сеток с линейками, тогда как обнаружение по пробелам держит внутренний порог 2. Продолжения помечаются по всему результату. Затем удаляется каждая таблица, которая короче MinRows вызывающего и не входит ни в одну цепочку. Фрагмент из одной строки выживает только потому, что был связан, а сетка в одну строку посреди обычной в остальном страницы отфильтровывается ровно как раньше

Как PDFium Component сохраняет строку с линейками, выехавшую за разрыв страницы: постраничный проход по сеткам идёт с порогом строк, равным единице, когда выставлены DetectContinuations и DetectRuledTables; продолжения помечаются по всему результату; и удаляются только фрагменты короче MinRows, не входящие ни в одну цепочку
Выехавшая строка выживает потому, что её связывает цепочка, тогда как отдельная сетка в одну строку на обычной странице отфильтровывается ровно как раньше, а таблицы, найденные по пробелам, сохраняют порог в две строки без такого послабления
// Пересобираем каждую цепочку в один CSV, отбрасывая
// повторённые строки заголовка на фрагментах продолжения
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R: Integer;
  Lines: TStringList;
  Csv: TStringList;
begin
  Csv := TStringList.Create;
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      if Tables[I].Continuation in [ptcNone, ptcStart] then
        Csv.Clear;
      Lines.Text := string(Tables[I].ToCsv);
      if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
         (Lines.Count > 1) and (Tables[I].RowCount > 1) then
        Lines.Delete(0);            // заголовок, повторённый текстовым процессором
      for R := 0 to Lines.Count - 1 do
        Csv.Add(Lines[R]);
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Csv.SaveToFile(Format('%s\page%d-group%d.csv',
          [Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
    end;
  finally
    Lines.Free;
    Csv.Free;
  end;
end;

Две детали в этой процедуре намеренные. Выехавшая одна строка никогда не срезается, потому что её удерживает проверка по RowCount, а текстовый процессор, повторяющий строку заголовка на каждой странице, даёт фрагмент, чья первая строка — снова заголовок, так что отбрасывание нулевой строки у средних и конечных фрагментов верно для этого случая и неверно для генератора, который заголовки не повторяет. Проверьте на одном документе, прежде чем выпускать процедуру на папку

Где правила всё ещё останавливаются

Проверка по содержимому хороша ровно настолько, насколько хорош текстовый слой, который она читает. На сканированной странице без текста вообще записанные крайние точки основного текста откатываются к границам страницы, условие «между ними ничего» выполняется вхолостую, и остаются только ворота строки подписи и столбцов; сетка с линейками на такой странице всё равно находится как пустой скелет, так что цепочка может связаться верно, но насчёт окружающего текста ничего на самом деле не проверено. Если это важно, сначала добавьте текстовый слой. Колонтитулы, отрисованные изображениями, а не текстом, для логики полос невидимы и по той же причине безвредны

Полосы — это одно число. Колонтитул глубже ContinuationMargin оставляет свои нижние строки внутри зоны основного текста, из-за чего ранний фрагмент выглядит так, будто за ним идёт текст, и связь блокируется; поднимите опцию до реальной глубины полосы, как это делает первый пример. Поднимите слишком высоко — и короткий завершающий абзац у низа страницы проскользнёт в полосу и будет проигнорирован, что свяжет таблицу с тем, что идёт за ней. У правила подписи отказ зеркальный: генератор, пишущий объединённый баннер «continued» первой строкой каждого фрагмента продолжения, получит эти фрагменты отвергнутыми как новые таблицы, и единственное лекарство сегодня — сшить по ContinuationGroup самостоятельно, ничего не ослабляя, потому что у правила нет переключателя

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

Пометка продолжений, правило подписи и проход по одной строке — всё это живёт в пути уровня документа, общем для сборок Delphi, C++Builder и Lazarus; полный API извлечения таблиц описан на странице PDFium Component для Delphi