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

Состояние потока содержимого в PDFlibPas: отслеживание CTM и обрезки

PDFlibPas, нативная библиотека компонента PDF на VCL для Delphi и C++Builder, воспроизводит поток содержимого страницы через свой класс TPDFContentStateTracker, вообще не касаясь холста рендеринга. Подача трекеру по одному разобранному оператору за раз поддерживает текущую запись графического состояния — текущую матрицу преобразования, текстовую матрицу, границы обрезки и стек сохранения q/Q — доступную для снимка до или после выполнения каждого оператора

Спросите, где реально оказывается фрагмент текста на печатной странице, и одни только сырые числа потока содержимого будут вводить вас в заблуждение каждый раз. TPDFContentProgram.GetTextRuns уже сообщает точку привязки каждой инструкции показа текста через поля OriginX и OriginY у TPDFTextRun, и комментарии к полям прямо указывают, что эта точка находится в пространстве текста, уже свёрнутом через Tm, Td, TD и T*. Чего до сих пор не хватает, и что, по словам этих комментариев, вызывающий код должен предоставить сам, — это активный CTM именно в этой инструкции: произведение каждого cm, сконкатенированного до сих пор, вложенное в столько пар q/Q, сколько случайно открыто в этой точке потока

Почему воспроизводить поток содержимого вместо его рендеринга?

PDFlibPas хранит два отдельных понятия графического состояния для двух отдельных задач, и это разделение намеренно. Внутренняя запись состояния рендерера несёт живой дескриптор устройственного холста, дескриптор области обрезки и кэши растрирования шрифтов — реальные ресурсы, привязанные к той поверхности, что рисуется прямо сейчас, и бессмысленные, как только эта поверхность исчезает. TPDFContentGraphicsState ничего из этого не несёт: это обычная запись, ограниченная значениями, которые ISO 32000-1 §8.4 определяет как достижимые исключительно из операторов потока содержимого, — CTM, стиль линии, цвет, текстовое состояние и производные границы обрезки и пути. Поскольку запись не хранит ни ссылки на холст, ни открытого дескриптора файла, вызывающий код может разобрать поток содержимого, обойти его через TPDFContentStateTracker и продолжать использовать получившиеся снимки ещё долго после того, как то, что произвело эти байты, уже исчезло

Как TPDFContentStateTracker строит CTM

TPDFContentStateTracker.Apply конкатенирует шесть операндов оператора cm в CTM трекера, используя то же предумножение, что определяет сам PDF: новая матрица M2 сочетается с текущим CTM как M2 × CTM, в соглашении строчных векторов, где точка преобразуется как P′ = P × M (ISO 32000-1 §8.4). Часть, которую легко сделать неправильно, находится в члене переноса, а не в линейной части: собственный перенос M2 должен пройти через компонент вращения-и-масштаба текущего CTM прежде, чем сверху добавится перенос текущего CTM. Пропустите этот шаг и жёстко закодируйте наивное покомпонентное сочетание вместо этого, и первый изолированный cm, что вы протестируете, будет выглядеть корректным, тогда как каждая координата ниже по потоку после второго или третьего вложенного cm незаметно уплывёт, — именно тот тип ошибки, что переживает ревью кода, потому что модульному тесту, способному её поймать, нужны как минимум два сцепленных преобразования, чтобы провалиться

var
  Prog: TPDFContentProgram;
  Runs: TPDFTextRunArray;
  States: TPDFContentGraphicsStateArray;
  DeviceX, DeviceY: Double;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  try
    Prog.Parse(ContentBytes);
    Runs := Prog.GetTextRuns;
    // One before-instruction snapshot per operator, computed in a single pass
    States := Prog.TraceGraphicsStates(nil, False);
    for I := 0 to High(Runs) do
    begin
      // OriginX/OriginY already fold in Tm/Td/TD/T*; only the CTM active
      // at this instruction is still missing (ISO 32000-1 8.4)
      with States[Runs[I].InstructionIndex].CTM do
      begin
        DeviceX := Runs[I].OriginX * M11 + Runs[I].OriginY * M21 + DX;
        DeviceY := Runs[I].OriginX * M12 + Runs[I].OriginY * M22 + DY;
      end;
      LogTextOrigin(Runs[I].Text, DeviceX, DeviceY); // caller-supplied handler
    end;
  finally
    Prog.Free;
  end;
end;

Цикл выше отвечает на болевую точку из вступления: TPDFContentProgram.GetTextRuns возвращает OriginX и OriginY, уже свёрнутые через Tm, Td, TD и T*, а TraceGraphicsStates(nil, False) предоставляет единственную оставшуюся часть — CTM до инструкции, точно на том индексе, на котором был захвачен каждый фрагмент, за один линейный проход по всей программе. Передача nil позволяет методу самому владеть приватным трекером для этого вызова и освободить его внутри себя, что правильный выбор для разового сканирования; передача же существующего экземпляра TPDFContentStateTracker — то, что удерживает состояние непрерывным на странице, собранной более чем из одного потока содержимого, поскольку ISO 32000-1 трактует массив /Contents страницы как один логический поток, и стеку q/Q приходится с этим согласовываться

