Чек-лист PDF/A-3 обычно говорит, что XMP-пакет должен нести две записи идентификации, pdfaid:part и pdfaid:conformance. Корректно записанный файл PDF/A-4 несёт pdfaid:part 4 и pdfaid:rev 2020, а для базового профиля записи conformance нет вовсе. Добавить букву, чтобы «починить», — значит сделать файл неправильным: ISO 19005-4 идентифицирует базовую часть номером и годом ревизии, и только два расширения сохраняют букву
У PDF/UA-2 похожая ловушка уровнем ниже, внутри дерева структуры. Объявить пользовательскую роль в собственном пространстве имён выглядит как вся работа, ведь пространства имён — то, что добавила часть 2. Это половина работы. Роль вроде WidgetGroup в https://example.com/ns/widgets всё ещё обязана мапиться на стандартную роль, прежде чем конформный чекер её примет, и ничто в отрисованном листе не подсказывает, что маппинга нет
Выбор PDF/A-4 через PDFACompliance
В HotPDF профили PDF/A-4 — три значения существующего свойства PDFACompliance: '4' для базовой части, '4E' для инженерных документов и '4F' для файлов со встроенными файлами. С выбранным '4' XMP-пакет получает pdfaid:part 4 и pdfaid:rev 2020 и больше ничего из пространства pdfaid. '4E' и '4F' добавляют pdfaid:conformance с E или F. Функции декодирования сообщают тот же расклад: PDFAPart возвращает 4 для всех трёх значений, а PDFAConformanceLevel — E, F или #0 для базового профиля
Все три принуждают заголовок PDF 2.0. Апгрейд происходит внутри EndDoc, поэтому документ, начавшийся как PDF 1.4, выходит как 2.0 без отдельного вызова. Единственный случай, который его останавливает, — StrictVersionLock. Тот флаг запрещает автоматические апгрейды, поэтому EndDoc бросает «PDF/A-4 requires PDF 2.0; StrictVersionLock prevents the version upgrade», а не пишет файл 1.7, заявляющий часть 4 в метаданных. Кодовая база, закрепившая версию ради старого печатного процесса, увидит ровно это сообщение, когда кто-нибудь впервые щёлкнет профиль
Два правила PDF/A-4 меняют то, что попадает в трейлер и каталог. ISO 19005-4 §6.1.3 допускает словарь информации о документе, только когда у каталога есть запись PieceInfo, и даже тогда ограничивает его ModDate, поэтому HotPDF не пишет словарь Info для части 4, и заголовок уезжает как dc:title в XMP. Title и Lang по-прежнему обязательны. Оставите любое пустым — EndDoc бросит исключение. Он бросит и когда не был добавлен выходной интент через AddPDFAOutputIntent, а EnableEncrypt отказывается работать, пока выбран любой профиль PDF/A, потому что зашифрованный архивный файл — противоречие, которого стандарт не допускает
Ассоциированные файлы под PDF/A-4f и 4e
Ассоциированные файлы — главная причина выбрать PDF/A-4f: XML-счёт за отрисованной страницей, JSON-полезная нагрузка, которую парсит нижестоящая система. AddPDFAssociatedFile принимает имя файла, MIME-тип, описание, значение AFRelationship и байты, возвращая непрямой словарь FileSpec, так что можно прикрепить собственные опциональные ключи
var
Pdf: THotPDF;
Icc: TFileStream;
begin
Pdf := THotPDF.Create(nil);
Icc := TFileStream.Create('sRGB2014.icc', fmOpenRead or fmShareDenyWrite);
try
Pdf.FileName := 'invoice-archive.pdf';
Pdf.PDFACompliance := '4F'; // часть 4, ревизия 2020, соответствие F
Pdf.Title := 'Invoice 2026-0731'; // пишется только в XMP dc:title
Pdf.Lang := 'en-US';
Pdf.BeginDoc;
Pdf.AddPDFAOutputIntent('sRGB IEC61966-2.1', 'sRGB IEC61966-2.1',
Icc, 3, 'DeviceRGB');
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-0731');
// LoadInvoiceBytes — ваша функция, возвращающая TBytes
Pdf.AddPDFAssociatedFile('invoice.xml', 'text/xml',
'Structured invoice data', 'Data', LoadInvoiceBytes);
Pdf.EndDoc;
finally
Icc.Free;
Pdf.Free;
end;
end;
Каждый вызов делает четыре вещи. Пишет поток EmbeddedFile, чей /Subtype держит MIME-тип как имя PDF, со словарём /Params, несущим размер, дату модификации и MD5-контрольную сумму. Строит FileSpec с /F и /UF, описанием как /Desc и именем отношения. Затем регистрирует тот FileSpec дважды, в массиве /AF каталога и в дереве имён EmbeddedFiles. Обе регистрации обязательны. Файл, зарегистрированный лишь в одном месте, может всё ещё открываться из списка вложений просмотрщика — вот почему визуальная выборочная проверка почти ничего не говорит об архиве
Строка отношения обязана быть одной из Source, Data, Alternative, Supplement или Unspecified; всё прочее бросает исключение до записи хоть байта, как и пустой MIME-тип. Профиль тоже проверяется. AddPDFAssociatedFile принимает PDF/A-3, PDF/A-4e и PDF/A-4f, а базовый профиль '4' бросает, потому что не допускает ассоциированных файлов. В обратную сторону AddDocumentAttachment отказывается работать под PDF/A-3, 4e и 4f, поскольку у простой вложенности нет регистрации /AF, которую те профили требуют. Код, написанный для PDF/A-3 против AddPDFA3AssociatedFile, продолжает компилироваться и ведёт себя так же; AddPDFAssociatedFile — нейтральное к профилю имя того метода
Два расширения делят оставшиеся разрешения между собой. Под '4F' EndDoc следит, чтобы каталог нёс дерево имён EmbeddedFiles, и AddFileAttachmentAnnotation принимается, тогда как базовый профиль и 4e его отвергают. Под '4E' разрешены Add3DAnnotation и AddRichMediaAnnotation вместе с действиями SetOCGState и GoTo3DView. Все прочие профили PDF/A отвергают все четыре
Почему пользовательская роль с пространством имён всё ещё проваливает PDF/UA-2
Пользовательская роль проходит PDF/UA-2, только когда выполняются два условия: её запись /NS указывает на словарь пространства имён, перечисленный в корне дерева структуры, и роль разрешается через RoleMapNS этого пространства в роль стандартного пространства. HotPDF раскладывает работу по трём методам. RegisterStructureNamespace создаёт непрямой словарь /Type /Namespace, добавляет его в StructTreeRoot /Namespaces и возвращает его; повторный вызов с тем же URI возвращает существующий словарь вместо добавления нового. AddStructureElementNS создаёт элемент и ставит его /NS, по пути регистрируя пространство. AddStructRoleMapNS пишет маппинг
const
PDF2NS = 'http://iso.org/pdf2/ssn';
WidgetNS = 'https://example.com/ns/widgets';
var
DocElem, Group: THPDFDictionaryObject;
begin
Pdf.PDFUACompliance := True;
Pdf.PDFUAPart := 2; // PDF/UA-2 требует PDF 2.0
Pdf.Title := 'Widget catalog'; // пустой Title вызывает исключение на EndDoc
Pdf.Lang := 'en-US';
Pdf.BeginDoc;
DocElem := Pdf.AddStructureElement('Document', nil); // привязан к PDF2NS
Group := Pdf.AddStructureElementNS('WidgetGroup', WidgetNS, DocElem);
// без этого маппинга EndDoc бросает ua2.rolemap.unresolved
Pdf.AddStructRoleMapNS('WidgetGroup', WidgetNS, 'Div', PDF2NS);
Pdf.CurrentPage.SetFont('Arial', [], 18);
Pdf.CurrentPage.BeginMarkedContentMCID('H1', 0);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Widgets');
Pdf.CurrentPage.EndMarkedContent;
Pdf.AddStructureElement('H1', Group, 0, 0); // индекс страницы 0, MCID 0
Pdf.EndDoc;
end;
Часть того перечисления происходит без запроса. С PDFUAPart в 2 вызов AddStructureElement('Document', nil) привязывает элемент к стандартному пространству имён PDF 2.0, http://iso.org/pdf2/ssn, потому что корень дерева PDF/UA-2 обязан быть Document в том пространстве. Та же привязка происходит для каждой роли из словаря PDF 2.0, поэтому H1 и P аргумент пространства не нужны. Роли, существующие только в наборе PDF 1.7, вроде Note, Code или BlockQuote, остаются в пространстве 1.7
Цель маппинга — настоящий выбор. Пустое целевое пространство в AddStructRoleMapNS пишет короткую форму — голое имя роли, разрешаемое по набору PDF 1.7. URI PDF 2.0 пишет двухэлементную форму [роль пространство]. В документе PDF/UA-2 мапьте на пространство PDF 2.0: тогда всё дерево разрешается в один словарь, а цели вроде Aside или FENote, у которых нет аналога в 1.7, становятся доступны. Две формы маппинга отвергаются на EndDoc сразу: цепь, зацикливающаяся на себе, и роль в явном пространстве, замапленная на другую роль того же пространства
Порядок вызовов свободнее, чем кажется. Маппинг можно добавить до или после существования элемента, потому что до окончания дерева никто его не проверяет. Что падает немедленно, так это AddStructureElementNS при PDFUAPart в 1. У части 1 пространств нет, и вызов на месте бросает «structure namespaces require PDFUAPart=2»
Что EndDoc проверяет до записи файла
С включённым PDFUACompliance и PDFUAPart в 2 EndDoc прогоняет полную валидацию структуры PDF/UA-2 до сериализации чего-либо, и первая ошибка становится исключением. Четыре его проверки охраняют скелет дерева. Корень обязан держать ровно один элемент Document верхнего уровня. Каждый словарь пространства имён обязан быть непрямым, типизированным Namespace и нести непустой URI, встречающийся только один раз. Каждая ссылка /NS обязана разрешаться в словарь, перечисленный в корневом массиве /Namespaces. И каждая роль обязана разрешаться в стандартное пространство — напрямую или через RoleMap и RoleMapNS. Элементы, не достигаемые от элемента Document, тоже сообщаются
Тот же проход покрывает требования уровня документа: непустой валидный Lang; MarkInfo /Marked в true; включённый DisplayDocTitle; XMP-пакет с непустым dc:title и pdfuaid:part 2. HotPDF сам ставит флаг разметки, предпочтение просмотрщика и XMP-идентификацию, как только включён PDF/UA, и пишет pdfuaid:rev 2024 рядом с номером части. Заголовок и язык — ваша забота, и пустое значение любого из них бросает исключение ещё до старта валидатора. Заголовки, списки, таблицы, аннотации и формы проверяются в том же прогоне
Ожидание до EndDoc — намеренное. На середине генерации дерево законно неполно: у элемента Document может ещё не быть детей, пространство может быть зарегистрировано до того, как его использует какой-либо элемент, маппинг может приехать после своего элемента. Проверка на каждом вызове отвергала бы эти состояния. На EndDoc дерево закончено и всё ещё в памяти, и исключение называет код правила и путь элемента, который его сломал
try
Pdf.EndDoc;
except
on E: Exception do
begin
// E.Message имеет форму
// PDF/UA-2 validation failed [ua2.heading.skipped at ...]:
// Heading level skips from H2 to H4
LogBuildFailure(Pdf.FileName, E.Message); // ваш собственный логгер
raise;
end;
end;
Вывод PDF/A-4 получает то же обращение от собственного валидатора. Когда PDFACompliance держит значение части 4, EndDoc сверяет сгенерированный граф с ISO 19005-4 и бросает с кодом правила и пунктом, например pdfa4.filespec.relationship для FileSpec без валидного отношения или pdfa4.trailer.info_entries для чужой записи Info. Редакция Enterprise выставляет оба валидатора как ValidatePDFUA2Structure и ValidatePDFA4, которые заполняют массив проблем всеми удержанными диагностиками вместо остановки на первой ошибке. Для шага CI, который должен печатать полный список, это лучший вход
Роли PDF 2.0 и заголовки дальше H6
Типизированное перечисление THPDFStandardStructureType пополняется sstDocumentFragment, sstAside, sstTitle, sstFENote, sstSub, sstEm, sstStrong и sstArtifact. Три из них меняют, как размечаются обычные деловые документы. Aside даёт боковым панелям и врезкам дом, не являющийся злоупотреблённым Sect. FENote помечает сноски и концевые примечания как примечания, поэтому скринридер может предложить их по запросу вместо чтения посреди предложения. Em и Strong несут выделение как структуру, тогда как дерево PDF 1.7 могло лишь завернуть его в Span
Заголовки в PDF 2.0 открыты. Перечисление останавливается на sstH6, но строковая перегрузка AddStructureElement принимает H7, H8 и дальше, потому что набор PDF 2.0 определяет Hn для любого n от 1 вверх. PDF 1.7 останавливался на H6, что заставляло глубокие технические документы сплющивать оглавление или переиспользовать уровни. Для документов стандартов, правовых кодексов и каталогов запчастей одно это может оправдать перевод вывода на PDF 2.0
Более глубокие заголовки приходят с более строгим правилом. Проход PDF/UA-2 внутри EndDoc требует последовательности уровней заголовков: первый заголовок обязан быть H1, а прыжок с H2 на H4 бросает ua2.heading.skipped. Иерархия может начаться ниже только внутри DocumentFragment — роли для содержимого, вырезанного из большего документа. Генератор, мапящий оглавление Markdown или HTML прямо на роли Hn, наследует каждый уровень, который автор пропустил ради визуального размера, так что нормализуйте оглавление перед разметкой
Перед переключением производственного вывода на PDF 2.0
PDF 2.0 начинается со смены заголовка, а заголовок — первое, что читают старые потребители. Сервис приёма, печатный процесс или бизнес-просмотрщик, принимающий только до PDF 1.7, остановится на %PDF-2.0, как бы чист ни остальной файл. Выбор любого профиля PDF/A-4 или PDF/UA части 2 выбирает PDF 2.0, просили вы того или нет, так что подтвердите каждую потребляющую систему до переключения, а не после
Наименее рискованный разворот держит PDF/A-3 для документов, уходящих читателям, которых вы не контролируете, использует PDF/A-4f для внутренних архивов, где приём на вашей стороне, и принимает PDF/UA-2 там, где его называет политика доступности. StrictVersionLock заодно служит охранником при переходе: поставьте Version в pdf17 и лок в кодовых путях, обязанных остаться на 1.7, и случайный выбор PDF/A-4 упадёт на EndDoc с сообщением вместо того, чтобы доехать до системы, которая файл не откроет. Руководства по валидации PDF/A, PDF/X и PDF/UA и по гибридным счетам ZUGFeRD и Factur-X на PDF/A-3 покрывают решения профиля, которые стоят до номера версии, а заметки об автоматизированной preflight-отчётности показывают, как превратить вердикт в шаг сборки
Всё это работает как нативный VCL-код внутри компонента для Delphi и C++Builder, поэтому вывод PDF/A-4 и PDF/UA-2 уезжает с вашим исполняемым файлом и не требует внешнего движка. Поддерживаемые профили и версии RAD Studio перечислены на странице компонента HotPDF для Delphi