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

ทำไมการเซฟ PDF ที่ไม่แก้อะไรถึงทำ Info กับ XMP เสียหาย

PDF Library for Delphi v3.539.18 กับ v3.539.20 แก้สองเส้นทางที่การเซฟ PDF ซึ่งไม่แก้อะไรเลยยังทำ metadata ของเอกสารเสียหายได้: เมื่อ /CreationDate กับ /ModDate อ้างถึง string object ตัวเดียวกัน การอัปเดต ModDate อัตโนมัติเขียนทับทั้งคู่ และเมื่อ XMP object ถูกสร้างขึ้นก่อนที่ stream /Metadata ต้นฉบับจะถูกอ่าน packet ดีฟอลต์ก็แทนที่ตัวเดิม fix แทนที่การอ้างอิงใน dictionary แทนที่จะ mutate object ที่ใช้ร่วมกัน และจับ packet เดิมไว้ก่อนการ initialize XMP แบบ lazy

การเซฟเป็น operation ที่น่าสนใจน้อยที่สุดที่ library PDF ทำ: โหลดไฟล์ เซฟในชื่อใหม่ ไม่แตะอะไรในระหว่างนั้น หน้า render เหมือนกันทั้งก่อนและหลัง hash ของ content stream ตรงกัน ไฟล์ผ่านทุกการตรวจที่เรามี และมันยังผิดอยู่สองจุดที่ renderer ไม่มีทางแสดงให้เห็น ข้อบกพร่องทั้งสองอยู่ในเส้นทาง read-modify-write ที่การแก้ไขจริงทุกครั้งต้องผ่าน การเซฟใด ๆ จึงพอจะกระตุ้นมันได้ และทั้งคู่ถูกเจอเมื่อ parser อิสระตัวที่สองเทียบ semantics ที่ไม่ใช่ภาพของไฟล์สองไฟล์

ทำไมการเซฟ PDF ถึงเปลี่ยน CreationDate ของมัน

เพราะ document information dictionary ได้รับอนุญาตให้สองคีย์อ้างถึง indirect string object ตัวเดียวกัน และ library กำลังอัปเดต object ไม่ใช่คีย์ ISO 32000-1 §7.3.10 ยอมให้ค่าของ dictionary เป็น indirect reference ได้ และไม่มีอะไรใน §14.3.3 ตาราง 317 บอกว่าค่าใต้ /CreationDate ต้องเป็น object คนละตัวกับค่าใต้ /ModDate producer ที่เขียน timestamp เดียวกันสองครั้งตอนสร้างไฟล์สามารถชี้ทั้งสองคีย์ไปที่ 2728 0 R ตัวเดียวได้อย่างถูกต้องตามสเปก ซึ่งก็คือสิ่งที่เอกสารออกแบบตัวอักษร CJK ตัวหนึ่งในคลังไฟล์ทดสอบของเราทำ

ตัวกระตุ้นคือวันที่แก้ไขอัตโนมัติ ถ้าไม่ได้ตั้ง UserModDate ไว้ SaveToFile จะเรียก SetInfo('ModDate', ...) ด้วยเวลาปัจจุบันก่อนเขียนไฟล์ ซึ่งไปถึง SetRawInfo SetRawInfo ตัวเก่าค้น object ใต้คีย์นั้น และถ้าเจอ TPDFString ก็เรียก SetTo กับมัน นั่นคือการเขียนทับ object ที่คีย์ resolve ไปถึงอยู่ในที่ และเมื่อ object นั้นถูกใช้ร่วมกัน /CreationDate ก็รายงานเวลาเซฟด้วย เอกสารยังเปิด พิมพ์ และ render เหมือนเดิมทุกพิกเซล ชุดทดสอบ visual regression จึงผ่านฉลุย