Текстовая матрица переживает Q; графическое состояние — нет

ISO 32000-1 §9.4.2 определяет Td, TD, Tm и T* как операторы, строящие текстовую матрицу и матрицу текстовой строки внутри блока BT/ET, и PDFlibPas сохраняет это различие чётким: Td и TD конкатенируют чистый перенос в матрицу текстовой строки, T* делает то же самое, используя отрицательное значение текущего интерлиньяжа, и только Tm полностью заменяет обе матрицы шестью числами, которые ему переданы. BT сбрасывает обе матрицы к единичной, ровно один раз, в начале текстового объекта — но q и Q их вообще не трогают. TPDFContentStateTracker.Apply обрабатывает coRestoreState как особый случай именно по этой причине: прежде чем снять сохранённое состояние со стека, он захватывает текущую текстовую матрицу, матрицу текстовой строки и флаг BT/ET и заново применяет их поверх того, что оказалось в снятом состоянии, потому что пара q/Q, обёрнутая вокруг фрагмента текста, не должна возвращать позицию текста назад

var
  Tracker: TPDFContentStateTracker;
  Prog: TPDFContentProgram;
  I: Integer;
begin
  Prog := TPDFContentProgram.Create;
  Tracker := TPDFContentStateTracker.Create;
  try
    Prog.Parse('BT 100 700 Td q 2 0 0 2 0 0 cm (A) Tj Q (B) Tj ET');
    for I := 0 to Prog.Count - 1 do
    begin
      Tracker.Apply(Prog[I]);
      if Prog[I].Op in [coShowText, coRestoreState] then
        LogState(Prog[I].OpName, Tracker.Snapshot); // caller-supplied handler
    end;
  finally
    Tracker.Free;
    Prog.Free;
  end;
end;

Выполните эту последовательность, и CTM, сообщённый на втором Tj, вернётся к единичному масштабу, что был до q, — 2 0 0 2 0 0 cm внутри пары сохранения/восстановления исчезает, как того требует q/Q. TextMatrix.DX, однако, в той же инструкции всё ещё равен 100: Td, что его установил, выполнился до q, так что это не то графическое состояние, которое Q вообще имел право трогать, и инструмент, предположивший обратное, сообщил бы, что второй фрагмент глифов начинается не с той горизонтальной позиции на странице

Что происходит, когда выполняется оператор пути обрезки?

Оператор W или W* не сжимает обрезку немедленно; он лишь фиксирует, какое правило заливки использовать, а фактическое пересечение ждёт того оператора отрисовки пути, что последует за ним, включая оператор-пустышку n, который авторы PDF регулярно используют именно для обрезки без какой-либо отрисовки. TPDFContentStateTracker точно отражает этот двухшаговый тайминг: coClip и coClipEvenOdd лишь устанавливают ожидающий флаг правила обрезки, а EndCurrentPath, вызываемый каждым оператором отрисовки пути, — то, что реально пересекает границы ожидающего пути в ClipMinX, ClipMinY, ClipMaxX и ClipMaxY. Правильная организация этой постановки важна для самого контракта снимков до/после: снимок «до», взятый точно на инструкции W, всё ещё должен показывать старую, более широкую обрезку, потому что обрезка ещё не вступила в силу в этой точке потока, а схлопывание двух шагов в один незаметно сломало бы каждого вызывающего кода, полагающегося на то, что состояние «до» означает именно то, что говорит

ClipBoundsExact сообщает вызывающему коду, с какой из двух ситуаций он имеет дело, и оно равно True только для единственного прямоугольника, выровненного по осям, построенного через re на в остальном пустом пути, — единственной формы, которую PDFlibPas может представить точно как четыре числа. Всё остальное — повёрнутый прямоугольник, изогнутый контур, составной путь с несколькими подпутями или обрезка, построенная из режима отрисовки текста, — по-прежнему производит от ClipMinX до ClipMaxY, но с ClipBoundsExact, сброшенным в False, честным сигналом, что эти четыре числа — безопасная внешняя граница, а не истинная форма обрезки; вызывающему коду, которому нужна только эта граница, например для выделения прямоугольной подобласти перед понижением до полутонов GDI, описанным в статье о рендеринге страниц PDF в 1-битный монохром, можно читать её напрямую, а не заново выводить из геометрии страницы

Кривые Безье: точная граница или безопасная

