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

Автоматизована переддрукарська перевірка та аудит ризиків PDF за допомогою PDFium

PDF-файл, який надходить на межу виробництва — у чергу друку, архів, на портал завантаження клієнтів — повинен пройти аудит перед тим, як будь-що його відобразить. Файл може містити дію Launch, налаштовану на запуск зовнішньої програми, занадто грубі зображення, які не витримають друку, словник шифрування, який забороняє саме те завдання друку, для якого його було надіслано, або мітку PDF/A, якій він не відповідає. Перевірка документа за такими правилами перед його потраплянням у робочий процес називається переддрукарською перевіркою (preflighting), і PDFium C API надає Delphi все необхідне для реалізації перевірок безпосередньо, не візуалізуючи жодної сторінки

Ця стаття розробляє самі перевірки: чотири класи аудиту, кожен з яких є невеликою процедурою, що додає знайдені проблеми до спільного списку результатів. Інтерактивні елементи, метрики ресурсів, стан безпеки та маркери стандартів отримують робочий код, включаючи арифметику. Якщо вам потрібен механізм навколо перевірок — пакетні цикли папок, файли звітів JSON та HTML, ізоляція кожного файлу — компонент PDFium постачається з готовим рушієм переддрукарської перевірки, і стаття про пакетний CLI для переддрукарської перевірки охоплює цю складову. Вони обидва навмисно використовують спільний словник кодів виходу, тому написаний тут аудитор без проблем підключається прямо під цей пакетний драйвер

Запис про знайдені проблеми та контракт коду виходу

Кожна перевірка записує дані в один плоский тип запису, оскільки альтернатива, коли кожна перевірка виводить власну прозу, не дозволяє потім підраховувати, фільтрувати або встановлювати порогові значення. Чотирьох полів достатньо

uses
  System.SysUtils, System.Math, System.IOUtils,
  System.Generics.Collections, pdfium_lib;

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // stable machine key, e.g. 'ACT-LAUNCH'
    Page: Integer;      // 1-based; 0 means document level
    Message: string;    // for humans; free to reword between releases
  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, який може вільно змінюватися. Код виходу процесу дотримується того ж контракту з трьома значеннями, що і в статті про пакетну обробку: 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;   // in-document jump: harmless
  PDFACTION_REMOTEGOTO   = 2;   // jump into another local file
  PDFACTION_URI          = 3;   // opens an external URL
  PDFACTION_LAUNCH       = 4;   // starts an external program
  PDFACTION_EMBEDDEDGOTO = 5;   // jump into an embedded file

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;                 // destination-only link, nothing to flag
    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 stays silent by design
  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;

Розподіл серйозності кодує політику. Дія Launch — це помилка, тому що запуск довільної програми — це найнебезпечніше, що може зробити клік у PDF, і жодному рахунку-фактурі це не потрібно. Зовнішні URI — це попередження: вони поширені в легітимних документах, але рецензент повинен бачити ціль без натискання, оскільки видимий текст посилання та фактичне місце призначення можуть не збігатися. Внутрішньодокументні переходи GoTo — це структура, а не поведінка, і вони повністю не потрапляють у звіт — переддрукарська перевірка, яка піднімає хибну тривогу на кожному записі змісту, привчає людей ігнорувати її. Що стосується читання тіл скриптів за підрахунком JavaScript, а також рівнів підпису MDP та виявлення XFA, стаття про аудит ризиків безпеки проходить ту саму поверхню через обгортку об'єктів компонента

Метрики ресурсів: ефективний DPI зображення

Зображення всередині PDF не має власного DPI. Воно має пікселі, і сторінка розміщує ці пікселі в прямокутник, що вимірюється в пунктах (points), де 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;              // placed size on the page, in points
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 points = 1 inch, so placed inches = points / 72, and
    // effective DPI = source pixels / placed inches.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // the worse axis decides print quality

    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, не дає видимої якості, але роздмухує розмір файлу, тому це класифікується як інформаційне роздування, а не як дефект. Одне чесне застереження: FPDFPageObj_GetBounds повертає прямокутник, вирівняний по осях, тому для зображення, розміщеного з обертанням, обчислене значення недооцінює справжню щільність. Структура FPDF_IMAGEOBJ_METADATA також містить поля horizontal_dpi та vertical_dpi, які PDFium виводить з повної матриці перетворення, і порівняння цих двох результатів — це простий спосіб виявити розміщення з обертанням. Та сама арифметика перетворення пунктів у пікселі керує рендерингом у зворотному напрямку, що розглядається в статті про експорт JPEG

