Artykuł techniczny

Konwersja PDF do PDF/A i naprawa jego metadanych

Weź plik, którego słownik informacji mówi /Title (Annual Maintenance Report), podczas gdy jego pakiet XMP nie ma dc:title w ogóle. Przeglądarki pokazują tytuł bez skargi, a jednak walidator PDF/A odrzuca plik, bo dwie kopie metadanych się nie zgadzają. Drugi plik wygląda równie zdrowo, ale nie ma wpisu pdfaid:part w swoim XMP, i walidator odpowiada dziesiątkami błędów: sądzi plik jako zwykły PDF i raportuje każdą regułę rozczonej części jako niespełnioną, wszystko z jednej brakującej linii

Te dwa problemy potrzebują bardzo różnych ilości roboty. Uzgodnienie metadanych to wąska poprawka, którą możesz puścić na każdym pliku. Zamiana dowolnego PDF w archiwalny znaczy obcięcie tego, czego zabrania część docelowa, dodanie tego, czego wymaga, zadeklarowanie roszczenia, a potem walidację bajtów, które faktycznie wyszły. Konwersję zbudowaliśmy wokół tego ostatniego kroku celowo. ConvertToPDFA, które płynie z edycją Enterprise (TPDFlibEnterprise) razem z GetPDFAConversionReport, zapisuje skonwertowany dokument do pamięci, puszcza sprawdzenie PDF/A na tych bajtach i zwraca 1 tylko, gdy to sprawdzenie przechodzi

Diagram potoku ConvertToPDFA w PDF Library for Delphi usuwający zabronioną zawartość, naprawiający metadane, deklarujący część PDF/A i weryfikujący przed archiwizacją przy pozytywnym werdykcie
ConvertToPDFA oczyszcza, uzupełnia, stempluje i weryfikuje w jednym wywołaniu, a tylko zaliczona kontrola czyni twierdzenie archiwalne czymś bezpiecznym do archiwizacji

Dlaczego poprawnie wyglądający PDF nie przechodzi sprawdzenia PDF/A?

Obie porażki pochodzą z reguł metadanych. PDF zapisuje tytuł, autora, producenta i daty w słowniku informacji o dokumencie, a PDF/A wymaga też tych samych faktów w pakiecie metadanych XMP; ISO 19005-1 §6.7.3 każe tym dwóm być równoważnymi, a PDF/A-2 i PDF/A-3 trzymają regułę. Wśród plików zawodzących na tej regule zwykły wzorzec to producent, który wypełnił słownik informacji i nigdy nie napisał połowy XMP, więc walidator znajduje tytuł w jednym miejscu i nic w drugim

Brakująca identyfikacja jest cichsza. Wpisy pdfaid:part i pdfaid:conformance to to, co znaczy plik jako PDF/A, a plik może być doskonale poprawny bez nich. Przypadek, który widzimy najczęściej, to aplikacja podająca własny pakiet XMP, zastępujący ten wygenerowany przez pisarza PDF; własny pakiet niesie pola aplikacji i żadnych wpisów pdfaid, więc lista błędów wskazuje daleko od prawdziwej przyczyny

RepairDocumentMetadata obsługuje oba przypadki i zwraca liczbę wpisów, które zmienił. Jest dostępny w każdej edycji, jak SetPDFAMode i SanitizeDocument. Naprawa przechodzi Title, Author, Subject, Keywords, Creator, Producer, CreationDate i ModDate. Gdy tylko XMP niesie wartość, słownik informacji jest z niego wypełniany, więc nic już zapisanego nie zostaje wyrzucone. Gdy oba niosą wartości i się różnią, słownik informacji wygrywa. Podjęliśmy tę decyzję, bo słownik informacji to połowa, którą producenci ustawiają najczęściej, co czyni ją bardziej prawdopodobnym zapisem intencji autora

Jeśli twoje XMP jest kopią rozstrzygającą (powiedzmy, że było pielęgnowane w systemie zarządzania dokumentami), wepchnij wartości, których chcesz, do słownika informacji przez SetInformation i dopiero potem puść naprawę. Zrobienie tego na odwrót nadpisze twój pielęgnowany tytuł XMP tym, cokolwiek napisał pierwotny producent

Diagram pokazujący RepairDocumentMetadata doprowadzający słownik Info i pakiet XMP do zgodności oraz przywracający utraconą identyfikację standardu PDF/A w Delphi
RepairDocumentMetadata wypełnia każdą pustą połowę metadanych z drugiej oraz przywraca utraconą identyfikację standardu, więc walidatory widzą zamierzoną część PDF/A

