บทความเทคนิค

แก้ไขเมตาดาตา PDF ที่โหลดไว้ใน Delphi โดยไม่ต้องเขียนใหม่

คุณมีไฟล์ PDF สัญญาจำนวนมากจากตัวสร้างที่ต่างกันหลายเจ้าและฝ่ายกฎหมายต้องการให้ทุกไฟล์มี Author ที่ถูกต้อง สตริง Producer ที่แก้ไขแล้ว และโหมดการเปิดที่แสดงแผงบุ๊กมาร์กทันที เมื่อมองผ่านมุมง่าย ๆ เราอาจคิดว่าการโหลดแต่ละไฟล์ใหม่และเขียนเอกสารใหม่ทั้งหมดเป็นวิธีสั้น แต่แนวทางนั้นทิ้งหมายเลข object เดิม ประวัติ incremental-update ลายเซ็นดิจิทัล และ xref ที่ปรับละเอียดมากจากเครื่องมือเดิมไปพร้อมกัน ทำให้ไฟล์ดูเหมือนเดิมแต่โครงสร้างคือเอกสารใหม่ที่ไม่ใช่ชุดเดิม

ทางที่ปลอดภัยคือใช้ document ที่โหลดแล้วเป็น object graph และแก้ไขด้วยการแก้ในที่เดิม โดยเข้าไปปรับใน Info dictionary, สตรีม /Metadata และ Catalog เฉพาะค่าที่ต้องการ จากนั้นบันทึกกลับ HotPDF ซึ่งเป็นคอมโพเนนต์ VCL PDF สำหรับ Delphi และ C++Builder มี API เขียนบน loaded-document สำหรับงานนี้ บทความนี้อธิบายการใช้ให้ถูกที่ และชี้จุดผิดพลาดทั่วไปว่ามักแก้ Info แล้วลืมว่า metadata อีกสำเนาเดิมยังอยู่ใน XMP อยู่เสมอ

มีที่เก็บ metadata สองแห่ง และอาจไม่ตรงกัน

PDF เก็บข้อมูลเอกสารไว้พร้อมกันในสองตำแหน่ง นี่คือสาเหตุหลักของข้อความแจ้งเตือนแบบ "เปลี่ยน title แล้ว Acrobat ยังแสดงชื่อเดิม" ตัวแรกคือ Info dictionary แบบดั้งเดิม /Info ที่มีคีย์ /Title, /Author, /Subject, /Keywords, /Creator และ /Producer ตาม ISO 32000-1 §14.3.3 ตัวที่สองคือ XMP packet ซึ่งเป็นเอกสาร XML ที่เก็บเป็น stream ภายใต้ Catalog ที่ /Metadata ตาม §14.3.2 และพึ่งโครงสร้างข้อมูล Adobe XMP

ทั้งสองที่เก็บสามารถมี title ได้ และข้อกำหนดไม่บังคับให้ค่าตรงกันแบบเสมอไป ถ้าไฟล์มี XMP modern viewers และส่วนใหญ่ของ PDF/A validator มักให้น้ำหนัก XMP ก่อน ถ้าไม่มี XMP จึงค่อยกลับไปอ่าน Info dictionary ดังนั้นการแก้เฉพาะ /Info อย่างเดียว ซึ่งเป็นแบบที่พบมากในโค้ดการอัปเดต metadata จะทำให้ reader ที่เชื่อถือ XMP ยังคงแสดงค่าค้างเดิม และ validator PDF/A รายงานความไม่สอดคล้อง การเขียนแบบครบสองทางบนไฟล์ที่มี XMP อยู่แล้วจึงต้องแก้ทั้ง Info และสร้าง XMP ใหม่ให้ตรงกัน ทุกอย่างใน HotPDF มีให้ แต่การใช้งานให้ครบคือความรับผิดชอบของคุณ

แก้ไข Info dictionary

เมธอดฝั่ง Info ของ HotPDF ตรงไปตรงมาและทำนายได้ชัด SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator และ SetLoadedProducer รับ AnsiString เดี่ยวและเขียนคีย์ที่สอดคล้องลงใน Info dictionary ของไฟล์ที่โหลดแล้ว โดยแทนที่ค่าถ้ามีคีย์เดิมหรือเพิ่มใหม่หากไม่มี สำหรับการลบคีย์ทั้งหมด เช่น /Creator ที่ไม่ต้องการ เรียก RemoveLoadedInfoKey ด้วยชื่อคีย์ที่แท้จริง เมธอดเหล่านี้ไม่แตะ XMP ในงานนี้ และทำงานเฉพาะกับ object /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;

