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

Round trip на annotation appearance в Delphi с PDFium

В PDFium Component преди v3.121.1 прочитането на annotation чрез TPdf.Annotation[] и присвояването на записа обратно можеше да добави празни /R и /D записи в /AP appearance речника му, дори когато оригиналът е носел само /N. PDF/A валидаторите отхвърлят такъв речник. От v3.121.1 getter-ът докладва appearance само ако наистина го е прочел, така че непроменен round trip не записва нищо ново. Провалът си заслужава да се разбере в детайли, защото обичайният спусък е fix, чиято цел е файлът да стане по-съвместим, а не по-малко

Диаграма на annotation round trip в PDFium Component, при която добавянето на afPrint чрез TPdf.Annotation[] и SetAnnotationData записва и празни /R и /D stream-ове чрез FPDFAnnot_SetAP и превръща чистия PDF/A appearance речник в такъв, който veraPDF отхвърля, докато v3.121.1 не започне да докладва само appearance-ите, които действително е прочел
Прочитането на annotation и записът му обратно без промяна добавяше празни rollover и down appearance stream-ове и точно това проваля PDF/A, а не Print флагът, който сте искали да добавите

Какво се обърква, когато запишете 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 флага, за да удовлетвори една клауза на стандарта, счупи друга

Appearance речникът на annotation от ISO 32000-1 с записи normal, rollover и down: PDFium връща 2 байта и за липсващ stream, и за съществуващ празен, така че и двата се четат като без съдържание през TPdf, докато само проверка на байтово ниво като TPdf.ValidatePdfA намира празния stream, който PDF/A не допуска
Липсващ /R се връща на /N; празен /R рисува празен правоъгълник и пак проваля PDF/A, а през записа двете са неразличими

Защо 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

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

Как 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