Самый дешёвый способ ограничить кубический сегмент Безье — взять выпуклую оболочку его четырёх контрольных точек, и это всегда безопасно, потому что кривая никогда её не покидает, — но пологая, широкая кривая может сообщить ограничивающий прямоугольник намного больше, чем реально занимает кривая, что ослабляет фильтрацию на основе обрезки именно тогда, когда это важнее всего, на крупных декоративных путях. PDFlibPas вместо этого решает более точную задачу: для каждой оси он решает производную кубической кривой на предмет корней внутри открытого интервала (0, 1) и вычисляет кривую в любых найденных корнях вместе с обеими конечными точками, что является стандартным способом в замкнутой форме получить истинную протяжённость кривой, выровненную по осям, а не переоценку. Точность по отдельным кривым, впрочем, не переносится на саму обрезку: как только изогнутый контур становится путём обрезки, ClipBoundsExact для него всё равно падает до False, потому что ограничивающий прямоугольник, каким бы точным он ни был, всё же не та же форма, что кривая, которую он ограничивает, и трекер состояния предпочитает об этом сказать, чем позволить вызывающему коду предположить прямоугольник там, где реально находится кривая

Чтение состояния до и после каждого оператора

Хочет ли вызывающий код состояние до или после, целиком зависит от того, что делает оператор: вопрос об отрисовке или проверке попадания для пути или фрагмента текста хочет состояние таким, каким оно было мгновение перед выполнением этого оператора, поскольку именно это на самом деле определило, как оператор отрисовал, тогда как диагностический вопрос об операторе, устанавливающем состояние, вроде gs, обычно хочет увидеть, что он только что изменил. TPDFContentProgram.TraceGraphicsStates(Tracker, AfterInstruction) предоставляет именно этот выбор как единственное булево значение, вычисляя один TPDFContentGraphicsState на инструкцию за один линейный проход по всей программе независимо от того, какой момент запрошен. GetGraphicsState(InstructionIndex, AfterInstruction, State) предлагает тот же выбор до/после для одной инструкции вместо всей программы, но воспроизводит с нулевой инструкции при каждом вызове, чтобы туда добраться, так что сканирование множества индексов вызовом его в цикле стоит O(n²) против единственного вызова O(n) в TraceGraphicsStates по той же программе

var
  Before, After: TPDFContentGraphicsState;
begin
  // Same instruction index, two different instants: before vs. after it runs
  Prog.GetGraphicsState(CmIndex, False, Before);
  Prog.GetGraphicsState(CmIndex, True, After);
  // Before.CTM reflects every earlier cm; After.CTM already folds in
  // this instruction's own concatenation as well
end;

Жизнь с некорректно построенными потоками содержимого

Два вида некорректного ввода достаточно распространены в реальных производителях PDF, чтобы TPDFContentStateTracker пришлось терпеть их, а не проваливаться на них. Первый — путь, пересекающий границу q/Q: текущий путь, текущая точка и счётчик подпутей — не параметры графического состояния (ISO 32000-1 §8.4 описывает, что сохраняют и восстанавливают q и Q, и строящийся текущий путь среди этого не значится), так что TPDFContentStateTracker отслеживает эти данные целиком вне сохранённого состояния, и подпуть, начатый до q, по-прежнему остаётся там, неотрисованным, сразу после соответствующего Q. Второй — голый Q без соответствующего q где-либо раньше в потоке, что нередко в выводе генераторов, собирающих фрагменты потока содержимого конкатенацией и неверно ведущих учёт. TPDFContentStateTracker.RestoreUnderflowCount подсчитывает каждое такое событие вместо выброса исключения или порчи состояния: несопоставленный Q просто оставляет текущее графическое состояние в точности таким, каким оно было, как если бы эта инструкция была пустышкой, так что остаток потока продолжает воспроизводиться на разумном состоянии, а вызывающий код всё равно может впоследствии решить по этому счётчику, стоит ли отметить ввод обратно тому, кто его произвёл

Композиция CTM, независимость текстовой матрицы от q/Q и поэтапная реализация пути обрезки не зависят от того, как и будет ли вообще поток содержимого когда-либо отрисован, и в этом весь смысл: тот же снимок TPDFContentStateTracker корректен независимо от того, никогда ли страница не отрисовывается вообще или вот-вот будет передана тому бэкенду, что PDFlibPas выберет для этого файла, включая переключение движка во время выполнения, описанное в руководстве по многодвижковому рендерингу PDF в PDFlibPas. Анализ содержимого, сопоставление координат и инструментарий закрашивания все могут работать целиком на выводе трекера, задолго до или вовсе без обращения к рендереру

Воспроизведение потока содержимого через TPDFContentStateTracker — часть фреймворка структурированного редактирования содержимого, встроенного в PDFlibPas, нативную библиотеку компонента PDF на VCL для Delphi и C++Builder