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

Round trips appearance анотацій PDF у Delphi з PDFium

У PDFium Component до v3.121.1 читання анотації через TPdf.Annotation[] і присвоєння запису назад могли додати порожні входи /R і /D до її словника appearance /AP, навіть коли оригінал ніс лише /N. Валідатори PDF/A відкидають такий словник. Від v3.121.1 геттер звітує лише про appearance, який він справді прочитав, тож незмінений round trip не пише нічого нового. Цей збій варто зрозуміти в деталях, бо звичайний тригер — виправлення, задумане зробити файл більш відповідним стандарту, а не менш

Схема round trip анотації в PDFium Component, де додавання afPrint через TPdf.Annotation[] і SetAnnotationData також пише порожні потоки /R і /D через FPDFAnnot_SetAP, перетворюючи чистий словник appearance PDF/A на такий, який veraPDF відкидає, аж поки v3.121.1 не звітує лише про appearance, справді прочитані
Читання анотації й запис назад без змін раніше додавали порожні потоки appearance rollover і down, і саме це валить PDF/A, а не прапорець Print, який ви хотіли додати

Що ламається, коли ви записуєте анотацію назад без змін?

Коротка відповідь: анотація набуває потоків appearance, яких ніколи не мала, а файл, що проходив валідацію PDF/A до вашої правки, провалює її після. Типовий сценарій розгортається так. Клієнтський архів приходить із square- і text-анотаціями без прапорця Print, PDF/A вимагає, щоб кожна анотація друкувалася, тож ви обходите сторінки циклом, додаєте afPrint і присвоюєте кожен запис назад. Нічого в тому коді не торкається appearance. Запис із TPdf.Annotation[] — це TPdfAnnotation, а SetAnnotationData пише кожне поле, чий сентинел Has* поставлено, — рівно так, як мають працювати пари HasContents / ContentsText. Проблема була в тому, що геттер ставив HasAppearanceRollover і HasAppearanceDown у True з порожніми рядками для режимів, яких не існувало, а сетер сумлінно писав два порожні потоки:

procedure MarkAnnotationsPrintable(const FileName: string);
var
  Pdf: TPdf;
  PageNo, I: Integer;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for PageNo := 1 to Pdf.PageCount do
    begin
      Pdf.PageNumber := PageNo;
      for I := 0 to Pdf.AnnotationCount - 1 do
      begin
        A := Pdf.Annotation[I];
        if not (afPrint in A.Flags) then
        begin
          A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
          // До v3.121.1 це присвоєння також писало порожні /AP/R і
          // /AP/D потоки, коли вихідна анотація мала лише /AP/N
          Pdf.Annotation[I] := A;
        end;
      end;
    end;
    Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
  finally
    Pdf.Free;
  end;
end;

ISO 32000-1 §12.5.5 визначає словник appearance із трьох входів: /N для нормального appearance, /R для rollover і /D для down. /R і /D опціональні, і коли їх немає, переглядач падає назад до /N. Порожній потік /R — це не відсутність, втім. Це валідний потік, що не малює нічого, тож переглядач, який поважає rollover appearance, показує порожній прямокутник у мить, щойно вказівник рухається над анотацією. PDF/A суворіший ще й тому: ISO 19005-1 (з Corrigendum 2) та ISO 19005-2 / 19005-3 дозволяють лише /N у словнику appearance анотації. veraPDF звітує про файл після round trip за правилом 6.5.3-4 для PDF/A-1 і правилом 6.3.3-2 для PDF/A-2 та PDF/A-3, а вбудований TPdf.ValidatePdfA перелічує його як pvaiAnnotationApDictViolation. Правка, що додала прапорець Print заради одного пункту стандарту, зламала інший

Словник appearance анотації з ISO 32000-1 із входами normal, rollover і down: PDFium повертає 2 байти і для відсутнього потоку, і для наявного порожнього, тож обидва читаються через TPdf як без вмісту, тоді як лише перевірка на рівні байтів на кшталт TPdf.ValidatePdfA знаходить порожній потік, який PDF/A не дозволяє
Відсутній /R падає назад до /N; порожній /R малює порожній прямокутник і досі валить PDF/A, а крізь запис ці два невиразні

Чому FPDFAnnot_GetAP повертає 2 для відсутнього appearance?

PDFium ніколи не повертає нуль із FPDFAnnot_GetAP, навіть коли запитаний потік appearance не існує. Функція слідує звичному патерну PDFium із двох викликів: передайте nil-буфер, щоб отримати потрібний розмір у байтах, виділіть пам'ять, тоді викличте знову, щоб скопіювати текст UTF-16LE. Розмір завжди включає термінатор UTF-16, тож відсутній потік звітує 2 байти — порожній рядок плюс його термінатор. Геттер до v3.121.1 тестував ByteLength >= SizeOf(FPDF_WCHAR) — перевірку, яку проходить кожен виклик, тож усі три прапорці HasAppearance* поверталися True для будь-якої анотації з будь-яким appearance взагалі. Round trip крізь запис тоді просив FPDFAnnot_SetAP зберегти порожній рядок для кожного режиму, і PDFium створював потік, щоб його вмістити. Жодного винятку, жодного попередження, і видима сторінка виглядала ідентично — тому дефект вилазив у фікстурі veraPDF, а не в переглядачі

