Artykuł techniczny

Edycja metadanych wczytanego PDF w Delphi bez przepisywania

Masz dziesięć tysięcy kontraktowych PDF-ów z tuzina różnych generatorów, a dział prawny chce, żeby każdy z nich miał właściwy Author, poprawiony ciąg Producer i tryb odczytu, który otwiera panel zakładek po uruchomieniu. Naiwne rozwiązanie to wczytać każdy plik, ponownie ułożyć strony i zapisać świeży dokument. Zrobisz tak i wyrzucisz do kosza każdy istniejący numer obiektu, historię aktualizacji przyrostowych, wszelkie podpisy cyfrowe oraz starannie dostrojony xref wygenerowany przez oryginalne narzędzie. Strony wyglądają identycznie, ale plik strukturalnie staje się kimś innym. Przy edycji metadanych to zupełnie zły układ

Właściwe podejście polega na traktowaniu wczytanego dokumentu jak grafu obiektów, który modyfikujesz in place: zaglądasz do słownika Info, strumienia /Metadata i Catalog, zmieniasz tylko kilka interesujących cię wpisów i zapisujesz wynik z powrotem. HotPDF, natywny komponent PDF VCL dla Delphi i C++Builder, udostępnia dokładnie taką powierzchnię przez API zapisu wczytanego dokumentu. Ten artykuł pokazuje, jak używać go poprawnie, oraz jaki jeden błąd popełnia prawie każdy: edycję słownika Info i zapomnienie, że druga kopia tych samych metadanych żyje w XMP

Dwa miejsca przechowują te same metadane i się nie zgadzają

PDF przechowuje informacje o dokumencie w dwóch równoległych miejscach i to jest źródło większości zgłoszeń w stylu „zmieniłem tytuł, a Acrobat nadal pokazuje stary”. Pierwsze to słownik informacji o dokumencie, klasyczny obiekt /Info z kluczami /Title, /Author, /Subject, /Keywords, /Creator i /Producer, zdefiniowany w ISO 32000-1 §14.3.3. Drugie to pakiet XMP, dokument XML przechowywany jako strumień podpięty do Catalog przez /Metadata, zdefiniowany w §14.3.2 i oparty na modelu danych Adobe XMP

Oba mogą przechowywać tytuł. Specyfikacja nie wymusza, żeby były zgodne. Nowoczesne przeglądarki i większość walidatorów PDF/A preferują pakiet XMP, jeśli jest obecny, a dopiero potem sięgają do słownika Info, gdy XMP brakuje. Jeśli więc zaktualizujesz tylko /Info, co robi zdecydowana większość kodu od ustawiania metadanych PDF, czytnik ufający XMP nadal pokaże starą wartość, a walidator PDF/A zgłosi rozbieżność. Poprawna operacja na każdym pliku, który już ma pakiet XMP, to podwójny zapis: zmienić wpis Info i wygenerować XMP ponownie, aby oba widoki pozostały spójne. HotPDF daje ci obie połówki, ale dyscyplina używania ich razem należy do ciebie

Edycja słownika Info

Pomocniki po stronie Info są proste i przewidywalne. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator i SetLoadedProducer przyjmują pojedynczy AnsiString i zapisują odpowiedni klucz do wczytanego słownika Info, zastępując wartość, jeśli klucz istnieje, albo dodając ją, jeśli nie. Jeśli chcesz usunąć klucz całkowicie, na przykład przeciekający /Creator, który zdradza wewnętrzne narzędzie, wywołaj RemoveLoadedInfoKey z samą nazwą klucza. Żadna z tych metod nie dotyka XMP; działają wyłącznie na obiekcie /Info, który LoadFromFile znalazł podczas analizy pliku

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
    begin
      Pdf.SetLoadedTitle('Master Services Agreement 2026');
      Pdf.SetLoadedAuthor('Legal Department');
      Pdf.SetLoadedSubject('Executed contract, retention 7 years');
      Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
      Pdf.SetLoadedProducer('Acme Document Pipeline');
      Pdf.RemoveLoadedInfoKey('Creator');  // drop the originating tool name
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Jedna rzecz musi pozostać uczciwa: wszystkie te metody przyjmują AnsiString. W przypadku tytułów ASCII to żaden problem, ale ciągi tekstowe PDF, które potrzebują znaków spoza łaciny, muszą zostać zakodowane zgodnie ze specyfikacją, czyli jako UTF-16BE z BOM albo PDFDocEncoding, zanim je przekażesz. Biblioteka zapisuje do obiektu string dokładnie te bajty, które jej podasz, i nie zgaduje za ciebie kodowania. Jeśli tytuły są po prostu angielskie, możesz to zignorować. Jeśli zawierają znaki diakrytyczne albo CJK, koduj świadomie i testuj w prawdziwej przeglądarce