แผนภาพการ mutate string ของ Info ที่ใช้ร่วมกันใน PDFlibPas: /CreationDate กับ /ModDate อ้าง string object 2728 0 R ตัวเดียวกันได้ตามสเปก, SetRawInfo ตัวเก่าเรียก SetTo กับสิ่งที่คีย์ resolve ไปถึงและเขียนวันที่ทั้งสองด้วยเวลาเซฟ ส่วน SetRawInfo ตัวใหม่เพิ่ม string ใหม่ใต้คีย์นั้นโดยรักษาโหมด hex string ไว้
การอัปเดต entry ใน dictionary ตอนนี้แทนที่การอ้างอิงของ entry นั้น ไม่ได้ mutate object ที่ใช้ร่วมกัน การเขียน ModDate อัตโนมัติครั้งเดียวจึงเปลี่ยน CreationDate ไม่ได้อีก และ object ที่ถูกแทนที่ยังถูกเก็บไว้ให้การอ้างอิงอื่นใช้ต่อ
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 คือ CreationDate, 8 คือ ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

fix ใน TPDFDocument.SetRawInfo นั้นเล็ก และหลักการข้างหลังมันเป็นเรื่องทั่วไป: การอัปเดต entry ใน dictionary จะแทนที่การอ้างอิงของ entry นั้น ไม่เคยแทนที่ object ที่มันบังเอิญ resolve ไปถึง โค้ดใหม่อ่าน TPDFStringMode ที่มีอยู่เพื่อให้ hex string ยังเป็น hex และ literal string ยังเป็น literal แล้วเพิ่ม string ใหม่จาก FStructure.NewString(Value, StringMode) ใต้คีย์นั้น รายละเอียดอีกสองอย่างสำคัญพอ ๆ กับการเปลี่ยนหลัก branch เก่าสำหรับ entry ที่เป็น stream จะล้าง stream ด้วย SetTo('') ก่อนแทนที่ ซึ่งจะทำให้ค่าของทุกคีย์ที่ยังชี้ไปที่ stream นั้นว่างเปล่าไปด้วย การล้างนั้นจึงถูกถอดออก และ object ที่ถูกแทนที่ก็ไม่ถูกลบ เพราะ structure เป็นเจ้าของมันและการอ้างอิงอื่นอาจยังต้องใช้

// แบบเก่า: mutate object อะไรก็ตามที่คีย์ resolve ไปถึงตอนนั้น
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// แบบใหม่: รักษารูปแบบการเก็บไว้ แทนที่เฉพาะการอ้างอิงของคีย์นี้
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

regression ใน Tests\SharedInfoSemantics.inc สร้างการ alias ขึ้นมาเองโดยตั้งใจ ไม่ได้พึ่งไฟล์ในคลัง: hex string หนึ่งตัวถูกอ้างจากคีย์วันที่ทั้งสอง, string แบบตรงตัวหนึ่งตัวถูกใช้ร่วมกันโดย /Title กับ /Subject และ stream หนึ่งตัวถูกใช้ร่วมกันโดย /Author กับ /Keywords หลังอัปเดตคีย์หนึ่งของแต่ละคู่ อีกคีย์ต้องยังอ่านค่าเดิมได้ และ string ที่อัปเดตแล้วต้องยังเป็น hex เอกสารอ้างอิงสาธารณะของ SetInformation ตอนนี้ระบุหลักประกันไว้ประโยคเดียว: การอัปเดตฟิลด์ใน Info แทนที่เฉพาะฟิลด์นั้น แม้ฟิลด์อื่นจะอ้าง object เดียวกัน

ทำไม XMP packet ที่มีอยู่ถึงถูกแทนที่ด้วยค่าดีฟอลต์

