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

สลับ optional content layer ของ PDF ใน Delphi ด้วย PDFium

PDFium Component ควบคุม optional content layer (OCG) ของ PDF ใน Delphi ผ่านสองเมธอดของ TPdf: InspectOptionalContent ลิสต์ทุก layer พร้อมสถานะการมองเห็นที่ PDFium จะ render จริง และ SaveAsOptionalContentConfigured เขียนสำเนาที่ผ่านการตรวจยืนยันซึ่ง layer ที่คุณเลือกถูกเปิดหรือปิด เมธอดตัวที่สองยังทำให้กฎ Usage กับ /AS ที่ไม่งั้นจะแอบย้อนการแก้ของคุณเป็นโมฆะด้วย ทั้งคู่ทำงานบนเอกสารที่เปิดอยู่แล้วใน TPdf จึงไม่มี parser ตัวที่สองต้องคอย sync กับสิ่งที่ viewer แสดง

คำขอมักมาจากร้าน CAD หรือ GIS: ชุดแบบส่งมาพร้อมเส้นบอกขนาด, annotation และ title block วางแยกเป็น layer คนละตัว และลูกค้าอยากได้สำเนาที่ซ่อนเส้นบอกขนาดก่อนส่งให้ผู้รับเหมา PDFium render optional content ได้ถูกต้อง แต่ ABI สาธารณะของมันไม่มีฟังก์ชัน enumerate OCG, เลือก configuration หรือพลิกสถานะ layer คุณจึงต้องลงไประดับ object, แก้ /OCProperties, save, โหลดใหม่ และ layer ก็ยังอยู่เฉย ๆ เหตุผลคือตรรกะการมองเห็นของ PDFium ซึ่งควรเข้าใจให้ครบก่อนจะไปแตะ byte สักตัว

ทำไมแก้ /ON กับ /OFF แล้วสิ่งที่ PDFium render ไม่เปลี่ยน

การแก้ array /ON กับ /OFF ของ configuration dictionary ไม่พอ เพราะ PDFium ให้สถานะที่ระบุชัด ๆ ภายใน /Usage dictionary ของ OCG เองชนะ array พวกนั้น และกฎ auto-state ของ /AS ก็อาจมาแซงทั้งคู่อีกที ISO 32000-1 §8.11.4 อธิบาย configuration กับ usage dictionary เป็นกลไกแยกกัน แต่ renderer ของ PDFium ยุบพวกมันเป็นการตัดสินเดียว และ InspectOptionalContent ก็เล่นซ้ำมันตามลำดับนี้:

  • เริ่มจาก /BaseState ของ configuration โดย /ON กับ /Unchanged นับเป็นมองเห็นทั้งคู่ มีแต่ /OFF เท่านั้นที่ซ่อน
  • apply array /ON ของ configuration แล้วตามด้วย array /OFF กลุ่มที่ถูกลิสต์ในทั้งคู่จึงจบลงที่การถูกซ่อน
  • apply สถานะ Usage ที่ระบุชัดของกลุ่มสำหรับ usage ที่ขอ เช่น /Usage << /View << /ViewState /OFF >> >> ซึ่งแซงทุกอย่างข้างบน
  • ถือว่ากลุ่มที่ /Intent ไม่มีทั้ง /View และ /All เป็นมองเห็น เพราะมันไม่มีส่วนร่วมในความมองเห็นแบบ view-intent
  • สุดท้ายรัน array /AS ของ configuration ที่เลือก โดย entry ที่ตรงกับ event จะตั้งสถานะของกลุ่มที่มันลิสต์ไว้
การตัดสินความมองเห็นห้าขั้นที่ PDFium Component เล่นซ้ำให้ทุก optional content group ของ PDF ใน Delphi: BaseState วางจุดเริ่ม, array ON กับ OFF ของ configuration ถูก apply ตามลำดับ, entry Usage ที่ระบุชัดอย่าง ViewState หรือ PrintState แซงทั้งคู่, Intent ที่ไม่มีส่วนร่วมนับเป็นมองเห็น และ array AS รันเป็นลำดับสุดท้าย
แก้แค่ array ON กับ OFF ไม่พอ เพราะ PDFium ยุบ BaseState, สอง array, สถานะ Usage ของกลุ่ม และสุดท้ายกฎ auto-state ของ AS เป็นคำตัดสินเดียว ที่ InspectOptionalContent เล่นซ้ำทีละขั้น