Przepisywanie pakietu XMP

SetLoadedXMPMetadata to druga połowa podwójnego zapisu. Przekazujesz mu cały pakiet XMP jako AnsiString, a on robi jedną z dwóch rzeczy: jeśli Catalog już odwołuje się do strumienia /Metadata, podmienia jego zawartość in place, zachowując ten sam numer obiektu; jeśli strumienia metadanych nie ma, tworzy go, oznacza jako /Type /Metadata i /Subtype /XML, przydziela numer obiektu i podłącza go do Catalog. W obu przypadkach kończysz z poprawnym obiektem metadanych, który przeglądarki potrafią odczytać

Ty dostarczasz XML, więc to ty kontrolujesz schemat, czyli dc:title, dc:creator, xmp:CreatorTool i tak dalej. To jednocześnie siła i odpowiedzialność, bo biblioteka nie parsuje ani nie waliduje twojego pakietu, a bajty zapisuje bez kompresji, bez filtra strumienia. Uszkodzony pakiet przejdzie przez wywołanie bez protestu i później objawi się jako błąd metadanych. Buduj XML starannie i odzwierciedlaj dokładnie te same wartości, które zapisałeś do słownika Info, żeby oba widoki nigdy sobie nie przeczyły

const
  XMP_TEMPLATE =
    '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>' +
    '<x:xmpmeta xmlns:x="adobe:ns:meta/">' +
    '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
    '<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
    '<dc:title><rdf:Alt><rdf:li xml:lang="x-default">%s</rdf:li></rdf:Alt></dc:title>' +
    '<dc:creator><rdf:Seq><rdf:li>%s</rdf:li></rdf:Seq></dc:creator>' +
    '</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
begin
  // After setting the Info dictionary, mirror the same values into XMP:
  Pdf.SetLoadedTitle('Master Services Agreement 2026');
  Pdf.SetLoadedAuthor('Legal Department');
  Pdf.SetLoadedXMPMetadata(
    AnsiString(Format(XMP_TEMPLATE,
      ['Master Services Agreement 2026', 'Legal Department'])));
  Pdf.SaveLoadedDocument('contract-out.pdf');
end;

Kolejność ma znaczenie: najpierw Info, potem XMP, a na końcu zapis. To wzorzec, który warto zapamiętać. Te dwa wywołania są niezależne, a spójność istnieje tylko dlatego, że podałeś im te same ciągi. Jeśli pominiesz wywołanie XMP w pliku, który już ma pakiet XMP, wracasz do błędu cichego zestarzenia danych, któremu ma zapobiegać cały ten rozdział

Diagram pokazujący słownik Info PDF i strumień metadanych XMP, które oba przechowują tytuł i autora, edytowane in place obok drzewa zakładek
Metadane żyją w dwóch miejscach, czyli w słowniku Info i strumieniu XMP, a obok nich są jeszcze wskazówki odczytu na poziomie Catalog i drzewo konspektu. Edycja in place dotyka każdego z tych miejsc bez przebudowy dokumentu.

Jak sterować tym, jak przeglądarka otwiera plik

Trzy wpisy Catalog decydują o tym, co czytnik widzi dokładnie w chwili otwarcia dokumentu, a każdy z nich to jednolinijkowa edycja wczytanego grafu. SetLoadedPageMode zapisuje /PageMode jako obiekt nazwy: podaj 'UseOutlines', aby otworzyć panel zakładek, 'UseThumbs' dla panelu miniaturek, 'FullScreen' dla trybu prezentacji albo 'UseAttachments', aby pokazać panel załączników (ISO 32000-1 §7.7.3.1, tabela 28). SetLoadedPageLayout zapisuje /PageLayout w ten sam sposób, na przykład 'SinglePage', 'OneColumn', 'TwoColumnLeft' i pozostałe. Obie metody przyjmują nazwę bez wiodącego ukośnika, a biblioteka dodaje go przy wyjściu

SetLoadedLanguage zapisuje wpis /Lang w Catalog, czyli znacznik języka naturalnego dla całego dokumentu, na przykład 'en-US' albo 'de-DE', zgodny z BCP 47. Zwróć uwagę na różnicę typów, która często myli ludzi: /PageMode i /PageLayout są PDF-owymi obiektami name, a /Lang jest string. HotPDF robi to poprawnie wewnętrznie, ale jeśli kiedyś sprawdzisz wynik, zobaczysz /PageMode /UseOutlines obok /Lang (en-US) i będzie jasne, dlaczego. Wpis /Lang ma większe znaczenie, niż się wydaje: to on mówi technologii wspomagającej, jak dobrać wymowę, i jest twardym wymaganiem zgodności z PDF/UA

