Teknisk artikel

Redigera metadata i en inläst PDF i Delphi utan att skriva om dokumentet

Du har tiotusen kontrakts-PDF:er från ett dussin olika generatorer, och juristavdelningen vill att varenda en ska bära rätt Author, en korrigerad Producer sträng och ett läsläge som öppnar bokmärkespanelen vid start. Den naiva lösningen är att läsa in varje fil, lägga om sidorna och skriva ett nytt dokument. Gör du det kastar du bort varje befintligt objektnummer, historiken för inkrementella uppdateringar, alla digitala signaturer och den noggrant avstämda xref som det ursprungliga verktyget skapade. Sidorna ser identiska ut, men filen är strukturellt sett en främling. För en metadataändring är det helt fel avvägning

Det rätta draget är att behandla det inlästa dokumentet som en objektgraf du muterar på plats: gå in i Info-ordboken, den /Metadata streamen och Katalogen, ändra de få poster du bryr dig om och skriv tillbaka resultatet. HotPDF, den inbyggda VCL-PDF-komponenten för Delphi och C++Builder, exponerar exakt den ytan via sitt skriv-API för inlästa dokument. Den här artikeln handlar om att använda det rätt, och om det enda misstag nästan alla gör: att redigera Info-ordboken och glömma att en andra kopia av samma metadata finns i XMP

Två platser lagrar samma metadata, och de är inte överens

PDF lagrar dokumentinformation på två parallella ställen, och det är roten till de flesta "Jag ändrade titeln men Acrobat visar fortfarande den gamla"-ärenden. Den första är dokumentinformationsordboken, det klassiska /Info objektet med /Title, /Author, /Subject, /Keywords, /Creator, och /Producer nycklar, definierade i ISO 32000-1 §14.3.3. Den andra är ett XMP-paket, ett XML-dokument som lagras som en stream som hänger från Katalogen under /Metadata, definierat i §14.3.2 och byggt på Adobes XMP-datamodell

Båda kan bära en titel. Inget i specifikationen tvingar dem att stämma överens. Moderna visare och de flesta PDF/A-validerare föredrar XMP-paketet när det finns och faller tillbaka till Info-ordboken när det inte gör det. Så om du bara uppdaterar /Info - vilket är vad den stora majoriteten av kod som "sätter PDF-metadata" gör - en läsare som litar på XMP kommer att fortsätta visa det gamla värdet, och en PDF/A-kontroll kommer att flagga avvikelsen. Den korrekta åtgärden för alla filer som redan har ett XMP-paket är en dubbel skrivning: ändra Info-posten och skapa om XMP, så att de två förblir konsekventa. HotPDF ger dig båda halvorna; disciplinen att använda dem tillsammans är upp till dig

Redigera Info-ordboken

Info-hjälparna är tunna och förutsägbara. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator, och SetLoadedProducer tar emot en enda AnsiString och skriver motsvarande nyckel i den inlästa Info-ordboken, och ersätter värdet om nyckeln finns och lägger till det om det inte gör det. För att ta bort en nyckel helt - säg en läckande /Creator som namnger ditt interna verktyg - anropa RemoveLoadedInfoKey med det rena nyckelnamnet. Ingen av dessa rör XMP; de arbetar enbart på /Info objektet som LoadFromFile hittade när den parsade filen

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;

En detalj att hålla korrekt: de här tar AnsiString. För ASCII-titlar är det inget problem, men PDF-textsträngar som behöver icke-latinska tecken måste kodas enligt specifikationen - UTF-16BE med byteordningsmärke eller PDFDocEncoding - innan du lämnar över dem. Biblioteket skriver de byte du ger det till ett string-objekt; det gissar ingen kodning åt dig. Om dina titlar är vanlig engelska kan du ignorera detta. Om de innehåller accenttecken eller CJK-tecken ska du koda dem medvetet och testa i en riktig visare

Skriva om XMP-paketet

SetLoadedXMPMetadata är den andra halvan av dubbel skrivning. Skicka det fullständiga XMP-paketet som ett AnsiString och den gör en av två saker: om Katalogen redan hänvisar till en /Metadata stream ersätter den innehållet i den streamen på plats och behåller samma objektnummer; om det inte finns någon metadata-stream skapar den en, markerar den /Type /Metadata och /Subtype /XML, allokerar ett objektnummer och länkar den från Katalogen. I båda fallen får du ett giltigt metadataobjekt som visarna läser

Du levererar XML, vilket betyder att du styr schemat - dc:title, dc:creator, xmp:CreatorTool, och så vidare. Det är makt och ansvar i ett: biblioteket parsar eller validerar inte ditt paket, och det skriver byten okomprimerade utan att någon streamfilter används. Ett felaktigt paket passerar utan problem genom anropet och dyker upp senare som ett klagomål om trasig metadata. Bygg XML noggrant och spegla exakt de värden du skrev i Info-ordboken så att de två vyerna aldrig motsäger varandra

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;

Den ordningen - Info först, XMP sedan, därefter sparande - är mönstret att ta till sig. De två anropen är oberoende; konsekvensen finns bara för att du matade dem med samma strängar. Hoppar du över XMP-anropet på en fil som har ett XMP-paket är du tillbaka i buggen med tyst föråldring som hela det här avsnittet finns till för att förhindra

