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

Export ตัวเลือกของ PDFium: Capability Gate ใน Delphi

pdfium.dll ของคุณโหลดสำเร็จ และมี procedure หนึ่งที่ยังหายไป PDFium Component จัดการเรื่องนี้ด้วยการแบ่ง binding ของมันเป็นสองประเภท export ที่จำเป็นซึ่ง resolve ผ่าน CheckGetProcAddress ที่ยกเลิกการโหลดทันที และ export ตัวเลือกที่ resolve ผ่าน TryGetProcAddress ที่ทิ้ง nil pointer และการตรวจสอบ capability ไว้แทน

นี่ไม่ใช่ปัญหาเดียวกับ DLL ที่หาไม่เจอ ถ้าแอปพลิเคชันของคุณตายด้วย bad EXE format error, ไฟล์ที่หายไป หรือ architecture mismatch เรื่องนั้นถูกเล่าไว้ใน บทความคู่หูเรื่องการ deploy pdfium.dll และการวินิจฉัยความล้มเหลวในการโหลด ที่นี่ตัวโหลดสำเร็จ module handle ถูกต้อง export หลายร้อยตัว resolve ได้ และการรันยังจบลงก่อนที่หน้าแรกของคุณจะเรนเดอร์ เพราะ entry point ตัวหนึ่งที่มาพร้อมกับ PDFium build ใหม่กว่าไม่ได้อยู่ใน binary บนดิสก์

ทำไม export ที่หายไปตัวเดียวถึงทำให้ทั้งไลบรารีพัง

เพราะ required binding คือสัญญาที่แข็งกร้าว และมันถูกบังคับใช้ระหว่างลำดับการ bind แบบ all-or-nothing ครั้งเดียว PDFium Component resolve ตาราง export ทั้งหมดของมันภายใน LoadLibrary ด้วยการเรียก CheckGetProcAddress ต่อกันไป nil ผลลัพธ์แรกจะ raise EPdfError และเรียก UnloadLibrary ก่อนที่จะทำ ซึ่งตั้งใจไว้ การ bind แบบครึ่ง ๆ กลาง ๆ จะปล่อยให้ pointer ที่ resolve ไปแล้วชี้เข้าไปยัง module ที่กำลังจะถูกปล่อยทิ้ง ทำให้ทุก Assigned guard ปลายทางถูกเอาชนะอย่างเงียบ ๆ

ผลลัพธ์คือรูปแบบความล้มเหลวที่พาคนมาที่นี่ คุณอัปเกรด component ส่งไปพร้อมกับ pdfium.dll ตัวเดิมที่คุณส่งมาสองปีแล้ว และแอปพลิเคชันจะไม่เริ่มทำงานเลย error ระบุชื่อ export สำหรับฟีเจอร์ที่คุณไม่เคยเรียกเลย ไม่มีอะไรที่คุณทำที่ call site ช่วยได้ เพราะ call site ไม่เคยรันเลย ความล้มเหลวเกิดขึ้นระหว่างการ binding ก่อนที่เอกสารใด ๆ จะถูกเปิด

function CheckGetProcAddress(const Name: string): Pointer;
begin
  Result := GetProcAddress(PDFiumLibrary, PChar(Name));
  if Result = nil then
  begin
    // A missing required export means the deployed pdfium.dll is older
    // than this build of the binding. Drop every pointer resolved so far
    // so no caller can reach into the module we are about to free.
    UnloadLibrary;
    raise EPdfError.Create('Required PDFium export not found: ' + Name);
  end;
end;

function TryGetProcAddress(const Name: string): Pointer;
begin
  // Optional export. nil is a legitimate answer here; every caller is
  // required to test Assigned() before dereferencing the variable.
  Result := GetProcAddress(PDFiumLibrary, PChar(Name));
end;

จำเป็นหรือเลือกได้: เส้นแบ่งอยู่ตรงไหนจริง ๆ

กฎที่ PDFium Component ใช้นั้นตรงไปตรงมา export หนึ่งจำเป็นเมื่อการหายไปของมันทำให้ component ทำงานที่มันมีอยู่ไม่ได้ และเป็นตัวเลือกเมื่อการหายไปของมันแค่ตัดฟีเจอร์ปลายทางออกไปตัวเดียว FPDF_InitLibrary, FPDF_LoadDocument, FPDF_RenderPageBitmap, FPDF_ClosePage เป็นสิ่งจำเป็น และการล้มเหลวอย่างชัดเจนกับสิ่งเหล่านั้นถูกต้องแล้ว viewer ที่เรนเดอร์ไม่ได้ไม่ใช่ viewer ที่ลดระดับ มันคือของที่พัง

