Artykuł techniczny

Walidacja zeskanowanych dokumentów PDF/raster w Delphi

Batch skanów może być niczym innym jak jednym JPEG na stronę, bez czcionek, bez adnotacji i bez zakładek, i nadal zawieść jako PDF/raster. ISO 23504-1 żąda od pliku więcej niż samych stron rastrowych: musi się identyfikować komentarzem %PDF-raster-1.0 w obszarze traileru, nieść metadane XMP i trailerowy /ID oraz trzymać katalog i słownik Info na krótkich dozwolonych listach. Sprawdzanie batcha akwizycji bierze więc dwa różne odczyty: jeden po surowych bajtach i jeden po tym, co każda strona faktycznie rysuje

Dwa odczyty tego samego pliku

Walidacja PDF/R i SaveAsPdfR przychodzą z edycjami Small Business i Enterprise PDFium Component, które dzieli walidację właśnie po tej linii. ValidatePdfRCompliance w jednostce FPdfPdfr czyta strumień i sprawdza reguły bajtowe: wersję nagłówka, znacznik rastrowy, strumienie obiektów, wpisy katalogu i Info, XMP, trailerowy /ID, pochodzenia MediaBox, zasoby czcionek, zakazane nazwy filtrów i to, czy liczba obiektów obrazów zgadza się z liczbą stron. ValidatePdfRPageFacts sprawdza jedną stronę na raz z rekordu TPdfRPageFacts: ile obiektów tekstu, ścieżek, form i cieniowań rysuje strona, gdzie siedzi pojedynczy obraz paskowy, jego rozmiar w pikselach, głębię bitową, przestrzeń barw i łańcuch filtrów

Oba odczyty raportują przez jedną enumerację, TPdfRValidationIssue, a połączone punkty wejścia zlewają je w jeden TPdfRValidationResult. Jego pole Conformance staje się prc1 tylko, gdy znacznik rastrowy zostanie znaleziony, a IsCompliant zwraca True tylko, gdy poziom to prc1 i zbiór problemów jest pusty. Plik doskonały w każdym innym względzie, ale bez znacznika, raportuje prcNone, co zaskakuje ludzi przy pierwszym przepuszczeniu czystego skanu

Pytanie brzmi, skąd biorą się fakty stron. TPdf.ValidatePdfR zbiera je z wczytanego dokumentu, ale najpierw serializuje ten dokument do tymczasowego magazynu i przegania kontrole bajtowe po ponownie zapisanej kopii, więc werdykty znacznika i katalogu opisują świeży zapis, który może różnić się od pliku na dysku. ValidatePdfRasterSource w FPdfBusinessWorkflows bierze jeden snapshot strumienia wejściowego i przegania wobec niego oba odczyty. Dla pliku, który zaraz trafi do archiwum, to jest wywołanie właściwe: werdykt odnosi się wtedy do bajtów, które są archiwizowane

uses
  System.Classes, System.SysUtils, System.TypInfo,
  FPdfPdfr, FPdfReport, FPdfBusinessWorkflows;

procedure CheckDeliveredScan(const FileName: string; Log: TStrings);
var
  Src: TFileStream;
  Options: TPdfRasterSourceValidationOptions;
  Report: TPdfRasterSourceValidationReport;
  Issue: TPdfRValidationIssue;
  I: Integer;
