В PDFium Component преди v3.121.1 прочитането на annotation чрез TPdf.Annotation[] и присвояването на записа обратно можеше да добави празни /R и /D записи в /AP appearance речника му, дори когато оригиналът е носел само /N. PDF/A валидаторите отхвърлят такъв речник. От v3.121.1 getter-ът докладва appearance само ако наистина го е прочел, така че непроменен round trip не записва нищо ново. Провалът си заслужава да се разбере в детайли, защото обичайният спусък е fix, чиято цел е файлът да стане по-съвместим, а не по-малко
Какво се обърква, когато запишете annotation обратно непроменен?
Краткият отговор: annotation-ът печели appearance stream-ове, които никога не е имал, и файл, минал през PDF/A валидация преди редакцията ви, я проваля след нея. Типичният сценарий върви така. Пристига клиентски архив с square и text annotations без Print флага, PDF/A иска всяка annotation да се печата, така че обхождате страниците, добавяте afPrint и присвоявате всеки запис обратно. Нищо в този код не пипа appearance-и. Записът от TPdf.Annotation[] е TPdfAnnotation, а SetAnnotationData записва всяко поле, чийто Has* sentinel е вдигнат — точно така трябва да работят двойките HasContents / ContentsText. Проблемът беше, че getter-ът вдигаше HasAppearanceRollover и HasAppearanceDown на True с празни string-ове за режими, които не съществуват, а setter-ът старателно записваше два празни stream-а:
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 stream-ове, когато изходната annotation е имала само /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 са опционални и когато ги няма, viewer-ът се връща на /N. Празен /R stream обаче не значи „липсващ“. Това е валиден stream, който не рисува нищо, така че viewer, който спазва rollover appearance-ите, показва празен правоъгълник още щом показателят мине върху annotation-а. PDF/A е още по-строг: ISO 19005-1 (с Corrigendum 2) и ISO 19005-2 / 19005-3 допускат само /N в appearance речника на annotation. 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 stream не съществува. Функцията следва обичайния PDFium модел с две извиквания: подавате nil буфер, за да узнаете нужния размер в байтове, заделяте памет и викате отново, за да копирате UTF-16LE текста. Размерът винаги включва UTF-16 терминатора, така че липсващ stream докладва 2 байта — празен string плюс терминаторът му. Getter-ът преди v3.121.1 проверяваше ByteLength >= SizeOf(FPDF_WCHAR) — проверка, която всяко извикване минава, така че и трите флага HasAppearance* се връщаха True за всяка annotation с какъвто и да е appearance. Round trip през записа после караше FPDFAnnot_SetAP да съхрани празен string за всеки режим и PDFium създаваше stream-а, който да го побере. Без exception, без предупреждение, а видимата страница изглеждаше идентична — затова дефектът изплува във veraPDF fixture, а не в viewer
Как v3.121.1 решава, че appearance съществува
ReadAppearance — помощникът вътре в GetPageAnnotation, който пълни AppearanceNormal, AppearanceRollover и AppearanceDown — вече приема резултат за съдържание само когато носи поне един символ извън терминатора. Първото извикване трябва да върне повече от SizeOf(FPDF_WCHAR) байта и четен брой байтове, защото нечетна дължина не може да е UTF-16. Второто извикване, което действително копира текста, пак се валидира: върната дължина 2 или по-малко, или по-голяма от заделения буфер, връща HasValue на False и оставя string-а празен. От страната на записа нищо не се е променило. SetAnnotationData продължава да вика FPDFAnnot_SetAP само за режими, чийто флаг HasAppearance* е True, така че запис, прочетен от annotation само с /N, вече записва обратно само /N. Регресионната fixture покрива и двете посоки: square annotation с нормален appearance, прочетена и записана обратно без промяна, минава PDF/A-1b, PDF/A-2b и PDF/A-3b, а същата annotation без Print флага се проваля само по очакваното правило за флага и по нищо друго
Липсващ и празен stream изглеждат идентично, затова getter-ът остава консервативен
Нативният API не може да различи липсващ appearance stream от такъв, който съществува, но е празен, а PDFium Component не се преструва, че може. И двата случая връщат едни и същи 2 байта от FPDFAnnot_GetAP, така че и двата се четат обратно като HasAppearanceRollover = False с празен AppearanceRollover. Оттова следват две последствия, за които да проектирате. Първо, False sentinel значи „не е прочетено съдържание, така че записът обратно ще остави този режим насаме“, а не „ключът /R липсва от речника“. Второ, записът не може да засече празен stream, който вече е във файла: документ, повреден от по-стар build или от друг инструмент, се чете обратно като чист, а присвояването на записа не го поправя, но и не го влошава. За да намирате тези файлове, трябва проверка на байтово ниво, каквато са TPdf.ValidatePdfA и PDF/A preflight работният процес за валидация с PDFium Component
Как изчиствате appearance нарочно?
Вдигате sentinel-а изрично и подавате празен string; setter-ът го записва. Блокирането на празни string-ове в SetAnnotationData би било тъпият fix за този бъг, но би счупило и извикващите, които изчистват appearance нарочно — същият договор, който HasContents и HasAuthor спазват за текст. Затова fix-ът живее изцяло в getter-а, а setter-ът продължава да се съобразява с това, което извикващият поиска:
// Заменете 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 := ''; // запишете празен stream нарочно
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// Чете се обратно като HasAppearanceRollover = False с празен string:
// празен stream и липсващ тук са неразличими
Имайте предвид, че изрично изпразнен /R или /D продължава да се брои за допълнителен ключ по цитираните по-горе PDF/A правила. Ако целта е archive профил, записването на непразен /N и оставянето на другите два режима недокоснати е единствената форма, която валидира. Всеки работен процес, който мести annotations между документи, като XFDF export и import с PDFium Component, трябва да следва същото правило: копирайте режимите, които източникът действително е имал, и оставете останалите sentinel-и False
Read-modify-write шаблон, който остава PDF/A безопасен
Ъпгрейднете на v3.121.1 или по-нова, оставете appearance sentinel-ите точно както ги е върнал getter-ът и валидирайте записания файл, преди да го изпратите. Понеже остарял празен stream се чете обратно като липсващ, стъпката за проверка трябва да гледа сериализирания документ, а не записа, и е достатъчно евтина, за да се пуска след всяка партида:
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 annotation review работен процес с PDFium Component: записът е моментна снимка на онова, което engine-ът е могъл да прочете, а sentinel, който вие не сте вдигнали, трябва да пътува обратно непроменен. Пълният annotation API, PDF/A preflight и нативният PDFium engine пътуват заедно в PDFium Component за Delphi, C++Builder и Lazarus