Articol tehnic

Editarea metadatelor PDF încărcate în Delphi fără rescriere

Ai zece mii de PDF-uri de contract dintr-o duzină de generatoare diferite, iar departamentul juridic vrea ca fiecare să aibă Author-ul corect, un Producer corectat și un mod de citire care deschide panoul de bookmark-uri la pornire. Remediul naiv este să încarci fiecare fișier, să rearanjezi paginile și să scrii un document nou. Fă asta și ai aruncat la gunoi fiecare număr de obiect existent, istoricul de actualizări incrementale, orice semnătură digitală și xref-ul atent calibrat pe care l-a emis instrumentul original. Paginile arată identic și fișierul este, structural, alt document. Pentru o editare de metadate, acesta este schimbul greșit

Abordarea corectă este să tratezi documentul încărcat ca pe un graf de obiecte pe care îl modifici la locul lui: intri în dicționarul Info, în fluxul /Metadata și în Catalog, schimbi puținele intrări care te interesează și scrii rezultatul înapoi. HotPDF, componenta PDF nativă VCL pentru Delphi și C++Builder, expune exact această suprafață prin API-ul său de scriere pentru documente încărcate. Articolul acesta arată cum o folosești corect și care este greșeala pe care o fac aproape toți: editează dicționarul Info și uită că o a doua copie a acelorași metadate trăiește în XMP

Două locuri stochează aceleași metadate și nu sunt de acord

PDF păstrează informațiile despre document în două locații paralele, iar aceasta este rădăcina celor mai multe tichete de tipul "am schimbat titlul, dar Acrobat încă arată vechea valoare". Prima este dicționarul de informații al documentului, obiectul clasic /Info cu cheile /Title, /Author, /Subject, /Keywords, /Creator și /Producer, definite în ISO 32000-1 §14.3.3. A doua este un pachet XMP, un document XML stocat ca flux atârnat de Catalog sub /Metadata, definit în §14.3.2 și construit pe modelul de date Adobe XMP

Ambele pot ține un titlu. Nicio regulă din specificație nu le obligă să coincidă. Vizualizatoarele moderne și majoritatea verificatoarelor PDF/A preferă pachetul XMP când există și revin la dicționarul Info când nu există. Așa că, dacă actualizezi doar /Info, ceea ce face marea majoritate a codului de "setare a metadatelor PDF", un cititor care are încredere în XMP va continua să afișeze valoarea veche, iar un verificator PDF/A va semnala neconcordanța. Operația corectă pe orice fișier care are deja un pachet XMP este o scriere dublă: schimbi intrarea Info și regenerezi XMP, astfel încât cele două să rămână coerente. HotPDF îți oferă ambele jumătăți, disciplina de a le folosi împreună depinde de tine

Editarea dicționarului Info

Ajutoarele de pe partea Info sunt subțiri și previzibile. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator și SetLoadedProducer primesc fiecare un singur AnsiString și scriu cheia corespunzătoare în dicționarul Info încărcat, înlocuind valoarea dacă cheia există și adăugând-o dacă nu există. Ca să elimini complet o cheie, de exemplu un /Creator care divulgă numele instrumentului intern, apelează RemoveLoadedInfoKey cu numele brut al cheii. Niciuna dintre aceste funcții nu atinge XMP, ele lucrează doar pe obiectul /Info pe care LoadFromFile l-a localizat la parsarea fișierului

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;

Un detaliu important: aceste metode primesc AnsiString. Pentru titluri ASCII nu este o problemă, dar șirurile PDF care au nevoie de caractere nelatine trebuie codificate după cum cere specificația, fie UTF-16BE cu BOM, fie PDFDocEncoding, înainte să le trimiți mai departe. Biblioteca scrie în obiectul de tip șir exact octeții pe care îi primește, nu ghicește ea codarea. Dacă titlurile tale sunt simple, în engleză, poți ignora asta. Dacă au caractere accentuate sau CJK, codifică-le deliberat și testează-le într-un viewer real

Rescrierea pachetului XMP

SetLoadedXMPMetadata este cealaltă jumătate a scrierii duble. Îi transmiți pachetul XMP complet ca AnsiString și face una dintre două lucruri: dacă Catalogul referă deja un flux /Metadata, îi înlocuiește conținutul la locul lui, păstrând același număr de obiect; dacă nu există flux de metadate, îl creează, îl marchează /Type /Metadata și /Subtype /XML, alocă un număr de obiect și îl leagă din Catalog. În ambele cazuri obții un obiect de metadate valid pe care îl vor citi vizualizatoarele

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;

Ordinea asta, Info mai întâi, XMP al doilea, apoi salvarea, este modelul pe care trebuie să-l reții. Cele două apeluri sunt independente, coerența există doar fiindcă le-ai dat aceleași șiruri. Dacă sari peste apelul XMP pe un fișier care are deja un pachet XMP, te întorci la bug-ul cu valoarea rămasă veche, exact cel pe care această secțiune încearcă să-l prevină

Diagramă care arată un dicționar Info PDF și un flux XMP de metadate care păstrează ambele titlul și autorul, editate la locul lor împreună cu arborele de bookmark-uri al Catalogului
Metadatele trăiesc în două locuri, dicționarul Info și fluxul XMP, plus indiciile de lectură la nivel de Catalog și arborele de bookmark-uri. O editare la locul ei le atinge pe toate, fără să reconstruiască documentul.