ทุกอย่างที่เข้าถึงผ่าน tolerant loader ในวันนี้เป็นปลายทาง FPDFBookmark_GetColor มาถึงหลัง M109 และให้แค่ array สี /C ตัวเลือกของ outline entry เท่านั้น ดังนั้น DLL ที่เก่ากว่ามันจึงแค่รายงานว่าไม่มีสีบุ๊กมาร์ก V8 helper คือ FPDF_GetRecommendedV8Flags และ FPDF_GetArrayBufferAllocatorSharedInstance และ XFA string helper คือ FPDF_BStr_Init, FPDF_BStr_Set และ FPDF_BStr_Clear จะหายไปจาก build ที่ไม่ใช่ V8 ใด ๆ โดยธรรมชาติของการสร้าง ดังนั้นการถือว่ามันจำเป็นจะทำให้ pdfium.dll ธรรมดาโหลดไม่ได้ และคู่ที่จุดประกายบทความนี้ FPDFAttachment_SetDescription กับ FPDFAttachment_GetDescription ถูกเพิ่มขึ้นต้นทางเมื่อ 2026-07-13 ซึ่งใหม่กว่าวันที่ build ของ PDFium binary ทั้งสี่ตัวที่โปรเจกต์นี้ส่งไปพร้อมกับ DLLs/Win32 และ DLLs/Win64 กรณีสุดท้ายนั้นคือรูปแบบทั่วไปของปัญหา ไม่ใช่เหตุการณ์ครั้งเดียว binding layer ติดตาม header ต้นทาง ซึ่งเคลื่อนที่ตลอดเวลา ในขณะที่ DLL ใน installer ของคุณเคลื่อนที่เป็นช่วง ๆ ทุกครั้งที่มีใคร rebuild มัน มีช่วงเวลาเสมอที่ฝั่ง Pascal รู้จัก export ที่ binary ที่ deploy อยู่ยังไม่มี และการตัดสินใจล่วงหน้าว่า export ใหม่แต่ละตัวควรอยู่ฝั่งไหนของเส้นแบ่งจำเป็น/เลือกได้ คือสิ่งเดียวที่ทำให้ช่วงเวลานั้นรอดพ้นไปได้

FPDFDoc_GetAttachmentCount    := CheckGetProcAddress('FPDFDoc_GetAttachmentCount');
FPDFDoc_AddAttachment         := CheckGetProcAddress('FPDFDoc_AddAttachment');
FPDFAttachment_GetName        := CheckGetProcAddress('FPDFAttachment_GetName');
FPDFAttachment_GetStringValue := CheckGetProcAddress('FPDFAttachment_GetStringValue');
// Attachment descriptions were added after the bundled DLL revision.
// Keep them optional so older deployments continue to load.
FPDFAttachment_SetDescription := TryGetProcAddress('FPDFAttachment_SetDescription');
FPDFAttachment_GetDescription := TryGetProcAddress('FPDFAttachment_GetDescription');
FPDFAttachment_SetFile        := CheckGetProcAddress('FPDFAttachment_SetFile');
FPDFAttachment_GetFile        := CheckGetProcAddress('FPDFAttachment_GetFile');

capability gate ควรทำอะไรที่ call site

มันควรไม่สมมาตร และความไม่สมมาตรนั้นคือการออกแบบทั้งหมด การอ่านที่รันไม่ได้มีคำตอบว่างเปล่าที่ซื่อสัตย์ การเขียนที่รันไม่ได้ไม่มีคำตอบที่ซื่อสัตย์เลย ดังนั้นมันต้อง raise PDFium Component แบ่ง property คำอธิบายไฟล์แนบตรงตามเส้นนั้นพอดี และการแบ่งนั้นคือสิ่งที่หยุด export ที่หายไปไม่ให้กลายเป็นการสูญเสียข้อมูลอย่างเงียบ ๆ TPdf.GetAttachmentDescription ทดสอบ Assigned(FPDFAttachment_GetDescription) และออกไปพร้อม WString ว่างเปล่า นั่นไม่ใช่การโกหก บน DLL ที่ไม่มี export component ไม่สามารถบอกได้จริง ๆ ว่าไฟล์แนบพก entry /Desc อยู่หรือไม่ และคำอธิบายว่างเปล่าอ่านเหมือนกับไฟล์แนบที่ไม่เคยมีคำอธิบายเลย API ไฟล์แนบที่เหลือ ครอบคลุมใน บทความเรื่องการทำงานกับไฟล์แนบ PDF ใน Delphi ยังคงทำงานได้โดยไม่ถูกแตะต้อง

