Техническа статия

Автоматизиран PDF preflight и одит на риска с PDFium

PDF, който пристига на производствена граница — опашка за печат, архив, портал за качване от клиенти — трябва да бъде одитиран, преди каквото и да било да го рендира. Файлът може да носи действие Launch, свързано да стартира външна програма, изображения, които са твърде груби, за да преживеят печат, речник за криптиране, който забранява самата задача за печат, за която е изпратен, или PDF/A етикет, на който не отговаря. Инспектирането на документ спрямо такива правила, преди да влезе в работен процес (workflow), се нарича preflighting, и PDFium C API дава на Delphi всичко необходимо за имплементиране на проверките директно, без рендиране на нито една страница

Тази статия изгражда самите проверки: четири класа за одит, всеки малка рутина, която добавя констатации към споделен списък с резултати. Интерактивните елементи, метриките на ресурсите, състоянието на сигурност и маркерите за стандарти всички получават работещ код, включително аритметиката. Ако това, от което се нуждаете, е машината около проверките — цикли за партиди от папки (batch folder loops), JSON и HTML файлове с отчети, изолация на всеки файл — компонентът PDFium доставя готов preflight енджин, а статията за CLI за партиден preflight (batch preflight CLI) покрива тази водопроводна система (plumbing). Двете умишлено споделят един речник на кодовете за изход (exit-code vocabulary), така че одитор, написан тук, се вписва директно под този драйвер за партиди

Записът на констатация и договорът за код за изход (exit-code contract)

Всяка проверка записва в един плосък тип запис, защото алтернативата, всяка проверка да отпечатва своя собствена проза, не може да бъде броена, филтрирана или подлагана на прагове (thresholded) след това. Четири полета са достатъчни

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;

Инструментите надолу по веригата (Downstream tooling) се ключови (keys) по Code, никога по текста Message, който е свободен да се променя. Кодът за изход на процеса следва същия договор с три стойности като статията за партиди: 0 означава, че файлът не е произвел констатации, 1 означава, че съществуват констатации, и 2 означава, че самият одит не е могъл да се изпълни, защото файлът не е успял да се парсне или изисква парола. Поддържането на код 2 отделно има значение. Папка с повредени сканирания е счупен скенер нагоре по веригата (upstream), а не внезапен срив в съответствието, и сгъването (folding) на двете заедно изпраща някой да преследва грешния проблем

Интерактивни елементи: скриптове, launch цели, външни връзки

PDFium класифицира всяко действие, което намери, чрез целочислен тип, и константите от fpdf_doc.h си струва да бъдат заковани точно, защото неправилно копирани стойности правят скенера тихо сляп. Истинското изброяване е PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 и PDFACTION_EMBEDDEDGOTO = 5. Забележете какво липсва: няма JavaScript член. Скриптовете на ниво документ не са действия на връзки (link actions) и никога не се появяват чрез 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;

Разделянето на тежестта (severity split) кодира политика. Действие Launch е грешка, защото стартирането на произволна програма е най-опасното нещо, което кликване в PDF може да направи, и никоя фактура не се нуждае от него. Външните URI са предупреждения: често срещани в легитимни документи, но рецензентът трябва да види целта, без да кликва, тъй като видимият текст на връзката и действителната дестинация не е необходимо да съвпадат. Вътрешните за документа GoTo скокове са структура, а не поведение, и остават напълно извън отчета — preflight, който вика "вълк" за всеки запис в съдържанието, обучава хората да го игнорират. За четене на телата на скриптовете зад броя на JavaScript и за нивата на подпис MDP и откриване на XFA, статията за одитиране на рискове за сигурността обхожда същата повърхност чрез обектната обвивка (object wrapper) на компонента

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

Едно изображение вътре в PDF няма свой собствен DPI. То има пиксели, а страницата поставя тези пиксели в правоъгълник, измерен в пунктове (points), където 72 пункта правят инч. Разделителната способност (Resolution) съществува само като съотношение между двете, поради което една и съща снимка 600 на 400 е остра като бръснач като миниатюра (thumbnail) и размазана бъркотия като hero на цяла страница. Следователно одитът се нуждае и от двете числа за всяко изображение: изходни размери в пиксели от метаданните на изображението и поставения правоъгълник от границите на обекта (object bounds)

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 не купува видимо качество, докато надува размера на файла, поради което се отчита като информационно раздуване (informational bloat), а не като дефект. Едно честно предупреждение (honest caveat): FPDFPageObj_GetBounds връща кутия, подравнена по осите (axis-aligned box), така че за изображение, поставено с ротация, изчислената цифра подценява истинската плътност. Структурата FPDF_IMAGEOBJ_METADATA също носи полета horizontal_dpi и vertical_dpi, които PDFium извлича от пълната матрица на трансформация, и сравняването на двата резултата е евтин начин да се забележат завъртяни разположения (rotated placements). Същата аритметика точки-към-пиксели задвижва рендирането в обратната посока, обхванато в статията за експорт в JPEG

