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

page label ของ PDF ใน Delphi: แก้ number tree ที่มี /Kids

PDF Library for Delphi เขียนช่วง page label ด้วย AddPageLabels และตั้งแต่ v3.539.10 call นี้ใช้กับไฟล์ที่โหลดมาซึ่ง number tree /PageLabels ถูกแตกเป็น node /Kids ได้ด้วย: root ถูก flatten เป็น leaf /Nums เดียวก่อนช่วงใหม่จะลงไป label จึงโผล่ขึ้นใน viewer จริง ๆ แทนที่จะถูกเมินเงียบ ๆ เหยื่อตัวยกคือ PDF แนวหนังสือที่มาจาก layout tool ซึ่งเลขโรมันอยู่หน้าแรก ๆ เลขอารบิกอยู่เนื้อหา และภาคผนวกติดป้าย A-1, A-2 โดยคุณแค่อยากเปลี่ยนป้ายภาคผนวกเท่านั้น แต่อะไรก็ไม่ขยับ

page label ของ PDF คืออะไรและถูกเก็บอย่างไร

page label คือข้อความที่ viewer แสดงในช่องหน้าแทน index หน้าแบบกายภาพ และ ISO 32000-1 §12.4.2 เก็บมันเป็น number tree ใต้คีย์ catalog /PageLabels คีย์แต่ละตัวคือ index หน้าที่เริ่มที่ศูนย์ซึ่งเปิดช่วงการติดป้าย ส่วนค่าแต่ละค่าเป็น page label dictionary ที่มี entry ได้ถึงสามตัว: /S สำหรับสไตล์การนับ (D, R, r, A หรือ a), /P สำหรับข้อความ prefix และ /St สำหรับค่าตัวเลขของหน้าแรกในช่วง ซึ่ง default เป็น 1 ช่วงหนึ่งวิ่งไปจนถึงคีย์ถัดไป และสเปกบังคับว่า tree ต้องมีค่าสำหรับ index หน้า 0 ทุกหน้าจึงต้องมีช่วงใดช่วงหนึ่งครอบอยู่

การเก็บ page label ในแง่ของ PDFlibPas: number tree /PageLabels ตั้งคีย์ของแต่ละช่วงด้วยหน้าเริ่มต้นที่เริ่มที่ศูนย์ ทุกค่าเป็น label dictionary ที่มี /S สไตล์, /P prefix และ /St เลขเริ่มต้น ตัวอย่างหนังสือจับเลขโรมันหน้าแรก ๆ, หน้าเนื้อหาอารบิกและภาคผนวก A- ลงบนสามช่วง
ช่วงหนึ่งวิ่งไปจนถึงคีย์ถัดไป สเปกบังคับว่าต้องมีค่าสำหรับ index หน้า 0 และ GetPageLabel ใช้ช่วงสุดท้ายที่คีย์อยู่ที่หน้าหรือต่ำกว่าหน้านั้น ทุกหน้าจึง resolve ได้เป็นอะไรบางอย่างเสมอ
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // หน้า 1-4: i, ii, iii, iv (เลขโรมันตัวพิมพ์เล็ก)
    Lib.AddPageLabels(1, 3, 1, '');
    // หน้า 5-120: 1, 2, 3 ... (ทศนิยม)
    Lib.AddPageLabels(5, 1, 1, '');
    // หน้า 121 เป็นต้นไป: A-1, A-2 ... (ทศนิยมพร้อม prefix)
    Lib.AddPageLabels(121, 1, 1, 'A-');
    WriteLn(Lib.GetPageLabel(5));    // 1
    WriteLn(Lib.GetPageLabel(122));  // A-2
    Lib.SaveToFile('handbook-labeled.pdf');
  finally
    Lib.Free;
  end;
end;

TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) จับอาร์กิวเมนต์ลงบน dictionary นั้นไร้ความประหลาด เมื่อคุณรู้กฎสามข้อ Start เริ่มที่ 1 เหมือนอาร์กิวเมนต์หน้าอื่นทุกตัวใน library และถูกเขียนลง tree เป็น Start - 1 Style วิ่งจาก 0 ถึง 5 โดย 0 หมายถึงมีแค่ prefix และ 1 ถึง 5 กลายเป็นค่า /S คือ D, R, r, A และ a อะไรที่นอกช่วงนี้คืน 0 และไม่แตะอะไรเลย Offset กลายเป็น /St เมื่อมันมากกว่าศูนย์เท่านั้น ส่ง 0 เข้าไปคือละคีย์นั้นไว้และ viewer ย้อนไป default ที่ 1 เพราะ page label เข้ามาใน PDF 1.3 call นี้จึงรัน EnsureMinVersion('1.3', '/PageLabels') ด้วย ซึ่งยกเวอร์ชัน output ของไฟล์เก่าขึ้น เว้นแต่คุณได้ล็อกเวอร์ชันการเซฟไว้ชัดเจน

ทำไม page label ใหม่หายไปเมื่อ tree มี /Kids

label ใหม่หายไปเพราะ ISO 32000-1 §7.9.7 (Table 37) บังคับว่า root ของ number tree แบกได้อย่างใดอย่างหนึ่งระหว่าง /Kids กับ /Nums ห้ามมีทั้งคู่ ขณะที่ helper NumTreeSet รุ่นก่อนรู้วิธีหาแต่ /Nums ตัว producer ที่ปล่อยเอกสารยาวมักแตก tree เป็น node ระดับกลาง แต่ละตัวมีคู่ /Limits แล้วแขวนไว้กับ root ที่มีแต่ /Kids โค้ดเดิมไม่เจอ /Nums บน root นั้น จึงสร้างตัวใหม่วางข้าง /Kids ที่มีอยู่แล้วหยอดช่วงใหม่ลงตรงนั้น ผลคือ root ที่มีจุดเข้าสองจุดที่กันโลกกันเอง viewer เดินลงผ่าน /Kids และไม่เคยแล array เร่ร่อน EnumNumTree ของ library เองก็เช็ก /Kids ก่อนเช่นกัน และ NumTreeLookup ปฏิเสธ node ที่ HasKids xor HasNums เป็น false AddPageLabels ยังคืน 1 และไฟล์ที่เซฟยังเปิดได้เรียบร้อย ซึ่งเป็นความล้มเหลวที่แย่ที่สุดแบบหนึ่ง: ไม่มีอะไรร้องเรียก label แค่คงเดิมอยู่อย่างนั้น

การแก้ใน NumTreeSet คือแปลง root เป็น leaf ก่อนแทรกอะไรลงไป เมื่อ root แบก /Kids อยู่ EnumNumTree จะเดินทุก leaf ตามลำดับแล้วเก็บคู่คีย์กับค่าแต่ละคู่ array /Nums แบบแบนใหม่ถูกสร้างจากรายการนั้น และ /Kids, /Limits รวมถึง /Nums ค้างเก่าถูกกวาดออกจาก root ก่อน array แบบจะถูกผูกเข้าไป การทิ้ง /Limits ไม่ใช่เรื่องทำความสวย เพราะ Table 37 ยอมรับ entry นั้นเฉพาะบน node ระดับกลางกับ leaf เท่านั้น ห้ามบน root จากจุดนั้นการแทรกคือการแทรกแบบเรียงลำดับธรรมดาลงใน array เดียว และช่วงเดิมรอดตาม label dictionary ต้นฉบับของมัน trade-off นี้ตั้งใจมา: tree ไม่ถูกสร้างกลับเป็น node /Kids ที่สมดุลภายหลัง สำหรับ page label มันไม่เสียอะไร เพราะแม้คู่มืออ้างอิงก้อนใหญ่ก็หายากที่จะมีช่วงเกินสิบกว่าช่วง และ leaf เดียวก็คือสิ่งที่ producer ส่วนใหญ่เขียนอยู่แล้ว

