PDF-файл, поступающий на производственную границу — в очередь печати, архив, портал загрузки клиентов — должен пройти аудит до того, как что-либо его отрендерит. Файл может содержать действие Launch (запуск), настроенное на запуск внешней программы, изображения, слишком грубые, чтобы пережить печать, словарь шифрования, запрещающий именно то задание печати, для которого он был отправлен, или метку PDF/A, которой он не соответствует. Проверка документа на соответствие подобным правилам до того, как он попадет в рабочий процесс, называется предпечатной проверкой (preflight), и C API PDFium дает Delphi всё необходимое для реализации этих проверок напрямую, без рендеринга единой страницы
Эта статья создает сами проверки: четыре класса аудита, каждый из которых представляет собой небольшую процедуру, добавляющую результаты в общий список. Интерактивные элементы, метрики ресурсов, состояние безопасности и маркеры стандартов — все получают рабочий код, включая арифметику. Если вам нужна инфраструктура вокруг проверок — циклы по пакетам папок, файлы отчетов JSON и HTML, изоляция каждого файла — то PDFium Component поставляется с готовым движком preflight, и статья о пакетном CLI для preflight охватывает эту систему. Оба они намеренно используют общий словарь кодов возврата, поэтому написанный здесь аудитор встраивается прямо под этот пакетный драйвер
Запись о результатах и контракт кодов возврата
Каждая проверка пишет в один плоский тип записи (record), потому что в противном случае, если бы каждая проверка печатала свой собственный текст, их нельзя было бы подсчитать, отфильтровать или применить к ним пороговые значения постфактум. Четырех полей достаточно
uses
System.SysUtils, System.Math, System.IOUtils,
System.Generics.Collections, pdfium_lib;
type
TFindingSeverity = (fsInfo, fsWarning, fsError);
TPreflightFinding = record
Severity: TFindingSeverity;
Code: string; // стабильный машинный ключ, например, 'ACT-LAUNCH'
Page: Integer; // 1-индексация; 0 означает уровень документа
Message: string; // для людей; можно переформулировать между релизами
end;
TFindings = TList<TPreflightFinding>;
procedure Add(Findings: TFindings; Severity: TFindingSeverity;
const Code: string; Page: Integer; const Msg: string);
var
F: TPreflightFinding;
begin
F.Severity := Severity;
F.Code := Code;
F.Page := Page;
F.Message := Msg;
Findings.Add(F);
end;
Последующие инструменты опираются на ключ Code, а не на текст Message, который можно свободно изменять. Код возврата процесса (exit code) следует тому же трехзначному контракту, что и в статье о пакетной обработке: 0 означает, что в файле не найдено проблем, 1 означает, что проблемы найдены, а 2 означает, что сам аудит не смог выполниться, потому что файл не удалось распарсить или он требует пароль. Отделение кода 2 имеет значение. Папка с поврежденными сканами — это проблема сломанного сканера выше по потоку, а не внезапный крах соответствия стандартам, и объединение этих двух факторов заставит кого-то искать не ту проблему
Интерактивные элементы: скрипты, цели запуска, внешние ссылки
PDFium классифицирует каждое найденное им действие с помощью целочисленного типа, и константы из fpdf_doc.h стоит зафиксировать точно, потому что неверно скопированные значения сделают сканер слепым. Реальное перечисление — это PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 и PDFACTION_EMBEDDEDGOTO = 5. Обратите внимание на то, чего здесь нет: нет элемента JavaScript. Скрипты на уровне документа не являются действиями ссылок и никогда не отображаются через FPDFAction_GetType; они перечисляются отдельным семейством вызовов. Аудитор, который проверяет типы действий на соответствие воображаемой константе JavaScript, скомпилируется, запустится и никогда ничего не найдет
const
PDFACTION_GOTO = 1; // переход внутри документа: безобидно
PDFACTION_REMOTEGOTO = 2; // переход в другой локальный файл
PDFACTION_URI = 3; // открывает внешний URL
PDFACTION_LAUNCH = 4; // запускает внешнюю программу
PDFACTION_EMBEDDEDGOTO = 5; // переход во встроенный файл
function ActionTarget(Doc: FPDF_DOCUMENT; Action: FPDF_ACTION;
AType: ULONG): string;
var
Buf: array[0..2047] of AnsiChar;
begin
FillChar(Buf, SizeOf(Buf), 0);
if AType = PDFACTION_URI then
FPDFAction_GetURIPath(Doc, Action, @Buf, SizeOf(Buf))
else
FPDFAction_GetFilePath(Action, @Buf, SizeOf(Buf));
Result := string(UTF8String(PAnsiChar(@Buf)));
end;
procedure AuditPageActions(Doc: FPDF_DOCUMENT; Page: FPDF_PAGE;
PageNo: Integer; Findings: TFindings);
var
StartPos: Integer;
Link: FPDF_LINK;
Action: FPDF_ACTION;
AType: ULONG;
begin
StartPos := 0;
while FPDFLink_Enumerate(Page, @StartPos, @Link) <> 0 do
begin
Action := FPDFLink_GetAction(Link);
if Action = nil then
Continue; // ссылка только с пунктом назначения, отмечать нечего
AType := FPDFAction_GetType(Action);
case AType of
PDFACTION_LAUNCH:
Add(Findings, fsError, 'ACT-LAUNCH', PageNo,
'Launch action targets "' + ActionTarget(Doc, Action, AType) + '"');
PDFACTION_URI:
Add(Findings, fsWarning, 'ACT-URI', PageNo,
'link opens ' + ActionTarget(Doc, Action, AType));
PDFACTION_REMOTEGOTO, PDFACTION_EMBEDDEDGOTO:
Add(Findings, fsWarning, 'ACT-XFILE', PageNo,
'cross-file destination "' + ActionTarget(Doc, Action, AType) + '"');
end; // PDFACTION_GOTO остается тихим по замыслу
end;
end;
procedure AuditDocumentBehaviors(Doc: FPDF_DOCUMENT; Findings: TFindings);
var
N: Integer;
begin
N := FPDFDoc_GetJavaScriptActionCount(Doc);
if N > 0 then
Add(Findings, fsError, 'JS-DOC', 0,
Format('%d document-level JavaScript action(s) run on open', [N]));
N := FPDFDoc_GetAttachmentCount(Doc);
if N > 0 then
Add(Findings, fsWarning, 'ATT-EMB', 0,
Format('%d embedded file attachment(s)', [N]));
end;
Разделение по степени серьезности кодирует политику (policy). Действие Launch (запуск) — это ошибка, потому что запуск произвольной программы — самая опасная вещь, которую может сделать клик в PDF, и ни одному счету-фактуре это не нужно. Внешние URI — это предупреждения: они часто встречаются в легитимных документах, но рецензент должен видеть пункт назначения, не кликая, поскольку видимый текст ссылки и реальный пункт назначения не обязательно совпадают. Переходы GoTo внутри документа — это структура, а не поведение, и они вообще не попадают в отчет: preflight, который бьет тревогу на каждой записи оглавления, приучит людей игнорировать себя. Для чтения тел скриптов, стоящих за количеством JavaScript, а также для уровней MDP подписей и обнаружения XFA, статья об аудите рисков безопасности проходит по той же поверхности с использованием объектной обертки компонента
Метрики ресурсов: эффективный DPI изображения
Изображение внутри PDF не имеет собственного DPI. У него есть пиксели, а страница помещает эти пиксели в прямоугольник, измеряемый в пунктах, где 72 пункта составляют дюйм. Разрешение существует только как отношение этих двух величин, именно поэтому одна и та же фотография 600 на 400 пикселей получается невероятно четкой в виде миниатюры и размытым месивом в виде полностраничного главного изображения. Следовательно, аудиту нужны оба числа для каждого изображения: размеры в пикселях исходного изображения из его метаданных, и размещенный прямоугольник из границ объекта
procedure AuditPageImages(Page: FPDF_PAGE; PageNo: Integer;
Findings: TFindings);
var
I, ObjCount: Integer;
Obj: FPDF_PAGEOBJECT;
Meta: FPDF_IMAGEOBJ_METADATA;
L, B, R, T: Single;
WidthPt, HeightPt, DpiX, DpiY, EffDpi: Double;
begin
ObjCount := FPDFPage_CountObjects(Page);
for I := 0 to ObjCount - 1 do
begin
Obj := FPDFPage_GetObject(Page, I);
if FPDFPageObj_GetType(Obj) <> FPDF_PAGEOBJ_IMAGE then
Continue;
if FPDFImageObj_GetImageMetadata(Obj, Page, @Meta) = 0 then
Continue;
if FPDFPageObj_GetBounds(Obj, @L, @B, @R, @T) = 0 then
Continue;
WidthPt := R - L; // размещенный размер на странице, в пунктах
HeightPt := T - B;
if (WidthPt <= 0) or (HeightPt <= 0) or
(Meta.Width = 0) or (Meta.Height = 0) then
Continue;
// 72 пункта = 1 дюйм, поэтому размещенные дюймы = пункты / 72, и
// эффективный DPI = исходные пиксели / размещенные дюймы.
DpiX := Meta.Width / (WidthPt / 72.0);
DpiY := Meta.Height / (HeightPt / 72.0);
EffDpi := Min(DpiX, DpiY); // наихудшая ось определяет качество печати
if EffDpi < 150.0 then
Add(Findings, fsWarning, 'IMG-LOWRES', PageNo,
Format('image %dx%d px placed at %.1fx%.1f pt = %.0f DPI effective',
[Meta.Width, Meta.Height, WidthPt, HeightPt, EffDpi]))
else if EffDpi > 600.0 then
Add(Findings, fsInfo, 'IMG-BLOAT', PageNo,
Format('image is %.0f DPI at placed size; resampling would ' +
'shrink the file with no visible loss', [EffDpi]));
end;
end;
Пороги — это политика, а не физика: 150 DPI — это пол, ниже которого офисная печать заметно пикселизируется, 300 — обычная цель для коммерческой печати, а все, что выше 600, не приносит видимого улучшения качества, при этом раздувая размер файла, поэтому оно сообщается как информационное раздувание (bloat), а не дефект. Один честный нюанс: FPDFPageObj_GetBounds возвращает прямоугольник, выровненный по осям, поэтому для изображения, размещенного с поворотом, вычисленная цифра недооценивает истинную плотность. Структура FPDF_IMAGEOBJ_METADATA также содержит поля horizontal_dpi и vertical_dpi, которые PDFium вычисляет из полной матрицы трансформации, и сравнение двух результатов — это дешевый способ обнаружить повернутые размещения. Та же самая арифметика перевода пунктов в пиксели управляет рендерингом в обратном направлении, что описано в статье об экспорте в JPEG
Состояние безопасности: шифрование и биты разрешений
Шифрование PDF определяет два пароля с разными задачами. Пароль пользователя (user password) блокирует расшифровку: без него файл вообще не откроется, и FPDF_LoadDocument вернет nil, а FPDF_GetLastError сообщит об ошибке FPDF_ERR_PASSWORD. Пароль владельца (owner password) блокирует разрешения: файл, защищенный только паролем владельца, открывается без каких-либо учетных данных, но несет биты ограничений, которые соответствующий стандартам читатель (conforming reader) должен соблюдать. Следовательно, сама попытка загрузки является первой проверкой безопасности, и это различие определяет код возврата — файл с паролем пользователя не поддается аудиту (код 2), в то время как файл с паролем владельца проходит нормальный аудит и просто накапливает результаты
const
FPDF_ERR_PASSWORD = 4;
function AuditSecurity(const FileName: string;
Findings: TFindings): FPDF_DOCUMENT;
var
Perms: ULONG;
Revision: Integer;
begin
Result := FPDF_LoadDocument(PAnsiChar(AnsiString(FileName)), nil);
if Result = nil then
begin
if FPDF_GetLastError() = FPDF_ERR_PASSWORD then
Add(Findings, fsError, 'SEC-USERPW', 0,
'user (open) password required; audit cannot proceed')
else
Add(Findings, fsError, 'DOC-BROKEN', 0, 'file failed to parse');
Exit;
end;
Revision := FPDF_GetSecurityHandlerRevision(Result);
if Revision >= 0 then // -1 означает, что файл не зашифрован
begin
// Открыт с пустым паролем, но зашифрован: только пароль владельца.
// Любой может прочитать его, но биты разрешений ограничивают то, что
// соответствующий стандартам читатель позволяет им делать. Незашифрованные файлы сообщают, что
// установлены все биты, поэтому проверка ревизии идет первой.
Perms := FPDF_GetDocPermissions(Result);
Add(Findings, fsInfo, 'SEC-ENC', 0,
Format('encrypted, security handler revision %d', [Revision]));
if (Perms and 4) = 0 then // бит 3: печать
Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
'printing is not permitted');
if (Perms and 16) = 0 then // бит 5: копирование / извлечение содержимого
Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
'content extraction is not permitted');
if (Perms and 2048) = 0 then // бит 12: печать в высоком разрешении
Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
'only low-resolution printing is permitted');
end;
end;
Маски взяты из таблицы 22 ISO 32000-1, где биты нумеруются с 1: бит 3 значения /P — это маска 4, бит 5 — это 16, бит 12 — это 2048. То, имеет ли значение конкретная находка, является решением маршрутизации. Бюро печати должно отклонить файл с SEC-NOPRINT на этапе приема, где отправитель получит четкое сообщение, а не на RIP-процессоре за три часа до дедлайна. Архив должен рассматривать саму по себе SEC-ENC как блокирующий фактор, поскольку шифрование и долгосрочное хранение несовместимы — момент, который формально обозначит проверка стандартов чуть ниже
Маркеры стандартов: чтение декларации PDF/A
Файл объявляет о соответствии PDF/A в своем пакете метаданных XMP, через свойство pdfaid:part (от 1 до 4) и pdfaid:conformance (буква уровня, например, b для визуальной достоверности или a для полного структурного тегирования). C API PDFium не предлагает доступа к XMP; FPDF_GetMetaText читает только словарь Info, в котором эта идентификация не хранится. Лазейкой здесь выступает правило в самом стандарте: ISO 19005 требует, чтобы поток метаданных XMP хранился в несжатом виде, именно для того, чтобы инструменты могли найти его без полного парсера PDF. Таким образом, сканирование сырых байтов является легитимным детектором деклараций — а файл, чья декларация прячется внутри сжатого потока, уже нарушил стандарт, на который он претендует
function PdfAClaim(const FileName: string): string;
var
Bytes: TBytes;
S: RawByteString;
P, Limit: Integer;
begin
Result := ''; // пусто = декларация PDF/A отсутствует
Bytes := TFile.ReadAllBytes(FileName);
if Length(Bytes) = 0 then
Exit;
SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
P := Pos('pdfaid:part', S); // Схема идентификации XMP
if P = 0 then
Exit;
// Обрабатывает как <pdfaid:part>2</pdfaid:part>, так и pdfaid:part="2":
// берем первую цифру после имени свойства.
Limit := Min(P + 32, Length(S));
Inc(P, Length('pdfaid:part'));
while (P <= Limit) and not (S[P] in ['1'..'4']) do
Inc(P);
if P <= Limit then
Result := 'PDF/A-' + Char(S[P]);
end;
Находка, которую это производит, намеренно является информационной, потому что декларация — это заявление (declaration), а не свойство самого файла. Запись XMP — это одна строка XML, которую может написать любой производитель ПО, включая сломанного; соответствие — это фактическое выполнение файлом сотен правил, касающихся встроенных шрифтов, аппаратно-независимого цвета и запрещенных функций. Обнаружение декларации просто сообщает вам, какие файлы следует направить на реальную валидацию, и не более того. Встроенный движок preflight компонента выполняет эту валидацию по профилям PDF/A, PDF/UA и PDF/X, а статья о пакетном CLI показывает, как встроить ее в конвейер с отчетами, которые аудитор может открыть позже
Запуск на проблемном файле
Драйвер связывает проверки воедино: сначала безопасность, потому что она решает, запустится ли аудит вообще, затем поведение на уровне документа и декларация стандартов, затем цикл по страницам для действий и изображений
function AuditFile(const FileName: string; Findings: TFindings): Integer;
var
Doc: FPDF_DOCUMENT;
Page: FPDF_PAGE;
I: Integer;
Claim: string;
begin
Doc := AuditSecurity(FileName, Findings);
if Doc = nil then
Exit(2); // сбой аудита, а не вердикт
try
AuditDocumentBehaviors(Doc, Findings);
Claim := PdfAClaim(FileName);
if Claim <> '' then
Add(Findings, fsInfo, 'STD-PDFA', 0,
Claim + ' conformance claimed (declaration only, not validated)');
for I := 0 to FPDF_GetPageCount(Doc) - 1 do
begin
Page := FPDF_LoadPage(Doc, I);
if Page = nil then
begin
Add(Findings, fsError, 'PAGE-BROKEN', I + 1, 'page failed to parse');
Continue;
end;
try
AuditPageActions(Doc, Page, I + 1, Findings);
AuditPageImages(Page, I + 1, Findings);
finally
FPDF_ClosePage(Page);
end;
end;
finally
FPDF_CloseDocument(Doc);
end;
if Findings.Count > 0 then
Result := 1
else
Result := 0;
end;
При проверке брошюры, вернувшейся из внешнего агентства, вывод выглядит так:
> preflight_audit brochure_final.pdf
brochure_final.pdf: 5 finding(s)
[ERROR] ACT-LAUNCH page 3 Launch action targets "..\tools\setup.exe"
[ERROR] JS-DOC doc 2 document-level JavaScript action(s) run on open
[WARNING] IMG-LOWRES page 7 image 412x287 px placed at 396.0x275.8 pt = 75 DPI effective
[WARNING] SEC-NOPRINT doc printing is not permitted
[INFO] STD-PDFA doc PDF/A-2 conformance claimed (declaration only, not validated)
exit code 1
Каждая строка сама по себе является поводом для действий, но настоящим вердиктом является их комбинация. Этот файл претендует на PDF/A-2, при этом неся в себе словарь шифрования и активный JavaScript, а PDF/A категорически запрещает и то, и другое — поэтому декларация доказуемо ложна до запуска любого глубокого валидатора. Именно такое противоречие выводит на поверхность плоский список находок, и скрывает логическое прошел/не прошел (pass/fail)
О чем этот аудит вам не расскажет
Честность в отношении охвата — это то, что сохраняет доверие к инструменту preflight. Всё, что описано выше, читает то, что файл заявляет о себе: PDFium парсит структуру, а этот аудит проводит её инвентаризацию. Он не выполняет валидацию PDF/A — никаких проверок охвата глифов по встроенным шрифтам, никакого анализа цветового пространства на соответствие намерениям вывода, никаких правил уровня пунктов, которые отделяют декларацию от соответствия; для этого вам нужен специализированный валидатор, такой как движок preflight компонента или veraPDF. Биты разрешений — это декларации, которые соблюдают соответствующие стандартам читатели, а не криптографические стены, поэтому SEC-NOPRINT описывает намерение, а не принуждение. Сканирование действий охватывает аннотации ссылок и скрипты уровня документа; скрипты, зарытые в словарях событий полей форм, нуждаются в API форм поверх этого. А проверка подписи, если вы расширите ею аудит, сообщает о заявленном намерении, а не о проверенной криптографии — валидация цепочки сертификатов — это отдельная работа. Предпечатный аудит — это первичное собеседование, а не суд: его работа — сделать решение о маршрутизации информированным, быстрым и повторяемым
Примечание: API объектов документа, страницы, аннотации и изображения, используемые на протяжении всего этого аудита, вместе с высокоуровневой оберткой Delphi и полным движком предпечатной проверки и валидации стандартов, поставляются вместе с компонентом PDFium