Plik PDF trafiający na granicę produkcyjną — do kolejki wydruku, archiwum, portalu przesyłania plików przez klienta — powinien zostać poddany audytowi, zanim cokolwiek go wyrenderuje. Plik może zawierać akcję Launch skonfigurowaną do uruchomienia zewnętrznego programu, obrazy o zbyt niskiej rozdzielczości, by nadawały się do druku, słownik szyfrowania, który zabrania dokładnie tego zadania drukowania, do którego został przesłany, lub etykietę PDF/A, której nie spełnia. Sprawdzanie dokumentu pod kątem takich reguł, zanim trafi on do cyklu pracy, nazywane jest preflightem (wstępną weryfikacją), a API C PDFium daje Delphi wszystko, co potrzebne, by bezpośrednio wdrożyć te kontrole, bez renderowania ani jednej strony
Ten artykuł buduje same kontrole: cztery klasy audytu, z których każda jest małą procedurą dodającą ustalenia do wspólnej listy wyników. Interaktywne elementy, metryki zasobów, stan bezpieczeństwa i znaczniki standardów zyskują działający kod, łącznie z arytmetyką. Jeśli potrzebujesz maszynerii wokół tych kontroli — pętli wsadowych po folderach, plików raportów JSON i HTML, izolacji dla każdego pliku — komponent PDFium (PDFium Component) jest dostarczany z gotowym silnikiem preflightu, a artykuł o wsadowym CLI do raportów preflight omawia tę infrastrukturę. Oba celowo współdzielą to samo słownictwo kodów wyjścia, więc audytor napisany w tym miejscu można bezpośrednio podpiąć pod ten sterownik wsadowy
Rekord ustaleń i umowa kodów wyjścia
Każda kontrola zapisuje dane do jednego płaskiego typu rekordu, ponieważ alternatywa – każda kontrola wypisująca własną prozę – uniemożliwia późniejsze zliczanie, filtrowanie lub progowanie. Wystarczą cztery pola
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;
Narzędzia w dalszej części łańcucha bazują na Code, nigdy na tekście Message, który może swobodnie ulegać zmianom. Kod wyjścia procesu postępuje zgodnie z tą samą trójwartościową umową, co w artykule o przetwarzaniu wsadowym: 0 oznacza, że plik nie wygenerował żadnych ustaleń, 1 oznacza obecność ustaleń, a 2 oznacza, że sam audyt nie mógł zostać uruchomiony, ponieważ plik nie dał się przetworzyć (sparować) lub wymaga hasła. Oddzielenie kodu 2 ma znaczenie. Folder uszkodzonych skanów to po prostu zepsuty skaner na wcześniejszym etapie, a nie nagłe załamanie zgodności, a łączenie obu przypadków w jeden posyła kogoś do szukania problemu nie tam, gdzie trzeba
Elementy interaktywne: skrypty, cele uruchamiania, linki zewnętrzne
PDFium klasyfikuje każdą znalezioną akcję według typu całkowitoliczbowego, a stałe z pliku fpdf_doc.h warto precyzyjnie przypiąć, ponieważ błędnie skopiowane wartości mogą sprawić, że skaner będzie bezgłośnie ślepy. Rzeczywiste wyliczenie to PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 i PDFACTION_EMBEDDEDGOTO = 5. Zauważ, czego brakuje: nie ma tam członka dla JavaScriptu. Skrypty na poziomie dokumentu nie są akcjami linków i nigdy nie pojawiają się przez FPDFAction_GetType; są one wyliczane przez odrębną rodzinę wywołań. Audytor testujący typy akcji względem wyobrażonej stałej JavaScript pomyślnie się kompiluje, uruchamia i nic nie znajduje, na zawsze
const
PDFACTION_GOTO = 1; // in-document jump: harmless
PDFACTION_REMOTEGOTO = 2; // jump into another local file
PDFACTION_URI = 3; // opens external URL
PDFACTION_LAUNCH = 4; // starts external program
PDFACTION_EMBEDDEDGOTO = 5; // jump into 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;
Podział powag koduje politykę. Akcja Launch to błąd, ponieważ uruchomienie dowolnego programu to najbardziej niebezpieczna rzecz, jaką może spowodować kliknięcie w PDF-ie, a żadna faktura tego nie potrzebuje. Zewnętrzne URI to ostrzeżenia: powszechne w autentycznych dokumentach, jednak recenzent powinien zobaczyć cel bez konieczności klikania, ponieważ widoczny tekst linku i rzeczywiste miejsce docelowe nie muszą być ze sobą zgodne. Wewnętrzne skoki w dokumencie (GoTo) to struktura, nie zachowanie, i całkowicie omijają raport – preflight, który bije na alarm przy każdym wpisie spisu treści, trenuje ludzi, by go ignorowali. Aby dowiedzieć się o odczytywaniu treści skryptów, które stoją za licznikiem JavaScriptu, oraz wykrywaniu poziomów MDP podpisów i XFA, artykuł dotyczący audytu ryzyka bezpieczeństwa przeprowadza przez ten sam obszar za pomocą obiektu opakowującego (object wrapper) z komponentu
Metryki zasobów: efektywne DPI obrazów
Obraz wewnątrz pliku PDF nie posiada własnego DPI. Składa się z pikseli, a strona umieszcza te piksele w prostokącie mierzonym w punktach, gdzie 72 punkty stanowią cal. Rozdzielczość istnieje jedynie jako stosunek tych dwóch wartości, dlatego to samo zdjęcie o rozmiarze 600 na 400 będzie ostre jak brzytwa jako miniaturka i będzie jedną wielką plamą na pełnej stronie. Z tego powodu audyt potrzebuje obu wartości dla każdego obrazu: wymiarów źródłowych w pikselach z metadanych obrazu oraz umiejscowionego prostokąta z granic obiektu
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;
Progi to kwestia polityki, nie fizyki: 150 DPI to minimum, poniżej którego drukowanie biurowe ulega wyraźnej pikselizacji, 300 to zwykły cel komercyjny, a wszystko powyżej 600 nie daje widocznej jakości, za to zwiększa rozmiar pliku, dlatego zgłaszane jest informacyjnie jako tzw. "bloat" (nadmierny rozmiar pliku), a nie jako defekt. Szczerze zaznaczając: FPDFPageObj_GetBounds zwraca wyrównane do osi ramy ograniczające (axis-aligned box), więc dla obrazu umieszczonego z rotacją wyliczona wartość zaniża rzeczywistą gęstość. Struktura FPDF_IMAGEOBJ_METADATA przenosi również pola horizontal_dpi i vertical_dpi, które PDFium wywodzi z pełnej macierzy przekształcenia, a porównywanie wyników z obu metod to tani sposób na wykrycie obróconych umiejscowień. Taka sama arytmetyka zamiany punktów na piksele napędza renderowanie w przeciwnym kierunku, co zostało opisane w artykule o eksporcie do JPEG
Stan bezpieczeństwa: szyfrowanie i bity uprawnień
Szyfrowanie PDF definiuje dwa hasła pełniące różne zadania. Hasło użytkownika ogranicza dostęp do odszyfrowywania: bez niego plik w ogóle się nie otworzy, a FPDF_LoadDocument zwraca nil, podczas gdy FPDF_GetLastError raportuje FPDF_ERR_PASSWORD. Hasło właściciela ogranicza dostęp do uprawnień: plik chroniony tylko hasłem właściciela otwiera się bez uwierzytelniania, ale przenosi bity ograniczeń, które zgodny z formatem czytnik musi honorować. Próba wczytywania sama w sobie stanowi więc pierwszą weryfikację bezpieczeństwa, a z tego zróżnicowania wynika kod wyjścia – plik z hasłem użytkownika jest niesprawdzalny (kod 2), podczas gdy audyt pliku z hasłem właściciela przebiega normalnie, polegając jedynie na gromadzeniu ustaleń
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;
Maski pochodzą z Tabeli 22 normy ISO 32000-1, w której bity są numerowane od 1: bit 3 z wartości /P to maska 4, bit 5 to 16, bit 12 to 2048. To, czy dane ustalenie ma znaczenie, to decyzja zależąca od celu routingu dokumentu. Drukarnia powinna odbijać plik z kodem SEC-NOPRINT już na etapie przyjmowania, dając przesyłającemu jasny komunikat, zamiast zostawić to do momentu rastrowania (RIP) trzy godziny przed deadlinem. Archiwum powinno potraktować SEC-ENC w zasadzie jako bloker, ponieważ szyfrowanie z długoterminowym przechowywaniem nie idą w parze — fakt ten ma zamiar formalnie podkreślić kontrola pod kątem zgodności ze standardami
Znaczniki standardów: odczytywanie deklaracji PDF/A
Plik deklaruje zgodność z PDF/A w swoim pakiecie metadanych XMP, za pomocą właściwości pdfaid:part (od 1 do 4) oraz pdfaid:conformance (poziom w postaci litery, takiej jak b, odpowiadającej za wierność wizualną lub a do pełnego, strukturalnego tagowania). API C PDFium nie oferuje żadnego akcesora XMP; FPDF_GetMetaText odczytuje tylko słownik Info, a nie tam umiejscowiona jest identyfikacja. Wyjściem awaryjnym (escape hatch) okazuje się reguła znajdująca się w samym standardzie: ISO 19005 wymaga, by strumień metadanych XMP był przechowywany w formie nieskompresowanej, z myślą właśnie o tym, by różnego rodzaju narzędzia mogły go odnaleźć bez pełnego parsera PDF. Skan surowych bajtów jest więc prawomocnym detektorem deklaracji (claim detector) — a plik, którego deklaracja chowa się wewnątrz skompresowanego strumienia, właściwie od razu ulega złamaniu standardu, do którego pretenduje
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;
Generowane przez to znalezisko jest celowo informacyjne, ponieważ deklaracja to jedynie roszczenie, a nie właściwość pliku. Wpis XMP to jedna linia XML, którą może utworzyć dowolny generator (nawet wadliwy); natomiast rzeczywista zgodność oznacza, że plik faktycznie spełnia setki reguł dotyczących osadzonych czcionek, kolorów niezależnych od urządzenia oraz zakazanych funkcji. Wykrycie deklaracji mówi nam tylko, które pliki należy skierować do właściwej walidacji - i nic więcej. Wbudowany w komponent silnik preflightowy wykonuje tę walidację dla profili PDF/A, PDF/UA i PDF/X, a artykuł o CLI dla wsadowego preflightu pokazuje, jak włączyć go w cykl przetwarzania z raportami, które audytor będzie mógł później otworzyć
Uruchomienie dla problematycznego pliku
Sterownik łączy te kontrole ze sobą: na pierwszym miejscu stawia sprawdzanie bezpieczeństwa, ponieważ właśnie od niego zależy to, czy audyt w ogóle zadziała, a zaraz po nim na liście znajdują się m.in. zachowania operujące na poziomie całego dokumentu oraz deklaracja standardów (standards claim). Potem rozpoczyna się sprawdzanie operujące w pętli dla każdej strony, by ocenić akcje oraz obrazy
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;
W odniesieniu do ulotki, która do nas trafiła ze współpracującej z nami zewnętrznej agencji, otrzymany wynik mógłby kształtować się w następujący sposób
> 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
Każda linia sama w sobie stanowi podstawę do podjęcia działań, ale ich połączenie jest prawdziwym werdyktem. Ten plik deklaruje standard PDF/A-2, podczas gdy niesie za sobą słownik szyfrujący i aktywny JavaScript, a PDF/A zabrania wręcz ich obu – tak więc deklaracja okazuje się obiektywnie fałszywa zanim jeszcze jakikolwiek gruntowny walidator zacznie działać. To jest właśnie ten rodzaj sprzeczności, który uwidacznia płaska lista ustaleń, a który zostałby ukryty w logicznym wyniku (boolean) pass/fail (zaliczony/oblany)
Czego ten audyt Ci nie powie
Uczciwość co do zakresu jest tym, co buduje zaufanie do narzędzia preflight. Wszystko powyżej odczytuje to, co plik deklaruje sam o sobie: PDFium parsuje strukturę, a ten audyt ją inwentaryzuje. Nie przeprowadza on walidacji PDF/A — nie ma tu weryfikacji pokrycia glifów dla osadzonych czcionek, analizy przestrzeni barw w stosunku do docelowych profili wyjściowych (output intents), ani żadnych reguł na poziomie poszczególnych punktów (klauzul), które odróżniałyby deklarację od rzeczywistej zgodności; do tego potrzebujesz dedykowanego walidatora, takiego jak silnik preflightu tego komponentu lub veraPDF. Bity uprawnień to deklaracje, które honorują zgodne czytniki, a nie ściany kryptograficzne, więc SEC-NOPRINT opisuje raczej zamiar niż egzekwowanie blokady. Skan akcji obejmuje adnotacje z linkami i skrypty na poziomie dokumentu; skrypty ukryte w słownikach zdarzeń pól formularzy potrzebują interfejsów API formularzy. Ponadto, weryfikacja podpisów, jeśli o nią rozszerzysz audyt, zgłasza deklarowany zamiar, a nie zweryfikowaną kryptografię — walidacja łańcucha certyfikatów to oddzielne zadanie. Audyt preflight to wywiad wstępny, a nie rozprawa sądowa: jego zadaniem jest sprawienie, aby decyzje o przepływie (routingu) były poinformowane, szybkie i powtarzalne
Uwaga: Używane w całym tym audycie API dla dokumentu, strony, adnotacji i obiektów obrazów, razem z wysokopoziomowym wrapperem Delphi oraz pełnym silnikiem preflightu walidującym standardy, są dostarczane z komponentem PDFium Component