Технічна стаття

Дескриптори об'єктів сторінки 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 все ще того розміру, що завжди був, і переглядач показуватиме оригінальний аркуш зі стиснутим усередині нього кресленням. Ніщо з цього не екзотичне — це звичайний наслідок C API, що видає покажчики в розібраний стан і залишає час життя викликачу. Виправлення — те, що працює всюди інде: визначте точно, коли знімок закінчується, оновлюйтесь на цій межі, і ніколи не дозволяйте невдалому виклику маскуватись під значення

Якщо ви розбираєтесь у поведінці матриць ширше, порядок множення, що вирішує, куди приземляється трансформація, охоплено в статті про prepend, append та pivot з матрицями. API трансформації та об'єктів сторінки, описані тут, постачаються з PDFium Component для Delphi та C++Builder, чия сторінка продукту несе повну довідку для запису знімка об'єкта сторінки та його полів-сентинелів