begin
  Options := TPdfRasterSourceValidationOptions.Default;
  Options.MaxInputBytes := 512 * 1024 * 1024;     // domyślny budżet to 64 MiB
  Options.Validation.MinimumResolutionDpi := 200; // nasz kontrakt akwizycji
  Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if not ValidatePdfRasterSource(Src, Options, Report) then
    begin
      Log.Add(Format('Check stopped (%s): %s',
        [GetEnumName(TypeInfo(TPdfRasterSourceStatus), Ord(Report.Status)),
         Report.ErrorMessage]));
      Exit;
    end;
  finally
    Src.Free;
  end;

  if Report.IsCompliant then
    Log.Add('PDF/R-1 conformant')
  else
  begin
    // Validation.Issues trzyma problemy bajtowe plus każdy problem strony
    for Issue := Low(TPdfRValidationIssue) to High(TPdfRValidationIssue) do
      if Issue in Report.Validation.Issues then
        Log.Add('File: ' + GetEnumName(TypeInfo(TPdfRValidationIssue), Ord(Issue)));
    // PageIssues mówi, które strony przeskanować ponownie
    for I := 0 to Report.ProcessedPageCount - 1 do
      for Issue := Low(TPdfRValidationIssue) to High(TPdfRValidationIssue) do
        if Issue in Report.PageIssues[I] then
          Log.Add(Format('Page %d: %s', [Report.Pages[I].PageNumber,
            GetEnumName(TypeInfo(TPdfRValidationIssue), Ord(Issue))]));
  end;

  // Trzymaj werdykt czytelny dla maszyny obok archiwizowanego pliku
  Log.Add(Report.ToJson(TPdfReportLimits.Default));
end;

Dwa domyślne w TPdfRasterSourceValidationOptions znaczą dla batchów skanów. MaxInputBytes to 64 MiB, a MaxPages to 1000; długi batch skanów kolorowych może przekroczyć budżet bajtowy i kontrola kończy wtedy statusem prssBudgetExceeded przy Complete równym False. MemoryLimitBytes to 8 MiB, poza którymi snapshot przelewa się do TemporaryDirectory, a raport ustawia SnapshotSpilled. Report.IsCompliant wymaga też Complete, więc kontrola zatrzymana w połowie nigdy nie czyta się jako zaliczona

Mapa walidacji PDF/R w PDFium Component dla Delphi: ValidatePdfRCompliance czyta strumień do TPdfRValidationResult, którego zbiór problemów obejmuje reguły strony jak pvriFontForbidden i pvriBadMediaBox, białą listę filtrów obrazów ISO 23504-1 oraz reguły kontenera jak pvriObjStmPresent
Jedno wywołanie zwraca poziom zgodności plus zbiór konkretnych problemów, a kontrole dzielą się na reguły strony, czterofiltrową białą listę obrazów i reguły kontenera odpalane, zanim jakakolwiek strona zostanie przeczytana

Dlaczego warstwa tekstu OCR wywala PDF/R?

Bo strona rastrowa rysuje zeskanowany obraz i nic więcej. Niewidzialna warstwa OCR to normalny dodatek w przepływie PDF/A i wkłada do pliku dwie rzeczy, które PDF/R wyklucza: zasób czcionki, raportowany przez odczyt bajtowy jako pvriFontForbidden, i obiekty tekstu na stronie, raportowane przez odczyt strony jako pvriNonRasterPageContent. Ten sam problem strony odpala się dla ścieżek wektorowych, cieniowań i form XObjects, więc ostemplowany numer strony albo narysowana linia obramowania zawodzą tak samo jak ukryty tekst

Relacja strony do obrazu też jest surowa. Każda strona musi nieść dokładnie jeden obraz paskowy; pvriPageImageMismatch odpala się na stronie bez obrazu i na stronie, której skan został zapisany jako dwa obrazy obok siebie. Po stronie bajtów liczenie idzie po każdym obiekcie, którego /Subtype to /Image, więc każdy dodatkowy obiekt obrazu, na przykład miękka maska doklejona do paska, pcha sumę ponad liczbę stron

Geometria dostaje dwie kontrole. pvriBadMediaBox raportuje MediaBox, który nie startuje od [0 0 ...]; odczyt bajtowy ogląda wbudowane tablice numeryczne. Odczyt strony porównuje potem narysowane granice paska z MediaBox i raportuje pvriStripGeometryInvalid, gdy jakakolwiek krawędź odbiega o więcej niż GeometryTolerancePoints, domyślnie 0.5 punkta. Sterownik skanera zostawiający biały margines przez skalowanie obrazu do 98% strony zawodzi tutaj, choć strona wygląda dobrze na ekranie. Ustawienie RequireFullPageStrip na False wyłącza test pokrycia dla wewnętrznych plików, gdzie margines jest akceptowalny

