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