การซ่อม number tree ใน PDFlibPas: root ที่แบก /Kids พร้อม array /Nums เร่ร่อนมองไม่เห็นจากฝั่ง viewer เพราะ ISO 32000-1 ยอมให้มีอย่างใดอย่างหนึ่งเท่านั้น NumTreeSet จึง flatten ทุก leaf เป็น array /Nums เดียวและกวาด /Kids กับ /Limits ซึ่ง Table 37 ไม่เคยยอมให้อยู่บน root ออกไป
ไม่มีอะไรร้องเรียกเพราะการเช็กทุกอย่างผ่านหมด: AddPageLabels คืน 1, ไฟล์ที่เซฟเปิดได้เรียบร้อย และมีแค่ reader ที่ลงผ่าน /Kids ก่อน อย่างที่ viewer กับ library เองก็ทำ ที่จะไม่เจอช่วงใหม่เลย
// เปลี่ยนป้ายภาคผนวกในไฟล์ที่ root /PageLabels ใช้ /Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // เช่น A-1
  // แทนที่ช่วงที่เริ่มที่หน้า 121: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // ช่วงเลขโรมันกับทศนิยมเดิมยังอยู่ใน leaf ที่ flatten แล้ว
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, ไม่เปลี่ยน
end;

/Nums array ถูกอ่านผิดเป็นคีย์ได้อย่างไร

/Nums array ถูกอ่านผิดเป็นคีย์เมื่อโค้ดเดินมันทีละ element เพราะ array เป็นแถวแบนสลับคู่ [key0 value0 key1 value1 ...] และมีแค่ตำแหน่งคู่เท่านั้นที่เป็นคีย์ loop ของ NumTreeSet รุ่นเก่าทดสอบทุก element ว่าเป็นชนิดตัวเลขไหม ค่าที่บังเอิญเป็นตัวเลขจึงถูกเทียบเหมือนเป็นคีย์ กรณีที่เทียบแบบน้อยกว่าติดอาจตั้งจุดแทรกไปที่ index คี่แล้วหยอดคู่ใหม่เข้ากลางคู่ที่มีอยู่ ทำให้คู่ทุกตัวหลังจากนั้นเลื่อนเฟส EnumNumTree เดินทีละขั้นแบบเดียวกัน ทั้งคู่ตอนนี้วนเป็นคู่ด้วย stride สอง อ่านคีย์ที่ X * 2 และค่าที่ X * 2 + 1 และคีย์ที่ตรงเป๊ะจะแทนที่ค่าแล้วออกด้วย Break พูดตามตรง page label มีค่าเป็น dictionary บั๊กตัวที่สองนี้จึงแทบไม่ยิงบน /PageLabels เอง แต่ helper ของ number tree ที่อ่าน stride ผิดเป็นของเสียทันทีที่ค่าไหนเป็นตัวเลข มันจึงถูกแก้ในรอบเดียวกัน

การแก้ stride ของคู่ใน number tree ของ PDFlibPas: array /Nums เป็นแถวแบนสลับ entry คีย์กับค่า การเดินที่ทดสอบทุก element อาจแทรกคู่ใหม่ที่ index คี่แล้วทำให้คู่หลัง ๆ เลื่อนเฟส ขณะที่การเดินที่แก้แล้วอ่านคีย์ที่ X*2 และค่าที่ X*2+1
บั๊กนี้แทบไม่ยิงบน /PageLabels เพราะค่าของ label เป็น dictionary แต่ helper ของ number tree ที่อ่าน stride ผิดเป็นของเสียทันทีที่ค่าไหนเป็นตัวเลข การเดินทั้งสองจึงก้าวเป็นคู่ตั้งแต่ตอนนี้

อ่าน label กลับและทำ round trip