จุดสำคัญคือ methods เหล่านี้รับค่าเป็น AnsiString ถ้าเป็น ASCII อย่างเดียวปกติไม่ใช่ปัญหา แต่ข้อความที่มีอักขระไม่ใช่ละตินต้องเข้ารหัสให้ตรงข้อกำหนดก่อนเสมอ โดยทั่วไปคือ UTF-16BE พร้อม byte-order mark หรือ PDFDocEncoding ไลบรารีจะเขียน byte ที่คุณส่งลง object โดยไม่เดารูปแบบของคุณเอง ถ้า title ของคุณใช้อังกฤษล้วนอาจผ่านได้ แต่ถ้ามีตัวอักษรสำเนียงหรือ CJK ต้องเข้ารหัสอย่างตั้งใจและทดสอบด้วย viewer จริงก่อนใช้งานจริง

เขียน XMP packet ใหม่

SetLoadedXMPMetadata คืออีกส่วนของกระบวนการสองขั้นตอน ส่ง XMP packet แบบเต็มเป็น AnsiString ให้กับเมธอดนี้ได้ สองทางเกิดขึ้นตามสถานะปัจจุบันของไฟล์ ถ้า Catalog มีสตรีม /Metadata อยู่แล้ว จะถูกแทนที่ที่เดิมและคงหมายเลข object เดิมไว้ ถ้าไม่มีสตรีมนี้จะถูกสร้างใหม่ด้วย /Type /Metadata และ /Subtype /XML พร้อมจอง object number แล้วผูกจาก Catalog ผลลัพธ์สุดท้ายคือ object metadata ที่สมบูรณ์และอ่านได้

คุณเป็นผู้ควบคุม XML โดยตรง จึงกำหนด schema ได้เอง เช่น dc:title, dc:creator หรือ xmp:CreatorTool ข้อดีคือยืดหยุ่น ข้อควรระวังคือไลบรารีไม่ parse หรือ validate XMP ให้ และจะเขียน byte แบบไม่บีบอัดโดยไม่มี stream filter หาก XML ผิดรูปแบบ ความล้มเหลวอาจเกิดภายหลังในขั้นตรวจสอบ metadata ได้ง่ายที่สุดคือสร้าง XML ให้ชัดเจนและให้ค่าที่เขียนใน Info dictionary เหมือนกันทุกประการ

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 และค่อย Save การเรียกสองรอบนี้เป็นอิสระต่อกัน แต่ความสอดคล้องได้ต้องป้อนค่าเดียวกันเสมอ ถ้าข้ามการเรียก XMP ในไฟล์ที่มี XMP อยู่แล้ว คุณจะกลับไปสู่ปัญหาค่าค้างแบบเดิมอีกครั้ง

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 อยู่ในที่เก็บสองแห่ง ทั้ง Info dictionary และ XMP stream และยังมีคำแนะนำระดับ Catalog และ outline tree การแก้ไขในที่เดิมแตะทุกชั้นโดยไม่สร้างเอกสารใหม่ทั้งหมด

กำหนดวิธีที่ viewer เปิดไฟล์

Catalog มีสามค่าที่ตัดสินว่าผู้ใช้เห็นอะไรทันทีเมื่อเปิดไฟล์ และทั้งสามแก้ได้ด้วยการแก้ค่าบนกราฟที่โหลดแล้วโดยตรง SetLoadedPageMode เขียน /PageMode แบบ name object โดยค่าสำคัญคือ 'UseOutlines' เปิดแผงบุ๊กมาร์ก, 'UseThumbs' เปิดแถบภาพย่อ, 'FullScreen' เปิดโหมดสไลด์, หรือ 'UseAttachments' เปิดแผงไฟล์แนบ ตาม ISO 32000-1 §7.7.3.1 ตาราง 28 ส่วน SetLoadedPageLayout เขียน /PageLayout ด้วยลักษณะเดียวกัน เช่น 'SinglePage', 'OneColumn', 'TwoColumnLeft' และตัวเลือกอื่น ๆ โดยไม่ต้องใส่ slash นำหน้า ไลบรารีจะเพิ่มให้