ขั้นที่สามคือขั้นที่เผาคนที่สุด ไฟล์ที่ save โดยเครื่องมือจัดหน้ามักพก /ViewState /ON บนทุก OCG แล้ว PDFium ก็เมิน array /OFF ที่คุณแก้มาอย่างพิถีพิถัน: save สำเร็จ ไฟล์เปิดใหม่ได้เนียน และ layer ก็ยังวาดอยู่ดี ฝั่ง Print กับ Export OcExplicitUsageState อ่าน PrintState หรือ ExportState ก่อน แล้ว fallback ไปที่ ViewState เมื่อไม่มี entry เฉพาะทาง แต่ ViewState /ON ตัวเดียวก็ตรึง layer ไว้สำหรับการพิมพ์ด้วย marked content ที่อ้างถึง OCMD (§8.11.2.2) จะถูก resolve เทียบกับผลต่อกลุ่มพวกนี้ ผ่านนโยบาย /P หรือ expression ความมองเห็น /VE ถ้ามี

จะลิสต์ layer ที่ PDFium จะแสดงจริงอย่างไร

TPdf.InspectOptionalContent คืน TPdfOptionalContentInventory ที่ array Groups พก object number, ชื่อ, intents, สถานะ Usage ทั้งสาม, ภาษา, ช่วง zoom, flag Locked, index radio group และ EffectiveVisible ที่คำนวณแล้วของแต่ละ OCG เมธอดจะให้ PDFium save เอกสารในหน่วยความจำปัจจุบันก่อน ขยาย object stream แล้วสแกนผลลัพธ์ การแก้ที่ทำไว้ก่อนหน้าในเซสชันจึงถูกสะท้อน index configuration 0 คือ /D dictionary ค่าเริ่มต้นเสมอ และ entry ของ /Configs ต่อจาก index 1 argument default -1 เลือก index 0 เอกสารที่ไม่มี /OCProperties ทำให้เมธอดคืน False พร้อมเหตุผลใน ErrorMessage แทนที่จะ raise

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  G: TPdfOptionalContentGroup;
begin
  // Usage ค่าเริ่มต้น ocuView; -1 เลือก configuration 0 คือ /D dictionary
  if not Pdf.InspectOptionalContent(Inv) then
  begin
    Memo1.Lines.Add('No usable layers: ' + Inv.ErrorMessage);
    Exit;
  end;
  Memo1.Lines.Add(Format('Configuration %d: %s',
    [Inv.SelectedConfigurationIndex,
     string(Inv.Configurations[Inv.SelectedConfigurationIndex].Name)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %s  visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

array Memberships รายงาน OCMD ทุกตัวพร้อม Policy (ocmpAnyOn, ocmpAllOn, ocmpAnyOff, ocmpAllOff), ข้อความดิบของ VisibilityExpression และ EffectiveVisible ของตัวเอง กฎขอบสองสามข้อถูกตั้งใจให้เป็นแบบนี้ /P default เป็น /AnyOn และ OCMD ที่ไม่มีกลุ่มนับเป็นมองเห็น การอ้างถึง object number ที่ไม่ใช่ OCG ที่รู้จักถือเป็นมองเห็น แทนที่จะทำให้ expression ทั้งอันพัง การประเมิน /VE หยุดที่ความลึกซ้อน 32 และถือว่าอะไรที่ลึกกว่าเป็นถูกซ่อน เพื่อกัน expression ที่เป็นอันตรายหรืออ้างถึงตัวเองจากการเปลี่ยนการตรวจสอบให้กลายเป็น stack overflow

เขียนสถานะ layer ใหม่ด้วย SaveAsOptionalContentConfigured

TPdf.SaveAsOptionalContentConfigured รับ array ของ record TPdfOptionalContentStateChange (object number ของกลุ่มบวก Visible) แล้วเขียนเอกสารที่ configuration ที่เลือกผลิตสถานะนั้นได้เป๊ะ ๆ configuration ที่ถูกเลือกจะได้ /BaseState /ON พร้อม array /ON กับ /OFF ครบชุดที่ครอบทุกกลุ่ม และ OCG ที่มี Usage dictionary อยู่แล้วแต่ละตัวจะได้ ViewState ที่ระบุชัด (หรือ PrintState / ExportState ตาม Options.Usage) ตรงกับสถานะใหม่ของมัน ด้วย TPdfOptionalContentConfigureOptions.Default key /AS ของ configuration ที่เลือกจะถูกถอดออก เพื่อไม่ให้ event open, print หรือ export พลิก layer กลับไปได้

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer);
var
  Changes: TPdfOptionalContentStateChanges;
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  SetLength(Changes, 2);
  Changes[0].GroupObjectNumber := DimensionsObj;
  Changes[0].Visible := False;
  Changes[1].GroupObjectNumber := NotesObj;
  Changes[1].Visible := True;

  // Configuration 0, ocuView, DisableAutomaticState กับ EnforceRadioGroups เป็น True
  Options := TPdfOptionalContentConfigureOptions.Default;

  if not Pdf.SaveAsOptionalContentConfigured('C:\Out\Drawing-NoDims.pdf',
    Changes, Options, Report) then
    raise Exception.Create('Layer update rejected: ' + Report.ErrorMessage);

  Log(Format('%d of %d groups changed, %d Usage states rewritten, /AS removed: %s',
    [Report.ChangedGroupCount, Report.GroupCount,
     Report.UpdatedUsageStateCount,
     BoolToStr(Report.RemovedAutomaticState, True)]));
end;

เส้นทางเขียนเก็บผลลัพธ์ที่ PDFium save เองไว้เป็น prefix แบบ byte ต่อ byte แล้ว append เพิ่มเฉพาะ configuration owner ที่ถูกเขียนใหม่กับ object OCG ที่พก Usage dictionary ตามด้วย xref section กับ trailer ใหม่ ก่อน byte เดียวจะถึงปลายทางของคุณ ผลลัพธ์ถูกเปิดซ้ำใน TPdf ตัวแยกใต้นโยบายโหลดแบบเข้มงวด และเมธอดจะ fail ถ้าตาราง cross-reference ตรวจไม่ผ่าน overload แบบไฟล์ไปต่ออีกขั้น: มันเขียนไปที่ไฟล์ชั่วคราวข้าง ๆ เป้าหมาย แล้วเปลี่ยนเป้าหมายเฉพาะเมื่อการตรวจยืนยันสำเร็จ การอัปเดตที่ถูกปฏิเสธจึงไม่มีวันทิ้งแบบที่เขียนครึ่ง ๆ กลาง ๆ ไว้ เป็นแนวทาง incremental revision ที่ตรวจยืนยันแล้วแบบเดียวกับที่ตัวแก้ name tree กับ number tree ของ PDF ใน PDFium Component ใช้

SaveAsOptionalContentConfigured ใน PDFium Component เขียน PDF ใน Delphi ที่สลับ layer แล้วอย่างไร: การเปลี่ยนสถานะกับ options ถูกป้อนเข้าไป, configuration ที่เลือกถูกเขียนใหม่ด้วย array ON กับ OFF ครบชุดกับสถานะ Usage, revision แบบขยายที่ตรวจยืนยันแล้วถูก append ต่อ และต้องเปิดซ้ำแบบเข้มงวดให้ validate ผ่านก่อนมีอะไรถูกเขียนออกไป
การ save แบบตั้งค่าเก็บ re-save ของ PDFium เองไว้เป็น byte prefix, append configuration owner ที่เขียนใหม่พร้อม xref section ใหม่ แล้วเปิดผลลัพธ์ซ้ำใน TPdf ตัวแยกก่อนปลายทางจะถูกแตะ

การ save แบบตั้งค่าปฏิเสธทำอะไร

การ save แบบตั้งค่าปฏิเสธการเปลี่ยนใด ๆ ที่เอกสารห้ามเองหรือเก็บได้ไม่ปลอดภัย และการปฏิเสธทุกครั้งเกิดก่อนปลายทางจะถูกแตะ object number ที่ไม่อยู่ใน /OCGs fail ทันที การแก้กลุ่มที่ถูกลิสต์ใน array /Locked ของ configuration ก็ fail แม้การพูดซ้ำค่าปัจจุบันของมันจะอนุญาตก็ตาม เมื่อเปิด EnforceRadioGroups ชุด /RBGroups ใดที่จะจบลงด้วยสมาชิกที่มองเห็นมากกว่าหนึ่งตัวจะถูกปฏิเสธ แทนที่จะปิดตัวอื่นเงียบ ๆ เอกสารที่เข้ารหัสถูกปฏิเสธ เพราะ object ขยายแบบ plaintext พก security handler ที่ใช้งานอยู่ไม่ได้ เอกสารที่ลงลายเซ็น raise EPdfError เว้นแต่คุณจะส่ง AllowSignedDocument = True เพราะการเปลี่ยนสิ่งที่หน้าแสดงอาจทำลายขอบเขตครอบคลุมของลายเซ็นหรือนโยบายการ certify

ประตูปฏิเสธที่ SaveAsOptionalContentConfigured ใช้ก่อนเขียน PDF แบบตั้งค่าใน Delphi: object number นอก OCGs จะ fail, กลุ่มที่ล็อกจะ fail, ชุด RBGroups ที่เหลือสมาชิกมองเห็นเกินหนึ่งจะถูกปฏิเสธ, เอกสารที่เข้ารหัสพก object ขยายแบบ plaintext ไม่ได้ และไฟล์ที่ลงลายเซ็นต้องการ AllowSignedDocument
การปฏิเสธทุกครั้งเกิดก่อนปลายทางจะถูกแตะ และเหตุผลของความล้มเหลวลงใน Report.ErrorMessage แทนที่จะทิ้งแบบที่เขียนครึ่ง ๆ กลาง ๆ ไว้
function TFormMain.SavePrintPreset(Target: TStream;
  const Changes: TPdfOptionalContentStateChanges): Boolean;
var
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  Options := TPdfOptionalContentConfigureOptions.Default;
  Options.Usage := ocuPrint;          // เขียน /Print << /PrintState ... >>
  Options.ConfigurationIndex := 1;    // entry แรกของ /Configs ไม่ใช่ /D
  try
    Result := Pdf.SaveAsOptionalContentConfigured(Target, Changes, Options,
      Report);                        // AllowSignedDocument คงเป็น False
    if not Result then
      ShowMessage(Report.ErrorMessage);
  except
    on E: EPdfError do
    begin
      ShowMessage(E.Message);         // ไฟล์ที่ลงลายเซ็น: ไม่มีอะไรถูกเขียนลง Target
      Result := False;
    end;
  end;
end;

รู้การแลกทั้งหมดก่อนต่อมันเข้า batch job revision ที่ append ต่อวางอยู่บน re-save เต็มรูปแบบของ PDFium ไม่ใช่ byte ไฟล์ต้นฉบับของคุณ นั่นแหละเหตุผลเป๊ะ ๆ ที่ input ที่ลงลายเซ็นต้องการความยินยอมแบบชัดเจน การเขียนใหม่ยังทำ configuration ที่เลือกให้เป็นมาตรฐาน /BaseState /ON baseline /Unchanged หรือ /OFF ของผู้เขียนจึงถูกแทนด้วย array ที่ระบุชัดซึ่งให้ความมองเห็นผลเท่าเดิม การถอด /AS ตัดลูกเล่นที่มีแค่ตอนพิมพ์อย่าง layer watermark ที่โผล่เฉพาะบนกระดาษไปด้วย ตั้ง DisableAutomaticState เป็น False เพื่อเก็บกฎพวกนั้นไว้ โดยยอมรับว่ามันอาจแซงสถานะที่คุณขอสำหรับ event นั้น ฝั่งบวกคือ PDF/A-2 (ISO 19005-2 clause 6.9) กับ PDF/UA (ISO 14289-1 clause 7.10) ห้าม /AS ใน configuration dictionary ทั้งคู่ ผลลัพธ์ default จึงตัดปัญหาหนึ่งข้อที่การ preflight validation ของ PDF/A ด้วย PDFium Component ไม่งั้นจะรายงาน

การควบคุม layer วางอยู่ตรงไหนใน PDF viewer ของ Delphi

ใน viewer การควบคุม layer คือ checklist ที่ถูกขับเคลื่อนด้วย inventory บวกการโหลดผลลัพธ์ที่ save แล้วใหม่ สร้าง checklist จาก Groups, ปิดใช้งาน entry ที่ Locked, ถือว่าสมาชิกที่ใช้ RadioGroupIndex ร่วมกันเป็นแบบเลือกได้ตัวเดียว และตอน apply ให้เขียนลง TMemoryStream แล้วโหลด stream นั้นกลับเข้า TPdf เพื่อให้ view วาดสถานะใหม่ การต่อสายระหว่าง TPdf กับ TPdfView อธิบายไว้ในการสร้าง PDF viewer ครบเครื่องด้วย PDFium VCL ใน Delphi เรื่องสิทธิ์การใช้งาน, ดาวน์โหลดเวอร์ชันทดลอง และฟีเจอร์ที่เหลืออยู่บนหน้าผลิตภัณฑ์ PDFium Component for Delphi