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