TPDFlib.GetPageLabel(Page) คืน label ของหน้าแบบเริ่มที่ 1 พร้อม fallback สองชั้นที่ควรรู้ ถ้าไม่มี entry /PageLabels เลยมันคืนเลขหน้าทศนิยม caller จึงใช้มันแบบไม่ต้องเช็กก่อนได้ ถ้ามี tree แต่ไม่มีช่วงไหนครอบหน้านั้นมันคืนสตริงว่าง ซึ่งเป็นอาการตรง ๆ ของไฟล์ที่ละ entry index 0 ที่บังคับไว้ทิ้ง เอกสารอ้างอิงบอกว่าต้องมีช่วงที่เริ่มที่หน้า 1 label ถึงจะแสดงถูกต้อง และโค้ดก็ทำให้ข้อบังคับนี้มองเห็นได้ สไตล์ตัวอักษรเดินตามสเปก ไม่ใช่ตามคอลัมน์ของ spreadsheet: หลัง Z คือ AA แล้ว BB วนซ้ำตัวอักษรแทนการทด

var
  P: Integer;
  Data: WideString;
begin
  // ตรวจด่วนว่า viewer จะแสดงอะไรในช่องหน้า
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // ค่า option 4 export เฉพาะช่วง label เป็น record PageLabelBegin
  Data := Lib.ExportDocumentData(4);
  // การ import เล่นซ้ำผ่าน ClearPageLabels + AddPageLabels
  Lib.ImportDocumentData(Data, 0);
end;

สำหรับแก้เป็นชุด ExportDocumentData ด้วยค่า option 4 เขียนทุกช่วงออกมาเป็นบล็อก PageLabelBegin พร้อมบรรทัด PageLabelNewIndex, PageLabelStart, PageLabelPrefix และ PageLabelNumStyle ส่วน ImportDocumentData ถือ record label ตัวแรกที่เจอเป็นการแทนที่ทั้งชุด: เรียก ClearPageLabels หนึ่งครั้งแล้วป้อน record ทีละตัวให้ AddPageLabels round trip ผ่านข้อความจึง deterministic แม้ไฟล์ต้นฉบับใช้ tree แบบ /Kids เพราะการล้างเอา entry ทั้งหมดใน catalog ออก และ tree ที่สร้างใหม่เป็น leaf เดียวตั้งแต่แรก

อะไรบ้างที่การแก้นี้ยังไม่การันตี

การ flatten เดินทางเดียวและเชื่อลำดับที่มันเจอ EnumNumTree เก็บคู่ตามลำดับในไฟล์ และ GetPageLabel ใช้ช่วงสุดท้ายที่คีย์น้อยกว่าหรือเท่ากับ index หน้า ไฟล์จากภายนอกที่ leaf เรียงไม่ถูกต้อง ซึ่ง §7.9.7 ห้ามแต่ยังหมุนวนอยู่ในโลกจริง อาจให้ label ผิดได้จนกว่าคุณจะสร้างช่วงใหม่ด้วย ClearPageLabels กับ call AddPageLabels ชุดใหม่ label ยังผูกกับ index หน้า ไม่ใช่ page object การกระทำใดที่เปลี่ยนจำนวนหน้าหรือลำดับจึงทิ้งช่วงไว้ที่เดิม การสลับในที่เช่นการแทนหน้าโดยคงหมายเลข object รักษาจำนวนและ label จึงยังตรงตำแหน่ง ขณะที่การ merge อย่างการ collate งานสแกน duplex แบบสลับหน้า ผลิตลำดับหน้าใหม่ที่สมควรได้ช่วงที่เขียนใหม่สด ๆ

call กลุ่ม page label, การจัดการ number tree และการ export กับ import ข้อมูลเอกสารที่เล่าไว้ทั้งหมด ship มากับ PDF Library for Delphi สำหรับ Delphi, C++Builder และ Lazarus โดย entry อ้างอิงของ AddPageLabels ระบุค่าสไตล์และรหัสการคืนค่าไว้