Naprawa identyfikacji potrzebuje od ciebie jednej rzeczy: dokument musi wiedzieć, na jaką część celuje. Świeżo wczytany plik nie ma ustawionego trybu PDF/A, więc wołaj SetPDFAMode przed naprawą. Z trybem na miejscu RepairDocumentMetadata zapisuje pasujące wpisy pdfaid, dodaje pdfaid:rev z rokiem 2020 dla trybów części 4 i ustawia dc:format na application/pdf, co walidatory też sprawdzają

uses
  SysUtils, PDFlibrary;

procedure RepairIncoming(const InName, OutName: string);
var
  Lib: TPDFlib;
  Fixed: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(InName, '') <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [InName]);

    // Pielęgnowany tytuł idzie do słownika informacji najpierw,
    // bo ta kopia wygrywa, gdy obie połowy się różnią
    Lib.SetInformation(2, 'Annual Maintenance Report 2025');

    Lib.SetPDFAMode(3);                  // 3 = PDF/A-2b, patrz tabela niżej
    Fixed := Lib.RepairDocumentMetadata;
    Writeln(Format('%d metadata entries brought into agreement', [Fixed]));

    Lib.SaveToFile(OutName);
  finally
    Lib.Free;
  end;
end;

Wybór części docelowej i jej numeru trybu

Wybierz część z tego, co dokument ma nieść, i dopiero potem odszukaj numer. SetPDFAMode i ConvertToPDFA dzielą jeden schemat numeracji, i nie biegnie on w kolejności, którą sugerują nazwy części

TrybCzęść i poziomUwagi
1PDF/A-1aPDF 1.4, otagowany
2PDF/A-1bPDF 1.4
3PDF/A-2bPDF 1.7
4PDF/A-2aPDF 1.7, otagowany
5, 6, 7PDF/A-3b, 3a, 3uOsadzone pliki dowolnego typu dozwolone
8PDF/A-2uPDF 1.7, wymagany tekst Unicode
9, 10, 11PDF/A-4, 4e, 4fPDF 2.0; 4e dodaje 3D i rich media, 4f pozwala na osadzone pliki

Tryb 3 to PDF/A-2b, a tryb 4 to PDF/A-2a. Ta inwersja łapie ludzi, którzy liczą od 1a w górę i zakładają, że poziom a zawsze prowadzi. PDF/A-2u wypada na 8, po trybach części 3

Wybór z realnymi konsekwencjami dotyczy załączników. Części 1 i 2 zabraniają osadzonych plików, więc ConvertToPDFA usuwa je dla trybów 1 do 4 i trybu 8. Skonwertuj fakturę Factur-X albo ZUGFeRD do trybu 3, a ustrukturyzowana faktura XML znika z wyjścia, razem z powodem istnienia pliku. Dla wszystkiego, co niesie załącznik, celuj w tryb 5 (PDF/A-3b), który jest też częścią, na której te formaty faktur są zdefiniowane, albo tryb 11, jeśli potrzebujesz PDF 2.0. Każdy załącznik w pliku części 3 albo części 4 musi podać, jak się odnosi do dokumentu, przez /AFRelationship, i PDF Library for Delphi zapisuje ten wpis dla każdego osadzonego pliku, który zapisuje w tych trybach

Część 4 identyfikuje się inaczej. Nie ma litery zgodności dla czystego PDF/A-4, używa E albo F dla dwóch rozszerzeń i wymaga pdfaid:rev z rokiem publikacji swojej części. ConvertToPDFA podnosi plik do PDF 2.0 dla trybów 9 do 11, bo ISO 19005-4 §6.1.2 buduje część na PDF 2.0, a sprawdzenie sądzi takie pliki względem PDF 2.0 zamiast 1.7

Co ConvertToPDFA zmienia w pliku

