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

Перевірка PDF/A Preflight у Delphi з PDFium VCL

Шлюз приймання архіву відхилив партію файлів «PDF/A-2b», які без проблем відкривалися в кожній програмі на столі. Постачальник клявся, що вони відповідають стандарту. Це було не так: у кожному ховалася JavaScript-дія в каталозі, такий собі трюк, який випадковий погляд не помітить, а повний PDF/A-валідатор на кшталт veraPDF ловить миттєво. Проблема в тому, що ніхто не хотів підключати Java-ланцюжок інструментів до пакетного сервісу Delphi лише заради відповіді так або ні для кожного файла. Саме цю прогалину ValidatePdfACompliance у PDFium Component закриває, і варто зрозуміти, як він доходить до висновку, жодного разу повністю не розбираючи потік вмісту

Чому сам PDFium не може дати на це відповідь

Перше, у чому треба чесно зізнатися: вбудований pdfium.dll PDFium Component не має жодної підтримки PDF/A. У ньому немає ConvertToPDFA, немає записувача OutputIntent, немає API XMP у публічній поверхні. Увесь PDF/A у цій бібліотеці, і запис, і перевірка, живе в чистому Pascal у FPdfPdfa.pas і працює через байтовий розбір плюс інкрементальне оновлення. Тож коли ви викликаєте валідатор, ви не питаєте нічого у рендерера Chromium. Ви запускаєте Pascal-сканер токенів по структурних байтах файла

Публічний API навмисно дуже компактний. Одна функція читає потік з позиції 0 і повертає запис:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

IsCompliant кодує правило, яке має значення для шлюзу: файл проходить лише тоді, коли виявлено справжній рівень відповідності і множина проблем порожня. Якщо розбір успішний, але маркера pdfaid немає, результат стає pacNone, що явно не є проходженням. Саме таку саму думку ззовні робить batch preflight report CLI ззовні: порожній список знахідок у нерозпізнаному файлі не є підтвердженням, що з ним усе гаразд

Видалення тіл потоків перед будь-яким скануванням токенів

Ось найважливіша деталь реалізації, і саме та, на якій найпростіше помилитися, якщо писати власний сканер. Детектор знаходить порушення, шукаючи розділені name-токени, наприклад /JavaScript, /LZWDecode, /BM. Якщо ви скануєте сирі байти файла, вбудовані бінарні тіла потоків, стиснені зображення, ICC-профілі, програми шрифтів, випадково міститимуть послідовності байтів, схожі на ці токени. Ви повідомите про /AA або /3D "знайдено" лише тому, що три байти всередині JPEG випадково склалися в це слово. Це фабрика хибнопозитивних спрацювань

Виправлення такеPdfStructureBytes: він проходить файл і замінює байти між кожним stream і endstream ключовим словом на пробіли, залишаючи структуру словника незмінною. Лише після цього запускається сканування. Кожна перевірка name-токена у валідаторі працює з цією очищеною копією. Якщо винести з цієї статті лише одну ідею, нехай це буде вона. Така сама дисципліна відтворена у валідаторі PDF/UA, який тримає власну копію цієї процедури, бо обидва стандарти еволюціонують незалежно

29 проблем і що означає кожна

TPdfAValidationIssue є задокументованим контрактом. Порядкові номери зафіксовані, бо на них спираються тести DUnitX, демо та шар звітів, тож нові знахідки лише додають у кінець. Станом на v1.63.0 є 29 елементів. Вони належать до кількох груп:

  • Метадані та ідентичність: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • Кольори та виведення: pvaiMissingOutputIntent, pvaiMissingIccProfile, і pvaiMixedDeviceColorSpaces коли одночасно присутні DeviceRGB і DeviceCMYK (6.2.3.3)
  • Жорсткі заборони для кожної частини: pvaiEncryptionPresent (/Encrypt словник заборонено повністю), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • Шрифти: pvaiFontNotEmbedded і більш суворий pvaiUnembeddedFont, а також pvaiUnicodeMappingMissing для заяви рівня U без /ToUnicode
  • Тегування: pvaiLevelAStructureMissing коли заява про відповідність=A не має тегованої структури