Które kodowania obrazów przechodzą

Walidator przyjmuje cztery filtry pasków: /CCITTFaxDecode, /DCTDecode, /JPXDecode i /FlateDecode. Odczyt bajtowy flaguje inne standardowe filtry po nazwie jako pvriForbiddenImageFilter: /LZWDecode, /RunLengthDecode, /ASCII85Decode, /ASCIIHexDecode, /JBIG2Decode i /Crypt

JBIG2 to odrzucenie, na które zespoły akwizycji trafiają najpierw. Jest dozwolony w PDF/A i bardzo dobrze kompresuje strony dwupoziomowe, więc profil akwizycji zbudowany pod PDF/A może już go emitować. Jego stratny tryb dopasowania wzorców jest też znany z zastępowania zeskanowanego glifu podobnie wyglądającym — udokumentowany problem z cyframi na zeskanowanych formularzach — a archiwum rastrowe istnieje właśnie po to, żeby to, co było na papierze, było w pliku. Przekodowanie tych stron jako CCITT Group 4 trzyma je dwupoziomowymi i bezstratnymi

Odczyt strony idzie o krok dalej i patrzy na łańcuch filtrów razem z głębią bitową i przestrzenią barw obrazu, raportując niezgodności jako pvriImageCompressionParametersInvalid:

  • Łańcuch dłuższy niż jeden filtr jest odrzucany, więc obraz Flate owinięty ASCII85 zawodzi dwa razy — raz po stronie bajtów i raz tutaj
  • /CCITTFaxDecode musi siedzieć na pasku 1-bitowym DeviceGray
  • /DCTDecode musi siedzieć na pasku 8-bitowym albo 24-bitowym w DeviceGray lub DeviceRGB
  • /FlateDecode jest przyjmowany na paskach 1-bitowych; strona Flate 24-bitowa zawodzi tę kontrolę
  • /JPXDecode jest przyjmowany przy 1, 8 albo 24 bitach na piksel

Niezależnie od filtra pasek, którego przestrzeń barw nie jest ani DeviceGray, ani DeviceRGB, dostaje pvriImageColorInvalid. Skan oparty na ICC albo CMYK musi być więc przekonwertowany przed zawinięciem, a najtańsze miejsce na to to profil skanera, przed zapisaniem jakiegokolwiek PDF

Biała lista filtrów obrazów PDF/R dla walidacji w Delphi: CCITTFaxDecode, DCTDecode, JPXDecode i FlateDecode są dozwolone na obrazach paskowych, podczas gdy filtry LZW, RunLength, ASCII85, ASCIIHex, JBIG2 i Crypt odpalają pvriForbiddenImageFilter
Walidator przyjmuje cztery kodowania pasków i raportuje każdy inny standardowy filtr, wliczając JBIG2 i filtry ASCII, jako pvriForbiddenImageFilter

Jak wyliczana jest rozdzielczość paska

Gdy obraz niesie własną rozdzielczość, odczyt strony jej używa. Gdy jej nie niesie, efektywna rozdzielczość jest wyprowadzana z pikseli i rozmiaru strony: szerokość w pikselach razy 72 podzielone przez szerokość MediaBox w punktach i to samo dla wysokości. Załóżmy, że skaner zapisuje pasek 2550 na 3300 pikseli na stronie US Letter o 612 na 792 punktów. Wychodzi dokładnie 300 dpi w obu kierunkach. Ta sama strona przy 600 na 776 pikseli wychodzi na około 70.6 dpi w poziomie, czyli poniżej domyślnego minimum 72, więc strona dostaje pvriImageResolutionInvalid

Domyślny zakres w TPdfRValidationOptions to od 72 do 2400 dpi. Te granice łapią tylko ewidentnie zepsute strony. Kontrakt akwizycji zwykle obiecuje konkretne minimum, często 200 albo 300 dpi, i ta wartość należy do MinimumResolutionDpi: strona 150 dpi przechodzi domyślny zakres, a to dokładnie ta strona, by wykluczyć którą napisano tamten kontrakt. Ustawienie którejkolwiek granicy na 0 wyłącza tę stronę kontroli