เพราะลำดับของสองบรรทัด TPDFDocument.GetMetadata มี fast path: เมื่อฟิลด์ XMP ถูก assign ไว้แล้ว มันจะคืน XMP.SaveToString แทนที่จะ decode stream /Metadata จาก catalog มี call site หลายแห่งที่ initialize แบบ lazy ด้วย XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata); ซึ่งอ่านแล้วเป็นธรรมชาติและผิด: กว่า GetMetadata จะรัน XMP ถูก assign ไปแล้ว สิ่งที่เป็น source ให้โหลดจึงเป็น packet ดีฟอลต์ที่ serialize ออกมาจาก object ที่เพิ่งสร้างเมื่อบรรทัดก่อน packet ต้นฉบับที่มี dc:creator, namespace ที่กำหนดเอง และ standards identification ใด ๆ ไม่เคยไปถึง object และถูกเขียนทับตอนเซฟ วันที่แก้ไขอัตโนมัติตัวเดียวกันก็พอจะกระตุ้นเรื่องนี้ได้ เพราะ SetInfo initialize XMP ก่อนแตะ Info dictionary เพื่อให้ xmp:ModifyDate ตรงกับ /ModDate สังเกตว่าข้อบกพร่องนี้ซ่อนอยู่หลังอะไร: การเทียบ Info dictionary จาก bug ตัวแรกผ่านฉลุย เพราะ /Author กับ /Title ใน /Info ไม่ถูกแตะ มีแค่ต้นไม้ XMP ที่เปลี่ยน และมีแค่การตรวจที่ parse แล้วเทียบต้นไม้นั้นเท่านั้นที่รู้ตัว

แผนภาพลำดับการ initialize XMP แบบ lazy ใน PDFlibPas: การสร้าง XMP object ก่อนเรียก GetMetadata ทำให้ fast path serialize packet ดีฟอลต์และทิ้ง dc:creator, namespace ที่กำหนดเองและ standards identification ส่วนการจับ Source ก่อน TPDFlibXMP.Create จะโหลด stream /Metadata ต้นฉบับจาก catalog
การเซฟทุกครั้งกระตุ้นการสลับนี้ เพราะ SetInfo initialize XMP เพื่อให้ xmp:ModifyDate ตรงกับ /ModDate การ initialize แบบ lazy ทุกจุดในเอกสารจึงไหลผ่าน EnsureXMP ตัวเดียวที่จับ packet เดิมไว้ก่อนสร้าง object
// ผิด: GetMetadata ตอนนี้ serialize object ที่เพิ่งสร้างเมื่อบรรทัดก่อน
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// ถูก: จับ stream /Metadata ก่อน แล้วค่อยสร้างและโหลด
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

fix ทำสองอย่าง TPDFDocument.EnsureXMP ตอนนี้จับ Source := GetMetadata ก่อน TPDFlibXMP.Create และการ initialize แบบ lazy ทุกจุดในเอกสารถูกแทนที่ด้วยการเรียกมัน: SetInfo, SetXMPInformation, GetXMPInformation, ตัวตั้งโหมด PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR และ PDF/UA และเส้นทางซ่อม metadata entry สาธารณะอย่าง SetXMPProperty ก็ผ่าน EnsureXMP อยู่แล้ว และ GetXMPProperty อ่านผ่าน GetDocumentMetadata พื้นผิวทั้งหมดจึงใช้ลำดับการ initialize ชุดเดียว สำเนาที่ถูกต้องของลำดับสามบรรทัดหนึ่งชุดมีค่ามากกว่าสำเนาสิบชุดที่บังเอิญตรงกันวันนี้

กับดักเล็ก ๆ สองอันที่เจอบนเส้นทางเดียวกัน

XMP serializer บน Windows ใช้ XML writer ของแพลตฟอร์ม ซึ่งปล่อย XML declaration ที่ packet ไม่ควรมี โค้ดเดิมตัดมันออกโดยลบตัวอักษรไปเรื่อย ๆ จนถึง <?xpacket ISO 16684-1 §7.3.2 ทำให้ wrapper xpacket เป็นทางเลือก และ producer ที่เขียนเฉพาะ element <x:xmpmeta> เปล่า ๆ ก็อยู่ในมาตรฐาน บน packet แบบนั้น loop จึงลบเอกสารที่ถูกต้องทั้งฉบับทิ้ง serializer ตอนนี้หาตำแหน่ง ?> ปิดของ declaration แล้วลบเฉพาะส่วนนั้น Tests\XMPRetentionSemantics.inc รันการตรวจ retention สองรอบ รอบหนึ่งมี wrapper และอีกรอบตัด wrapper ออก แล้ว assert ว่าเครื่องหมาย namespace ที่กำหนดเองกับชื่อผู้เขียนเดิมอยู่รอดผ่าน SetInfo, GetMetadata, SaveToString และการโหลดกลับ กับดักตัวที่สองเป็น symbol ของ preprocessor: การซิงก์ Info ไปเป็น XMP ใน SetInfo ถูก guard ด้วย NOVCL ซึ่งถูกตั้งสำหรับบิลด์ Free Pascal แต่ backend ของ XMP ขึ้นกับระบบปฏิบัติการ ไม่ใช่กับ framework เพราะ PDFlibXMP.pas นิยาม NO_XMP เฉพาะเมื่อไม่มี OS_WINDOWS บิลด์ Lazarus บน Windows จึงมี XMP object ที่ทำงานได้และมี SetInfo ที่ข้ามการอัปเดตมันเงียบ ๆ ตอนนี้ guard เป็น NO_XMP แอปพลิเคชัน Free Pascal บน Windows จึงได้การซิงก์เหมือน Delphi

