Технічна стаття

Відповідність PDF/A для архівування в Delphi з PDFium VCL

Ви постачаєте конвертер, який позначає кожен файл як PDF/A-1b, система обліку клієнта приймає їх протягом року, а потім аудит проганяє всю партію через veraPDF, і третина файлів повертається як така, що не відповідає стандарту. Нічого не впало, жодного винятку не сталося, файли чудово відкриваються в кожному переглядачі на вашому столі. Вони просто не були тим стандартом, який ви на них наклеїли. Це звичайний режим відмови для архівного PDF, і саме тому "ми просто поставили прапорець" ніколи не дорівнює твердженню "це проходить перевірку"

Перше, що треба зрозуміти про PDFium і PDF/A, це те, що сам рушій тут ні до чого. PDFium відображає, розбирає і записує PDF, але його публічний інтерфейс не має ConvertToPDFA, не має OutputIntent writer, не має XMP API. Уся частина архівної відповідності, пакет XMP, OutputIntent і його ICC profile, маркери каталогу, валідація, живе в самому PDFiumPas, у модулі на чистому Pascal приблизно на 2 000 рядків (FPdfPdfa.pas) який розбирає збережені байти й переписує їх через інкрементальне оновлення. Коли ви знаєте, де саме відбувається робота, ви знаєте й де ховаються баги, а ховаються вони не в PDFium

Що саме вимагає PDF/A і де він підставляє ногу

PDF/A - це не один формат. ISO 19005 визначає три частини (PDF/A-1, -2, -3) і, в межах кожної, рівні відповідності, які обіцяють різні речі. Рівень B (basic) гарантує лише, що візуальний вигляд можна відтворити. Рівень A (accessible) додає до B теговане дерево структури та зіставлення з Unicode. Рівень U, який існує лише для частин 2 і 3, стоїть між ними: надійний текст Unicode без повного дерева структури. ISO 19005-1 не має рівня U, і цю обмеженість бібліотека кодує напряму

Кілька правил формату саме й болять на практиці. Шифрування заборонене повністю (ISO 19005-1 §6.1.3 і наступні): файл PDF/A не може містити /Encrypt словник шифрування. Документ має оголосити умову візуалізації через OutputIntent, чиє призначення є дійсним ICC profile (§6.2.3.2). Саме заява про відповідність має з'явитися в XMP-метаданих за схемою PDF/A identification. Рівень A додатково вимагає §6.8 логічної структури, дерева тегів, яке робить документ придатним для машинного читання. Пропустіть будь-що з цього, і засіб перевірки відповідності відхилить файл, хоча він відображається бездоганно

Один виклик, який створює архів

PDFiumPas відкриває весь конвеєр через TPdf.SaveAsPdfA. Просте перевантаження приймає цільовий рівень відповідності і за замовчуванням використовує PDF/A-1b, що є правильним вибором для типового випадку «зробити це придатним для зберігання на роки»

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // Default conformance is pac1b (PDF/A-1b)
    if Pdf.SaveAsPdfA('invoice_archive.pdf') then
      // file now carries XMP, sRGB OutputIntent, and catalog markers
    else
      raise Exception.Create('PDF/A save failed');
  finally
    Pdf.Free;
  end;
end;

Під капотом це двоетапний процес. SaveAsPdfAСпочатку FPDF_SaveAsCopy потім передає цей потік байтів до InjectPdfAMarkers, який додає XMP-метадані, sRGB OutputIntent із вбудованим ICC profile і переписаний каталог як інкрементальне оновлення. Джерело читається з позиції нуль, а призначення записується з позиції нуль; початкове дерево об'єктів лишається недоторканим, а маркери заходять після наявного %%EOF. Якщо вам потрібні байти, а не файл, SaveAsPdfAToStream приймає TStream і ті самі параметри

Вибір відповідності через запис параметрів

Щоб націлитися на конкретну частину й рівень, передайте TPdfASaveOptions запис. Його Conformance поле приймає TPdfAConformance значення. Перелік охоплює кожну дійсну комбінацію і нічого більше: pac1b, pac1a для частини 1; pac2b, pac2u, pac2a для частини 2; pac3b, pac3u, pac3a для частини 3, а також pacUnknown і pacNone для сторони валідації. Немає pac1u, бо такого рівня в стандарті немає