Шість найновіших елементів, доданих під порядковими номерами 24-29, охоплюють тонкі випадки, на яких рецензенти справді спотикаються: pvaiTrappedTrue (/Trapped /True у словнику Info, хибний друг, оскільки значення має бути False або Unknown), pvaiForbiddenActionSubtype (Sound або Movie використовується як дія, а не лише як анотація), pvaiTransparentColorSpace (режим накладання, відмінний від Normal, або /CA//ca не дорівнює 1.0), pvaiAnnotationDictViolation, pvaiUnembeddedFont, а також pvaiMixedDeviceColorSpaces

Обмеження з урахуванням частини: A-1 суворий, A-2 і A-3 м’якші

PDF/A не є одним звідом правил. Три речі, які PDF/A-1 забороняє, прямо дозволені починаючи з PDF/A-2: прозорість (/Transparency група або активний /SMask, 6.4), додатковий вміст (/OCProperties, 6.1.13), а також вбудовані файли (/EmbeddedFiles або /EF, 6.1.11). Наївний валідатор, який позначає всі три для кожного файла, масово відхилятиме цілком чинні документи PDF/A-2

Тому валідатор читає номер частини з маркера pdfaid через PdfAPartOf і вмикає ці перевірки лише за PartNo = 1. Перевірки blend-mode та annotation-alpha для нових проблем із прозорістю так само належать лише до part 1:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

Один обережний варіант за замовчуванням варто згадати: коли немає жодного маркера pdfaid, частину вважають 1, тобто найсуворішою. Логіка така: невідомий файл слід тримати в межах найжорсткіших правил, а не пропускати далі. JavaScript, заборонені дії, LZW, XFA, NeedAppearances, заборонені анотації та не вбудовані шрифти лишаються забороненими для кожної частини, тож ці перевірки ніколи не ховаються за бар'єром

Розширення object streams, щоб нічого не ховалося

PDF 1.5 запровадив cross-reference stream і object stream (/Type /ObjStm), і вони створюють сліпу зону для наївного байтового сканера. Каталог, OutputIntent, словник дії, будь-що, що саме не є потоком, може бути Flate-стиснуте всередині ObjStm. Проскануйте сиру структуру, і ви не побачите нічого з цього, а потім отримаєте звіт про чистий файл, який таким не є

PdfExpandObjectStreams закриває цю прогалину. Перед запуском будь-якої перевірки validator виконує Data := PdfExpandObjectStreams(Data). Рути́на знаходить кожен ObjStm, читає його /N та /First заголовок, щоб отримати номери й зміщення вбудованих об'єктів, розпаковує тіло за допомогою PdfInflate (RTL zlib, System.ZLib у Delphi та zstream у FPC), і додає кожен вбудований об'єкт як звичайний N 0 obj ... endobj в кінець копії байтів. Існуючі перевірки токенів потім знаходять ці об'єкти без жодних змін у своїй логіці

Дві умови роблять це рішення акуратним, а не крихким. Stream objects, Metadata, ICC profile і font programs не можуть жити в object stream, лише словники, що не є потоками, тому розширення завжди працює тільки зі словниками, а додані об'єкти не несуть жодного stream ключового слова, яке могло б завадити проходу видалення тіла. А оскільки доданий вміст потрапляє після %%EOF, зворотний пошук від startxref усе ще знаходить оригінальний trailer. Сам trailer cross-reference stream уже обробили раніше, у v1.49.3, читаючи Root, Size і ID прямо з plaintext xref-stream dictionary, тема, яку розглянуто в супровідній статті про перевірку object та cross-reference stream; робота з object stream мала лише додати крок розпакування, без потреби декодувати записи type-2 xref чи розгортати PNG predictor

Чесні межі байтового перевірника

Це інструмент попередньої перевірки, а не сертифікований валідатор, і межі тут реальні. Вбудовування шрифтів є евристикою підрахунку, і виправити її правильно довелося за допомогою корекції, про яку варто знати. Початкова перевірка використовувала PdfCountName('/FontDescriptor'), але кожен шрифт додає два /FontDescriptor токени, одне посилання зі словника шрифту і одне /Type в об'єкті дескриптора, тож підрахунок був 2N проти N вбудованих програм і тест завжди був істинним. Виправлення таке: PdfCountDescriptorRefs, яке рахує лише форму посилання /FontDescriptor N G R, по одному на шрифт, і піднімає pvaiUnembeddedFont лише тоді, коли вбудованих програм справді менше:

K := PdfCountDescriptorRefs(Struct);                 // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

Навіть після виправлення це грубий підхід: змішаний документ, де в кожного дескриптора випадково є якийсь FontFile, усе ще може пропустити окремий невідповідний шрифт. Розширення object stream має й відомий побічний ефект: воно відкриває стандартні ресурси за замовчуванням, які несе AcroForm /DR, наприклад /Helv, і евристика сумлінно позначає їх як невбудовані, хоча veraPDF пропускає їх, бо їх ніколи фактично не використовують для відтворення. Перевірки на рівні операторів content stream (6.2.10) взагалі не входять у сферу, бо для них потрібен повний розбір вмісту, а не байтове сканування. Сприймайте validator як швидкий, незалежний від зовнішніх залежностей перший бар'єр, який ловить порушення, що їх не виправити ін'єкцією маркерів, а повну сертифікацію залишайте для фінального підтвердження

Це половина історії, що стосується перевірки. Додаткова сторона запису, де SaveAsPdfAвставляє XMP, OutputIntent і sRGB ICC profile та чесно понижує запит Level A, якщо в ньому немає позначеної структури, спирається на ту саму байтову механіку. Обидві частини постачаються в PDFium Component for Delphi, один пакет VCL поверх чисто Pascal-реалізації PDF/A без зовнішнього runtime для встановлення