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

round trip appearance ของ annotation ใน Delphi ด้วย PDFium

ใน PDFium Component ก่อน v3.121.1 การอ่าน annotation ผ่าน TPdf.Annotation[] แล้วกำหนด record กลับคืนอาจเติม entry /R กับ /D ว่าง ๆ เข้าไปใน appearance dictionary /AP ของมัน แม้ต้นฉบับจะพกแค่ /N ตัว validate PDF/A ปฏิเสธ dictionary แบบนั้น ตั้งแต่ v3.121.1 getter รายงานแต่ appearance ที่มันอ่านได้จริง การ round trip โดยไม่แก้อะไรจึงไม่เขียนสิ่งใหม่เพิ่ม ความล้มเหลวนี้คุ้มที่จะเข้าใจละเอียด เพราะตัวกระตุ้นที่พบบ่อยคือการแก้ที่ตั้งใจให้ไฟล์ถูกมาตรฐานขึ้น ไม่ใช่ให้แย่ลง

แผนภาพ round trip annotation ของ PDFium Component ที่การเพิ่ม afPrint ผ่าน TPdf.Annotation[] กับ SetAnnotationData ยังเขียน stream /R กับ /D ว่าง ๆ ผ่าน FPDFAnnot_SetAP ด้วย เปลี่ยน appearance dictionary ของ PDF/A ที่สะอาดให้กลายเป็นตัวที่ veraPDF ปฏิเสธ จน v3.121.1 รายงานแต่ appearance ที่อ่านได้จริง
การอ่าน annotation แล้วเขียนกลับโดยไม่แก้อะไรเคยเติม appearance stream แบบ rollover กับ down ว่าง ๆ และนั่นคือสิ่งที่ทำให้ PDF/A ไม่ผ่าน ไม่ใช่ธง Print ที่คุณตั้งใจจะเพิ่ม

อะไรพังบ้างเมื่อคุณเขียน annotation กลับโดยไม่แก้อะไร

คำตอบสั้น ๆ: annotation ได้ appearance stream ที่มันไม่เคยมีมาก่อน และไฟล์ที่ผ่านการตรวจ PDF/A มาก่อนการแก้ของคุณก็ไม่ผ่านหลังจากนั้น สถานการณ์ทั่วไปเดินแบบนี้ ไฟล์ archive ของลูกค้ามาพร้อม annotation แบบ square กับ text ที่ขาดธง Print PDF/A บังคับให้ annotation ทุกตัวถูกพิมพ์ คุณจึงวนไล่ทุกหน้า เพิ่ม afPrint แล้วกำหนด record แต่ละตัวกลับคืน ไม่มีบรรทัดไหนในโค้ดนั้นแตะต้อง appearance เลย record จาก TPdf.Annotation[] เป็น TPdfAnnotation และ SetAnnotationData เขียนทุกฟิลด์ที่ sentinel Has* ถูกตั้งไว้ ซึ่งเป็นวิธีที่คู่ HasContents / ContentsText ถูกออกแบบมาให้ทำงานพอดี ปัญหาคือ getter ตั้ง HasAppearanceRollover กับ HasAppearanceDown เป็น True พร้อม string ว่า ๆ ให้กับ mode ที่ไม่มีอยู่จริง และ setter ก็รับใช้อย่างขยันหมั่นไถเขียน stream ว่างสองตัวออกไป:

procedure MarkAnnotationsPrintable(const FileName: string);
var
  Pdf: TPdf;
  PageNo, I: Integer;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for PageNo := 1 to Pdf.PageCount do
    begin
      Pdf.PageNumber := PageNo;
      for I := 0 to Pdf.AnnotationCount - 1 do
      begin
        A := Pdf.Annotation[I];
        if not (afPrint in A.Flags) then
        begin
          A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
          // ก่อน v3.121.1 การกำหนดค่านี้ยังเขียน /AP/R ว่าง ๆ และ
          // stream /AP/D ด้วยเมื่อ annotation ต้นทางมีแค่ /AP/N
          Pdf.Annotation[I] := A;
        end;
      end;
    end;
    Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
  finally
    Pdf.Free;
  end;
