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