Dirijarea modului în care vizualizatorul deschide fișierul

Trei intrări din Catalog decid ce vede cititorul în clipa în care documentul se deschide, iar toate trei sunt editări de o linie pe graful încărcat. SetLoadedPageMode scrie /PageMode ca obiect de tip nume: transmite 'UseOutlines' pentru a deschide panoul de bookmark-uri, 'UseThumbs' pentru bara de miniaturi, 'FullScreen' pentru modul de prezentare sau 'UseAttachments' pentru a afișa panoul atașamentelor (ISO 32000-1 §7.7.3.1, Table 28). SetLoadedPageLayout scrie /PageLayout la fel, 'SinglePage', 'OneColumn', 'TwoColumnLeft' și restul. Ambele primesc numele fără slash inițial, biblioteca îl adaugă la ieșire

SetLoadedLanguage scrie intrarea Catalog /Lang, eticheta de limbă naturală pentru întregul document, 'en-US', 'de-DE', o etichetă BCP 47. Reține diferența de tip care îi încurcă pe oameni: /PageMode și /PageLayout sunt obiecte PDF de tip nume, în timp ce /Lang este un șir. HotPDF face asta corect în interior, dar dacă inspectezi vreodată ieșirea vei vedea /PageMode /UseOutlines lângă /Lang (en-US), iar acum știi de ce. Intrarea /Lang contează mai mult decât pare, pentru că tehnologia de asistență o citește ca să aleagă pronunția și este o cerință dură pentru conformitatea 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;

Redenumirea bookmark-urilor fără a deranja arborele

Titlurile bookmark-urilor sunt curățenie de rutină, o greșeală într-un titlu, un capitol renumerotat după ce a fost construit outline-ul. SetLoadedOutlineTitle primește un index zero-based în intrările de nivel superior din outline și un titlu nou, parcurge lanțul Catalog → /Outlines/First/Next până la poziția respectivă și înlocuiește șirul /Title al intrării. Schimbă doar titlul; destinația, starea deschis/închis și structura copiilor rămân intacte

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

Redenumirea este sigură tocmai fiindcă nu atinge contoarele structurale. Cazul care mușcă este ștergerea unei intrări din outline și merită înțeles chiar și atunci când doar redenumești, pentru că îți arată ce să nu editezi manual. Fiecare nod de outline poartă un /Count și, conform ISO 32000-1 §12.3.3, acest count nu este numărul de copii direcți. Este numărul total de descendenți vizibili: un /Count pozitiv de N înseamnă că N descendenți sunt expuși acum, iar o valoare negativă înseamnă că nodul are descendenți, dar este pliat. Când o intrare de nivel superior este eliminată, count-ul rădăcinii /Outlines nu poate fi doar decrementat cu unu; trebuie recalculat prin sumarea, pentru fiecare nod de nivel superior rămas, a expresiei "unul pentru nodul însuși plus /Count-ul lui pozitiv", ignorând descendenții oricărui nod pliat, cu count negativ. Dacă greșești asta, totalul de bookmark-uri pe care îl afișează cititorul se abate și sare cu mai mult de unu la fiecare ștergere. Redenumirea ocolește toată problema, ceea ce este încă un motiv să preferi helperul țintit în loc să împungi manual dicționarul

Cum rămâne salvarea la locul ei

Fiecare editare de mai sus modifică obiecte în memorie, nimic nu ajunge pe disc până când rulează SaveLoadedDocument. Motivul pentru care această abordare este ieftină este că salvarea nu regenerează documentul, păstrează numerele de obiect existente și structura pe care HotPDF a parsat-o la încărcare, apoi scrie înapoi același graf cu puținele obiecte schimbate și cele nou alocate. Asta ține un pas de metadate departe de rescrierea întregului fișier și este același mecanism de actualizare la locul lui care face să funcționeze object streams și incremental updates. Dacă fișierele tale sursă vin din Word sau dintr-un alt pachet Office, layout-ul lor de obiecte are propriile ciudățenii pe care merită să le știi înainte să le editezi; articolul despre hybrid-reference cross-reference streams în PDF-urile Office arată cum sunt structurate aceste fișiere și ce supraviețuiește unui round trip

Două limite trebuie respectate. Prima, acesta este un model de editare la locul lui, nu un instrument de redacție sau sanitizare: dacă elimini o cheie din Info, elimini acea cheie, dar nu cureți valorile mai vechi care ar putea persista într-o generație incrementală anterioară a aceluiași fișier. Dacă cerința ta este ștergerea reală a metadatelor sensibile, este o operație diferită și mai grea. A doua, scrierea XMP este literală, biblioteca are încredere în XML-ul tău și nu îl validează, așa că pentru orice fișier destinat PDF/A sau unui validator strict, generează pachetul dintr-un șablon bun cunoscut și verifică ieșirea. Folosit în aceste limite, editarea la locul ei a metadatelor este instrumentul potrivit: repară puținii octeți greșiți și lasă intacte cele nouăzeci și nouă la sută din fișier pe care producătorul original le-a scris deja corect

API-ul de scriere pentru documente încărcate prezentat aici face parte din HotPDF Component standard pentru Delphi și C++Builder, împreună cu întregul set de metode pentru metadate, outline și editarea Catalogului