ConvertToPDFA wykonuje stałą sekwencję napraw, odnotowuje każdą w tekstowym raporcie, zapisuje wynik do pamięci i waliduje te bajty. Niezaliczone sprawdzenie zwraca 0, i tryb poza 1 do 11 też, który wraca, zanim jakikolwiek raport zostanie napisany. Sekwencja jest warta znajomości, bo każdy krok to coś, co wyjście straci albo zyska:

  • Zaszyfrowany dokument o nieznanym haśle zatrzymuje konwersję od razu linią blocked:; niczego w środku nie da się odczytać, nic dopiero naprawić
  • JavaScript na poziomie dokumentu i akcja otwarcia, która go uruchamia, są usuwane, razem z każdym wpisem additional-actions i typami akcji, których żadna część nie pozwala, wśród nich Launch, Sound, Movie, ResetForm, ImportData, Hide, SetOCGState, Rendition i Trans. Łącza GoTo i URI zostają, więc nawigacja dalej działa
  • Osadzone pliki są usuwane dla części, które ich zabraniają, jak opisano wyżej
  • Adnotacje ukryte albo ustawione na nigdy-nie-wyświetlaj są usuwane. PDF/A wymaga, by adnotacje były widoczne i drukowalne, a ujawnienie ich zmieniłoby wygląd strony, więc wybraliśmy usunięcie
  • Wyglądy pól formularza są odtwarzane, zanim flaga /NeedAppearances zostanie wyczyszczona, więc wypełnione wartości pozostają widoczne
  • Czcionki używane, a nieosadzone, są osadzane z czcionek zainstalowanych na maszynie
  • Obiekty, do których nic już się nie odnosi, są zrzucane, i intencja wyjścia sRGB jest dodawana, jeśli plik jej nie ma
  • Na koniec część i poziom są deklarowane w XMP, a bajty są sprawdzane

Na serwerze krok czcionek jest tym, który najprawdopodobniej zatnie konwersję. Dokument używający czcionki, która nigdzie nie istnieje na hoście konwersji, nie da się naprawić od środka pliku, i raport to powie. Zainstaluj czcionki, których twoje dokumenty faktycznie używają, na maszynie konwersji; przewodnik po osadzaniu brakujących czcionek w istniejącym PDF pokrywa ten krok osobno

Jak zadanie wsadowe powinno obchodzić się z konwersją, która zawodzi?

Traktuj wartość zwracaną jako bramkę, a raport jako zlecenie robocze. Archiwizuj pliki zwracające 1, a resztę kieruj do człowieka z raportem przyczepionym. Nie zapisuj wyjścia nieudanej konwersji do archiwum tylko dlatego, że wygląda czyściej niż wejście. Ten plik niesie teraz roszczenie PDF/A, którego sprawdzenie odmówiło potwierdzić, i to jest dokładnie sytuacja, której istnienie kroku weryfikacji ma zapobiec

Raport z GetPDFAConversionReport to jedna linia na akcję, każda zaczynająca się od czasownika: removed:, regenerated:, repaired:, embedded:, added:, stated:, plus blocked:, jeśli coś zatrzymało przebieg, i zamykająca linia result:. Sprawdzenie za werdyktem staje na pierwszej porażce, więc gdy plik zawodzi, konwersja puszcza sprawdzenie jeszcze raz w całości, a raport pokazuje każdy problem naraz. Kierowanie na prefiksach zajmuje kilka linii

uses
  Classes, SysUtils, PDFlibrary, PDFlibEnterprise;

function ArchiveOne(const InName, ArchiveDir, ReviewDir: string): Boolean;
var
  Lib: TPDFlibEnterprise;
  Report: TStringList;
  I: Integer;
  Blocked: Boolean;
begin
  Result := False;
  Lib := TPDFlibEnterprise.Create;
  Report := TStringList.Create;
  try
    if Lib.LoadFromFile(InName, '') <> 1 then
      Exit;
    Result := Lib.ConvertToPDFA(5) = 1;      // 5 = PDF/A-3b, zachowuje załączniki
    Report.Text := Lib.GetPDFAConversionReport;
    if Result then
      Lib.SaveToFile(IncludeTrailingPathDelimiter(ArchiveDir) + ExtractFileName(InName))
    else
    begin
      Blocked := False;
      for I := 0 to Report.Count - 1 do
        if Pos('blocked:', Report[I]) = 1 then
          Blocked := True;
      // Zostaw oryginał nietkniętym; tylko raport podróżuje z nim
      Report.SaveToFile(IncludeTrailingPathDelimiter(ReviewDir) +
        ChangeFileExt(ExtractFileName(InName), '.pdfa-report.txt'));
      if Blocked then
        Writeln(InName, ': needs a password or a rebuilt source file');
    end;
  finally
    Report.Free;
    Lib.Free;
  end;
end;

Linia blocked: zwykle znaczy, że poprawka leży poza PDF: hasło, inny plik źródłowy albo ponowny przebieg tego, co wytworzyło dokument. Zwykłe problemy walidacyjne po pełnej konwersji częściej są treścią niosącą znaczenie, której nie da się po prostu usunąć, i to są przypadki, gdzie człowiek musi zdecydować

Odczytywanie znacznika PDF/A, jaki plik już niesie

