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

แก้ไข outline PDF และแมปหน้าใหม่ใน Delphi ด้วย PDFiumPas

ทิ้งเจ็ดหน้าออกจากคู่มือสองร้อยหน้า แล้ว bookmark ทุกอันตกลงผิดที่ ทางแก้ไม่ใช่การสร้าง outline ใหม่จากรายชื่อแบบแบน PDFiumPas เปิด TPdfOutlineEditor ให้ใช้ ซึ่งโหลดต้นไม้ outline จริง ให้คุณย้ายกับเปลี่ยนเป้า item แล้วรัน ApplyPageMap เพื่อเลื่อน explicit destination ทุกอันผ่านแผนหน้าของคุณ

ทำไมลบหน้าแล้ว bookmark พังทุกอัน?

เพราะ outline item ไม่เก็บเลขหน้า มันเก็บ reference ไปยัง page object และเมื่อ page object เปลี่ยน reference ชี้ไปที่หน้าที่ย้ายไปแล้ว หรือไม่ชี้ไปที่อะไรเลย ISO 32000-1 §12.3.2.2 นิยาม explicit destination เป็นอาร์เรย์ที่สมาชิกตัวแรกเป็น indirect reference ไปยัง page dictionary ตามด้วยชื่อ fit เช่น /Fit หรือ /XYZ ลบหน้าทิ้งแล้วคุณเหลือ reference ที่แขวนลอย สลับลำดับหน้าแล้ว reference ยัง valid แต่ตอนนี้บรรยายบทอื่น PDFiumPas resolve อาร์เรย์นั้นกลับเป็นเลขหน้าตอนโหลด TPdfOutlineItem.PageNumber จึงให้ index หน้าแบบ one-based ที่ตรงกับ API สาธารณะของ TPdf แทนเลขออบเจกต์ นั่นคือความหมายทั้งหมดของ abstraction นี้: ตรรกะ remap ของคุณทำงานในระบบพิกัดเดียวกับแผนหน้าที่คุณสร้างไว้ตอนแบ่ง สลับ หรือ impose เอกสาร ถ้าคุณกำลังสร้างแผนนั้น ธรรมเนียม one-based เดียวกันวิ่งทั่ว การแบ่งเอกสาร PDF เป็นหลายไฟล์ กับ n-up imposition กับการเรียงหน้าใหม่

outline เป็นต้นไม้ลิงก์สองทาง ไม่ใช่รายการ

เหตุที่คุณ serialize อาร์เรย์ชื่อเรื่องแบบแบนเฉย ๆ ไม่ได้ คือ ISO 32000-1 §12.3.3 ต่อ outline item ทุกตัวเข้ากับลิงก์แยกห้าเส้น: /Parent, /Prev, /Next, /First กับ /Last การย้าย subtree เดียวจึงเขียนใหม่แม่เดิม แม่ใหม่ พี่น้องเพื่อนบ้านทั้งสองฝั่งของจุดตัดกับจุดแทรก และพอยเตอร์แม่ของโหนดที่ถูกย้ายเอง ผิดข้อใดข้อหนึ่งแล้ว reader ตามข้อกำหนดจะแสดงต้นไม้ที่ถูกตัด หรือวนเป็นวง PDFiumPas เก็บสถานะแก้ไขเป็นอาร์เรย์ depth-first ของเรกคอร์ด TPdfOutlineItem ที่มี Id เชิงจำนวนเต็มคงเส้นคงวา subtree จึงเป็นช่วงต่อเนื่องหนึ่งช่วง และห่วงโซ่พี่น้องถูก derive ไม่เคยถูกดูแลมือ TPdfOutlineEditor.Move ยกช่วงนั้นขึ้น แทรกกลับใต้แม่ใหม่ที่ index พี่น้องที่ขอ และกำหนดใหม่เฉพาะรากของบล็อก มันยังปฏิเสธการย้ายสองแบบที่จะทำให้กราฟเสีย: ย้าย item ไปใน subtree ของตัวเอง และการระบุแม่ที่ไม่มีอยู่

การแก้ไข outline ด้วย PDFiumPas ใน Delphi: ย้าย Chapter 3 ออกจาก Part I ไปอยู่ใต้รากเอกสาร เขียนพอยเตอร์ /Parent ของโหนดที่ย้ายบวกลิงก์ /First กับ /Prev, /Next ของพี่น้องรอบจุดตัดและจุดแทรกทั้งสองข้าง
เรียก Move ครั้งเดียวเขียนใหม่พอยเตอร์แม่ของ subtree ที่ยกขึ้น กับลิงก์พี่น้องสองฝั่งของจุดตัดและจุดแทรก

ทำไม /Count ถึงมีเครื่องหมาย?

