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

Устаревшие дескрипторы объектов страницы PDFium после трансформации в Delphi

Когда FPDFPage_TransFormWithClip переписывает страницу, каждый дескриптор FPDF_PAGEOBJECT, который вы уже держите, всё ещё описывает разбор из состояния до трансформации. PDFium Component для Delphi и C++Builder решает это внутри TransformPageContent, который выгружает текстовую страницу, регенерирует содержимое, а затем перезагружает страницу, так что последующие запросы видят новые координаты

Симптом тихий. Вы применяете масштаб 0,9, чтобы добавить поле для печати, затем читаете PageObjectInfo и получаете в точности те же числа, что были до вызова. Ни исключения, ни кода ошибки, ничего в логе. Это отличается от сбоя с кэшированной текстовой страницей, описанного в статье об устаревших текстовых страницах после правки: там кэш — это единственный дескриптор FPDF_TEXTPAGE, который можно сбросить и перестроить, здесь проблема — в каждом дескрипторе объекта страницы в ваших собственных переменных, плюс класс геттеров, сообщающих о сбое через код возврата, который большинство вызывающих отбрасывает

Почему границы объектов страницы устаревают без ошибки?

Потому что дескриптор объекта страницы — это указатель в разобранное представление одного конкретного потока содержимого, а трансформация всей страницы заменяет этот поток содержимого новым. PDFium не обходит ваш стек вызовов в поисках дескрипторов для патча. Он строит свежий граф объектов и оставляет старый ровно таким, каким он был, так что чтение через старый дескриптор — совершенно корректное чтение структуры, которая больше не соответствует тому, что говорит файл

ISO 32000-1 §7.8.2 определяет поток содержимого как последовательность операторов, рисующую страницу, а §8.3.3 определяет, как текущая матрица преобразования отображает пространство пользователя в пространство устройства. Трансформация на уровне страницы выражается оборачиванием и переписыванием этих операторов, а не редактированием координат отдельных объектов на месте. Так что координаты, которые несут объекты, могут вообще не измениться; меняется матрица, действующая при их отрисовке. Любой дескриптор, разобранный под старой матрицей, отвечает на вопросы геометрии под старой матрицей, и отвечает на них без возражений

Что на самом деле переписывает FPDFPage_TransFormWithClip

Он переписывает страницу, а не ваши снимки. FPDFPage_TransFormWithClip принимает FS_MATRIX и прямоугольник обрезки FS_RECTF и применяет оба ко всему содержимому страницы. Это правильный вызов для полей, масштабирования спуска полос и нормализации страницы необычного размера под целевой блок. Это неверный вызов, если вы ожидаете, что существующие дескрипторы последуют за ним, и стоит также помнить, что он затрагивает только содержимое страницы: аннотации — отдельный слой и нуждаются в TransformPageAnnotations, который передаёт те же шесть коэффициентов матрицы в FPDFPage_TransformAnnots

var
  Info: TPdfPageObjectInfo;
  Scale: FS_MATRIX;
  Clip: TPdfRectangle;
begin
  Pdf.PageNumber:= 1;
  Info:= Pdf.PageObjectInfo(0);           // snapshot taken before the transform

  Scale.a:= 0.9;   Scale.b:= 0.0;
  Scale.c:= 0.0;   Scale.d:= 0.9;
  Scale.e:= 29.7;  Scale.f:= 42.0;        // 5% margin, A4 in points
  Clip:= Pdf.GetPageBox(pbMedia);
  Pdf.TransformPageContent(Scale, Clip);

  // Info.Bounds still holds pre-transform geometry, and Info.Handle now
  // points into a page that TransformPageContent has already replaced
end;

Порядок обновления, который использует TransformPageContent

Четыре шага, в таком порядке: выгрузить текстовую страницу, трансформировать, сгенерировать содержимое, перезагрузить страницу. TPdf.TransformPageContent выполняет ровно эту последовательность. Он вызывает CheckPageActive, копирует матрицу и обрезку в их нативные формы записей, вызывает UnloadTextPage, затем FPDFPage_TransFormWithClip, затем UpdatePage, что является обёрткой вокруг FPDFPage_GenerateContent, и наконец ReloadPage

Каждый шаг заслуживает своего места. UnloadTextPage идёт первым, потому что кэшированный FPDF_TEXTPAGE хранит боксы символов, вычисленные под старой матрицей, и он также сбрасывает производный список веб-ссылок и любую незавершённую сессию поиска, построенную на его основе. FPDFPage_GenerateContent должен выполниться до перезагрузки, потому что трансформация живёт в странице в памяти, пока не сериализована обратно в поток содержимого, а перезагрузка иначе заново разобрала бы немодифицированный поток. ReloadPage завершается вызовом FPDF_LoadPage для текущего индекса страницы, что и есть единственное, что на самом деле даёт вам свежий граф объектов