TPdf.SetAttachmentDescription ใช้เส้นทางตรงกันข้าม มันเรียก Check บนการทดสอบ Assigned เดียวกัน และ raise EPdfError พร้อมข้อความ "Attachment descriptions are not supported by the loaded PDFium DLL" การคืนค่าอย่างเงียบ ๆ ตรงนี้จะเป็นตัวเลือกที่แย่ที่สุดเท่าที่มี caller จะตั้งคำอธิบาย ไม่ได้ error บันทึกไฟล์ แล้วส่งมอบ PDF ที่คำอธิบายหายไปเฉย ๆ ไม่มีใครสังเกตเห็นจนกว่า consumer ปลายทางจะถามว่ามันหายไปไหน

function TPdf.GetAttachmentDescription(Index: Integer): WString;
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  Result := '';

  // Read side degrades: an old DLL cannot report /Desc, and '' is
  // indistinguishable from an attachment that carries no description.
  if not Assigned(FPDFAttachment_GetDescription) then
    Exit;
  // ... two-pass buffer sizing against FPDFAttachment_GetDescription ...
end;

procedure TPdf.SetAttachmentDescription(Index: Integer; const Value: WString);
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  // Write side refuses: silently dropping the value would produce a file
  // the caller believes carries a description and does not.
  Check(Assigned(FPDFAttachment_SetDescription),
    'Attachment descriptions are not supported by the loaded PDFium DLL');
  // ... FPDFDoc_GetAttachment, then FPDFAttachment_SetDescription ...
end;

การตรวจสอบ capability ก่อนที่จะเปิดฟีเจอร์ให้ใช้

การจับ exception เป็นวิธีที่แย่ในการค้นหาว่า deployment ของคุณทำอะไรได้บ้าง ดังนั้น PDFium Component จึงเปิดการทดสอบเดียวกันเป็นฟังก์ชันที่มีชื่อ AttachmentDescriptionFeaturesAvailable เรียก LoadLibrary และคืนค่าว่าทั้งสองส่วนของคู่นั้น resolve ได้หรือไม่ มันอยู่ข้าง V8FeaturesAvailable, XfaBStrHelpersAvailable และ XfaFeaturesAvailable ซึ่งทำตามรูปแบบเดียวกันสำหรับกลุ่มตัวเลือกของตัวเอง การตั้งชื่อ probe สำคัญกว่าที่ดูเยอะ boolean ที่ชื่อ AttachmentDescriptionFeaturesAvailable บอก maintainer คนถัดไปว่าฟีเจอร์นี้ขึ้นอยู่กับ binary ที่ deploy อยู่ ซึ่งการทดสอบ Assigned เปล่า ๆ ที่ฝังอยู่ใน property setter ไม่เคยบอกเลย มันยังให้ UI layer มีอะไรผูกไว้ ดังนั้นกล่องแก้ไขคำอธิบายจะถูกปิดใช้งานล่วงหน้าแทนที่จะรับข้อมูลนำเข้าแล้วปฏิเสธตอนบันทึก

procedure TAttachmentFrame.SyncCapabilities;
begin
  // Ask once, at form setup, instead of discovering the limit on save.
  DescriptionEdit.Enabled := AttachmentDescriptionFeaturesAvailable;
  if not DescriptionEdit.Enabled then
    DescriptionEdit.TextHint := 'Requires a newer pdfium.dll';
end;

procedure TAttachmentFrame.SaveDescription(Pdf: TPdf; Index: Integer);
begin
  if not AttachmentDescriptionFeaturesAvailable then
    Exit;
  Pdf.AttachmentDescription[Index] := DescriptionEdit.Text;
end;

ทำไมความครอบคลุมของ binding ต้องพิสูจน์ด้วยเครื่องมือ