end;

ISO 32000-1 §12.5.5 นิยาม appearance dictionary ด้วย entry สามตัว: /N สำหรับ appearance ปกติ, /R สำหรับ rollover และ /D สำหรับ down /R กับ /D เป็นตัวเลือก เมื่อไม่มี viewer จะตกกลับไปที่ /N แต่ stream /R ที่ว่างไม่ใช่การไม่มี มันเป็น stream ที่ถูกต้องซึ่งไม่วาดอะไรเลย viewer ที่เคารพ appearance แบบ rollover จะแสดงสี่เหลี่ยมว่างทันทีที่ pointer เลื่อนเข้ามาบน annotation PDF/A เข้มกว่านั้นอีก: ISO 19005-1 (พร้อม Corrigendum 2) กับ ISO 19005-2 / 19005-3 อนุญาตแค่ /N ใน appearance dictionary ของ annotation veraPDF รายงานไฟล์ที่ผ่าน round trip ภายใต้กฎ 6.5.3-4 สำหรับ PDF/A-1 และกฎ 6.3.3-2 สำหรับ PDF/A-2 กับ PDF/A-3 และ TPdf.ValidatePdfA ที่ build มาในตัวระบุมันเป็น pvaiAnnotationApDictViolation การแก้ที่เพิ่มธง Print เพื่อให้ผ่านข้อหนึ่งของมาตรฐานจึงพังอีกข้อหนึ่ง

appearance dictionary ของ annotation จาก ISO 32000-1 พร้อม entry แบบ normal, rollover และ down: PDFium คืน 2 ไบต์ทั้งสำหรับ stream ที่หายไปและ stream ว่างที่มีอยู่จริง ทั้งคู่จึงถูกอ่านกลับผ่าน TPdf เป็นไม่มีเนื้อหา ขณะที่การเช็กระดับไบต์อย่าง TPdf.ValidatePdfA เท่านั้นที่จับ stream ว่างที่ PDF/A ห้ามได้
/R ที่ไม่มีจะตกกลับไปที่ /N /R ที่ว่างวาดสี่เหลี่ยมว่างและยังไม่ผ่าน PDF/A และผ่าน record ทั้งสองแยกกันไม่ออก

ทำไม FPDFAnnot_GetAP ถึงคืน 2 สำหรับ appearance ที่ไม่มี

PDFium ไม่เคยคืนศูนย์จาก FPDFAnnot_GetAP แม้ appearance stream ที่ขอมาจะไม่มีอยู่เลย ฟังก์ชันนี้เดินตามแพตเทิร์นสองเรียกของ PDFium: ส่ง buffer nil เพื่อถามขนาดที่ต้องใช้เป็นไบต์ จัดสรร แล้วเรียกอีกครั้งเพื่อ copy ข้อความ UTF-16LE ขนาดรวม terminator ของ UTF-16 เสมอ stream ที่ไม่มีจึงรายงาน 2 ไบต์ ซึ่งคือ string ว่างบวกตัวจบของมัน getter ก่อน v3.121.1 เช็กด้วย ByteLength >= SizeOf(FPDF_WCHAR) เงื่อนไขที่ทุกการเรียกผ่าน ธง HasAppearance* ทั้งสามตัวจึงกลับมาเป็น True กับ annotation ใด ๆ ที่มี appearance สักอย่าง การ round trip ผ่าน record จึงขอให้ FPDFAnnot_SetAP เก็บ string ว่างให้แต่ละ mode และ PDFium ก็สร้าง stream มารองรับ ไม่มี exception ไม่มีเตือน หน้าที่มองเห็นก็หน้าตาเหมือนเดิม ข้อบกพร่องจึงโผล่ใน fixture ของ veraPDF แทนที่จะโผล่ใน viewer

