Technisch artikel

PDF-metagegevens van een geladen bestand bewerken in Delphi

Je hebt tienduizend contract-PDF's uit een dozijn verschillende generators, en de juridische afdeling wil dat ze allemaal de juiste Author hebben, een gecorrigeerde Producer-string en een leesmodus die bij openen het bladwijzerpaneel toont. De naïeve oplossing is om elk bestand te laden, de pagina's opnieuw op te bouwen en een nieuw document weg te schrijven. Doe je dat, dan ben je alle bestaande objectnummers, de incremental-updategeschiedenis, eventuele digitale handtekeningen en de zorgvuldig afgestemde xref van het oorspronkelijke gereedschap kwijt. De pagina's zien er hetzelfde uit en het bestand is structureel een vreemde eend. Voor een metadatawijziging is dat de verkeerde afweging

De juiste aanpak is het geladen document te behandelen als een objectgraph die je ter plekke aanpast: ga naar de Info-dictionary, de /Metadata-stream en de Catalog, wijzig de paar entries die je nodig hebt en schrijf het resultaat terug. HotPDF, de native VCL PDF-component voor Delphi en C++Builder, biedt precies dat via de write-API voor geladen documenten. Dit artikel laat zien hoe je dat goed doet, en ook welke fout bijna iedereen maakt: de Info-dictionary aanpassen en vergeten dat er nog een tweede kopie van dezelfde metadata in XMP staat

Twee plekken bewaren dezelfde metadata, en die lopen uiteen

PDF bewaart documentinformatie op twee parallelle plekken, en dat is de bron van de meeste tickets met de strekking "ik heb de titel gewijzigd maar Acrobat toont nog steeds de oude". De eerste is de document information dictionary, het klassieke /Info-object met de sleutels /Title, /Author, /Subject, /Keywords, /Creator en /Producer, gedefinieerd in ISO 32000-1 §14.3.3. De tweede is een XMP-pakket, een XML-document dat als stream aan de Catalog hangt onder /Metadata, gedefinieerd in §14.3.2 en gebaseerd op het Adobe XMP-datamodel

Beide kunnen een titel bevatten. Niets in de specificatie dwingt ze om gelijk te zijn. Moderne viewers en de meeste PDF/A-validaties geven de voorkeur aan het XMP-pakket wanneer dat aanwezig is en vallen terug op de Info-dictionary wanneer dat niet zo is. Als je dus alleen /Info bijwerkt, wat de overgrote meerderheid van de code voor "PDF-metadata instellen" doet, blijft een reader die XMP vertrouwt de oude waarde tonen en meldt een PDF/A-controle een mismatch. De juiste handeling bij elk bestand dat al een XMP-pakket heeft, is dubbel schrijven: werk de Info-entry bij en regenereer XMP, zodat de twee consistent blijven. HotPDF geeft je beide kanten; het is aan jou om ze samen te gebruiken

De Info-dictionary bewerken

De hulpfuncties aan de Info-kant zijn dun en voorspelbaar. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator en SetLoadedProducer nemen elk één AnsiString en schrijven de bijbehorende sleutel in de geladen Info-dictionary, waarbij de waarde wordt vervangen als de sleutel al bestaat en anders wordt toegevoegd. Om een sleutel helemaal te verwijderen, bijvoorbeeld een lekke /Creator die je interne tooling noemt, roep je RemoveLoadedInfoKey aan met alleen de sleutelnaam. Geen van deze functies raakt XMP; ze werken alleen op het /Info-object dat LoadFromFile heeft gevonden toen het bestand werd geparseerd

Eén detail moet je goed houden: deze functies nemen AnsiString. Voor ASCII-titels is dat geen probleem, maar PDF-tekststrings met niet-Latijnse tekens moeten worden gecodeerd zoals de specificatie voorschrijft, dus UTF-16BE met een byte-order mark of PDFDocEncoding, vóór je ze doorgeeft. De bibliotheek schrijft de bytes die je aanlevert gewoon in een stringobject; ze raadt geen codering voor je. Zijn je titels gewoon Engels, negeer dit dan. Bevatten ze accenten of CJK-tekens, codeer dan bewust en test in een echte viewer

Het XMP-pakket herschrijven

SetLoadedXMPMetadata is de andere helft van het dubbel schrijven. Geef het het volledige XMP-pakket als een AnsiString en het doet een van twee dingen: als de Catalog al naar een /Metadata-stream verwijst, vervangt het de inhoud van die stream ter plekke en behoudt het hetzelfde objectnummer; als er geen metadata-stream is, maakt het er een, markeert het die als /Type /Metadata en /Subtype /XML, reserveert een objectnummer en koppelt het vanaf de Catalog. Hoe dan ook eindig je met een geldig metadataobject dat viewers kunnen lezen

Je levert de XML aan, en daarmee bepaal je zelf het schema, dc:title, dc:creator, xmp:CreatorTool en meer. Dat is tegelijk kracht en verantwoordelijkheid: de bibliotheek parseert of valideert je pakket niet, en schrijft de bytes ongecomprimeerd weg, zonder streamfilter. Een foutief pakket glipt zo door de aanroep heen en duikt later op als een melding over kapotte metadata. Bouw de XML zorgvuldig op en spiegel exact de waarden die je in de Info-dictionary hebt geschreven, zodat de twee weergaven elkaar nooit tegenspreken

Die volgorde, Info eerst, XMP tweede, dan opslaan, is het patroon dat je moet onthouden. De twee aanroepen staan los van elkaar; de consistentie bestaat alleen omdat je ze dezelfde strings hebt gegeven. Sla de XMP-aanroep over bij een bestand dat al een XMP-pakket heeft en je bent weer terug bij de bug van stilletjes verouderde waarden die deze hele sectie juist moet voorkomen

