Technický článek

Úprava metadat načteného PDF v Delphi bez přepisování

Máte deset tisíc smluvních PDF z tuctu různých generátorů a právní oddělení chce, aby každé z nich neslo správný Author, opravený řetězec Producer a režim čtení, který po spuštění otevře panel záložek. Naivní oprava je načíst každý soubor, znovu vysázet stránky a zapsat nový dokument. Když to uděláte, zahodíte každé existující číslo objektu, historii přírůstkových aktualizací, jakýkoli digitální podpis i pečlivě vyladěný xref, který původní nástroj vyrobil. Stránky vypadají stejně a soubor je po strukturální stránce cizinec. Pro úpravu metadat je to úplně špatný obchod

Správný postup je zacházet s načteným dokumentem jako s grafem objektů, který upravujete na místě: sáhnout do slovníku Info, do streamu /Metadata a do Catalogu, změnit jen několik položek, které vás zajímají, a výsledek zapsat zpět. HotPDF, nativní VCL PDF komponenta pro Delphi a C++Builder, přesně tuto možnost nabízí přes své API pro zápis do načteného dokumentu. Tento článek je o tom, jak ji používat správně, a také o jedné chybě, kterou dělá téměř každý: upraví slovník Info a zapomene, že druhá kopie stejných metadat žije v XMP

Dvě místa ukládají stejná metadata a neshodnou se

PDF ukládá informace o dokumentu na dvou paralelních místech a právě to je kořen většiny hlášení typu „změnil jsem název, ale Acrobat pořád ukazuje ten starý“. Prvním místem je slovník informací o dokumentu, klasický objekt /Info s klíči /Title, /Author, /Subject, /Keywords, /Creator a /Producer, definovaný v ISO 32000-1 §14.3.3. Druhým místem je XMP paket, XML dokument uložený jako stream zavěšený na Catalogu pod /Metadata, definovaný v §14.3.2 a postavený na datovém modelu Adobe XMP

Oba mohou nést název dokumentu. Specifikace je nenutí, aby se shodovaly. Moderní prohlížeče a většina validátorů PDF/A dává přednost XMP paketu, pokud je přítomen, a vrací se ke slovníku Info, pokud není. Když tedy aktualizujete jen /Info, což dělá naprostá většina kódu pro nastavení PDF metadat, čtečka, která důvěřuje XMP, bude dál zobrazovat starou hodnotu a kontrola PDF/A nahlásí nesoulad. Správná operace u každého souboru, který už XMP paket obsahuje, je dvojitý zápis: změnit položku v Info a zároveň znovu vygenerovat XMP, aby oba pohledy zůstaly konzistentní. HotPDF vám dává obě poloviny, disciplína používat je spolu je na vás

Úprava slovníku Info

Pomocné funkce pro Info jsou úzké a předvídatelné. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator a SetLoadedProducer vždy přijmou jediný AnsiString a zapíší odpovídající klíč do načteného slovníku Info, přičemž existující hodnotu nahradí a novou přidají, pokud klíč ještě neexistuje. Chcete-li nějaký klíč odstranit úplně, třeba únikový /Creator, který prozrazuje vaše interní nástroje, zavolejte RemoveLoadedInfoKey s čistým názvem klíče. Nic z toho se nedotýká XMP, vše pracuje výhradně s objektem /Info, který LoadFromFile našel při parsování souboru

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;

Ještě jedna věc, kterou je potřeba držet přesně: tyto funkce pracují s AnsiString. U čistě ASCII názvů to není problém, ale textové řetězce PDF, které potřebují neznakové znaky, musí být před předáním zakódované tak, jak vyžaduje specifikace, tedy UTF-16BE s BOM nebo PDFDocEncoding. Knihovna zapisuje bajty, které jí dáte, do string objektu, žádné kódování za vás nehádá. Pokud máte názvy v obyčejné angličtině, můžete to ignorovat. Pokud obsahují diakritiku nebo znaky CJK, zakódujte je záměrně a otestujte je v reálném prohlížeči

Přepis XMP paketu

SetLoadedXMPMetadata je druhá polovina dvojitého zápisu. Předáte mu celý XMP paket jako AnsiString a stane se jedna ze dvou věcí: pokud Catalog už odkazuje na stream /Metadata, nahradí jeho obsah přímo na místě a zachová stejné číslo objektu; pokud metadata stream neexistuje, vytvoří ho, označí /Type /Metadata a /Subtype /XML, přidělí mu číslo objektu a propojí ho z Catalogu. V obou případech skončíte s platným objektem metadat, který prohlížeče přečtou

XML dodáváte vy, takže máte pod kontrolou schéma, tedy dc:title, dc:creator, xmp:CreatorTool a další položky. V tom je současně síla i odpovědnost, knihovna váš paket neparsuje ani nevaliduje a zapisuje bajty nekomprimované, bez použití stream filtru. Chybně sestavený paket projde voláním bez potíží a později se projeví jako chyba v metadatech. XML sestavujte pečlivě a přesně opakujte hodnoty, které jste zapsali do slovníku Info, aby se oba pohledy nikdy nelišily

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;

To pořadí, nejdřív Info, potom XMP a nakonec uložení, je vzor, který si chcete zafixovat. Obě volání jsou nezávislá, konzistence existuje jen proto, že jste jim předali stejné řetězce. Když u souboru, který už XMP paket obsahuje, vynecháte volání pro XMP, jste zpátky u chyby tichého zastarávání, které má celá tahle část zabránit