จะเก็บ ModDate เดิมไว้ในการเซฟแบบผ่านทางได้อย่างไร

ตั้ง KeepModDate ใน TPDFlibSaveOptions แล้วเซฟผ่าน SaveToFileOptions option นี้ตั้ง UserModDate ไว้ตลอดการเรียก และ SaveToFile จะข้าม timestamp อัตโนมัติ ซึ่งก็เป็นขั้นตอนที่ initialize XMP object แบบ lazy ด้วย เอกสารที่คุณไม่เคยแตะ metadata และไม่ได้เปิดโหมด compliance ใด ๆ จะเก็บทั้ง Info dictionary และ stream /Metadata ไว้ตามที่โหลดมา การเรียก SetInformation(8, ...) ให้ผลเดียวกันอย่างถาวร เพราะการตั้งวันที่แก้ไขเองเป็นการทำเครื่องหมายว่าผู้ใช้ควบคุมมัน

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // ไม่มี /ModDate อัตโนมัติ ไม่มี lazy XMP init
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

ซื่อสัตย์กับสิ่งที่มันให้ด้วย KeepModDate เป็นตัวเลือกที่ถูกสำหรับขั้นตอนผ่านทางที่ output ควรอธิบาย revision เดียวกับ input และเป็นตัวเลือกที่ผิดสำหรับอะไรก็ตามที่แก้เนื้อหาจริง เพราะ §14.3.3 คาดหวังให้ /ModDate สะท้อนการแก้ไขล่าสุด มันยังไม่ได้ซ่อม library ที่ mutate object ที่ใช้ร่วมกันย้อนหลังด้วย มันแค่เลี่ยงการเขียนครั้งเดียวที่เปิดโปงข้อบกพร่องนั้น fix สองข้อข้างบนคือสิ่งที่ทำให้การเซฟธรรมดาปลอดภัย และ option นี้คือสิ่งที่ทำให้การไม่แก้อะไรโดยตั้งใจซื่อสัตย์

จะยืนยันได้อย่างไรว่าเซฟแล้วไม่มีอะไรเปลี่ยนนอกจาก ModDate

ไม่ใช่ด้วยพิกเซลและไม่ใช่ด้วย hash ของ stream เพราะข้อบกพร่องทั้งสองทิ้งทุกหน้าและทุก content stream ให้เหมือนเดิมทุกไบต์ การตรวจที่จับได้คือ snapshot semantic ที่ไม่ใช่ภาพ ซึ่งถ่ายโดย parser อิสระที่ไม่ใช้โค้ดร่วมกับ library ที่ถูกทดสอบ จากไฟล์ต้นทางและจากไฟล์ที่เซฟแล้ว ตามด้วยการเทียบเชิงโครงสร้าง snapshot ครอบคลุม Info dictionary โดยตัด /ModDate ออก ต้นไม้ outline ที่ bookmark แต่ละตัวถูก resolve เป็นหมายเลขหน้าไม่ใช่หมายเลข object named destination และปลายทางของ link ที่ resolve แบบเดียวกัน ค่าฟิลด์ฟอร์ม ไบต์ของไฟล์แนบในรูป hash และ XMP packet ที่ parse เป็นต้นไม้แทนที่จะเทียบเป็นข้อความ หมายเลข object จงใจไม่อยู่ในนั้น เพราะการ rewrite เต็มรูปแบบเปลี่ยนหมายเลขใหม่หมดและการเทียบที่อิงหมายเลขจะรายงานแต่สัญญาณรบกวน

