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

Съответствие с PDF/A за архивиране в Delphi с PDFium VCL

Пускате конвертор, който маркира всеки файл като PDF/A-1b, системата на клиента ги приема година наред, и после audit пуска целия пакет през veraPDF, а една трета се връщат като non-conformant. Нищо не се срива, не е хвърлено изключение, файловете се отварят нормално във всеки viewer на бюрото ви. Те просто не са стандартът, който сте им поставили. Това е нормалният failure mode за архивен PDF и е причината „сложихме flag-а“ никога да не е същото твърдение като „валидира се“

Първото нещо, което трябва да разберете за PDFium и PDF/A, е че engine-ът няма нищо общо с това. PDFium рендерира, парсва и записва PDF, но неговата публична повърхност няма ConvertToPDFA, няма OutputIntent writer, няма XMP API. Всяка част от архивното съответствие - XMP packet-ът, OutputIntent и неговият ICC profile, catalog markers-ите, validation-а - живее в PDFiumPas самия, в приблизително 2000-редов pure-Pascal unit (FPdfPdfa.pas) който парсва запазените байтове и ги пренаписва чрез incremental update. Да знаете къде се случва работата, означава да знаете къде се крият бъговете, а те не се крият в PDFium

Какво PDF/A всъщност изисква и къде хваща

PDF/A не е един формат. ISO 19005 дефинира три части (PDF/A-1, -2, -3) и във всяка от тях - нива на съответствие, които обещават различни неща. Level B (basic) гарантира само, че визуалният вид може да се възпроизведе. Level A (accessible) добавя tagged structure tree и Unicode mapping върху B. Level U, който съществува само за parts 2 и 3, стои между тях: надежден Unicode текст без пълното structure tree. ISO 19005-1 няма Level U, ограничение, което библиотеката кодира директно

Няколко правила на формата са тези, които хващат на практика. Encryption е забранен категорично (ISO 19005-1 §6.1.3 и наследниците му): PDF/A файл не може да носи /Encrypt dictionary. Документът трябва да декларира output rendering condition чрез OutputIntent, чиято destination е валиден ICC profile (§6.2.3.2). Самият conformance claim трябва да присъства като XMP metadata под PDF/A identification schema. Level A допълнително изисква §6.8 logical structure, tag tree-ят, който прави документа machine-readable. Ако пропуснете някое от тези неща, verifier за съответствие отхвърля файла, въпреки че той се рендерира съвършено

Едното извикване, което произвежда архив

PDFiumPas излага целия pipeline зад TPdf.SaveAsPdfA. Простият overload приема target conformance и по подразбиране е PDF/A-1b, което е правилният default за обичайния случай „направи това да може да се рендерира завинаги“

Диаграма на двустепенния PDF/A конвейер за запис на PDFium Component в Delphi, където FPDF_SaveAsCopy сериализира документа, а InjectPdfAMarkers добавя XMP метаданни, sRGB OutputIntent и пренаписан каталог като една инкрементална актуализация
SaveAsPdfA тече в две стъпки — PDFium сериализира документа, после InjectPdfAMarkers добавя архивните маркери като едно инкрементно обновяване
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.Active := True;
    // Conformance по подразбиране е pac1b (PDF/A-1b)
    if Pdf.SaveAsPdfA('invoice_archive.pdf') then
      // файлът вече носи XMP, sRGB OutputIntent и catalog markers
    else
      raise Exception.Create('PDF/A save failed');
  finally
    Pdf.Free;
  end;
end;

Под капака това е движение в два етапа. SaveAsPdfA първо кара PDFium да сериализира документа с FPDF_SaveAsCopy, после подава този byte stream на InjectPdfAMarkers, който добавя XMP metadata, sRGB OutputIntent с вграден ICC profile и пренаписан catalog като incremental update. Source-ът се чете от позиция нула и destination-ът се записва от позиция нула; оригиналното object tree остава непокътнато, а marker-ите кацат след съществуващия %%EOF. Ако ви трябват байтовете вместо файл, SaveAsPdfAToStream приема TStream и същите options

Избор на conformance с options record-а

За да таргетирате конкретна part и level, подайте TPdfASaveOptions record. Неговото Conformance field приема TPdfAConformance value. Enumeration-ът покрива всяка валидна комбинация и нищо друго: pac1b, pac1a за part 1; pac2b, pac2u, pac2a за part 2; pac3b, pac3u, pac3a за part 3, плюс pacUnknown и pacNone за validation страната. Няма pac1u, защото такова ниво не съществува в стандарта

