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

Round-trip внешностей аннотаций в Delphi с PDFium

В PDFium Component до v3.121.1 чтение аннотации через TPdf.Annotation[] и запись записи обратно могло добавить пустые записи /R и /D в словарь внешности /AP, даже когда оригинал нёс только /N. Валидаторы PDF/A такой словарь отвергают. С v3.121.1 геттер отчитывается только о внешности, которую действительно прочитал, поэтому нетронутый round-trip не пишет ничего нового. Этот сбой стоит понимать в деталях, потому что обычный триггер — правка, задуманная сделать файл более соответствующим стандарту, а не менее

Схема round-trip аннотации в PDFium Component, где добавление afPrint через TPdf.Annotation[] и SetAnnotationData заодно пишет пустые потоки /R и /D через FPDFAnnot_SetAP, превращая чистый словарь внешности PDF A в такой, который veraPDF отвергает, — до v3.121.1, когда геттер стал отчитываться только о прочитанных внешностях
Чтение аннотации и запись её обратно нетронутой раньше добавляло пустые потоки rollover и down, и именно это проваливает PDF/A, а не флаг Print, который вы собирались добавить

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

Короткий ответ: аннотация обзаводится потоками внешности, которых у неё не было, и файл, проходивший валидацию PDF/A до вашей правки, проваливает её после. Типовой сценарий выглядит так. Приходит клиентский архив с square- и text-аннотациями без флага Print, PDF/A требует, чтобы каждая аннотация печаталась, вы обходите страницы в цикле, добавляете afPrint и записываете каждую запись обратно. Ничто в этом коде не трогает внешности. Запись из 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 определяет словарь внешности тремя записями: /N для нормальной внешности, /R для rollover и /D для нажатия. /R и /D опциональны, и когда их нет, вьюер откатывается к /N. Пустой поток /R — не отсутствие, однако. Это валидный поток, который не рисует ничего, поэтому вьюер, уважающий rollover-внешности, показывает пустой прямоугольник в момент, когда указатель попадает на аннотацию. PDF/A строже: ISO 19005-1 (с Corrigendum 2) и ISO 19005-2 / 19005-3 допускают в словаре внешности аннотации только /N. veraPDF рапортует файл после round-trip по правилу 6.5.3-4 для PDF/A-1 и 6.3.3-2 для PDF/A-2 и PDF/A-3, а встроенный TPdf.ValidatePdfA числит его как pvaiAnnotationApDictViolation. Правка, добавившая флаг Print ради одной клаузы стандарта, сломала другую

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

Почему FPDFAnnot_GetAP возвращает 2 для отсутствующей внешности?

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

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

Как v3.121.1 решает, что внешность существует

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

Отсутствующие и пустые потоки выглядят одинаково, поэтому геттер остаётся консервативным

Нативный API не отличит отсутствующий поток внешности от существующего, но пустого, и PDFium Component не притворяется обратным. Оба случая возвращают те же 2 байта из FPDFAnnot_GetAP, поэтому оба читаются как HasAppearanceRollover = False с пустым AppearanceRollover. Из этого следует два следствия, под которые стоит проектировать. Первое: False-сентинел означает «содержимое не прочитано, поэтому запись обратно не тронет этот режим», а не «ключа /R нет в словаре». Второе: запись не может детектировать пустой поток, уже лежащий в файле — документ, попорченный старой сборкой или чужим инструментом, читается как чистый, и запись его обратно ни чинит, ни усугубляет. Чтобы найти такие файлы, нужна байтовая проверка — для того и служат TPdf.ValidatePdfA и workflow предпечатной валидации PDF/A с PDFium Component

Как нарочно очистить внешность?

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

// Заменяем rollover-внешность, затем очищаем снова
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 и не трогать два других режима. Любой workflow, перекладывающий аннотации между документами, вроде экспорта и импорта XFDF с PDFium Component, должен следовать тому же правилу: копируйте режимы, которые реально были у источника, и оставляйте остальные сентинелы в False

Паттерн read-modify-write, который остаётся безопасным для PDF/A

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

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;

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