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

import annotation FDF ใน Delphi: แก้ค่าศูนย์เงียบ

ก่อน v3.539.30 TPDFlib.ImportAnnotationsFromFDFString ใน losLab PDF Library คืนจำนวน entry ของ annotation ใน FDF ที่มัน parse ได้ ทั้งที่ไม่ได้เพิ่มลงเอกสารสักตัว: นับได้ทุก entry ทิ้งไปทุก entry ตั้งแต่ v3.539.30 เป็นต้นมา FDF importer อ่าน key ได้ทุกลำดับ parse /Rect ได้ถูกต้องโดยไม่ขึ้นกับ locale และ exporter คู่กันเขียน /Rect จริงของ annotation ออกมา export ครั้งหนึ่ง import แล้ว export ซ้ำจึงได้ FDF ที่เหมือนกันทุกไบต์ ที่เหลือของโน้ตนี้เล่าว่า offset เริ่มต้นที่ผิดตัวเดียวผลิตความล้มเหลวเงียบที่สมบูรณ์แบบอย่างไร อีกสามบั๊กซ่อนอยู่ข้างหลังมันตัวไหน และจะเช็คการ import ด้วยตัวเองแทนการเชื่อค่าที่คืนกลับมาอย่างไร

สถานการณ์นี้ธรรมดามาก reviewer มาร์ก contract ไว้ คอมเมนต์เดินทางมาเป็นไฟล์ FDF (Acrobat เรียกมันว่า Export Comments) แล้ว service ภาษา Delphi ของคุณ merge มันเข้าสำเนาที่สะอาดด้วย ImportAnnotationsFromFDF การเรียกคืน 7, log บอก "7 comments imported", job เขียว และ PDF ที่ได้ออกมาไม่มีคอมเมนต์แม้แต่ตัวเดียว ไม่มีอะไร raise ไม่มีอะไรเตือน และตัวเลขดูน่าเชื่อเพราะมันคือจำนวน entry ที่แท้จริงในไฟล์นั่นเอง นั่นคือรูปร่างที่แย่ที่สุดที่บั๊กจะมีได้: ฟังก์ชันที่สัญญาณความสำเร็จเพียงอย่างเดียวคือตัวนับที่คำนวณแยกจากงานที่มันอ้างว่ารายงานโดยสิ้นเชิง

ทำไม ImportAnnotationsFromFDFString รายงานว่าสำเร็จแต่ไม่เพิ่มอะไรเลย