ValidatePdfRPageFacts jest publiczny, więc te same reguły mogą działać, zanim istnieje jakikolwiek PDF, wprost z tego, co skaner raportuje o każdym obrazie:

uses
  FPdfPdfr;

// Buduje fakty strony z wyjścia skanera; ten pomocnik jest twój, nie część biblioteki
function ScannerStripFacts(PixelW, PixelH: Integer;
  PageWidthPt, PageHeightPt: Single; const Filter: string;
  BitsPerPixel: LongWord; ColorSpace: Integer): TPdfRPageFacts;
begin
  Result := Default(TPdfRPageFacts);
  Result.PageNumber := 1;
  Result.MediaWidth := PageWidthPt;
  Result.MediaHeight := PageHeightPt;
  Result.ImageCount := 1;
  Result.ImageRight := PageWidthPt;   // pasek pokrywa całą stronę
  Result.ImageTop := PageHeightPt;
  Result.PixelWidth := PixelW;        // HorizontalDpi zostawione na 0: wyliczane
  Result.PixelHeight := PixelH;
  Result.BitsPerPixel := BitsPerPixel;
  Result.ColorSpace := ColorSpace;    // 1 = DeviceGray, 2 = DeviceRGB
  SetLength(Result.Filters, 1);
  Result.Filters[0] := Filter;
end;

var
  Opts: TPdfRValidationOptions;
  Issues: TPdfRValidationIssues;
begin
  Opts := TPdfRValidationOptions.Default;
  Opts.MinimumResolutionDpi := 300;

  // Kolorowy JPEG 300 dpi na Letter: pusty zbiór
  Issues := ValidatePdfRPageFacts(
    ScannerStripFacts(2550, 3300, 612, 792, 'DCTDecode', 24, 2), Opts);

  // Ta sama strona skompresowana Flate przy 24 bitach:
  // [pvriImageCompressionParametersInvalid]
  Issues := ValidatePdfRPageFacts(
    ScannerStripFacts(2550, 3300, 612, 792, 'FlateDecode', 24, 2), Opts);
end;

Katalog, słownik Info i XMP

Poza wymaganymi /Type i /Pages ISO 23504-1 pozwala w katalogu wyłącznie na Version, ViewerPreferences, PageLayout, PageMode, AcroForm i Metadata. Walidator sprawdza katalog wobec 21 standardowych kluczy poza tym zbiorem i podnosi pvriProhibitedCatalogEntry, gdy którykolwiek się pojawi — wśród nich Outlines, Names, PageLabels, OpenAction, StructTreeRoot, MarkInfo, Lang, OCProperties i OutputIntents. Ten ostatni ma znaczenie w praktyce: plik PDF/A zawsze niesie intencję wyjściową, więc jeden plik nie zaspokoi obu profili

Słownik Info może trzymać Creator, Producer, CreationDate i ModDate; Title, Author, Subject, Keywords i Trapped podnoszą pvriProhibitedInfoEntry. Katalog bez strumienia /Metadata dostaje pvriMissingXmpMetadata. Gdy strumień jest, ale jego XMP nie ma któregokolwiek z xmp:CreateDate, xmp:ModifyDate i xmp:MetadataDate, ustaleniem jest zamiast tego pvriMetadataInvalid. pvriInfoXmpMismatch porównuje Producer z pdf:Producer i Creator z xmp:CreatorTool. Daty są wyłączone z tego porównania, bo te dwa kodowania mogą się legalnie różnić, a wartości Info zapisane jako stringi hex są pomijane. Typowym wyzwalaczem jest późniejsze narzędzie przepisujące Info Producer bez dotykania XMP

Pozostałe reguły kontenera są krótkie. Nagłówek musi być %PDF-1.4 przez %PDF-1.7 albo %PDF-2.0 (pvriBadHeader), każdy strumień /Type /ObjStm podnosi pvriObjStmPresent, a zaszyfrowany plik musi używać nagłówka %PDF-2.0 (pvriEncryptVersionMismatch). Przy zostawionym RequireEncryptedPdf20Profile słownik szyfrowania musi też deklarować /V 5 albo 6 z /R 5 lub nowszym, inaczej plik dostaje pvriEncryptedPdf20ProfileInvalid

