PDF, které dorazí na hranici produkce — do tiskové fronty, do archivu, do zákaznického portálu pro nahrávání — by mělo být prověřeno dřív, než jej cokoli vykreslí. Soubor může nést akci Launch napojenou na spuštění externího programu, obrázky příliš hrubé na to, aby přežily tisk, šifrovací slovník, jenž zakazuje právě tu tiskovou úlohu, kvůli které byl odeslán, nebo označení PDF/A, kterému nedostojí. Prověřování dokumentu proti takovým pravidlům dřív, než vstoupí do workflow, se nazývá preflight a C API knihovny PDFium dává Delphi vše potřebné k tomu, aby tyto kontroly implementovalo přímo, bez vykreslení jediné stránky
Tento článek staví samotné kontroly: čtyři třídy auditu, každou jako malou rutinu, jež připojuje nálezy do sdíleného seznamu výsledků. Interaktivní prvky, metriky zdrojů, stav zabezpečení i značky standardů dostanou funkční kód včetně aritmetiky. Pokud potřebujete mašinerii kolem kontrol — dávkové procházení složek, soubory s reporty v JSON a HTML, izolaci jednotlivých souborů — PDFium Component přináší hotový preflight engine a článek o dávkovém preflight CLI tuto instalatérskou práci popisuje. Obojí záměrně sdílí jeden slovník návratových kódů, takže auditor napsaný zde zapadne rovnou pod onen dávkový ovladač
Záznam nálezu a smlouva o návratových kódech
Každá kontrola zapisuje do jednoho plochého typu záznamu, protože alternativu, kdy si každá kontrola tiskne vlastní prózu, nelze později spočítat, filtrovat ani prahovat. Čtyři položky stačí
uses
System.SysUtils, System.Math, System.IOUtils,
System.Generics.Collections, pdfium_lib;
type
TFindingSeverity = (fsInfo, fsWarning, fsError);
TPreflightFinding = record
Severity: TFindingSeverity;
Code: string; // stabilní strojový klíč, např. 'ACT-LAUNCH'
Page: Integer; // počítáno od 1; 0 znamená úroveň dokumentu
Message: string; // pro lidi; mezi vydáními se smí přeformulovat
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;
Navazující nástroje se klíčují podle Code, nikdy podle textu v Message, který se smí měnit. Návratový kód procesu se řídí toutéž trojhodnotovou smlouvou jako dávkový článek: 0 znamená, že soubor nevyprodukoval žádné nálezy, 1 znamená, že nálezy existují, a 2 znamená, že audit sám nemohl proběhnout, protože se soubor nepodařilo rozparsovat nebo vyžaduje heslo. Držet kód 2 odděleně má význam. Složka poškozených skenů je rozbitý skener na vstupu, ne náhlý kolaps souladu s pravidly, a smíchat obojí dohromady pošle někoho honit se za špatným problémem
Interaktivní prvky: skripty, cíle Launch, externí odkazy
PDFium každou nalezenou akci třídí podle celočíselného typu a konstanty z fpdf_doc.h stojí za to přesně přišpendlit, protože špatně opsané hodnoty udělají ze skeneru tiše slepého. Skutečný výčet je PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4 a PDFACTION_EMBEDDEDGOTO = 5. Všimněte si, co chybí: žádný člen pro JavaScript. Skripty na úrovni dokumentu nejsou akce odkazů a přes FPDFAction_GetType se nikdy neobjeví; vyjmenovává je samostatná rodina volání. Auditor, který testuje typy akcí proti vymyšlené konstantě pro JavaScript, se zkompiluje, poběží a nikdy nic nenajde
const
PDFACTION_GOTO = 1; // skok uvnitř dokumentu: neškodný
PDFACTION_REMOTEGOTO = 2; // skok do jiného místního souboru
PDFACTION_URI = 3; // otevře externí URL
PDFACTION_LAUNCH = 4; // spustí externí program
PDFACTION_EMBEDDEDGOTO = 5; // skok do vloženého souboru
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; // odkaz jen s cílem, není co hlásit
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 záměrně mlčí
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;
Rozdělení závažností kóduje politiku. Akce Launch je chyba, protože spuštění libovolného programu je to nejnebezpečnější, co klik v PDF umí, a žádná faktura to nepotřebuje. Externí URI jsou varování: u legitimních dokumentů běžná, ale recenzent by měl cíl vidět, aniž by klikal, protože viditelný text odkazu a skutečný cíl se shodovat nemusí. Skoky GoTo uvnitř dokumentu jsou struktura, ne chování, a do reportu se vůbec nedostanou — preflight, jenž vyje na každou položku obsahu, naučí lidi jej ignorovat. Ke čtení těl skriptů za počtem JavaScriptu a k úrovním MDP u podpisů i k detekci XFA prochází touž plochu přes objektovou obálku komponenty článek o auditu bezpečnostních rizik
Metriky zdrojů: efektivní DPI obrázku
Obrázek uvnitř PDF žádné vlastní DPI nemá. Má pixely a stránka tyto pixely umístí do obdélníku měřeného v bodech, kde 72 bodů dělá palec. Rozlišení existuje jen jako poměr obojího, a proto je táž fotografie 600 na 400 jako miniatura ostrá jako břitva a přes celou stránku rozmazaná kaše. Audit tedy potřebuje u každého obrázku obě čísla: rozměry zdroje v pixelech z metadat obrázku a umístěný obdélník z hranic objektu
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; // umístěná velikost na stránce, v bodech
HeightPt := T - B;
if (WidthPt <= 0) or (HeightPt <= 0) or
(Meta.Width = 0) or (Meta.Height = 0) then
Continue;
// 72 bodů = 1 palec, takže umístěné palce = body / 72 a
// efektivní DPI = pixely zdroje / umístěné palce.
DpiX := Meta.Width / (WidthPt / 72.0);
DpiY := Meta.Height / (HeightPt / 72.0);
EffDpi := Min(DpiX, DpiY); // horší osa rozhoduje o kvalitě tisku
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;
Prahy jsou politika, ne fyzika: 150 DPI je podlaha, pod kterou kancelářský tisk viditelně pixeluje, 300 je obvyklý komerční cíl a cokoli nad 600 nekoupí žádnou viditelnou kvalitu, jen nafoukne velikost souboru, a proto se hlásí jako informativní nadbytek, ne jako vada. Jedna poctivá výhrada: FPDFPageObj_GetBounds vrací obdélník zarovnaný s osami, takže u obrázku umístěného s rotací spočítané číslo skutečnou hustotu podceňuje. Struktura FPDF_IMAGEOBJ_METADATA nese i položky horizontal_dpi a vertical_dpi, které PDFium odvozuje z celé transformační matice, a porovnat oba výsledky je levný způsob, jak rotovaná umístění odhalit. Táž aritmetika mezi body a pixely žene vykreslování opačným směrem, jak popisuje článek o exportu do JPEG
Stav zabezpečení: šifrování a bity oprávnění
Šifrování PDF definuje dvě hesla s různými úkoly. Uživatelské heslo hlídá dešifrování: bez něj se soubor vůbec neotevře a FPDF_LoadDocument vrátí nil, přičemž FPDF_GetLastError hlásí FPDF_ERR_PASSWORD. Heslo vlastníka hlídá oprávnění: soubor chráněný jen heslem vlastníka se otevře bez přihlašovacích údajů, ale nese omezující bity, které vyhovující čtečka musí ctít. První bezpečnostní sondou je tedy sám pokus o načtení a tento rozdíl rozhoduje o návratovém kódu — soubor s uživatelským heslem je neauditovatelný (kód 2), kdežto soubor s heslem vlastníka se audituje normálně a jen sbírá nálezy
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 znamená, že soubor není šifrovaný
begin
// Otevřeno s prázdným heslem, a přesto šifrované: jen heslo vlastníka.
// Číst je smí kdokoli, ale bity oprávnění omezují, co mu vyhovující
// čtečka dovolí udělat. Nešifrované soubory hlásí všechny bity
// nastavené, a proto přijde kontrola revize na řadu první.
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: tisk
Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
'printing is not permitted');
if (Perms and 16) = 0 then // bit 5: kopírování / extrakce obsahu
Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
'content extraction is not permitted');
if (Perms and 2048) = 0 then // bit 12: tisk ve vysokém rozlišení
Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
'only low-resolution printing is permitted');
end;
end;
Masky pocházejí z tabulky 22 normy ISO 32000-1, která bity čísluje od 1: bit 3 hodnoty /P je maska 4, bit 5 je 16, bit 12 je 2048. Zda daný nález něco znamená, je rozhodnutí o směrování. Tiskové studio by mělo soubor SEC-NOPRINT odmítnout na příjmu, kde odesílatel dostane jasnou zprávu, ne až na RIPu tři hodiny před termínem. Archiv by měl brát jako blokátor už samo SEC-ENC, protože šifrování a dlouhodobé uchovávání se nesnesou — což kontrola standardů vzápětí řekne formálně
Značky standardů: čtení nároku na PDF/A
Soubor deklaruje shodu s PDF/A ve svém metadatovém balíčku XMP, vlastností pdfaid:part (1 až 4) a pdfaid:conformance (písmeno úrovně, třeba b pro vizuální věrnost nebo a pro plné strukturální tagování). C API knihovny PDFium žádný přístup k XMP nenabízí; FPDF_GetMetaText čte jen slovník Info, kde identifikace nesídlí. Únikovým východem je pravidlo v samotné normě: ISO 19005 vyžaduje, aby byl metadatový proud XMP uložen nekomprimovaně, právě proto, aby jej nástroje našly bez plného parseru PDF. Syrové procházení bajtů je tedy legitimní detektor nároku — a soubor, jehož nárok se skrývá uvnitř komprimovaného proudu, už normu, na kterou si dělá nárok, porušil
function PdfAClaim(const FileName: string): string;
var
Bytes: TBytes;
S: RawByteString;
P, Limit: Integer;
begin
Result := ''; // prázdné = žádný nárok na PDF/A
Bytes := TFile.ReadAllBytes(FileName);
if Length(Bytes) = 0 then
Exit;
SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
P := Pos('pdfaid:part', S); // identifikační schéma XMP
if P = 0 then
Exit;
// Zvládne <pdfaid:part>2</pdfaid:part> i pdfaid:part="2":
// bere se první číslice za názvem vlastnosti.
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;
Nález, který z toho vzejde, je záměrně informativní, protože nárok je prohlášení, ne vlastnost souboru. Zápis v XMP je jeden řádek XML, který umí napsat kterýkoli producent, i rozbitý; shoda je to, že soubor skutečně splňuje stovky pravidel o vložených fontech, na zařízení nezávislé barvě a zakázaných prvcích. Detekce nároku vám řekne, které soubory poslat na skutečnou validaci, a nic víc. Vestavěný preflight engine komponenty tuto validaci provádí napříč profily PDF/A, PDF/UA a PDF/X a článek o dávkovém CLI ukazuje, jak jej zapojit do linky s reporty, které si auditor otevře později
Průběh nad problémovým souborem
Ovladač navlékne kontroly za sebe: nejdřív zabezpečení, protože rozhoduje, zda audit vůbec proběhne, pak chování na úrovni dokumentu a nárok na standard a nakonec cyklus přes stránky pro akce a obrázky
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); // selhání auditu, ne verdikt
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;
U brožury, která se vrátila od externí agentury, vypadá výstup takto
> 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ždý řádek je akční sám o sobě, ale skutečný verdikt je jejich kombinace. Tento soubor si dělá nárok na PDF/A-2, a přitom nese šifrovací slovník a živý JavaScript, což PDF/A obojí přímo zakazuje — takže je ten nárok prokazatelně nepravdivý dřív, než se rozeběhne jakýkoli hloubkový validátor. Přesně takový rozpor plochý seznam nálezů ukáže a booleovské prošlo či neprošlo skryje
Co vám tento audit neřekne
Poctivost ohledně rozsahu je to, co drží preflight nástroj důvěryhodný. Všechno výše čte, co o sobě soubor deklaruje: PDFium rozparsuje strukturu a tento audit ji inventarizuje. Neprovádí validaci PDF/A — žádné kontroly pokrytí glyfů proti vloženým fontům, žádnou analýzu barevných prostorů proti výstupním záměrům, nic z pravidel na úrovni jednotlivých článků, jež oddělují nárok od shody; na to potřebujete vyhrazený validátor, například preflight engine komponenty nebo veraPDF. Bity oprávnění jsou prohlášení, která vyhovující čtečky ctí, ne kryptografické zdi, takže SEC-NOPRINT popisuje záměr, ne vynucení. Procházení akcí pokrývá anotace odkazů a skripty na úrovni dokumentu; skripty pohřbené ve slovnících událostí formulářových polí vyžadují navrch formulářové API. A kontrola podpisu, pokud jí audit rozšíříte, hlásí deklarovaný záměr, ne ověřenou kryptografii — validace řetězce certifikátů je samostatná práce. Preflight audit je vstupní pohovor, ne soud: jeho úkolem je udělat rozhodnutí o směrování informovaným, rychlým a opakovatelným
Poznámka: API pro dokument, stránku, anotace a obrázkové objekty použitá v celém tomto auditu spolu s vysokoúrovňovou obálkou pro Delphi a plným preflight enginem pro validaci standardů jsou součástí komponenty PDFium Component