Schéma ukazující slovník PDF Info a XMP stream metadat, oba s názvem a autorem, upravované na místě spolu se stromem záložek
Metadata žijí na dvou místech, ve slovníku Info a ve streamu XMP, k tomu se přidávají rady pro čtení na úrovni Catalogu a strom záložek. Úprava na místě zasáhne každou z těchto částí bez přestavby dokumentu.

Řízení toho, jak se soubor otevře

Tři položky Catalogu rozhodují o tom, co čtenář uvidí v okamžiku otevření dokumentu, a všechny tři se upraví jediným řádkem v načteném grafu. SetLoadedPageMode zapisuje /PageMode jako objekt typu name: předáním 'UseOutlines' otevřete panel záložek, 'UseThumbs' zobrazí lištu miniatur, 'FullScreen' spustí prezentační režim a 'UseAttachments' ukáže panel příloh (ISO 32000-1 §7.7.3.1, Tabulka 28). SetLoadedPageLayout zapisuje /PageLayout stejným způsobem, tedy 'SinglePage', 'OneColumn', 'TwoColumnLeft' a další. Obě funkce chtějí název bez úvodního lomítka, knihovna ho doplní při výstupu

SetLoadedLanguage zapisuje do Catalogu položku /Lang, tedy jazykovou značku pro celý dokument, například 'en-US', 'de-DE', zkrátka tag BCP 47. Všimněte si rozdílu v typu, který lidi často mate: /PageMode a /PageLayout jsou PDF objekty typu name, zatímco /Lang je string. HotPDF to uvnitř dělá správně, ale když se někdy podíváte na výstup, uvidíte /PageMode /UseOutlines vedle /Lang (en-US), a teď už víte proč. Položka /Lang je důležitější, než vypadá, protože podle ní asistivní technologie volí výslovnost a je to tvrdý požadavek pro shodu s 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;

Přejmenování záložek bez narušení stromu

Názvy záložek jsou běžná údržba, chyba v nadpisu, kapitola přečíslovaná poté, co byl strom obsahu vytvořen. SetLoadedOutlineTitle přijme nulou indexovaný vstup do nejvyšší úrovně položek stromu obsahu a nový název, projde řetězec Catalog → /Outlines/First/Next až na danou pozici a nahradí řetězec /Title u příslušné položky. Změní jen název, cíl, stav otevřeno/zavřeno i struktura potomků zůstávají beze změny

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

Přejmenování je bezpečné právě proto, že se nikdy nedotkne strukturálních čítačů. Smazání položky stromu obsahu je ten případ, který bolí, a stojí za to mu porozumět i tehdy, když jen přejmenováváte, protože ukazuje, co nemáte upravovat ručně. Každý uzel stromu obsahu nese /Count a podle ISO 32000-1 §12.3.3 tento počet neudává počet bezprostředních potomků. Je to celkový počet viditelných potomků: kladný /Count s hodnotou N znamená, že N potomků je právě rozbalených, zatímco záporná hodnota znamená, že uzel potomky má, ale je sbalený. Když odstraníte položku nejvyšší úrovně, nelze kořenový počet /Outlines prostě snížit o jedna, musí se přepočítat sečtením pro každý přeživší uzel nejvyšší úrovně, tedy „jedna za samotný uzel plus jeho kladný /Count“, a přitom vynechat potomky každého sbaleného uzlu se záporným počtem. Když to spočítáte špatně, začne celkový počet záložek, který čtenář zobrazuje, ujíždět, a skok po smazání bude větší než jedna. Přejmenování se tomu všemu vyhne, což je další důvod, proč dát přednost cílené pomocné funkci před ručním saháním do slovníku

Jak se ukládá na místě

Každá z výše uvedených úprav mění objekty v paměti, na disk se nedostane nic, dokud neběží SaveLoadedDocument. Důvod, proč je tenhle postup levný, je, že ukládání dokument neregeneruje, zachová existující čísla objektů i strukturu, kterou HotPDF při načtení parsoval, a zapíše zpět tentýž graf s několika změněnými a nově přidělenými objekty. Právě to zabraňuje tomu, aby průchod metadaty přepsal celý soubor, a je to stejný mechanismus přírůstkové úpravy na místě, který pohání objektové streamy a přírůstkové aktualizace. Pokud vaše zdrojové soubory pocházejí z Wordu nebo jiné kancelářské sady, jejich rozložení objektů má vlastní zvláštnosti, které je dobré znát dřív, než je začnete upravovat; článek o hybridních referenčních cross-reference streamech v Office PDF popisuje, jak jsou tyto soubory strukturované a co přežije jejich opakované uložení

Je potřeba respektovat dvě hranice. Za prvé, jde o model úpravy na místě, ne o nástroj pro redakci nebo sanitizaci, odstranění klíče z Info ten klíč sice smaže, ale nevyčistí starší hodnoty, které mohou zůstat v předchozí generaci přírůstkové aktualizace stejného souboru. Pokud skutečně potřebujete odstranit citlivá metadata, je to jiná a těžší operace. Za druhé, zápis XMP je doslovný, knihovna vašemu XML důvěřuje a nevaliduje ho, takže pro cokoli určené pro PDF/A nebo přísný validátor generujte paket z osvědčené šablony a výstup si ověřte. Při použití v těchto mezích je úprava metadat na místě správně zvolený nástroj, opraví několik špatných bajtů a ponechá devadesát devět procent souboru, které už bylo správně, přesně tak, jak je původní producent zapsal

API pro zápis do načteného dokumentu, které je zde ukázáno, je součástí standardní komponenty HotPDF pro Delphi a C++Builder, spolu s kompletní sadou metod pro úpravu metadat, stromu obsahu a Catalogu