var
  Pdf: TPdf;
  Opts: TPdfASaveOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.Active := True;
    Opts := TPdfASaveOptions.Default;
    Opts.Conformance := pac2u;           // PDF/A-2u: надежден Unicode текст
    Opts.Title := 'Quarterly Report 2026';
    Opts.Author := 'Finance';
    // Оставете IccProfileData празно, за да използвате вградения sRGB IEC61966-2.1 profile
    if not Pdf.SaveAsPdfA('report_a2u.pdf', Opts) then
      raise Exception.Create('PDF/A-2u save failed');
  finally
    Pdf.Free;
  end;
end;

Повечето от record-а може да остане празно. Оставете Title, Author, Subject, Keywords, Creator и Producer празни и SaveAsPdfA ги попълва автоматично от Info dictionary-а на документа чрез FPDF_GetMetaText. Оставете CreationDate и ModDate празни и библиотеката използва текущото UTC време и за двете XMP дати. Оставете DocumentId и InstanceId празни и библиотеката ги попълва предварително от FPDF_GetFileIdentifier, като пада обратно към deterministic ID, изведен от source bytes-овете. Единственото поле, което може да искате да override-нете умишлено, е IccProfileData: празно означава bundled sRGB IEC61966-2.1 profile, но CMYK или grayscale workflow трябва да подаде свой

Защо Level A деградира и защо това е честният избор

Ето една тънкост, която спъва хората, очакващи flag-ът да е гаранция. Можете да поискате pac1a за документ, който няма tag tree, но PDF/A-1a изисква §6.8 logical structure, а библиотеката не може да произведе structure tree от untagged PDF. Вместо да изведе файл, който твърди Level A, докато го проваля, SaveAsPdfA проверява за истински tagged structure (/StructTreeRoot плюс /MarkInfo с /Marked true) и, ако липсва, понижава твърдението: pac1a става pac1b, pac2a става pac2b, и така нататък през трите части. Вътрешните помощници са PdfAIsLevelA и PdfADowngradeToLevelB

Аргументацията си струва да се каже ясно: файл, който честно декларира нивото, което покрива, е по-полезен от такъв, който лъже за ниво, което не покрива. Level U се обработва различно. Засичането на истинско Unicode покритие би означавало наивен тест „има ли /ToUnicode“, който сваля прекалено много легитимни документи (WinAnsi и подобни encodings са изключение), така че save страната изписва U claim-а така, както го е подал caller-ът, и оставя несъответствието да бъде хванато на validation страната. Ако ви трябва гарантиран Level A archive, tag-нете документа преди да го конвертирате; converter-ът няма да измисли структура, която не съществува

Диаграма на решение, показваща SaveAsPdfA в Delphi, понижаващ претенция PDF/A Level A до Level B, когато документът няма маркирано дърво на структурата, докато претенция Level U се излъчва като декларирана
Липсващо тагнато дърво понижава твърдението честно — pac1a става pac1b, докато Level U се излъчва точно както е деклариран и се съди на страната на валидацията

Подводният камък с ICC, който хваща само истински validator

Това е провалът, който научи най-тежкия урок, защото собственият checker на библиотеката го приемаше, а veraPDF, ISO 19005 reference validator-ът, не. PDF/A изисква destination profile-ът на OutputIntent да е валиден ICCBased stream, а §6.2.3.2 кара verifier-ът да валидира този stream като colour space. Един ICCBased stream трябва да декларира /N, броя цветови компоненти. Ранна версия на injector-а записваше ICC stream dictionary-то само с /Length и без /N, и veraPDF отхвърли резултата с „The N entry (value null)... is missing“

Коварното беше, че отхвърлянето се случваше само за PDF/A-1b и -1a. Conformance моделите за part 2 и part 3 не пускаха точно тази проверка върху destination profile-а, така че идентичната инжектирана структура минаваше валидация под pac2b, pac3b и pac2u, но се проваляше под pac1b само заради стойността на pdfaid:part. Unit тест никога не би го видял, защото собственият ValidatePdfACompliance на библиотеката проверяваше само дали ключът /DestOutputProfile съществува, не какво живее вътре в stream dictionary-то. Вътрешните тестове оставаха зелени; реалната архивна валидация се проваляше

