Технічна стаття

Редагування метаданих завантаженого PDF у Delphi без переписування

У вас десять тисяч PDF-договорів від дюжини різних генераторів, а юридичний відділ хоче, щоб у кожному були правильний Author, виправлений рядок Producer і режим читання, який під час запуску відкриває панель закладок. Наївне виправлення - завантажити кожен файл, заново розкласти сторінки і записати новий документ. Зробіть так, і ви втратите всі наявні номери об’єктів, історію інкрементальних оновлень, будь-який цифровий підпис і ретельно налаштований xref, який видав початковий інструмент. Сторінки виглядають однаково, а файл структурно стає чужим. Для редагування метаданих це зовсім не той обмін.

Правильний підхід - сприймати завантажений документ як граф об’єктів, який ви змінюєте на місці: дістатися до словника Info, потоку /Metadata і Catalog, змінити кілька потрібних записів і записати результат назад. HotPDF, нативний VCL PDF-компонент для Delphi і C++Builder, відкриває саме такий доступ через API запису для завантаженого документа. Ця стаття про те, як користуватися ним правильно, і про одну помилку, яку робить майже кожен: редагує словник Info і забуває, що друга копія тих самих метаданих живе в XMP.

Два місця зберігають одні й ті самі метадані, і вони не збігаються

PDF зберігає відомості про документ у двох паралельних місцях, і саме це є коренем більшості звернень на кшталт "я змінив заголовок, а Acrobat усе ще показує старий". Перший - словник інформації документа, класичний об’єкт /Info з ключами /Title, /Author, /Subject, /Keywords, /Creator і /Producer, визначений в ISO 32000-1 §14.3.3. Другий - пакет XMP, XML-документ, що зберігається як потік, прикріплений до Catalog через /Metadata, визначений у §14.3.2 і побудований на моделі даних Adobe XMP.

Обидва можуть містити заголовок. Специфікація не змушує їх збігатися. Сучасні переглядачі та більшість валідаторів PDF/A надають перевагу пакету XMP, якщо він є, і переходять до словника Info, коли його немає. Тож якщо ви оновлюєте лише /Info - а саме так робить переважна більшість коду для "set PDF metadata" - читач, який довіряє XMP, і далі показуватиме застаріле значення, а перевірка PDF/A позначить невідповідність. Правильна операція для будь-якого файлу, де вже є пакет XMP, це подвійний запис: змінити запис Info і згенерувати XMP заново, щоб обидва джерела лишалися узгодженими. HotPDF дає вам обидві частини, а дисципліна використання їх разом залежить від вас.

Редагування словника Info

Допоміжні методи для Info прості й передбачувані. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator і SetLoadedProducer кожен приймає один AnsiString і записує відповідний ключ у завантажений словник Info, замінюючи значення, якщо ключ існує, і додаючи його, якщо ні. Щоб повністю прибрати ключ - скажімо, витіклий /Creator, який називає ваші внутрішні інструменти - викличте RemoveLoadedInfoKey з самим іменем ключа. Жоден із цих методів не торкається XMP; вони працюють лише з об’єктом /Info, який LoadFromFile знайшов під час розбору файлу.

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;

Одна деталь, яку важливо не спотворити: ці методи приймають AnsiString. Для ASCII-заголовків це не проблема, але текстові рядки PDF, які потребують нелатинських символів, слід кодувати так, як вимагає специфікація - UTF-16BE з BOM або PDFDocEncoding - перш ніж передавати їх. Бібліотека записує в об’єкт рядка ті байти, які ви їй дали, вона не вгадує кодування замість вас. Якщо ваші заголовки англійською, це можна ігнорувати. Якщо в них є символи з діакритикою або CJK, кодуйте свідомо і перевіряйте у реальному переглядачі.

Перезапис пакета XMP

SetLoadedXMPMetadata - це друга половина подвійного запису. Передайте йому повний пакет XMP як AnsiString, і він зробить одне з двох: якщо Catalog уже посилається на потік /Metadata, він замінить вміст цього потоку на місці, зберігши той самий номер об’єкта; якщо потоку метаданих немає, він створить його, позначить як /Type /Metadata і /Subtype /XML, виділить номер об’єкта і прив’яже його до Catalog. У будь-якому разі ви отримуєте валідний об’єкт метаданих, який читачі зможуть прочитати.

Ви постачаєте XML, а отже контролюєте схему - dc:title, dc:creator, xmp:CreatorTool тощо. Це і сила, і відповідальність водночас: бібліотека не розбирає і не перевіряє ваш пакет, а байти записує без стиснення і без застосування фільтра потоку. Некоректний пакет без проблем пройде через виклик і пізніше проявиться як скарга на пошкоджені метадані. Формуйте XML уважно і точно віддзеркалюйте значення, які ви записали в словник Info, щоб обидва подання ніколи не суперечили одне одному.

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;

Такий порядок - спочатку Info, потім XMP, потім збереження - це шаблон, який варто запам’ятати. Обидва виклики незалежні, а узгодженість існує лише тому, що ви подали їм ті самі рядки. Пропустіть виклик XMP для файлу, де пакет XMP уже є, і ви знову повернетеся до помилки тихої застарілості, яку весь цей розділ і покликаний запобігти.