FPDFAnnot_GetAP รายงาน appearance stream ที่หายไปใน PDFium อย่างไร: แพตเทิร์นสองเรียกคืนอย่างน้อยสองไบต์สำหรับ terminator ของ UTF-16 เสมอ เกณฑ์เก่าที่เทียบกับ SizeOf(FPDF_WCHAR) ผ่านทุกการเรียกและตั้ง sentinel HasAppearance เป็น true ทั้งหมด ส่วนเกณฑ์ของ v3.121.1 เรียกเกิน terminator บวกจำนวนไบต์คู่ด้วย
สองไบต์คือ string ว่างที่ถูกเข้ารหัส ไม่ใช่หลักฐานว่า appearance มีอยู่ getter ที่แก้แล้วถือว่าอะไรที่ยาวไม่เกิน terminator คือไม่มีเนื้อหา การเขียนกลับจึงเงียบสนิท

v3.121.1 ตัดสินว่า appearance มีอยู่จริงอย่างไร

ReadAppearance ซึ่งเป็น helper ใน GetPageAnnotation ที่เติม AppearanceNormal, AppearanceRollover และ AppearanceDown ตอนนี้ถือว่าผลลัพธ์เป็นเนื้อหาต่อเมื่อพกตัวอักษรอย่างน้อยหนึ่งตัวเกิน terminator การเรียกแรกต้องคืนมากกว่า SizeOf(FPDF_WCHAR) ไบต์และจำนวนไบต์ต้องเป็นเลขคู่ เพราะความยาวเลขคี่เป็น UTF-16 ไม่ได้ การเรียกที่สองซึ่ง copy ข้อความจริงก็ถูกตรวจซ้ำ: ความยาวที่คืนมา 2 หรือน้อยกว่า หรือยาวกว่า buffer ที่จัดสรรไว้ จะรีเซ็ต HasValue เป็น False และทิ้ง string ว่า ๆ ฝั่งเขียนไม่เปลี่ยน SetAnnotationData ยังเรียก FPDFAnnot_SetAP เฉพาะ mode ที่ธง HasAppearance* เป็น True เหมือนเดิม record ที่อ่านจาก annotation ที่มีแค่ /N จึงเขียนกลับแค่ /N fixture ของ regression ครอบคลุมทั้งสองทิศทาง: annotation แบบ square ที่มี appearance ปกติ อ่านแล้วเขียนกลับโดยไม่แก้อะไร ผ่าน PDF/A-1b, PDF/A-2b และ PDF/A-3b ขณะที่ annotation เดียวกันเมื่อถอดธง Print ออกจะไม่ผ่านที่กฎธงที่คาดไว้เท่านั้น ไม่พังตรงอื่นเลย

stream ที่หายไปกับที่ว่างหน้าตาเหมือนกัน getter จึงระมัดระวังไว้ก่อน

native API แยก appearance stream ที่หายไปออกจากตัวที่มีอยู่แต่ว่างเปล่าไม่ได้ และ PDFium Component ก็ไม่เว่อร์อ้างว่าทำได้ ทั้งสองกรณีคืนไบต์เดียวกันคือ 2 จาก FPDFAnnot_GetAP ทั้งคู่จึงถูกอ่านกลับเป็น HasAppearanceRollover = False พร้อม AppearanceRollover ว่าง ๆ ผลคือมีข้อสรุปสองข้อที่ควรออกแบบรอบมัน หนึ่ง sentinel เป็น False หมายถึง "ไม่ได้อ่านเนื้อหาอะไรมา การเขียนกลับจะปล่อย mode นี้ตามเดิม" ไม่ใช่ "key /R ไม่มีอยู่ใน dictionary" สอง record ตรวจจับ stream ว่างที่อยู่ในไฟล์อยู่แล้วไม่ได้ เอกสารที่ถูกบิลด์เก่าหรือเครื่องมืออื่นทำพังจะอ่านกลับมาสะอางเอี่ยม และการกำหนด record กลับคืนก็ไม่ซ่อมและไม่ทำให้แย่ลง จะหาไฟล์แบบนั้นต้องเช็กที่ระดับไบต์ ซึ่งเป็นหน้าที่ของ TPdf.ValidatePdfA กับworkflow ตรวจ PDF/A preflight ด้วย PDFium Component