เพราะเครื่องหมายแบกสถานะขยาย ไม่ใช่ขนาด /Count เป็นบวกแปลว่า item เปิดอยู่และตัวเลขคือจำนวนลูกหลานที่มองเห็นอยู่; /Count เป็นลบแปลว่า item ถูกยุบ PDFiumPas เขียนจำนวนลูกหลานให้ทุก item ที่มีลูก แล้วเติมลบเมื่อ IsOpen เป็น False และตอนโหลดอ่านสถานะกลับเป็น IsOpen := HasCount and (CountValue > 0) นี่คือบั๊กมือเขียนที่พบบ่อยที่สุดในตัวเขียน outline: ปล่อย count ไร้เครื่องหมายออกไปแล้วบังคับต้นไม้ทั้งต้นเปิดอย่างเงียบ ๆ

PDFiumPas เข้ารหัสสถานะการขยาย outline ใน Delphi อย่างไร: /Count เป็นบวกแปลว่า item เปิดและนับลูกหลานที่มองเห็น /Count เป็นลบแปลว่ายุบอยู่ และ count ไร้เครื่องหมายบังคับ reader ทุกตัวขยายต้นไม้ทั้งต้น
เครื่องหมายของ /Count คือสถานะการขยาย ขนาดคือจำนวนลูกหลานที่มองเห็น count ไร้เครื่องหมายจึงบังคับต้นไม้ทั้งต้นเปิดอย่างเงียบ ๆ
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // กลายเป็นลูกที่สองของราก
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // เขียน /Count ติดลบ
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget รองรับทั้งสองรูปที่ข้อกำหนดยอมให้ ส่ง DestinationInAction เป็น False แล้ว PDFiumPas เขียนอาร์เรย์ /Dest ตรง ๆ; ส่ง True แล้วมันเขียน action แบบ Go-To /A << /S /GoTo /D [ page ref suffix ] >> ตาม ISO 32000-1 §12.6.4.2 ไม่ว่าทางใดมันตัด /Dest กับ /A ที่มีอยู่ออกจาก item ก่อน เพื่อไม่ให้สองอย่างอยู่ร่วมกันแล้วขัดแย้งกัน suffix default เป็น /Fit และต้องเริ่มด้วยชื่อ PDF นี่คือเหตุที่ suffix ว่างหรือเสีย raise ทันที แทนการผลิตอาร์เรย์ปลายทางที่ไม่มี reader ไหน parse ได้

ApplyPageMap กินแผนหน้าอย่างไร?

ApplyPageMap รับเป๊ะอาร์เรย์ที่แผนหน้าของคุณ validate ไว้แล้ว: NewPageNumbers ซึ่ง index ด้วยเลขหน้าเดิมลบหนึ่ง เก็บเลขหน้าใหม่แบบ one-based หรือศูนย์เมื่อหน้านั้นไม่รอด มันเดินอาร์เรย์ item จากหลังมาหน้า เพื่อให้การลบ subtree ไม่ทำ index ที่ยังไม่เยี่ยมเสียหาย และมันรายงานผลผ่าน RemappedDestinationCount กับ RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // หนึ่งช่องต่อหน้าของเอกสารต้นฉบับ
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == หน้านี้ถูกทิ้ง

  NewPageNumbers[0] := 1;                // หน้าเดิม 1 -> หน้าใหม่ 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // หน้าเดิม 10 -> หน้าใหม่ 3

  // True: ลบ subtree ที่แขวนลอยทั้งชุด False: เก็บ item ตัดเป้าออก
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

flag DeleteDangling ตัดสินนโยบายของปลายทางที่แมปเป็นศูนย์ และสองกิ่งนี้เป็นการตั้งใจ ด้วย True PDFiumPas ลบ item กับ subtree ทั้งชุด เพราะโหนด outline ที่เป้าหมายหายไปมักหัวหน้าบทที่หายไปพร้อมกัน ด้วย False item รอดพร้อมชื่อเรื่องกับโครงสร้างลำดับชั้นครบ แต่ /Dest กับ /A ถูกตัดออก ซึ่งคือสิ่งที่คุณต้องการเมื่อมนุษย์จะเปลี่ยนเป้ามันในช่วงทบทวน อินพุตที่ผิดรูปจริง ๆ ยังล้มเหลวอย่างชัดเจนแทนการอุดชั่วคราว: รายการติดลบหรือปลายทางที่ชี้เกินปลายอาร์เรย์แมปที่ให้มาคืน False พร้อม IssueKind เป็น poviInvalidPageMap

ApplyPageMap ของ PDFiumPas เปลี่ยนทาง bookmark PDF ใน Delphi อย่างไร: page map ที่ index ด้วยเลขหน้าเดิมลบหนึ่งส่งปลายทางที่รอดไปเลขหน้าใหม่ ขณะที่รายการที่แมปเป็นศูนย์ถูกลบพร้อม subtree หรือถูกตัดเป้าออก
page map ถูก index ด้วยเลขหน้าเดิมลบหนึ่ง และรายการศูนย์ลบ subtree ที่แขวนลอยทั้งชุด หรือปล่อย item ไว้โดยตัดเป้าออก

รายการที่เข้าใจไม่ได้ กับการแลกเปลี่ยนที่ต้องพูดตรง ๆ