แผนภาพการตรวจสอบ semantic ที่ไม่ใช่ภาพสำหรับการเซฟของ PDFlibPas: parser อิสระที่ไม่ใช้โค้ดร่วมกันถ่าย snapshot ของ Info dictionary โดยตัด /ModDate, หน้าของ outline กับ destination, ค่าฟิลด์ฟอร์ม, hash ของไฟล์แนบ และต้นไม้ XMP แล้วเทียบไฟล์ต้นทางกับไฟล์ที่เซฟโดยตัด /ModDate, xmp:ModifyDate และ xmp:MetadataDate ออกเป็นความเปลี่ยนแปลงที่คาดไว้
พิกเซลและ hash ของ stream เหมือนเดิมทุกไบต์ผ่านข้อบกพร่องทั้งสอง การเทียบจึงทำบน semantics ที่ resolve แล้วแทนที่จะเป็นหมายเลข object และ metadata ที่รอดอยู่ถูกรายงานตามจริงว่าเก็บไว้ได้ ไม่ได้แปลว่าถูกต้องตาม schema หรือผ่าน PDF/UA กับ PDF/A

ส่วนที่ตัดออกสำคัญพอ ๆ กับส่วนที่ใส่เข้าไป /ModDate, xmp:ModifyDate และ xmp:MetadataDate ถูกคาดหมายให้เปลี่ยนและถูกตัดก่อนเทียบ ไฟล์ที่ต้นทางไม่มี XMP เลยก็ไม่ถูกหักคะแนนที่ได้ packet มาใหม่ สิ่งที่การตรวจนี้ไม่ได้อ้างก็ชัดเจนพอ ๆ กัน: การรักษา packet เดิมไว้ไม่ได้บอกอะไรว่า packet นั้นถูกต้องตาม schema หรือเอกสารผ่าน PDF/UA หรือ PDF/A part ใด นั่นเป็นคำถามคนละข้อกับเครื่องมือคนละชุด และการเอา metadata ยังอยู่ไปปนกับ metadata ผ่านมาตรฐานคือวิธีที่ bug ตัวแรกซ่อนตัวได้นานขนาดนั้น ฝั่ง library ตอนนี้ regression ทั้งสองรันทุก pass ที่กำหนดบน Delphi Win32 กับ Win64 และ Free Pascal Win32 กับ Win64 และการเทียบ semantic เป็นเงื่อนไขผ่านของ benchmark คลังเอกสารจริง

ถ้าคุณทำงานในระดับที่ต่ำกว่า fix เหล่านี้ กลไกของการที่การเซฟเขียน object ใหม่ถูกพูดถึงใน incremental update และการเซฟแบบต่อท้าย ซึ่งเป็นโหมดเซฟเดียวที่ object ที่ใช้ร่วมกันถูกปล่อยไว้ที่เดิม และใน modification level และการ diff revision ซึ่งเป็นอีกที่ที่วันที่ค้างเก่าหรือถูกเขียนใหม่ทำให้ผู้อ่านเข้าใจผิด มุมมองฝั่งซ่อมของคู่ Info กับ XMP เดียวกัน ซึ่งทำให้สองครึ่งตรงกันแทนที่จะแค่รักษาไว้ อยู่ใน การแปลงเป็น PDF/A และการซ่อม metadata

PDF Library for Delphi เป็น library PDF ที่เขียนด้วย Pascal แบบ native สำหรับ Delphi, C++Builder และ Lazarus และเส้นทาง read-modify-write ที่อธิบายตรงนี้ก็เป็นเส้นทางเดียวกับการแก้ไขทุกครั้งในโปรเซสของคุณ หลักประกันข้างบนจึงใช้ได้ไม่ว่าคุณจะเซฟวันละครั้งหรือวันละพันครั้ง ดูหน้าผลิตภัณฑ์ PDF Library for Delphi สำหรับ compiler และแพลตฟอร์มที่รองรับ