Zapisywanie znaczników

TPdfRSaveOptions niesie Creator, Producer, CreationDate, ModDate, DocumentId i InstanceId i nie ma pola dla Title, Author, Subject ani Keywords, bo profil ich zakazuje. StripInfoOptionalEntries i StripCatalogOptionalEntries domyślnie oba są True i usuwają wykluczone wpisy ze źródła; ustaw któreś na False, a wpisy zostaną, kosztem zgodności. TPdf.SaveAsPdfR wypełnia puste pola opcji z własnych metadanych wczytanego dokumentu i identyfikatorów pliku, usuwa zabezpieczenia, a potem zapisuje znacznik, przycięte słowniki, pakiet XMP i trailerowy /ID

uses
  System.Classes, System.SysUtils, PDFium, FPdfPdfr;

procedure WrapScanAsPdfR(const InFile, OutFile: string; Log: TStrings);
var
  Pdf: TPdf;
  Opts: TPdfRSaveOptions;
  Written: TFileStream;
  Res: TPdfRValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InFile;
    Pdf.Active := True;
    Opts := TPdfRSaveOptions.Default;
    Opts.Creator := 'Capture Station 4';   // Producer, daty i ID przychodzą ze źródła
    if not Pdf.SaveAsPdfR(OutFile, Opts) then
      raise Exception.Create('PDF/R save failed: ' + InFile);
  finally
    Pdf.Free;
  end;

  // Sprawdź zapisane bajty, nie dokument w pamięci
  Written := TFileStream.Create(OutFile, fmOpenRead or fmShareDenyWrite);
  try
    Res := ValidatePdfRCompliance(Written);
    if pvriPageImageMismatch in Res.Issues then
      Log.Add('A source page still has no single strip image');
    if pvriFontForbidden in Res.Issues then
      Log.Add('The source carries fonts; rescan or drop the text layer');
  finally
    Written.Free;
  end;
end;

Zawinięcie zmienia tylko metadane i identyfikację. Strona źródłowa rysująca tekst wektorowy albo niemieszcząca paska obrazu wychodzi z SaveAsPdfR z tą samą treścią strony i zawodzi te same kontrole strony. Treść strony jest zostawiana celowo, bo po cichu wyrenderowanie skanu na nowo zmieniłoby dokładnie te piksele, które archiwum ma zachować. Odpalenie CheckDeliveredScan z pierwszego zestawienia na OutFile daje pełny werdykt, z odczytami stron włącznie

Gdzie PDF/R pasuje w potoku akwizycji

PDF/R pasuje do deliverables, gdzie sam skan jest rejestrem, a wierność to cały kontrakt: obrazowanie dowodowe, akwizycja czeków i przekazów, archiwa rysunków ze skanera wielkoformatowego. Gdy tylko dokument potrzebuje tekstu do przeszukiwania, tagowania, załączników albo zakładek, należy do PDF/A, bo każda z tamtych funkcji ląduje na wykluczonych listach z wyżej

Wiele zespołów trzyma oba. Oryginał PDF/R jest zapisywany raz i nigdy się nie zmienia, a pochodna PDF/A z warstwą OCR obsługuje wyszukiwanie. Walidatory są niezależne, więc jedno zadanie batchowe może sprawdzić każdy plik wobec profilu, do którego się przyznaje. Stronę archiwalną pokrywają zgodność archiwalna PDF/A i walidacja preflight PDF/A, a wyjście drukarskie walidacja dokumentów PDF/X gotowych do druku

Kontrole PDF/R pokazane tutaj są częścią edycji Small Business i Enterprise PDFium Component dla Delphi, C++Builder i Lazarus, obok walidatorów PDF/A, PDF/X, PDF/E i PDF/UA; edycje i wspierane wersje IDE są na stronie produktu PDFium Component