Състояние на сигурност: криптиране и битове за разрешения

PDF криптирането дефинира две пароли с различни задачи. Потребителската парола (user password) управлява декриптирането: без нея файлът изобщо няма да се отвори, и FPDF_LoadDocument връща nil, като FPDF_GetLastError отчита FPDF_ERR_PASSWORD. Паролата на собственика (owner password) управлява разрешенията: файл, защитен само с парола на собственика, се отваря без идентификационни данни, но носи битове за ограничения (restriction bits), които съобразяващият се четец (conforming reader) трябва да спазва. Самият опит за зареждане следователно е първата сонда за сигурност и разграничението решава кода за изход — файл с потребителска парола не подлежи на одит (unauditable) (код 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. Дали дадена констатация има значение е решение за маршрутизиране. Едно печатно бюро (print bureau) трябва да отхвърли файл SEC-NOPRINT при приемането, където подателят получава ясно съобщение, а не в RIP три часа преди краен срок. Един архив трябва да третира самия SEC-ENC като блокер, тъй като криптирането и дългосрочното запазване не се смесват — точка, която проверката на стандартите е на път да направи формално

Маркери за стандарти: четене на претенция за PDF/A

Един файл декларира съответствие с PDF/A в своя XMP пакет с метаданни, чрез свойството pdfaid:part (1 до 4) и pdfaid:conformance (буквата на нивото, като b за визуална вярност или a за пълно структурно тагване). PDFium C API не предлага XMP аксесор; FPDF_GetMetaText чете само речника Info, което не е мястото, където живее идентификацията. Изходът за бягство (escape hatch) е правило в самия стандарт: ISO 19005 изисква потокът с XMP метаданни да се съхранява некомпресиран, точно за да могат инструментите да го намерят без пълен PDF парсер. Следователно сканирането на сурови байтове (raw byte scan) е легитимен детектор на претенции — и файл, чиято претенция се крие вътре в компресиран поток, вече е нарушил стандарта, който претендира

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;

Констатацията, която това произвежда, е умишлено информационна, защото претенцията (claim) е декларация, а не свойство на файла. Вписването в XMP е един ред XML, който всеки продуцент може да напише, включително и счупен такъв; съответствието (conformance) е файлът действително да удовлетворява стотици правила относно вградени шрифтове, независим от устройството цвят и забранени функции. Откриването на претенцията ви казва само кои файлове да маршрутизирате към истинска валидация, и нищо повече. Вграденият preflight енджин на компонента извършва тази валидация през профилите PDF/A, PDF/UA и PDF/X, а статията за batch CLI показва как да го свържете (wire it) в конвейер (pipeline) с отчети, които един одитор може да отвори по-късно

Изпълнение срещу проблемен файл

Драйверът нанизва проверките заедно: първо сигурността, защото тя решава дали одитът изобщо се изпълнява, след това поведенията на ниво документ и претенцията за стандарти, след това цикъл по страниците за действия и изображения

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

Всеки ред е actionable сам по себе си (подлежи на действие), но комбинацията е истинската присъда. Този файл претендира PDF/A-2, докато носи речник за криптиране и жив JavaScript, а PDF/A забранява и двете категорично — така че претенцията е доказуемо невярна, преди да се стартира какъвто и да е дълбок валидатор. Това е видът противоречие, което един плосък списък с констатации извежда на повърхността, а булевото pass/fail крие

Какво не може да ви каже този одит

Честността относно обхвата е това, което държи един preflight инструмент доверен. Всичко по-горе чете това, което файлът декларира за себе си: PDFium парсва структурата, а този одит я инвентаризира. Той не извършва PDF/A валидация — няма проверки за покритие на глифове (glyph-coverage) срещу вградени шрифтове, няма анализ на цветовото пространство (color space analysis) срещу output intents, никое от правилата на ниво клауза, които разделят претенцията от съответствието; за това се нуждаете от специализиран валидатор като preflight енджина на компонента или veraPDF. Битовете за разрешения са декларации, които съобразяващите се четци спазват, а не криптографски стени, така че SEC-NOPRINT описва намерение, а не налагане (enforcement). Сканирането на действия покрива анотации за връзки и скриптове на ниво документ; скриптовете, заровени в речници за събития (event dictionaries) на полета на формуляри, се нуждаят от API за формуляри отгоре. А проверката на подписа, ако разширите одита с такава, отчита декларирано намерение, а не проверена криптография — валидирането на сертификатната верига е отделна работа. Един preflight одит е интервюто при приемане, а не съдебният процес: неговата работа е да направи решението за маршрутизиране информирано, бързо и повторяемо

Забележка: API-тата за обекти на документи, страници, анотации и изображения, използвани в този одит, заедно с Delphi обвивка (wrapper) на високо ниво и пълен preflight енджин за валидиране на стандарти, се доставят с компонента PDFium