Sturen hoe de viewer het bestand opent

Drie Catalog-entries bepalen wat een lezer ziet op het moment dat het document opent, en alle drie zijn one-line aanpassingen aan de geladen graph. SetLoadedPageMode schrijft /PageMode als naamobject: geef 'UseOutlines' om het bladwijzerpaneel te tonen, 'UseThumbs' voor de miniaturenbalk, 'FullScreen' voor presentatiemodus of 'UseAttachments' om het bijlagenpaneel te tonen (ISO 32000-1 §7.7.3.1, Tabel 28). SetLoadedPageLayout schrijft /PageLayout op dezelfde manier, met 'SinglePage', 'OneColumn', 'TwoColumnLeft' en de rest. Beide nemen de naam zonder voorloop-slash; de bibliotheek voegt die bij uitvoer toe

SetLoadedLanguage schrijft de Catalog-entry /Lang, de taalmarkering voor het document als geheel, zoals 'en-US' of 'de-DE', een BCP 47-tag. Let op het typeverschil waar mensen over struikelen: /PageMode en /PageLayout zijn PDF-name-objecten, terwijl /Lang een string is. HotPDF doet dit intern goed, maar als je de uitvoer bekijkt zie je /PageMode /UseOutlines tegenover /Lang (en-US), en nu weet je waarom. De /Lang-entry is belangrijker dan hij lijkt: toegankelijkheidstechnologie gebruikt hem om de uitspraak te kiezen, en hij is een harde vereiste voor PDF/UA-conformiteit

Bladwijzers hernoemen zonder de boom te verstoren

Titels van bladwijzers zijn routineonderhoud, een typefout in een kop, een hoofdstuk dat opnieuw genummerd is nadat de outline was opgebouwd. SetLoadedOutlineTitle neemt een zero-based index in de top-level-outline-items en een nieuwe titel, volgt de Catalog → /Outlines/First/Next-keten naar die positie, en vervangt de /Title-string van het item. Alleen de titel verandert; de bestemming, de open/dicht-status en de kinderstructuur blijven onaangeroerd

Hernoemen is juist veilig omdat het nooit de structurele tellers raakt. Een outline-item verwijderen is het lastige geval, en het is de moeite waard om dat te begrijpen, zelfs als je alleen hernoemt, omdat het laat zien wat je niet handmatig moet aanpassen. Elk outline-knooppunt heeft een /Count, en volgens ISO 32000-1 §12.3.3 is dat niet het aantal directe kinderen. Het is het totale aantal zichtbare afstammelingen: een positieve /Count van N betekent dat N afstammelingen momenteel zichtbaar zijn, terwijl een negatieve waarde betekent dat het knooppunt afstammelingen heeft maar is samengevouwen. Wanneer een item op topniveau wordt verwijderd, kan de root-teller van /Outlines niet simpelweg met één worden verlaagd; hij moet opnieuw worden berekend door per overblijvend top-level knooppunt te sommeren: "één voor het knooppunt zelf plus zijn positieve /Count," waarbij de afstammelingen van elk samengevouwen, negatieve knooppunt worden overgeslagen. Doe je dat verkeerd, dan verschuift het totaal van de bladwijzers dat een viewer toont, en springt het met meer dan één per verwijdering. Hernoemen omzeilt dit alles, en dat is nog een reden om de gerichte helper te verkiezen boven zelf in de dictionary prikken

Hoe het opslaan op zijn plaats blijft

Elke wijziging hierboven past objecten in het geheugen aan; niets komt op schijf terecht tot SaveLoadedDocument draait. De reden dat deze aanpak goedkoop is, is dat het opslaan het document niet regenereert, maar de bestaande objectnummers en de structuur bewaart die HotPDF bij het laden heeft geparseerd, en dezelfde graph met jouw handjevol gewijzigde en nieuw aangemaakte objecten terugschrijft. Daardoor hoeft een metadata-pass niet het hele bestand te herschrijven, en hetzelfde in-place-update-mechanisme maakt ook object streams en incrementele updates mogelijk. Als je bronbestanden uit Word of een andere office suite komen, hebben hun objectlay-outs eigen eigenaardigheden die je beter kent voordat je ze bewerkt; het artikel over hybride cross-reference streams in Office-PDF's legt uit hoe die bestanden zijn opgebouwd en wat een ronde doorstaat

Twee grenzen zijn belangrijk. Ten eerste is dit een edit-in-place-model, geen redactie- of sanitization-tool: een Info-sleutel verwijderen verwijdert die sleutel, maar wist niet oudere waarden die misschien blijven bestaan in een eerdere incremental-update-generatie van hetzelfde bestand. Als je echt gevoelige metadata wilt verwijderen, is dat een andere, zwaardere operatie. Ten tweede is de XMP-schrijfactie letterlijk, de bibliotheek vertrouwt je XML en valideert die niet. Voor alles wat naar PDF/A of een strikte validator moet, genereer je het pakket daarom uit een bekend goed sjabloon en verifieer je de uitvoer. Binnen die grenzen is in-place metadata bewerken precies het juiste gereedschap: het corrigeert de paar bytes die fout zijn en laat de 99 procent van het bestand die al goed was exact staan zoals de oorspronkelijke producer het schreef

De write-API voor geladen documenten die hier is getoond, wordt meegeleverd met de standaard HotPDF Component voor Delphi en C++Builder, samen met de volledige set methoden voor metadata, outlines en Catalog-bewerking

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