if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
  Pdf.SetLoadedPageMode('UseOutlines');     // /PageMode, a name
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, a name
  Pdf.SetLoadedLanguage('en-US');           // /Lang, a string
  Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;

Zmiana nazw zakładek bez naruszania drzewa

Tytuły zakładek to zwykłe porządki, na przykład literówka w nagłówku albo rozdział ponumerowany od nowa po zbudowaniu konspektu. SetLoadedOutlineTitle przyjmuje indeks zerowy do wpisów konspektu na najwyższym poziomie oraz nowy tytuł, przechodzi łańcuchem Catalog → /Outlines/First/Next do wskazanej pozycji i podmienia ciąg /Title we wpisie. Zmienia tylko tytuł, a cel, stan rozwinięcia i struktura dzieci pozostają nienaruszone

if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
  Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
  Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
  Pdf.SaveLoadedDocument('report-renamed.pdf');
end;

Zmiana nazwy jest bezpieczna właśnie dlatego, że nigdy nie dotyka liczników strukturalnych. Usuwanie wpisu konspektu to przypadek, który potrafi ugryźć, i warto go rozumieć nawet wtedy, gdy tylko zmieniasz nazwę, bo pokazuje, czego nie edytować ręcznie. Każdy węzeł konspektu ma /Count i, zgodnie z ISO 32000-1 §12.3.3, nie jest to liczba bezpośrednich dzieci. To całkowita liczba widocznych potomków: dodatni /Count równy N oznacza, że N potomków jest obecnie rozwiniętych, a wartość ujemna oznacza, że węzeł ma potomków, ale jest zwinięty. Gdy usuwasz wpis najwyższego poziomu, liczby root /Outlines nie da się po prostu zmniejszyć o jeden; trzeba ją przeliczyć, sumując dla każdego ocalałego węzła najwyższego poziomu „jeden za sam węzeł plus jego dodatni /Count”, z pominięciem potomków każdego węzła zwiniętego z ujemnym licznikiem. Błąd w tym miejscu sprawia, że liczba zakładek pokazywana przez czytnik rozjeżdża się i zmienia o więcej niż jeden na każde usunięcie. Sama zmiana nazwy omija cały ten mechanizm, co jest kolejnym powodem, aby używać celowanego pomocnika zamiast grzebać w słowniku ręcznie

Jak zapis pozostaje in place

Każda z powyższych zmian modyfikuje obiekty w pamięci, a na dysk nic nie trafia, dopóki nie uruchomisz SaveLoadedDocument. Ten sposób jest tani dlatego, że zapis nie regeneruje dokumentu, tylko zachowuje istniejące numery obiektów i strukturę, którą HotPDF odczytał przy ładowaniu, zapisując z powrotem ten sam graf z kilkoma zmienionymi i nowo przydzielonymi obiektami. To właśnie sprawia, że przebieg metadanych nie przepisuje całego pliku, i to ten sam mechanizm aktualizacji in place odpowiada za działanie obiektów strumieniowych i aktualizacji przyrostowych. Jeśli źródłowe pliki pochodzą z Worda albo innego pakietu biurowego, ich układ obiektów ma własne niuanse, które warto znać przed edycją; artykuł o hybrydowych strumieniach cross-reference w PDF-ach z Office pokazuje, jak te pliki są zbudowane i co przetrwa rundę zapisu

Warto pilnować dwóch granic. Po pierwsze, to model edycji in place, a nie narzędzie do redakcji ani sanitizacji: usunięcie klucza Info usuwa ten klucz, ale nie czyści starszych wartości, które mogą przetrwać w poprzedniej generacji aktualizacji przyrostowych tego samego pliku. Jeśli wymaganiem jest rzeczywiste usunięcie wrażliwych metadanych, to zupełnie inna i cięższa operacja. Po drugie, zapis XMP jest literalny, czyli biblioteka ufa twojemu XML-owi i go nie waliduje, więc dla wszystkiego, co ma trafić do PDF/A albo pod ścisły walidator, generuj pakiet ze sprawdzonego szablonu i weryfikuj wynik. Używany w tych granicach zapis metadanych in place jest narzędziem właściwego rozmiaru: naprawia kilka błędnych bajtów i zostawia dziewięćdziesiąt dziewięć procent pliku, które już było poprawne, dokładnie tak, jak zapisał je oryginalny producent

Pokazany tutaj interfejs zapisu wczytanego dokumentu jest częścią standardowego HotPDF Component dla Delphi i C++Builder, razem z pełnym zestawem metod do edycji metadanych, konspektu i Catalog