var
  Pdf: TPdf;
  Opts: TPdfASaveOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf');
    Opts := TPdfASaveOptions.Default;
    Opts.Conformance := pac2u;           // PDF/A-2u: reliable Unicode text
    Opts.Title := 'Quarterly Report 2026';
    Opts.Author := 'Finance';
    // Leave IccProfileData empty to use the built-in 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;

Більшість запису може лишитися порожньою. Залиште Title, Author, Subject, Keywords, Creator, і Producer порожнім і SaveAsPdfA автоматично заповнює їх зі словника Info документа через FPDF_GetMetaText. Залиште CreationDate і ModDate порожніми, і він використовує поточний час UTC для обох дат XMP. Залиште DocumentId і InstanceId порожніми, і бібліотека попередньо заповнює їх з FPDF_GetFileIdentifier, а якщо це не вдається, переходить до детермінованого ID, отриманого з байтів джерела. Поле, яке ви, можливо, захочете свідомо перевизначити, це IccProfileData: порожнє значення означає вбудований профіль sRGB IEC61966-2.1, але робочий процес CMYK або у градаціях сірого має надати власний

Чому Level A знижується і чому це чесний вибір

Ось тонкий нюанс, який збиває з пантелику тих, хто сприймає прапорець як гарантію. Ви можете попросити pac1a для документа, в якому немає дерева тегів, але PDF/A-1a вимагає §6.8 логічної структури, а бібліотека не може з нічого створити дерево структури для нетегованого PDF. Замість того щоб випустити файл, який заявляє Level A, але не відповідає йому, SaveAsPdfA перевіряє наявність справжньої тегованої структури (/StructTreeRoot плюс /MarkInfo з /Marked true) і, якщо її немає, знижує заявлений рівень: pac1aстає pac1b, pac2aстає pac2b, і так далі для всіх трьох частин. Внутрішні допоміжні функції: PdfAIsLevelA та PdfADowngradeToLevelB

Обґрунтування варто сказати прямо: файл, який чесно заявляє той рівень, якому він відповідає, корисніший за той, що бреше про рівень, якого не має. Level U обробляється інакше. Визначення справжнього покриття Unicode означало б наївну перевірку на кшталт "чи має він /ToUnicode" тест, який надмірно знижував би легітимні документи (WinAnsi та подібні кодування є винятком), тож під час збереження бібліотека видає позначку U так, як її оголосив виклик, а розбіжність лишає для позначення на етапі перевірки. Якщо вам потрібен гарантований архів Level A, позначте документ тегами до конвертації; конвертер не вигадуватиме структуру, якої немає

Підводний камінь ICC, який ловить лише справжній валідатор

Це той збій, який навчив найважчого уроку, бо власна перевірка бібліотеки його пропустила, а veraPDF, еталонний валідатор ISO 19005, ні. PDF/A вимагає, щоб профіль призначення OutputIntent був коректним потоком ICCBased, а §6.2.3.2 змушує валідатор перевіряти цей потік як колірний простір. Потік ICCBased має оголошувати /N, тобто кількість кольорових компонентів. Рання версія інжектора записувала словник ICC-потоку лише з /Length і без /N, а veraPDF відхилив результат із повідомленням "The N entry (value null)... is missing"

Підступність полягала в тому, що відхилення спрацьовувало лише для PDF/A-1b та -1a. Моделі відповідності частин 2 і 3 не виконували саме цю перевірку для профілю призначення, тож ідентична вбудована структура проходила перевірку під pac2b, pac3b та pac2u але провалювалася під pac1b лише через pdfaid:part значення. Юніт-тест ніколи не міг цього побачити, бо власний ValidatePdfACompliance лише перевіряла, що /DestOutputProfile ключ існував, а не те, що було всередині словника потоку. Внутрішні тести лишалися зеленими; реальна перевірка архівної сумісності провалювалася