Przed konwersją sprawdź, co dokument już o sobie rości. Pliki przychodzące ze skanerów, pakietów biurowych i innych narzędzi PDF często niosą znacznik PDF/A już, i przekonwertowanie poprawnego pliku PDF/A-3 na część 2 kosztowałoby go załączniki na darmo

Pułapka jest taka, że XMP pozwala zapisać tę samą właściwość na dwa sposoby. Niektórzy producenci zapisują część jako element potomny, <pdfaid:part>2</pdfaid:part>; inni zapisują ją jako atrybut na rdf:Description, pdfaid:part="2". Oba to zwykły, poprawny XMP. Detektor szukający tylko jednej formy rozstrzyga, że pliki od drugiej rodziny producentów nie niosą znacznika, a sprawdzacz zbudowany tak kończy z sądzeniem poprawnego pliku PDF/A-2 względem części 1 i raportowaniem go jako nieoznaczonego i zbyt wysokiej wersji PDF, dokładnej odwrotności prawdy. Walidator w PDF Library for Delphi czyta obie formy. Jeśli węszysz znacznik sam, rób to samo:

function DeclaredPDFAPart(Lib: TPDFlib): string;
const
  ElemTag = '<pdfaid:part>';
  AttrDQ  = 'pdfaid:part="';
  AttrSQ  = 'pdfaid:part=''';
var
  Packet: string;
  P: Integer;
begin
  Result := '';
  Packet := Lib.GetDocumentMetadata;
  P := Pos(ElemTag, Packet);                   // forma elementu
  if P > 0 then
    Exit(Copy(Packet, P + Length(ElemTag), 1));
  P := Pos(AttrDQ, Packet);                    // forma atrybutu, podwójne cudzysłowy
  if P = 0 then
    P := Pos(AttrSQ, Packet);                  // forma atrybutu, pojedyncze cudzysłowy
  if P > 0 then
    Result := Copy(Packet, P + Length(AttrDQ), 1);
end;

Pusty wynik znaczy brak znacznika w obu formach. Cyfra mówi ci, którą część miał na myśli producent, i ta liczba jest tą do porównania z twoim celem, zanim zdecydujesz, czy konwertować w ogóle

Sanityzacja skryptów, zanim plik trafi do archiwum

Konwersja archiwalna i sanityzacja bezpieczeństwa zwykle biegną razem, bo treść, którą polityka bezpieczeństwa chce usunięta, mocno pokrywa się z tą, której PDF/A zabrania. SanitizeDocument biera bitmaskę flag SANITIZE_* i zwraca, ile usunięć zastosowała. ConvertToPDFA samo woła ją z SANITIZE_JAVASCRIPT or SANITIZE_OPEN_ACTION jako swoim pierwszym krokiem

Jeden szczegół ma znaczenie dla walidatorów. Skrypty poziomu dokumentu mieszkają w drzewie nazw /JavaScript pod słownikiem /Names katalogu, i gdy ostatni skrypt zostaje usunięty, teraz już puste drzewo jest usuwane z nim. Puste drzewo nadal ogłasza, że dokument ma skrypty, a niektóre sprawdzacze reagują na sam klucz bez liczenia, co pod nim siedzi

Cokolwiek dokonało sanityzacji, zweryfikuj zapisany plik świeżym wczytaniem zamiast ufać stanowi w pamięci. Ponowne otwarcie jest tanie:

var
  Lib, Check: TPDFlib;
begin
  Lib := TPDFlib.Create;
  Check := TPDFlib.Create;
  try
    Lib.LoadFromFile('upload.pdf', '');
    Lib.SanitizeDocument(SANITIZE_JAVASCRIPT or SANITIZE_OPEN_ACTION);
    Lib.SaveToFile('upload-clean.pdf');

    Check.LoadFromFile('upload-clean.pdf', '');
    if Check.GlobalJavaScriptCount > 0 then
      raise Exception.Create('Document-level scripts survived the save');
  finally
    Check.Free;
    Lib.Free;
  end;
end;

W sprawie sąsiednich kroków potoku archiwalnego zobacz przewodniki po preflighcie PDF/A i PDF/UA, prawdziwym zaczernieniu i usuwaniu treści oraz schematach rozszerzeń XMP PDF/A-3 dla Factur-X, który podejmuje stronę metadanych, gdy archiwizowany dokument niesie także ustrukturyzowane dane faktury

Cała konwersja, naprawa metadanych i walidacja pokazane tu biegą wewnątrz twojego własnego procesu Delphi, C++Buildera albo Lazarusa, bez zewnętrznego konwertera albo walidatora z linii poleceń w łańcuchu. Strona produktu PDF Library for Delphi wylicza części PDF/A, edycje i platformy docelowe