Діаграма показує словник Info PDF і потік метаданих XMP, де в обох зберігаються заголовок і автор, а поруч на місці редагується дерево закладок
Метадані зберігаються у двох місцях - у словнику Info та потоці XMP - разом із підказками читання на рівні Catalog і деревом закладок. Редагування на місці торкається кожного з них без перебудови документа.

Керування тим, як переглядач відкриває файл

Три записи Catalog визначають, що читач бачить у момент відкриття документа, і всі три редагуються однією стрічкою в завантаженому графі. SetLoadedPageMode записує /PageMode як об’єкт-ім’я: передайте 'UseOutlines', щоб відкрити панель закладок, 'UseThumbs' для смуги мініатюр, 'FullScreen' для режиму презентації або 'UseAttachments', щоб показати панель вкладень (ISO 32000-1 §7.7.3.1, Table 28). SetLoadedPageLayout записує /PageLayout так само - 'SinglePage', 'OneColumn', 'TwoColumnLeft' та інші. Обидва приймають ім’я без початкового слеша; бібліотека додає його під час виводу.

SetLoadedLanguage записує запис Catalog /Lang, мовний тег для всього документа - 'en-US', 'de-DE', тег BCP 47. Зверніть увагу на різницю типів, яка збиває людей з пантелику: /PageMode і /PageLayout - це PDF об’єкти типу name, а /Lang - рядок. HotPDF робить це правильно всередині, але якщо ви колись переглянете вихідний файл, то побачите /PageMode /UseOutlines поруч із /Lang (en-US), і тепер ви знаєте чому. Запис /Lang важливіший, ніж здається: саме його читають допоміжні технології, щоб вибрати вимову, і це жорстка вимога для відповідності 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;

Перейменування закладок без порушення дерева

Заголовки закладок - це звичайне очищення, наприклад, помилка в назві розділу або перенумерований розділ після побудови outline. SetLoadedOutlineTitle бере нульовий індекс у верхньорівневих записах outline і новий заголовок, проходить ланцюжок Catalog → /Outlines/First/Next до потрібної позиції і замінює рядок /Title у записі. Він змінює лише заголовок; призначення, стан відкрито/закрито і дочірня структура лишаються недоторканими.

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

Перейменування безпечне саме тому, що воно ніколи не чіпає структурні лічильники. Видалення запису outline - це той випадок, у якому легко помилитися, і його варто розуміти навіть коли ви лише перейменовуєте, бо він показує, чого не слід редагувати вручну. Кожен вузол outline має /Count, і, згідно з ISO 32000-1 §12.3.3, цей лічильник не є кількістю безпосередніх нащадків. Це загальна кількість видимих нащадків: додатне /Count зі значенням N означає, що N нащадків зараз розгорнуті, а від’ємне значення означає, що вузол має нащадків, але згорнутий. Коли верхньорівневий запис видаляють, лічильник кореня /Outlines не можна просто зменшити на одиницю; його треба перерахувати, підсумовуючи для кожного вцілілого верхньорівневого вузла "один за сам вузол плюс його додатний /Count", пропускаючи нащадки будь-якого згорнутого вузла з від’ємним рахунком. Помилка тут призводить до того, що загальна кількість закладок, яку показує читач, спотворюється - вона змінюється більше ніж на одиницю після кожного видалення. Перейменування обходить усе це, і це ще одна причина надавати перевагу цільовому допоміжному методу, а не копирсатися у словнику вручну.

Як збереження лишається на місці

Усі наведені вище зміни змінюють об’єкти в пам’яті; на диск нічого не потрапляє, доки не викличеться SaveLoadedDocument. Перевага такого підходу в тому, що збереження не перебудовує документ - воно зберігає наявні номери об’єктів і структуру, яку HotPDF розібрав під час завантаження, записуючи назад той самий граф із кількома зміненими та щойно виділеними об’єктами. Саме це не дає проходу з метаданими переписувати весь файл, і це та сама механіка оновлення на місці, завдяки якій працюють потоки об’єктів і інкрементальні оновлення. Якщо ваші вихідні файли походять із Word або іншого офісного пакета, їхня об’єктна розкладка має власні особливості, які варто знати до редагування; стаття про гібридні потоки перехресних посилань в Office PDF пояснює, як ці файли влаштовані і що переживає round trip.

Дві межі, яких слід дотримуватися. По-перше, це модель редагування на місці, а не інструмент для приховування або санітизації даних: видалення запису Info прибирає сам запис, але не змиває старі значення, які можуть лишитися в попередньому інкрементальному поколінні того самого файлу. Якщо ваша вимога - справді прибрати чутливі метадані, це інша, важча операція. По-друге, запис XMP є буквальним - бібліотека довіряє вашому XML і не перевіряє його, - тож для будь-якого файлу, призначеного для PDF/A або суворого валідатора, генеруйте пакет із перевіреного шаблону і перевіряйте результат. У межах цих правил редагування метаданих на місці - це інструмент потрібного розміру: він виправляє кілька неправильних байтів і залишає дев’яносто дев’ять відсотків файлу, які вже були правильними, саме такими, як їх записав початковий виробник.

Показаний тут API запису завантаженого документа постачається зі стандартним HotPDF Component для Delphi і C++Builder, разом із повним набором методів редагування метаданих, outline і Catalog.