SetLoadedLanguage เขียนค่าของ /Lang ใน Catalog ให้เป็นแท็กภาษาเหมาะสมของเอกสาร เช่น 'en-US' หรือ 'de-DE' ตามรูปแบบ BCP 47 ความต่างสำคัญคือชนิดข้อมูล /PageMode และ /PageLayout เป็น PDF name ในขณะที่ /Lang เป็น string เมื่ออ่านผลลัพธ์คุณจะเห็น /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;

เปลี่ยนชื่อบุ๊กมาร์กโดยไม่กระทบโครงสร้าง

บางครั้งต้องแก้ชื่อแค่บางรายการ เช่น ตัวสะกดผิดหัวข้อ หรือเลื่อนหมายเลขบท การแก้ SetLoadedOutlineTitle ทำได้โดยระบุดัชนีลำดับชั้นบนสุดแบบ 0-based และชื่อใหม่ เมธอดจะเดินผ่าน Catalog → /Outlines/First/Next มาหาตำแหน่งนี้ แล้วเปลี่ยนเฉพาะ /Title เท่านั้น ค่า destination, สถานะเปิดปิด และโครงสร้างลูกยังคงเดิม

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 ต้องเข้าใจรายละเอียดการคำนวณ /Count ให้ดี ข้อมูลนี้เป็นจำนวน descendants ที่มองเห็นได้ทั้งหมดตาม ISO 32000-1 §12.3.3 ค่าบวกแสดงจำนวนที่ต้องเห็น ค่าเป็นลบแปลว่ามีลูกแต่ถูกย่อ เมื่อเอาโหนดระดับบนออก ตัวนับที่ root ของ /Outlines จะต้องคำนวณใหม่โดยรวมหนึ่งบวกค่าบวกของลูกแต่ละโหนด ห้ามลดลงทีละหนึ่งธรรมดา ถ้าคิดผิดจำนวนบุ๊กมาร์กที่แสดงจะกระโดดเกิน และการลบหนึ่งรายการอาจกระทบมากกว่า 1 รายการ

การบันทึกไม่เขียนใหม่ทั้งไฟล์

การแก้ไขทั้งหมดอยู่บนหน่วยความจำก่อนเท่านั้น และไม่มีอะไรไปลงดิสก์จนกว่า SaveLoadedDocument จะรัน จุดที่ช่วยให้ราคาถูกคือ save ไม่ generate เอกสารใหม่ทั้งหมดแต่คงหมายเลข object เดิมและโครงสร้างที่อ่านไว้ตอนโหลด แล้วเขียนกราฟเดิมกลับพร้อม object ที่เปลี่ยนแปลงหรือตัวใหม่เท่านั้น ทำให้การอัปเดต metadata เหมาะกับงานรายละเอียดแทนการ rebuild ทั้งไฟล์ เป็นกลไกเดียวกับ object streams และ incremental updates ในไฟล์ที่สร้างจาก Word หรือชุด Office อื่น โครงสร้าง object อาจมีความคาดเดาได้จำกัดและเหมาะกับการอ่านคู่มือแบบ hybrid-reference cross-reference streams ใน Office PDFs ก่อนลงมือแก้จริง

ขอบเขตที่ต้องรับรู้มีสองข้อ ควรแยกชัดเจนว่าแนวทางนี้คือการแก้ไขในที่เดิม ไม่ใช่ redaction หรือลบลายละเอียดอย่างปลอดภัย การลบ Info key ตัดเฉพาะคีย์นั้น แต่ค่าที่ค้างในรอบ incremental update เก่าหรือไฟล์เวอร์ชันก่อนหน้าอาจยังเหลืออยู่ หากต้องลบ metadata ที่ละเอียดอ่อนจริง ๆ ต้องใช้ขั้นตอนที่รุนแรงกว่า ส่วนการเขียน XMP จะเขียน byte ตามที่ส่งมาโดยตรงโดยไม่ validate ดังนั้นถ้างานจะไปถึง PDF/A หรือ validator ที่เข้มงวด ควรสร้าง packet จากเทมเพลตที่เชื่อถือได้และตรวจผลลัพธ์เสมอ โดยใช้แนวทางนี้ metadata ที่แก้ก็จะช่วยแก้เฉพาะจุดที่เปลี่ยนและคงส่วนใหญ่ที่ถูกต้องตามเดิม

API สำหรับเขียนบน loaded-document นี้มีอยู่ใน HotPDF Component สำหรับ Delphi และ C++Builder พร้อมชุดเมธอดแก้ไข metadata, outline และ Catalog ครบถ้วน