Як FPDFAnnot_GetAP звітує про відсутній потік appearance у PDFium: патерн двох викликів завжди повертає щонайменше два байти за термінатор UTF-16, стара хвіртка з порівнянням проти SizeOf(FPDF_WCHAR) пропускала кожен виклик і ставила всі сентинели HasAppearance у true, а хвіртка v3.121.1 вимагає більше за термінатор плюс парну кількість байтів
Два байти — це закодований порожній рядок, а не доказ існування appearance: виправлений геттер трактує все на довжині термінатора та нижче як відсутність вмісту, і запис назад лишається мовчазним

Як v3.121.1 вирішує, що appearance існує

ReadAppearance, хелпер усередині GetPageAnnotation, що заповнює AppearanceNormal, AppearanceRollover і AppearanceDown, тепер трактує результат як вміст, лише коли він несе щонайменше один символ понад термінатор. Перший виклик мусить повернути більше ніж SizeOf(FPDF_WCHAR) байтів і парну кількість байтів, бо непарна довжина не може бути UTF-16. Другий виклик, який справді копіює текст, валідовується знову: повернена довжина 2 чи менше, або більша за виділений буфер, скидає HasValue у False і лишає рядок порожнім. На стороні запису нічого не змінилося. SetAnnotationData досі викликає FPDFAnnot_SetAP лише для режимів, чиї прапорці HasAppearance* — True, тож запис, прочитаний з анотації, що має лише /N, тепер пише назад лише /N. Регресійна фікстура покриває обидва напрямки: square-анотація з нормальним appearance, прочитана й записана назад без змін, проходить PDF/A-1b, PDF/A-2b і PDF/A-3b, тоді як та сама анотація з прибраним прапорцем Print провалюється на очікуваному правилі прапорця і ні на чому іншому

Відсутні й порожні потоки виглядають ідентично, тож геттер лишається консервативним

Нативний API не відрізнить відсутній потік appearance від наявного, але порожнього, і PDFium Component не прикидається інакше. Обидва випадки повертають ті самі 2 байти з FPDFAnnot_GetAP, тож обидва читаються назад як HasAppearanceRollover = False із порожнім AppearanceRollover. З цього випливають два наслідки, навколо яких варто проектувати. Перше: False-сентинел означає «вмісту не прочитано, тож запис назад лишить цей режим у спокої», а не «ключ /R відсутній у словнику». Друге: запис не може виявити порожній потік, який уже в файлі: документ, пошкоджений старішою збіркою чи іншим інструментом, читається назад чистим, а присвоєння запису назад ані лагодить, ані погіршує його. Щоб знайти ті файли, потрібна перевірка на рівні байтів — для того й існують TPdf.ValidatePdfA та робочий процес PDF/A preflight валідації з PDFium Component

Як навмисно очистити appearance?

Ви явно ставите сентинел і передаєте порожній рядок; сетер його пише. Блокування порожніх рядків у SetAnnotationData було б грубим виправленням цього бага, але воно зламало б і виклики, що очищають appearance навмисно, — той самий контракт, якого для тексту тримаються HasContents і HasAuthor. Тож виправлення живе повністю в геттері, а сетер досі виконує все, про що просить викликач:

// Замінити rollover appearance, потім знову очистити
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover — True, і текст проходить round trip як 'q Q'
A.HasAppearanceRollover := True;   // повторно заявити намір явно
A.AppearanceRollover := '';        // навмисно записати порожній потік
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// Читається назад як HasAppearanceRollover = False з порожнім рядком:
// порожній потік і відсутній тут невиразні

Майте на увазі, що явно спорожнений /R чи /D досі рахується зайвим ключем за цитованими вище правилами PDF/A. Якщо ціль — архівний профіль, запис непорожнього /N і залишення інших двох режимів недоторканими — єдина форма, що валідується. Будь-який робочий процес, що переміщує анотації між документами, як-от експорт та імпорт XFDF з PDFium Component, має слідувати тому самому правилу: копіюйте режими, які джерело справді мало, і лишайте решту сентинелів False

Патерн read-modify-write, що лишається безпечним для PDF/A

Оновіться до v3.121.1 чи новішої, лишіть сентинели appearance рівно такими, як їх повернув геттер, і валідуйте збережений файл, перш ніж відправляти його. Оскільки застарілий порожній потік читається назад як відсутній, крок верифікації мусить дивитися на серіалізований документ, а не на запис, і він досить дешевий, щоб ганяти його після кожної партії:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa оголошує TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // Валідує документ, завантажений зараз у Pdf, зокрема правки,
  // зроблені через Pdf.Annotation[] від моменту відкриття
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

Та сама дисципліна стосується будь-якої панелі, що перефарбовує чи анотує сторінки для рецензування, — робочий процес розібраний у побудові робочого процесу рецензування анотацій у Delphi з PDFium Component: запис — це знімок того, що рушій міг прочитати, і сентинел, який ви не ставили самі, мусить подорожувати назад без змін. Повний API анотацій, PDF/A preflight і нативний рушій PDFium виходять разом у PDFium Component для Delphi, C++Builder і Lazarus