Fix-ът е IccComponentCount, който чете data colour space signature-а на offset 16 от ICC header-а и го map-ва към component count: GRAY е 1, RGB , Lab и XYZ са 3, CMYK е 4, а unknown profile по подразбиране става 3. Този count влиза в stream dictionary-а като /N. Той е изчислен, не hard-coded към 3, така че caller, който подава CMYK или grayscale profile през IccProfileData, пак получава правилната стойност. По-широкият урок е методологичен: checker-ът в библиотеката и авторитетният validator имат по един сляп ъгъл и PDF/A output трябва да се тества end to end срещу reference implementation като veraPDF, вместо да се доверява на self-checks. Същата incremental-update дисциплина зад чистите архиви е описана в валидирането на compressed object и xref streams, което има значение, защото модерните PDF-и, които injector-ът консумира, често са построени върху cross-reference streams

Криптиране, xref streams и други граници

Понеже ISO 19005 забранява encryption, save пътят го премахва преди запис. SaveAsPdfA прилага FPDF_REMOVE_SECURITY при сериализацията, така че encrypted source (зареден с password-а си) се decrypt-ва по пътя към archive-а. При нешифрован документ това е no-op и не променя нищо. Следствието е същото ограничение, което HotPDF налага от другата посока: един файл не може да бъде едновременно encrypted и PDF/A. Когато workflow изисква и двете, отговорът са два artifacts-а, encrypted copy за разпространение и отделно чисто copy за archive-а

Още една граница е невидима, докато не ухапе: PDF 1.5+ документи, които използват чист cross-reference stream и нямат trailer keyword. Injector-ът чете trailer-а, за да намери source /Info и да добави своя incremental update, и трябва да приема xref-stream формата, иначе такъв документ би бил копиран с тихо изпуснати markers. ISO 32000-1 §7.5.6 изрично допуска класически trailer incremental update да следва xref-stream документ, с /Prev сочещ към xref-stream offset-а, което е точно структурата, която injector-ът изписва. Собственият FPDF_SaveAsCopy на PDFium винаги записва класически trailer, така че в нормалния pipeline injector-ът никога не среща чист xref-stream source, но read пътят го обработва за документи, идващи отвън

Проверка преди да повярвате на claim-а

Библиотеката доставя byte-level checker, TPdf.ValidatePdfA, който връща TPdfAValidationResult. Неговото Conformance поле отчита засеченото ниво, а Issues е set от TPdfAValidationIssue values; convenience методът IsCompliant е true само когато е засечено реално ниво и issue set-ът е празен. Пуснете го като бърза първа gate стъпка в batch

Диаграма на IccComponentCount, четещ подписа на ICC цветовото пространство при офсет 16 в заглавката, за да запише записа /N — липсващият речников запис, каращ veraPDF да отхвърля PDF/A-1b файлове, които вътрешният Delphi проверител приемаше
IccComponentCount извежда /N от ICC header signature-а, затваряйки празнината, която само veraPDF хващаше на part 1 файлове
var
  Pdf: TPdf;
  Res: TPdfAValidationResult;
  Issue: TPdfAValidationIssue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice_archive.pdf';
    Pdf.Active := True;
    Res := Pdf.ValidatePdfA;
    if Res.IsCompliant then
      Writeln('Conformant: detected level ', Ord(Res.Conformance))
    else
      for Issue in Res.Issues do
        Writeln('Issue: ', Ord(Issue));
  finally
    Pdf.Free;
  end;
end;

Бъдете честни какво ви купува това. Byte-level checker-ът хваща structural проблеми (липсващ OutputIntent, забранено действие, наличен /Encrypt, transparency там, където part 1 я забранява) с висока увереност, а откриването на font embedding използва count heuristic, който нарочно отчита само high-confidence signal, вместо да гони per-glyph покритие. Това, което не прави, е content-stream operator analysis, която би изисквала пълен content parser и е извън обхвата по дизайн. За release gate комбинирайте in-library checker-а с veraPDF: checker-ът е мигновен и работи навсякъде без DLL, veraPDF е authoritative. Да вградите тази двойка в batch run е темата на batch preflight report CLI, където тази validation принадлежи в реален архивен workflow

API-тата SaveAsPdfA, InjectPdfAMarkers и ValidatePdfA, показани тук, идват с PDFium Component за Delphi, C++Builder и Lazarus/FPC. Продуктовата страница линква пълния API reference, включително пълното conformance enumeration и options record-а зад тези примери