Виправлення полягає в IccComponentCount, яка зчитує сигнатуру колірного простору даних за зміщенням 16 у заголовку ICC і зіставляє її з кількістю компонентів: GRAY має 1, RGB , Lab , і XYZ мають 3, CMYK має 4, а профіль невідомого типу за замовчуванням дає 3. Ця кількість записується до словника потоку як /N. Вона обчислюється, а не жорстко задається як 3, тож виклик, який передає профіль CMYK або відтінки сірого через IccProfileDataширший урок тут методологічний: вбудована в бібліотеку перевірка і авторитетний валідатор мають сліпі зони, а вихід PDF/A треба перевіряти наскрізь на еталонній реалізації на кшталт veraPDF, а не довіряти самоперевіркам. Та сама дисципліна інкрементального оновлення, що стоїть за чистими архівами, описана в перевірці стиснених об'єктних і xref-потоків, що важливо, бо сучасні PDF, які обробляє інжектор, часто побудовані на xref-потоках

Шифрування, xref-потоки та інші крайові випадки

Оскільки ISO 19005 забороняє шифрування, шлях збереження прибирає його перед записом.SaveAsPdfA застосовується FPDF_REMOVE_SECURITY під час серіалізації, тож зашифроване джерело (завантажене з його паролем) розшифровується на шляху до архіву. Для незашифрованого документа це no-op і нічого не змінює. Наслідок той самий, що й обмеження, яке HotPDF реалізує у зворотному напрямку: один файл не може бути одночасно зашифрованим і PDF/A. Коли робочий процес потребує обох, відповідь одна: два артефакти, зашифрована копія для розповсюдження і окрема чиста копія для архіву

Ще один крайовий випадок лишається невидимим, доки не дасть про себе знати: документи PDF 1.5+ з чистим потоком перехресних посилань, які не містять ключа trailer trailer. Інжектор читає trailer, щоб знайти вихідний /Info і додати до нього своє інкрементальне оновлення, тож він має приймати форму xref-потоку, інакше такий документ буде скопійовано далі з мовчазно втраченими маркерами. ISO 32000-1 §7.5.6 прямо дозволяє класичному інкрементальному оновленню trailer слідувати за документом з xref-потоком, причому /Prev вказує на зміщення xref-потоку, а саме таку структуру й виводить інжектор. Сам FPDF_SaveAsCopyPDFium завжди записує класичний trailer, тож у звичайному ланцюжку інжектор ніколи не зустрічає чисте джерело xref-потоку, але шлях читання його підтримує для документів, що надходять ззовні

Перевіряйте, перш ніж довіряти твердженню

Бібліотека постачається з покроковою перевіркою на рівні байтів, TPdf.ValidatePdfA яка повертає TPdfAValidationResult. Її Conformance поле повідомляє виявлений рівень, а Issues це набір TPdfAValidationIssue значень; допоміжний метод IsCompliant є true лише тоді, коли справжній рівень виявлено і набір проблем порожній. Запускайте його як швидкий перший бар'єр у пакеті

var
  Pdf: TPdf;
  Res: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice_archive.pdf');
    Res := Pdf.ValidatePdfA;
    if Res.IsCompliant then
      Writeln('Conformant: detected level ', Ord(Res.Conformance))
    else
      Writeln('Issues found: ', SizeOf(Res.Issues), ' flags set');
  finally
    Pdf.Free;
  end;
end;

Будьте чесні щодо того, що це дає. Перевірка на рівні байтів ловить структурні проблеми (відсутній OutputIntent, заборонена дія, наявний /Encrypt, прозорість там, де частина 1 це забороняє) з високою впевненістю, а виявлення вбудовування шрифтів використовує евристику за кількістю, яка навмисно повідомляє лише сигнал високої впевненості, а не женеться за покриттям кожного гліфа. Чого вона не робить, так це аналізу операторів у content stream, що потребувало б повного парсера вмісту і свідомо не входить у сферу. Для release gate поєднуйте перевірку в бібліотеці з veraPDF: перевірка миттєва і працює всюди без DLL, veraPDF є авторитетним. Вбудовування цієї зв'язки в batch run є темою CLI звіту попередньої перевірки пакета, і саме там ця перевірка має жити в реальному архівному робочому процесі

Показані тут SaveAsPdfA, InjectPdfAMarkers, і ValidatePdfA API постачаються з PDFium Component для Delphi, C++Builder і Lazarus/FPC. Сторінка продукту містить посилання на повний довідник API, включно з повним переліком рівнів відповідності та записом параметрів, що стоїть за цими прикладами