จะล้าง appearance แบบตั้งใจอย่างไร

คุณตั้ง sentinel ชัดเจนแล้วส่ง string ว่างเข้าไป setter จะเขียนมันลงไป การห้าม string ว่างใน SetAnnotationData เป็นการแก้แบบตัดตอนสำหรับ bug นี้ แต่มันจะหักหลังผู้เรียกที่ล้าง appearance โดยตั้งใจด้วย ซึ่งเป็นข้อสัญญาเดียวกับที่ HasContents กับ HasAuthor ใช้กับข้อความ การแก้จึงอยู่ที่ getter ทั้งหมด ส่วน setter ยังทำตามสิ่งที่ผู้เรียกขอต่อไป:

// แทนที่ appearance แบบ rollover แล้วล้างมันซ้ำอีกรอบ
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover เป็น True และข้อความ round trip กลับมาเป็น 'q Q'
A.HasAppearanceRollover := True;   // ยืนยันเจตนาซ้ำอย่างชัดเจน
A.AppearanceRollover := '';        // เขียน stream ว่างโดยตั้งใจ
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// อ่านกลับมาเป็น HasAppearanceRollover = False พร้อม string ว่าง:
// stream ว่างกับ stream ที่หายไปแยกกันไม่ออกตรงนี้

อย่าลืมว่า /R หรือ /D ที่ถูกล้างชัดเจนก็ยังนับเป็น key เกินมาตามกฎ PDF/A ที่อ้างไว้ข้างบน ถ้าเป้าหมายคือโปรไฟล์ archive การเขียน /N ที่ไม่ว่างแล้วปล่อยอีกสอง mode ตามเดิมคือรูปร่างเดียวที่ผ่านการตรวจ workflow ใด ๆ ที่ย้าย annotation ระหว่างเอกสาร อย่างการ export กับ import XFDF ด้วย PDFium Component ควรเดินกฎเดียวกัน: copy เฉพาะ mode ที่ต้นทางมีจริง แล้วปล่อย sentinel ที่เหลือเป็น False

แพตเทิร์น read-modify-write ที่ปลอดภัยต่อ PDF/A

อัปเกรดเป็น v3.121.1 หรือใหม่กว่า ปล่อย sentinel ของ appearance ตามที่ getter คืนมาเป๊ะ ๆ และตรวจไฟล์ที่บันทึกก่อนส่งมอบ เพราะ stream ว่างตกค้างจะถูกอ่านกลับมาเป็นไม่มี ขั้นตอนตรวจสอบจึงต้องมองที่เอกสารที่ serialize แล้ว ไม่ใช่ที่ record และมันถูกพอที่จะรันหลังทุก batch:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa ประกาศ TPdfAValidationIssue

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // ตรวจเอกสารที่โหลดอยู่ใน Pdf ปัจจุบัน รวมถึงการแก้
  // ที่ทำผ่าน Pdf.Annotation[] ตั้งแต่เปิดมา
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

วินัยแบบเดียวกันใช้กับ panel ใด ๆ ที่เปลี่ยนสีหรือใส่ annotation ให้หน้าเอกสารเพื่อการตรวจทาน ซึ่งเป็น workflow ที่เล่าไว้ในการสร้าง workflow ตรวจทาน annotation บน Delphi ด้วย PDFium Component: record คือภาพตรึงของสิ่งที่ engine อ่านได้ และ sentinel ที่คุณไม่ได้ตั้งเองควรเดินทางกลับไปตามเดิม annotation API เต็มรูปแบบ, PDF/A preflight และ engine PDFium แท้ ๆ ship มาด้วยกันในPDFium Component for Delphi, C++Builder and Lazarus