outline item ไม่ใช่ทุกตัวมีเลขหน้าที่ PDFiumPas ให้เหตุผลได้ สามแบบถูกพาไปตามเดิมไม่แตะต้อง: named destination, action ที่ไม่ใช่ /S /GoTo และคีย์พจนานุกรมที่ไม่รู้จักซึ่งผู้ผลิตไฟล์เติมมา พวกมันโหลดมาด้วย PageNumber เท่ากับศูนย์ เก็บ byte เดิมไว้ใน item และถูกเขียนกลับตามเดิม เว้นแต่คุณเรียก Retarget กับมันอย่างชัดเจน

  • named destination เป็นคีย์เข้าสู่ name tree ของเอกสาร การแมปมันให้ถูกจึงหมายถึง resolve ต้นไม้แล้วเขียนรายการเป้าใหม่ ไม่ใช่การเดาที่ระดับ outline
  • action แบบ /URI, /Launch หรือ JavaScript ไม่มีความหมายเชิงหน้าเลย และห้ามถูกแปลงเป็น Go-To อย่างเงียบ ๆ
  • คีย์เฉพาะผู้ขายกับ structure destination ถูกเก็บไว้ เพราะการทิ้งสิ่งที่คุณไม่เข้าใจคือวิธีที่การ round-trip สูญเสียข้อมูล

ต้นทุนเป็นจริงและควรพูดตรง ๆ: ApplyPageMap ข้าม item กลุ่มนี้ทั้งหมด เอกสารที่ bookmark ใช้ named destination ทั้งหมดจึงผ่านการลบหน้ามาด้วย outline ที่ถูกต้องเชิงโครงสร้างแต่ล้าสมัยเชิงความหมาย นั่นคือทางเลือกที่ตั้งใจ — ลิงก์ที่ล้าสมัยซึ่งผู้ทบทวนจับได้ยังดีกว่าลิงก์ที่ผิดอย่างมั่นใจซึ่งไม่มีใครสังเกต ถ้าคุณกำลังคัดกรองไฟล์ขาเข้าก่อนแก้ไข การ inventory ผ่าน PDF intake review workbench จะบอกว่าเอกสารไหนตกกลุ่มนี้

การเซฟ: revision แบบเพิ่มหน่วย แล้วโหลดใหม่อย่างอิสระ

TPdfOutlineEditor.SaveIncremental เติม revision แบบ sparse เข้าไปท้ายไฟล์แทนการเขียนไฟล์ใหม่ทั้งไฟล์ item ที่โหลดมาเก็บ indirect object reference เดิมรวมถึง generation เป๊ะ ๆ cross-reference เดิมจึงยัง valid; เฉพาะ item ที่คุณเติมใหม่จับเลขใหม่ จัดสรรจากเลขถัดจาก maximum object number ของ revision catalogue ถูกอัปเดตใน revision เดียวกัน และรายการ /Outlines ที่หายไปถูกเติมเข้าไปเมื่อต้นฉบับไม่มี outline เลย

สิ่งที่เกิดหลังเขียนเสร็จคือส่วนที่น่าลอกใช้ PDFiumPas เปิด stream ปลายทางใหม่ด้วย editor ที่เป็นอิสระจากกันสนิท แล้วเทียบต้นไม้ที่โหลดซ้ำกับต้นไม้ในหน่วยความจำ — จำนวน item ชื่อเรื่อง เลขหน้า suffix ของปลายทาง รูปแบบ action เทียบกับปลายทางตรง สไตล์ สถานะขยาย กับความสัมพันธ์แม่ลูก ความไม่ตรงใด ๆ หรือการโหลดล้มเหลวใด ๆ จะเคลียร์ stream ปลายทางทิ้งแล้วคืน poviVerificationFailure แทนการยัดไฟล์ที่ดูเหมือนจริงให้คุณ ต้นฉบับที่ถูกเข้ารหัสถูกปฏิเสธตั้งแต่ต้นด้วย poviEncryptedInput เพราะชื่อเรื่องกับปลายทางใหม่สร้างเนื้อหาสตริงที่ไม่อาจผลิตได้ด้วยการคัดลอก trailer /Encrypt ต่อไปข้างหน้า

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

ปฏิบัติกับ outline ตามสิ่งที่มันคือ — กราฟออบเจกต์ที่ต่อกันด้วยลิงก์ซึ่งมี invariant ของตัวเอง — แล้วการลบหน้าจะเลิกเป็นหายนะของ bookmark และกลายเป็น page map ที่คุณยื่นให้การเรียกเมธอดหนึ่งครั้ง TPdfOutlineEditor, ApplyPageMap กับตัวเขียน incremental ที่ตรวจสอบแล้ว มาพร้อม PDFiumPas ตั้งแต่ v3.98.0 สำหรับ Delphi, C++Builder และ Lazarus คุณทบทวน API ฉบับเต็มกับดาวน์โหลดรุ่นทดลองได้บน หน้าผลิตภัณฑ์ PDFium Delphi Component