importer อ่าน /Subtype ทุกตัวออกมาเป็น string ว่าง และ helper ที่สร้าง annotation ถอยออกตั้งแต่เจอ subtype ว่าง ขณะที่ตัวเรียกเพิ่มค่า result ไปเฉย ๆ key finder คืนตำแหน่งที่อยู่ถัดจาก /Subtype ทันที ซึ่งก็คือช่องว่างก่อนถึงค่า ReadName เริ่มที่ช่องว่างนั้นแล้วหยุดที่ตัวอักขระ whitespace ตัวแรก มันจึงหยุดก่อนจะได้อ่านอะไรเลย AddAnnotationToPage ปฏิเสธสร้าง annotation ที่ไม่มี subtype ซึ่งเป็นทางเลือกป้องกันที่ถูกต้องในตัวมันเอง แต่มันเป็น procedure ที่ไม่มีค่าคืน และ Inc(Result) ก็วางอยู่ข้างนอก ประตูกันพังแต่ละบานเหตุผลดีในตัวของมันเอง พอรวมกันมันกลายเป็น "ไม่มีอะไรทำงาน" ถูกแปลงเป็น "ทำงานทุกอย่าง" การแก้ทำให้ ReadName ข้าม whitespace บังคับว่าต้องขึ้นต้นด้วย / ของ name object ใน PDF และหยุดที่ delimiter ใด ๆ รวมถึง [, ( กับ ) ทั้ง /Subtype/Text และ /Subtype /Text จึงได้ Text เหมือนกัน

ImportAnnotationsFromFDFString ของ PDFlibPas เจอ /Subtype เริ่ม ReadName ที่ whitespace หลัง key จึงได้ชื่อว่าง AddAnnotationToPage ถอยออกเพราะ subtype หายไป ขณะที่ตัวเรียกเพิ่มค่า result ไปเฉย ๆ รายงานว่า import คอมเมนต์ได้เจ็ดตัวทั้งที่ไม่ได้เพิ่มลงเอกสารแม้แต่ตัวเดียว
ประตูกันพังแต่ละบานเหตุผลดีในตัวเอง พอรวมกันมันแปลงไม่มีอะไรทำงานให้กลายเป็นทำงานทุกอย่าง ค่าที่คืนกลับมาจึงต้องไม่มีวันเป็นสิ่งเดียวที่การทดสอบ import เช็ค

ค่าที่คืนกลับมายังต้องดูแลต่อแม้หลังการแก้นั้น จนถึง v3.539.39 ImportAnnotationsFromFDFString ยังเพิ่มค่า result ให้กับ dictionary ที่สมบูรณ์ทุกตัวใน array /Annots รวมถึง entry ที่ /Page แบบเริ่มที่ศูนย์อยู่นอกช่วงหรือ /Subtype หายไป ซึ่งทั้งคู่โดนข้ามไป ตั้งแต่ PDFlibPas v3.539.40 ImportAnnotationsFromFDFString กับ ImportAnnotationsFromFDF คืนจำนวน annotation ที่ถูกเพิ่มจริงเหมือน import ฝั่ง XFDF: helper ฝั่ง FDF AddAnnotationToPage ตอนนี้คืน Boolean และตัวนับขยับเฉพาะตอนสำเร็จ การวัดที่เอกสารยังเป็นเช็คที่แน่นกว่า เพราะมันใช้ได้กับรุ่นเก่าด้วย โค้ดด้านล่างจึงเทียบ AnnotationCount ของแต่ละหน้าก่อนและหลัง import

function TotalAnnotations(Lib: TPDFlib): Integer;
var
  Page, Saved: Integer;
begin
  Result := 0;
  Saved := Lib.SelectedPage;
  for Page := 1 to Lib.PageCount do
    if Lib.SelectPage(Page) = 1 then
      Inc(Result, Lib.AnnotationCount);   // ต่อหนึ่งหน้าที่เลือก รวม widget ด้วย
  Lib.SelectPage(Saved);
end;

var
  Lib: TPDFlib;
  Before, Reported, Added: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contract.pdf', '');
    Before := TotalAnnotations(Lib);
    Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
    Added := TotalAnnotations(Lib) - Before;
    if Added <> Reported then   // เท่ากันตั้งแต่ v3.539.40
      Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
    Lib.SaveToFile('contract-reviewed.pdf');
  finally
    Lib.Free;
  end;
end;

อีกสามบั๊กซ่อนอยู่ข้างหลังบั๊กแรก

การแก้แค่ subtype อย่างเดียวจะเปิดโปงบั๊กอีกสามตัวในฟังก์ชันเดียวกัน แต่ละตัวมองไม่เห็นมาตลอดก็เพราะไม่มี annotation ไปถึงหน้าไหนเลยสักตัว หนึ่ง ReadNumber รับตำแหน่งเป็น value parameter การอ่านเลข /Rect ทั้งสี่ตามลำดับจึงอ่านจุดเดิมสี่รอบ และมันไม่ข้าม [ เปิดด้วย ในทางปฏิบัติจึงไม่ได้อ่านอะไรเลย สอง FindKey ใช้ cursor เดินหน้าตัวเดียวร่วมกันทุก lookup ฝั่ง exporter เขียน /Subtype, /Rect, /Page, /Contents, /T, /Subj แต่ importer ค้นตามลำดับ /Subtype, /Contents, /T, /Subj, /Page, /Rect พอ cursor ผ่าน /Contents ไปแล้ว การค้น /Page กับ /Rect จึงวิ่งทะลุ entry ปัจจุบันไปจบที่ไม่เจออะไรเลยหรือไม่ก็ไปติด key ของ annotation ถัดไป library อ่าน output ของตัวเองไม่ออก สาม ตัวเลขลอดผ่าน PLStrToFloat ที่ตามตัวคั่นทศนิยมของระบบ ISO 32000-1 §12.7.7 นิยาม FDF เป็น object syntax ของ PDF และ key ใน dictionary ของ PDF ไม่มีลำดับ (§7.3.7) ตัว parse FDF ใดที่สมมติลำดับ key จึงผิดตั้งแต่การออกแบบ ไม่ว่าไฟล์จะมาจาก tool ไหน

importer ที่ซ่อมแล้วผูกขอบเขตแต่ละ entry เป็นอย่างแรก FindDictEnd เดินจาก << เปิดไปจนถึง >> ที่จับคู่กัน โดยนับ dictionary ที่ซ้อนอยู่และข้ามเนื้อ string แบบ literal พร้อม escape แบบ backslash ของมัน จึงไม่มี >> ในคอมเมนต์อย่าง (see section >> 4) มาปิด entry ก่อนเวลา การค้น key ทุกครั้งจากนั้นเริ่มที่จุดเริ่มของ entry เองและถูกจำกัดที่สุดของมัน ลำดับ key จึงไม่มีผลและ annotation หนึ่งตัวยืม /Page ของอีกตัวไม่ได้ การ match key ยังรับ delimiter ที่ต่อท้ายชื่อได้ทันทีด้วย เพราะ /Contents(Hi) ถูกต้องเท่ากับ /Contents (Hi) ขณะที่กฎขอบเขตคำกัน /Subj ไป match ต้นของ /Subtype และกัน /T ไป match /Type ReadNumber ตอนนี้รับตำแหน่งเป็น parameter แบบ var ข้าม whitespace กับ [ และ parse ด้วย PLTryStrToFloatInvariant ที่ล้มแบบนุ่มนวลเมื่อเจอ token เพี้ยนแทนการยก exception ถ้าเลขสี่เหลี่ยมตัวใดพัง ทั้งสี่ตัวจะ fallback เป็นศูนย์พร้อมกัน แทนการผลิตสี่เหลี่ยมที่อ่านมาครึ่ง ๆ

FindDictEnd ของ PDFlibPas ตอนนี้ผูก annotation ใน FDF แต่ละตัวจาก << เปิดถึง >> ที่จับคู่ การค้น key ทุกครั้งจึงเริ่มใหม่ที่จุดเริ่มของ entry และหยุดที่สุดของมัน ขณะที่ ReadNumber รับตำแหน่งแบบ var ข้ามวงเล็บแล้ว parse ด้วย PLTryStrToFloatInvariant
cursor ที่ใช้ร่วมกันอ่าน export ของ library เองไม่ออก พอผ่าน /Contents ไป การค้น /Page กับ /Rect ก็วิ่งไปติด key ของ annotation ถัดไป ลำดับ key จึงไม่มีสิทธิ์มีผลอีกต่อไป

ทำไม round-trip ของ FDF ดันทุก annotation ขึ้นไปเท่าความสูงของตัวเอง

exporter รุ่นเก่าเขียนสี่เหลี่ยมด้วยโมเดลพิกัดที่ผิด /Rect ของ annotation คือ [llx lly urx ury] ใน default user space (ISO 32000-1 §12.5.2 สี่เหลี่ยมนิยามไว้ที่ §7.9.5) และ FDF แบก array เดียวกันนี้ แต่ ExportAnnotationsToFDFString กลับไปเรียก GetAnnotRectEx ที่รายงาน Left, Top, Width กับ Height ในพิกัดการวาดของ library ซึ่งเป็นสเปซที่ SetOrigin เป็นคนคุม แล้ว serialize มันออกมาเป็น [L T L+W T+H] พอ importer ทำงานได้ มันก็เขียนค่าสี่ตัวนี้กลับไปตรง ๆ เป็นสี่เหลี่ยม PDF ขอบบนจึงตกที่มุมล่างซ้ายควรจะอยู่ และทุกรอบของ round trip ดัน annotation ขึ้นไปเท่าความสูงของตัวเอง exporter ตอนนี้คัดลอกตัวเลข /Rect ของ annotation เองมาใช้ ทศนิยมสามตำแหน่ง คั่นด้วยจุด ไม่มีเลขชี้กำลัง และ fallback ไปที่สี่เหลี่ยมที่คำนวณได้เฉพาะเมื่อ array ที่เก็บไว้หายหรือไม่ใช่ตัวเลขสี่ตัว

PDFlibPas เคย serialize /Rect ใน FDF เป็น left, top, width, height ในพิกัดการวาด การ import เลขสี่ตัวนั้นกลับเป็น llx lly urx ury จึงวางขอบบนตรงที่มุมล่างซ้ายควรอยู่ และดันทุก annotation ขึ้นไปเท่าความสูงของตัวเองในแต่ละรอบของ round trip
exporter ตอนนี้คัดลอกตัวเลข /Rect ของ annotation เอง — ทศนิยมสามตำแหน่ง คั่นด้วยจุด ไม่มีเลขชี้กำลัง — และชุดทดสอบ regression เทียบ export รอบที่สองกับรอบแรกทีละไบต์

ชุดทดสอบ regression ที่ตอกตะปูเรื่องนี้สมควรเอาไปใช้ต่อ เพราะมัน assert กับเอกสารและกับ export รอบที่สอง ไม่ใช่กับค่าที่ importer คืนมา สังเกตค่าที่คาดหวังเป็น 2: AddNoteAnnotation สร้าง annotation แบบ Text พร้อม Popup ของมัน และทั้งคู่เดินทางไปด้วยกัน ชุดทดสอบยังรัน export กับ import ภายใต้ตัวคั่นทศนิยมแบบจุลภาค ซึ่งเป็นที่ที่อีกครึ่งของเรื่องนี้อาศัยอยู่

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // ตอนนี้มีสองหน้า
    Source.SelectPage(2);
    Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
      'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
    Target.NewPages(1);

    OldSep := FormatSettings.DecimalSeparator;
    FormatSettings.DecimalSeparator := ',';   // จำลองเดสก์ท็อปเยอรมันหรือฝรั่งเศส
    try
      FDF := Source.ExportAnnotationsToFDFString;   // ยังเขียน /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // note กับ popup ของมัน
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

ต้องชัดเจนว่าเส้นทาง FDF แบกอะไรไปได้ importer สร้างแต่ละ entry ใหม่เป็น dictionary ที่มี /Type, /Subtype, /Rect, /Contents, /T กับ /Subj สี ธง สไตล์ขอบ ลิงก์ popup และ appearance stream ไม่ได้อยู่บนเส้นทางนี้ และ exporter ข้าม annotation แบบ Widget เพราะ form field เป็นของเมธอดฝั่ง form data ภาพรวมว่าข้อมูลไหนเดินทางผ่านเมธอดไหนอยู่ในภาพรวมการแลกเปลี่ยนข้อมูลฟอร์ม FDF, XFDF และ XFA และถ้าต้องตรวจว่าอะไรมาถึงจริง ๆ ตัวอ่านแบบ per-index อย่าง GetAnnotType, GetAnnotTitle กับ GetAnnotContentsEx อยู่ในการส่อง outline, annotation และ action

อ่านไฟล์ FDF กับ XFDF ที่ใช้จุลภาคคั่นทศนิยมจาก export รุ่นเก่าอย่างไร

ฝั่ง FDF คำตอบชัดเจนไม่ต้องตีความ: จุลภาคไม่ใช่ delimiter ใน syntax ของ PDF เลข token ที่มีจุลภาคพอดีหนึ่งตัวและไม่มีจุดจึงเป็นทศนิยมที่เขียนบนเครื่อง comma-locale เท่านั้น รุ่นก่อนหน้าเขียนไฟล์แบบนี้จริง เช่น /Rect [10,500 20,250 40,750 60,125] และ ReadNumber ตัวใหม่แปลงจุลภาคตัวเดียวนั้นเป็นจุดก่อน parse token ที่มีจุลภาคสองตัว หรือทั้งจุลภาคและจุด จะถูกปฏิเสธ ไม่ใช่เดาเอา ตัวอ่านไม่รับเลขชี้กำลังด้วย ซึ่งตรงกับ ISO 32000-1 §7.3.3: เลขใน PDF ไม่เคยใช้มัน

XFDF ยากกว่า เพราะใน attribute ของ XML จุลภาคคือตัวคั่น XFDF มาตรฐาน (ISO 19444-1) เขียน rect="50.5,80.25,70.75,100.125" กับ dashes="4,2" ขณะที่ v3.539.28 ลงไป บนระบบ comma-locale เขียน rect="50,500 80,250 70,750 100,125" กับ opacity="0,600" และยังล้มด้วย EConvertError เวลาอ่าน opacity="0.6" มาตรฐาน ตั้งแต่ v3.539.29 ทั้งสองทิศทางเป็น invariant และรูปร่างแบบเก่าถูกจับได้ด้วย XFDFNormalizeLegacyDecimals เฉพาะเมื่อ attribute แยกด้วย whitespace ได้จำนวน token พอดีที่คาด (สี่ตัวสำหรับ rect, ตัวเดียวสำหรับ opacity กับ width) และทุก token มีรูปเลข-จุลภาค-เลข rect มาตรฐานไม่เคย match: มันคือไม่ token เดียวที่มีจุลภาคสามตัว ก็ token ที่จบด้วยจุลภาค ส่วน dashes ถูกทิ้งไว้ตามเดิมโดยตั้งใจ เพราะ 4,2 จะเป็นความยาวขีดสองค่าหรือเลขเก่า 4.2 ก็ได้ และไม่มีกฎไหนแยกคู่นี้ออกจากกันได้

const
  // key เรียงผิดลำดับ exporter พร้อมเลขทศนิยมจุลภาคจาก export รุ่นเก่าบนเครื่อง comma-locale
  LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
    '<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
    '/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
    '] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;               // เอกสารใหม่มีหน้าเดียว
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // export ซ้ำเป็น XFDF ด้วยทศนิยมจุด: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

ชุดทดสอบ import annotation ควร assert กับอะไรจริง ๆ

ชุดทดสอบ import ที่มีประโยชน์ต้อง assert กับสถานะของเอกสารปลายทาง ไม่ใช่แค่กับสิ่งที่ importer พูดถึงตัวเองเด็ดขาด อะไรในชุดทดสอบก็ไม่เคยเช็ค AnnotationCount หลัง import จาก FDF และค่าที่คืนกลับมา ตัวเลขเดียวที่ทุกคนมอง ก็คือตัวเลขเดียวที่บั๊กปล่อยให้สมบูรณ์ assertion สามข้อจะจับความพังที่เล่ามาทั้งหมดได้: จำนวน annotation บนหน้าที่คาดไว้, อ่าน field หนึ่งตัวกลับผ่าน GetAnnotType หรือ GetAnnotContentsEx และ export รอบที่สองเทียบกับรอบแรกทีละไบต์ วินัยแบบเดียวกันใช้กับ API ใดที่เขียนโครงสร้างเอกสารทั้งก้อนซ้ำ รวมถึงการรวม field ที่ซ้ำกันที่เล่าไว้ในการ merge form field ที่ซ้ำกัน: เช็คต้นไม้ที่ได้ออกมา ไม่ใช่จำนวนรวมที่ถูกคืนกลับมา เมธอด annotation ฝั่ง FDF กับ XFDF ทั้งแบบไฟล์และแบบ string มาพร้อมlosLab PDF Library for Delphi and C++Builder รุ่นที่ควรรันคือ v3.539.30 ขึ้นไปถ้าคอมเมนต์ต้องรอดจากการเดินทาง และ v3.539.40 ขึ้นไปถ้าจำนวนที่คืนมาต้องตรงกับที่เพิ่มลงไปจริง