เพราะตัวเลขผ่านจุดที่มนุษย์เชื่อถือได้ไปแล้ว PDFium Component ตรวจสอบ 21 public PDFium header เทียบกับ upstream baseline วันที่ 2026-07-29 และพบฟังก์ชัน exported C ABI 470 ตัว binding ครอบคลุม 468 ตัวไปแล้ว ไม่มีใครหาช่องว่างสองตัวนั้นเจอด้วยการอ่าน header สคริปต์เจอมันในหนึ่งวินาที และมันจะทำซ้ำอีกครั้งในการอัปเดต upstream ครั้งถัดไป tools/audit_pdfium_public_api.py เล็กโดยตั้งใจ มัน regex-match FPDF_EXPORT ... FPDF_CALLCONV name( ข้ามทุก header ใน public directory, regex-match ทุก CheckGetProcAddress('Name') และ TryGetProcAddress('Name') ใน PDFium.pas และพิมพ์ความต่างของสองเซตนั้น คือ missing สำหรับ export ที่ไม่มี binding และ stale สำหรับ binding ที่ export ของมันไม่มีอยู่แล้วต้นทาง มันจะ exit แบบไม่เป็นศูนย์เมื่อเซตใดเซตหนึ่งไม่ว่างเปล่า ดังนั้นจึงใส่เข้าไปใน build step ได้โดยไม่ต้องมีพิธีการเพิ่มเติม ผลลัพธ์ปัจจุบันคือ bound 470 จาก 470 ตัว, missing 0, stale 0

ทิศทาง stale คุ้มค่าไม่แพ้ทิศทาง missing เลย export ที่ upstream ลบทิ้งจะทิ้งบรรทัด CheckGetProcAddress ไว้ที่จะทำให้การโหลดในอนาคตทุกครั้งล้มเหลวอย่างหนัก และการเสื่อมสภาพแบบนี้มองไม่เห็นจนกว่าจะถึงวันที่มีคนอัปเดต DLL การรีวิวด้วยมือหาฟังก์ชันที่คุณกำลังคิดถึงเจอ มันไม่เจอตัวที่คุณไม่ได้คิดถึง โปรดสังเกตด้วยว่าการตรวจสอบตั้งใจนับทั้งสอง loader เป็นความครอบคลุม ซึ่งเป็นการตัดสินใจที่ถูกต้องสำหรับ API drift และเป็นเหตุผลที่การแบ่งจำเป็น/เลือกได้ต้องเป็นการตัดสินใจที่มีเอกสารกำกับ ไม่ใช่ผลพลอยได้จากใครก็ตามที่เพิ่มบรรทัดนั้นเข้าไป

จุดที่การ binding แบบเลือกได้หยุดความซื่อสัตย์

มีขอบเขตสองอย่างที่ควรพูดให้ชัด เพราะรูปแบบนี้ถูกนำไปใช้เกินขอบเขตได้ง่าย ข้อแรกคือ nil function pointer จะปลอดภัยก็ต่อเมื่อทุก path ที่แตะมันทดสอบ Assigned ก่อนจริง ๆ เท่านั้น ใน unit ที่ประกาศตัวแปรฟังก์ชัน cdecl เป็นร้อย ๆ ตัว การเรียกที่ไม่มี guard เดียวคือ access violation ที่ address ที่ไม่มีความหมายอะไรเลยใน stack trace วินัยเดียวกันที่ควบคุม calling convention และอายุการใช้งานข้ามขอบเขต C ใช้ได้ตรงนี้ด้วย และเป็นหัวข้อของ บทความเรื่องการเสริมความแข็งแกร่งให้ PDFium binding ต่อความผิดพลาดด้าน ABI และความปลอดภัยหน่วยความจำ

ขอบเขตที่สองคือขอบเขตการใช้งาน การ binding แบบเลือกได้ไม่ใช่ใบอนุญาตทั่วไปให้ทำทุกอย่างเป็นแบบผ่อนปรน ถ้า FPDF_RenderPageBitmap เป็นตัวเลือก component จะโหลดได้อย่างมีความสุขแล้วล้มเหลวในทุกหน้า เปลี่ยน startup error ที่ชัดเจนหนึ่งจุดให้กลายเป็นความล้มเหลว runtime กระจัดกระจายที่ไม่มีสาเหตุชัดเจน จำเป็นคือค่าเริ่มต้นที่ถูกต้อง เลือกได้คือข้อยกเว้นที่คุณเอื้อมมือไปใช้เมื่อฟีเจอร์เป็นปลายทางจริง ๆ เมื่อการหายไปมีพฤติกรรมลดระดับที่ปกป้องได้ในฝั่งอ่าน และเมื่อฝั่งเขียนสามารถปฏิเสธพร้อมข้อความที่ระบุเหตุผลได้

การออกแบบ loader, capability probe และเครื่องมือ audit ที่กล่าวถึงในบทความนี้มาพร้อมกับ PDFium Component สำหรับ Delphi และ C++Builder หน้าผลิตภัณฑ์มีรายการ PDFium binary ที่แถมมาและ API surface ฉบับเต็มที่มันเปิดให้ใช้