Вы выпускаете конвертер, который помечает каждый файл как PDF/A-1b, система хранения клиента год их принимает, а потом аудит прогоняет весь пакет через veraPDF, и треть файлов возвращается как non-conformant. Ничего не падало, исключений не было, файлы прекрасно открываются во всех viewer на вашем столе. Просто они никогда не были тем стандартом, который вы на них поставили. Это нормальный режим отказа для архивного PDF, и именно поэтому утверждение "мы поставили флаг" никогда не означает то же самое, что утверждение "это проходит валидацию"
Первое, что нужно понять про PDFium и PDF/A: сам движок к этому не имеет отношения. PDFium рендерит, парсит и пишет PDF, но в его публичной поверхности нет ConvertToPDFA , нет writer для OutputIntent, нет XMP API. Вся архивная совместимость, XMP packet, OutputIntent с его ICC profile, catalog markers и валидация, живет в самом PDFiumPas, примерно в 2,000-строчном 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, поверх B требует tagged structure tree и Unicode mapping. Level U, существующий только для частей 2 и 3, находится между ними: надежный Unicode text без полного structure tree. У ISO 19005-1 нет Level U, и библиотека кодирует это ограничение напрямую
На практике больнее всего бьют несколько правил формата. Шифрование запрещено полностью, ISO 19005-1 §6.1.3 и последующие части: файл PDF/A не может содержать словарь /Encrypt . Документ обязан объявить output rendering condition через OutputIntent, чья цель указывает на корректный ICC profile, §6.2.3.2. Само заявление о conformance должно присутствовать в метаданных XMP по схеме PDF/A identification. Level A дополнительно требует логическую структуру по §6.8, то есть tag tree, делающий документ машинно-читаемым. Пропустите любой из этих пунктов, и verifier отклонит файл, даже если он рендерится идеально
Один вызов, который создает архив
PDFiumPas выставляет весь pipeline через TPdf.SaveAsPdfA . Простой overload принимает целевой уровень conformance и по умолчанию использует PDF/A-1b. Для типового сценария "сделать это вечно рендеримым" это именно тот default, который нужен
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 сначала просит PDFium сериализовать документ через FPDF_SaveAsCopy , затем передает этот поток байтов в InjectPdfAMarkers , который через incremental update дописывает XMP metadata, sRGB OutputIntent со встроенным ICC profile и переписанный catalog. Источник читается с позиции zero, а назначение записывается с позиции zero. Исходное object tree остается нетронутым, а маркеры приезжают после существующего %%EOF . Если вам нужны сами байты, а не файл, используйте SaveAsPdfAToStream , который принимает TStream и те же опции
Выбор уровня conformance через options record
Чтобы нацелиться на конкретную часть и уровень, передавайте запись 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 dictionary документа через FPDF_GetMetaText . Если оставить пустыми CreationDate и ModDate , библиотека подставит текущее время UTC для обеих XMP dates. Если не задавать DocumentId и InstanceId , библиотека предварительно заполнит их из FPDF_GetFileIdentifier , а если это невозможно, возьмет детерминированный ID, вычисленный из байтов источника. Единственное поле, которое может иметь смысл переопределять осознанно, это IccProfileData : пустое значение означает встроенный профиль sRGB IEC61966-2.1, но для CMYK или grayscale workflow стоит передать собственный профиль
Почему Level A понижается и почему это честный выбор
Здесь есть тонкость, на которой регулярно спотыкаются люди, ожидающие, что один флаг что-то гарантирует. Вы можете запросить pac1a у документа, у которого нет tag tree, но PDF/A-1a требует логическую структуру по §6.8, а библиотека не умеет изготовлять structure tree из нетегированного PDF. Вместо того чтобы выпустить файл, который заявляет Level A и тут же его нарушает, SaveAsPdfA проверяет наличие реальной tagged structure, /StructTreeRoot плюс /MarkInfo со значением /Marked true , и если ее нет, честно понижает заявление: pac1a превращается в pac1b , pac2a превращается в pac2b и так далее для всех трех частей. Внутренние помощники здесь - PdfAIsLevelA и PdfADowngradeToLevelB
Логику стоит проговорить прямо: файл, который честно объявляет тот уровень, которому действительно соответствует, полезнее, чем файл, который врет о более высоком уровне. С Level U ситуация другая. Надежное определение настоящего покрытия Unicode означало бы наивную проверку вроде "есть ли /ToUnicode" , а такая проверка излишне понижала бы вполне законные документы, потому что WinAnsi и похожие encoding освобождены от этого требования. Поэтому сторона сохранения пишет U-claim именно так, как его запросил вызывающий код, а расхождение, если оно есть, оставляет validation side. Если вам нужен гарантированный архив уровня A, сначала тегируйте документ, а уже потом конвертируйте. Конвертер не будет выдумывать структуру, которой в файле нет
Ловушка с ICC, которую видит только настоящий validator
Именно этот отказ и дал самый жесткий урок, потому что собственная проверка библиотеки пропускала его, а veraPDF, эталонный validator ISO 19005, нет. PDF/A требует, чтобы destination profile в OutputIntent был корректным потоком ICCBased, а §6.2.3.2 заставляет verifier проверять этот поток как color space. Поток ICCBased обязан объявлять /N , то есть число цветовых компонентов. В ранней версии injector писал в словарь ICC stream только /Length и не писал /N . veraPDF отклонял результат с ошибкой "The N entry (value null)... is missing"
Особенно коварным это делало то, что отказ срабатывал только для PDF/A-1b и -1a. Модели conformance из частей 2 и 3 не запускали именно эту проверку по destination profile, поэтому идентичная внедренная структура проходила под pac2b , pac3b и pac2u , но падала под pac1b исключительно из-за значения pdfaid:part . Ни один unit test не мог это заметить, потому что собственная ValidatePdfACompliance проверка смотрела только на то, существует ли ключ /DestOutputProfile , а не на то, что именно находится внутри словаря потока. Внутренние тесты оставались зелеными, а реальная архивная валидация проваливалась
Исправление - это IccComponentCount , который читает сигнатуру цветового пространства данных на смещении 16 в заголовке ICC и переводит ее в число компонентов: GRAY равно 1, RGB , Lab и XYZ равны 3, CMYK равно 4, а неизвестный профиль по умолчанию считается трехкомпонентным. Это число попадает в словарь потока как /N . Значение вычисляется, а не жестко прошивается как 3, поэтому вызывающий код, передающий собственный CMYK или grayscale profile через IccProfileData , тоже получает правильное число. Более общий урок здесь методологический: у встроенной проверки и у авторитетного validator разные слепые зоны, и output PDF/A нужно тестировать end to end на эталонной реализации вроде veraPDF, а не доверять только само-проверкам. Та же дисциплина incremental update, которая лежит под чистыми архивами, разбирается в validating compressed object and xref streams , что особенно важно, потому что современные PDF, которые ест injector, нередко построены на cross-reference stream
Шифрование, xref stream и другие края
Поскольку ISO 19005 запрещает шифрование, путь сохранения удаляет его перед записью. SaveAsPdfA применяет FPDF_REMOVE_SECURITY во время сериализации, поэтому зашифрованный источник, если он был загружен с паролем, на пути в архив расшифровывается. Для незашифрованного документа это no-op и ничего не меняет. Обратная сторона здесь та же, что HotPDF принудительно держит и в другом направлении: один и тот же файл не может быть одновременно и зашифрованным, и PDF/A. Если workflow требует и то и другое, правильный ответ - две артефакта, зашифрованная копия для распространения и отдельная чистая копия для архива
Еще один край незаметен, пока не укусит: документы PDF 1.5+, использующие чистый cross-reference stream и не содержащие ключевого слова trailer . Injector читает trailer, чтобы найти исходный /Info и дописать свой incremental update, а значит, обязан принимать и форму xref-stream, иначе такой документ просто пройдет через копирование с молча отброшенными marker. ISO 32000-1 §7.5.6 прямо разрешает классическому trailer incremental update следовать за документом на xref-stream, при этом /Prev указывает на смещение xref-stream. Именно такую структуру injector и выпускает. Собственный FPDF_SaveAsCopy у PDFium всегда пишет классический trailer, поэтому в обычном pipeline injector никогда не встречает чистый xref-stream source, но path чтения его поддерживает для документов, пришедших извне
Проверка перед тем, как доверять заявлению
Библиотека поставляется с byte-level checker, TPdf.ValidatePdfA , который возвращает TPdfAValidationResult . Его поле Conformance сообщает обнаруженный уровень, а Issues представляет собой набор значений TPdfAValidationIssue . Convenience method IsCompliant возвращает true только тогда, когда найден реальный уровень и набор issues пуст. В пакетной обработке используйте его как быстрые первые ворота
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;
Важно честно понимать, что это дает. Byte-level checker с высокой уверенностью ловит структурные проблемы, отсутствие OutputIntent, запрещенное действие, наличие /Encrypt , transparency там, где часть 1 ее запрещает. А обнаружение не встроенных шрифтов основано на подсчете и нарочно сообщает только сигнал высокой уверенности вместо того, чтобы гоняться за покрытием каждого glyph. Чего он не делает, так это анализа операторов content stream, потому что для этого нужен полноценный content parser, а это намеренно вне зоны ответственности. Для release gate объединяйте встроенный checker с veraPDF: checker мгновенный и запускается везде без DLL, veraPDF - авторитетный. Встраивание этой пары в batch run и является темой batch preflight report CLI , где такой validation и должен жить в реальном архивном workflow
Показанные здесь API SaveAsPdfA , InjectPdfAMarkers и ValidatePdfA поставляются вместе с PDFium Component для Delphi, C++Builder и Lazarus/FPC. На странице продукта есть ссылка на полный API reference, включая полное перечисление conformance и options record, стоящую за этими примерами