Стан безпеки: шифрування та біти дозволів

Шифрування PDF визначає два паролі з різними завданнями. Пароль користувача контролює розшифрування: без нього файл взагалі не відкриється, і FPDF_LoadDocument поверне nil, а FPDF_GetLastError повідомить про FPDF_ERR_PASSWORD. Пароль власника контролює дозволи: файл, захищений лише паролем власника, відкривається без облікових даних, але містить біти обмежень, яких відповідна програма для читання повинна дотримуватися. Тому сама спроба завантаження є першою перевіркою безпеки, і ця відмінність визначає код виходу — файл із паролем користувача не підлягає аудиту (код 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 means the file is not encrypted
  begin
    // Opened with an empty password yet encrypted: owner-password-only.
    // Anyone may read it, but the permission bits restrict what a
    // conforming reader lets them do. Unencrypted files report all
    // bits set, which is why the revision gate comes first.
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('encrypted, security handler revision %d', [Revision]));
    if (Perms and 4) = 0 then      // bit 3: print
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: copy / extract content
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: high-resolution print
      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 := '';                     // empty = no PDF/A claim present
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // XMP identification schema
  if P = 0 then
    Exit;
  // Handles both <pdfaid:part>2</pdfaid:part> and pdfaid:part="2":
  // take the first digit after the property name.
  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;

Знайдені результати є навмисно інформаційними, оскільки заява — це декларація, а не властивість файлу. Запис XMP — це один рядок XML, який може написати будь-який виробник, включно зі зламаним; відповідність полягає в тому, що файл фактично задовольняє сотні правил щодо вбудованих шрифтів, апаратно-незалежного кольору та заборонених функцій. Виявлення заяви лише вказує вам, які файли слід відправити на реальну валідацію, і не більше того. Вбудований механізм переддрукарської перевірки компонента виконує цю валідацію для профілів 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);                        // audit failure, not a verdict
  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 категорично забороняє і те, і інше — отже, ця заява є доказово хибною ще до запуску будь-якого глибокого валідатора. Саме таке протиріччя виявляє плоский список знахідок і приховує логічний результат «пройдено/не пройдено»

Про що цей аудит не може вам сказати

Чесність щодо масштабів — це те, що зберігає довіру до інструменту переддрукарської перевірки. Все вищезазначене зчитує те, що файл заявляє про себе: PDFium розбирає структуру, а цей аудит проводить її інвентаризацію. Він не виконує валідацію PDF/A — жодних перевірок покриття гліфів для вбудованих шрифтів, жодного аналізу колірного простору для намірів виводу, жодних правил на рівні пунктів стандарту, які відокремлюють заяву від відповідності; для цього вам потрібен спеціальний валідатор, такий як механізм переддрукарської перевірки компонента або veraPDF. Біти дозволів — це декларації, яких дотримуються відповідні програми для читання, а не криптографічні стіни, тому SEC-NOPRINT описує намір, а не примус. Сканування дій охоплює анотації посилань та скрипти на рівні документа; для скриптів, прихованих у словниках подій полів форми, додатково потрібні API форм. І перевірка підпису, якщо ви доповните нею аудит, повідомляє про заявлений намір, а не про перевірену криптографію — перевірка ланцюжка сертифікатів є окремим завданням. Переддрукарський аудит — це співбесіда під час прийому, а не судове засідання: його завдання — зробити рішення про маршрутизацію обґрунтованим, швидким та повторюваним

Примітка: API об'єктів документів, сторінок, анотацій та зображень, що використовуються в цьому аудиті, разом із високорівневою обгорткою Delphi та повним механізмом переддрукарської перевірки за стандартами, постачаються разом з PDFium Component