// After the transform, re-enumerate. Do not reuse anything captured earlier.
var
  I: Integer;
  Info: TPdfPageObjectInfo;
begin
  Pdf.TransformPageContent(Scale, Clip);   // unload text page, transform,
                                           // generate content, reload page
  for I:= 0 to Pdf.ObjectCount- 1 do
  begin
    Info:= Pdf.PageObjectInfo(I);          // handle and bounds from the new parse
    if Info.Bounds.Right> PageWidth then
      Log('object '+ IntToStr(I)+ ' still overflows after scaling');
  end;
end;

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

Не переносите дескрипторы через перезагрузку

После перезагрузки старые дескрипторы не просто устарели, они висячие. Предыдущий FPDF_PAGE закрыт, а значения FPDF_PAGEOBJECT, принадлежавшие ему, — указатели в освобождённую память. TPdfPageObjectInfo предоставляет нативный дескриптор в своём поле Handle, что по-настоящему полезно для передачи объекта прямо в низкоуровневый вызов, и в равной степени по-настоящему опасно держать в поле формы или списке через операцию, перезагружающую страницу. Относитесь к записи снимка как к действительной только до следующего вызова, регенерирующего содержимое, в том же духе, что и правила владения, обсуждаемые в заметках об ABI и безопасности памяти на границе PDFium

Может ли геттер провалиться и всё же выглядеть как действительные данные?

Да, и это вторая половина той же проблемы. FPDFPageObj_GetRotatedBounds и FPDFPageObj_GetIsActive — геттеры с выходными параметрами: они возвращают флаг успеха int и записывают настоящий ответ в аргумент-ссылку. Оба могут вернуть FALSE для объекта, который был создан, но чья страница ещё не была перезаписана заново. Когда это происходит, выходной параметр остаётся нетронутым, а запись на Pascal, инициализированная через Default(TPdfPageObjectInfo), состоит из одних нулей, так что вызывающий код видит четырёхугольник с четырьмя точками в начале координат и флагом Active равным False. Неудавшийся вызов был молча превращён в правдоподобно выглядящие данные

TPdfPageObjectInfo отвечает на это явными сторожевыми значениями. HasRotatedBounds несёт результат вызова FPDFPageObj_GetRotatedBounds, HasActiveState несёт результат FPDFPageObj_GetIsActive, а поля геометрии и состояния записываются только тогда, когда соответствующий сторож равен True. Та же форма повторяется по всей записи для других геттеров с выходными параметрами, так что HasMatrix, HasFillColor, HasStrokeColor и HasStrokeWidth все означают одно и то же: нативный вызов удался, и соседнее поле осмысленно

Info:= Pdf.PageObjectInfo(I);

if Info.HasRotatedBounds then
  // RotatedBounds is array [1..4] of TPdfPoint, in draw order
  UseQuad(Info.RotatedBounds[1], Info.RotatedBounds[2],
          Info.RotatedBounds[3], Info.RotatedBounds[4])
else
  // the native call failed; fall back to the axis-aligned rectangle
  UseRect(Info.Bounds);

if Info.HasActiveState and (not Info.Active) then
  SkipObject(I);         // genuinely inactive
// if HasActiveState is False, the object state is unknown, not inactive

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

Где это всё ещё кусается

Три честных ограничения. Во-первых, обновление происходит по странице: трансформируйте страницу два, и любые дескрипторы, которые вы держите для страницы один, не затронуты, но теперь у вас две страницы, разобранные в разное время, и на вас лежит обязанность помнить, какие снимки откуда взялись. Во-вторых, стабильность индекса не гарантирована через регенерацию содержимого — после перезагрузки индекс 3 — это то, чем является индекс 3 в новом разборе, так что переидентифицируйте объекты по их типу и геометрии, а не полагайтесь на сохранение позиций. В-третьих, прямоугольник обрезки в FPDFPage_TransFormWithClip применяется к содержимому страницы и не изменяет размер ни одного из блоков страницы; если вы масштабируете содержимое вниз, чтобы создать поле, MediaBox остаётся тем же размером, каким всегда был, и просмотрщик покажет исходный лист с уменьшенным внутри него рисунком. Ничто из этого не экзотично — это обычное следствие API на C, раздающего указатели в разобранное состояние и оставляющего время жизни на усмотрение вызывающего кода. Исправление то же, что работает везде: определите точно, когда снимок истекает, обновляйте на этой границе и никогда не позволяйте неудавшемуся вызову маскироваться под значение

Если вы разбираетесь с поведением матриц в более общем плане, порядок умножения, решающий, куда попадает трансформация, разобран в статье о prepend, append и pivot с матрицами. Описанные здесь API трансформации и объекта страницы поставляются с PDFium Component для Delphi и C++Builder, чья страница продукта содержит полный справочник по записи снимка объекта страницы и её сторожевым полям