Diagram showing a PDF Info dictionary and an XMP metadata stream both holding title and author, edited in place alongside the bookmark outline tree
Metadata finns på två ställen - Info-ordboken och XMP-streamen - plus lästipsen på Katalognivå och dispositionssträdet. En redigering på plats påverkar var och en utan att bygga om dokumentet.

Styra hur visaren öppnar filen

Tre katalogposter avgör vad en läsare ser i samma ögonblick dokumentet öppnas, och alla tre är enkla engångsredigeringar på den inlästa grafen. SetLoadedPageMode skriver /PageMode som ett namnobjekt: skicka 'UseOutlines' för att öppna bokmärkespanelen, 'UseThumbs' för miniatyrraden, 'FullScreen' för presentationsläge, eller 'UseAttachments' för att visa bilagepanelen (ISO 32000-1 §7.7.3.1, tabell 28). SetLoadedPageLayout skriver /PageLayout på samma sätt - 'SinglePage', 'OneColumn', 'TwoColumnLeft', och resten. Båda tar namnet utan inledande snedstreck; biblioteket lägger till det vid utmatningen

SetLoadedLanguage skriver Katalogens /Lang post, den naturliga språktaggen för dokumentet som helhet - 'en-US', 'de-DE', en BCP 47-tagg. Lägg märke till typ-skillnaden som brukar ställa till det: /PageMode och /PageLayout är PDF name objekt, medan /Lang är en string. HotPDF gör detta rätt internt, men om du någonsin inspekterar utdata ser du /PageMode /UseOutlines mot /Lang (en-US), och nu vet du varför. /Lang posten betyder mer än den ser ut att göra: det är det assistiv teknik läser för att välja uttal, och det är ett hårt krav för PDF/UA-tillgänglighetsöverensstämmelse

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;

Byta namn på bokmärken utan att störa trädet

Bokmärkesrubriker är vanligt städarbete - ett stavfel i en rubrik, ett kapitel som har numrerats om efter att dispositionsstrukturen byggdes. SetLoadedOutlineTitle tar ett nollbaserat index in i poster på översta nivån i dispositionsstrukturen och en ny titel, går längs kedjan Katalog → /Outlines/First/Next till den positionen och ersätter postens /Title sträng. Den ändrar bara titeln; målet, det öppna/stängda läget och barnstrukturen lämnas orörda

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

Att byta namn är säkert just för att det aldrig rör de strukturella räknarna. Att ta bort en dispositionspost är det som brukar bita tillbaka, och det är värt att förstå även när du bara byter namn, eftersom det visar vad du inte ska redigera för hand. Varje dispositionsnod har en /Count, och - enligt ISO 32000-1 §12.3.3 - är det talet inte antalet omedelbara barn. Det är det totala antalet synliga efterföljare: ett positivt /Count på N betyder att N efterföljare just nu visas, medan ett negativt värde betyder att noden har efterföljare men är hopfälld. När en post på översta nivån tas bort kan /Outlines rotantalet inte bara minskas med ett; det måste räknas om genom att man summerar, för varje överlevande nod på översta nivån, "ett för noden själv plus dess positiva /Count," och hoppar över efterföljarna till varje hopfälld (negativ) nod. Gör du fel där driver den bokmärkestotal som läsaren visar iväg - den hoppar med mer än ett per borttagning. Att byta namn kringgår allt detta, vilket är ännu en anledning att föredra den riktade hjälpfunktionen framför att peta i ordboken själv

Hur sparandet stannar på plats

Varje redigering ovan muterar objekt i minnet; inget når disken förrän SaveLoadedDocument körs. Anledningen till att detta tillvägagångssätt är billigt är att sparandet inte återskapar dokumentet - det bevarar de befintliga objektnumren och den struktur som HotPDF tolkade vid inläsning och skriver tillbaka samma graf med dina få ändrade och nyligen allokerade objekt. Det är det som gör att en metadataomgång inte skriver om hela filen, och det är samma mekanism för in-place-uppdatering som gör att objektströmmar och inkrementella uppdateringar fungerar. Om dina källfiler kommer från Word eller någon annan kontorssvit har deras objektlayout egna egenheter som är värda att känna till innan du redigerar dem; artikeln om hybridreferens-korsreferensströmmar i Office-PDF:er går igenom hur de filerna är uppbyggda och vad som överlever en tur fram och tillbaka

Två gränser att respektera. För det första är detta en modell för redigering på plats, inte ett redigerings- eller saneringsverktyg: att ta bort en Info-nyckel tar bort just den nyckeln, men det rensar inte äldre värden som kan leva kvar i en tidigare inkrementell uppdateringsgeneration av samma fil. Om du verkligen behöver ta bort känslig metadata är det en annan och tyngre åtgärd. För det andra är XMP-skrivningen bokstavlig - biblioteket litar på din XML och validerar den inte - så för allt som är avsett för PDF/A eller en strikt validerare ska du generera paketet från en beprövad mall och verifiera utdata. Använt inom de ramarna är redigering av metadata på plats rätt verktyg i rätt storlek: den fixar de få byte som är fel och lämnar de nittionio procent av filen som redan var korrekt precis som den ursprungliga producenten skrev den

Skriv-API:t för inlästa dokument som visas här följer med standarden HotPDF Component för Delphi och C++Builder, tillsammans med hela uppsättningen metoder för metadata, disposition och Katalogredigering