คลังข้อมูลที่ถูกสแกนอาจมีขนาดใหญ่ถึงหลายกิกะไบต์ในไฟล์ PDF เดียว โปรแกรมดูที่เปิดไฟล์ดังกล่าวโดยปกติมักจะต้องการแสดงเพียงหน้าเดียว อาจจะเป็นสารบัญ หรือหน้าที่ผู้ใช้ข้ามมาจากบุ๊กมาร์ก การอ่านทั้งไฟล์ลงในหน่วยความจำเพื่อเรนเดอร์แค่สองหน้านั้นเป็นการสิ้นเปลืองในทุกๆ ด้าน: มันผลาญพื้นที่ของ address space ทำให้ผู้ใช้ต้องหยุดรอการอ่านข้อมูลเบื้องต้นที่ยาวนาน และในโปรเซสของ Delphi แบบ 32-บิต มันอาจจะล้มเหลวไปเลยก่อนที่หน้าแรกจะปรากฏขึ้นมาด้วยซ้ำ PDFium ถูกสร้างขึ้นมาโดยคำนึงถึงเรื่องนี้ มันสามารถโหลดเอกสารผ่านคอลแบ็ก (callback) ที่จะร้องขอช่วงไบต์เฉพาะที่ต้องการเมื่อถึงเวลาที่ต้องใช้ และมันไม่เคยเรียกร้องหาทั้งไฟล์ในคราวเดียว มีขอบเขตประการหนึ่งที่ต้องรู้ไว้ก่อน: ช่องทางการสตรีมนี้ระบุรายละเอียดของไฟล์ด้วยความยาว 32-บิต ดังนั้นมันจึงให้บริการไฟล์เดี่ยวได้สูงสุดที่ 4 GiB ซึ่งในทางปฏิบัติครอบคลุมคลังข้อมูลที่ถูกสแกนเกือบทั้งหมด ไฟล์ที่เกินเส้นนั้นไม่ใช่เรื่องของบทความนี้; ไฟล์เหล่านั้นควรถูกแบ่งออกเป็นโวลุ่มต่างๆ ในขั้นตอนการสแกน หรือไม่ก็เปิดผ่านกลยุทธ์การเข้าถึงโดยตรงแทน และระบบป้องกันเพื่อบังคับใช้เพดานนี้ก็มีส่วนอธิบายแยกไว้ต่างหากที่ด้านล่างอย่างจริงใจ
คอมโพเนนต์เปิดเผยเส้นทางนั้นผ่าน stream adapter คุณสามารถส่ง TStream ใดๆ ให้กับมัน แล้ว PDFium จะดึงข้อมูลแบบบล็อกจากสตรีมนั้นตามความต้องการ ไฟล์นั้นอาจอยู่บนดิสก์ ในฟิลด์ blob ของฐานข้อมูล หรืออยู่เบื้องหลัง TStream ชนิดอื่นที่สืบทอดมา และจะไม่มีส่วนใดของไฟล์ถูกคัดลอกลงในหน่วยความจำล่วงหน้าเลย
PDFium ร้องขอไบต์ข้อมูลอย่างไร
C API ของ PDFium โหลดเอกสารจากอ็อบเจ็กต์ที่จัดหาโดยผู้เรียก ซึ่งถูกอธิบายโดยโครงสร้าง FPDF_FILEACCESS โครงสร้างนี้มีส่วนสำคัญที่เกี่ยวข้องในที่นี้สามส่วน: ฟิลด์ความยาว, คอลแบ็กการอ่าน, และพารามิเตอร์ของผู้ใช้แบบทึบแสง (opaque) จุดเข้า (entry point) ที่ใช้งานโครงสร้างนี้คือ FPDF_LoadCustomDocument เมื่อ PDFium ถือโครงสร้างนั้นไว้ มันจะแยกวิเคราะห์ส่วนท้าย (trailer) ค้นหาตำแหน่งตาราง cross-reference และจากนั้นเป็นต้นไปก็จะอ่านเฉพาะสิ่งที่การทำงานนั้นๆ ต้องการ การเปิดเอกสารจะไปแตะที่ส่วนท้ายของไฟล์และอ็อบเจ็กต์แค็ตตาล็อกอีกหยิบมือหนึ่ง การเรนเดอร์หน้าที่ 400 จะอ่านสตรีมเนื้อหาและทรัพยากรสำหรับหน้านั้น และจะไม่อ่านอย่างอื่นเลย
นี่คือความแตกต่างระหว่างการโหลดแบบบัฟเฟอร์ (buffered load) และการโหลดแบบสตรีมมิ่ง (streaming load) การโหลดแบบบัฟเฟอร์จะอ่านไฟล์ตั้งแต่ต้นจนจบก่อนที่ PDFium จะเห็นไบต์ที่ศูนย์ การโหลดแบบสตรีมมิ่งจะกลับความสัมพันธ์นั้น: PDFium จะเป็นผู้ควบคุมการอ่าน และไบต์ข้อมูลที่ไม่เคยถูกแตะต้องก็จะไม่เคยถูกอ่านเลย สำหรับไฟล์ขนาดหลายกิกะไบต์ที่เปิดดูทีละหน้า นั่นคือช่องว่างระหว่างการโหลดที่ไม่สามารถใช้งานได้เลยกับการโหลดแบบทันทีทันใด
ตัวแปลงสตรีม (Stream adapter)
อแดปเตอร์ที่ทำหน้าที่เป็นสะพานเชื่อมระหว่าง TStream ของ Delphi กับ FPDF_FILEACCESS คือ TPdfStreamAdapter คอนสตรัคเตอร์ของมันจะรับสตรีมและแฟล็กความเป็นเจ้าของ ทำการบันทึกความยาวของสตรีมไว้หนึ่งครั้ง เติมข้อมูลลงในระเบียน FPDF_FILEACCESS และเชื่อมต่อสายของคอลแบ็กการอ่าน เมื่อ PDFium โทรกลับมาในภายหลังพร้อมกับค่าออฟเซ็ตและขนาด อแดปเตอร์ก็จะค้นหาสตรีมไปยังออฟเซ็ตนั้น และคัดลอกช่วงนั้นลงไปในบัฟเฟอร์ที่ PDFium จัดเตรียมไว้ให้อย่างแม่นยำ
// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
inherited Create;
if AStream = nil then
raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
FStream := AStream;
FOwnsStream := AOwnsStream;
// FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
// that would silently truncate past 4 GiB.
if AStream.Size > High(FPDF_DWORD) then
raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');
FillChar(FFileAccess, SizeOf(FFileAccess), 0);
FFileAccess.m_FileLen := FPDF_DWORD(AStream.Size);
FFileAccess.m_GetBlock := GetBlockCallback;
FFileAccess.m_Param := Self;
end;
แฟล็กความเป็นเจ้าของจะเป็นตัวตัดสินว่าใครเป็นคนคืนหน่วยความจำ (free) สตรีม การส่งค่า False เข้าไป ผู้เรียกจะยังคงเก็บสตรีมเอาไว้และจะต้องรักษามันให้ทำงานอยู่ตลอดช่วงอายุการใช้งานของเอกสารนั้น การส่งค่า True เข้าไป อแดปเตอร์จะเป็นผู้รับช่วงต่อ และคืนหน่วยความจำสตรีมเมื่อปิดเอกสาร ไม่ว่าจะเป็นวิธีใด สตรีมจะต้องมีอายุยืนยาวกว่าการอ่านทุกครั้งที่ PDFium จะดำเนินการ เพราะ PDFium ถือตัวชี้ (pointer) ของ FPDF_FILEACCESS เอาไว้และจะทำการเรียกกลับที่จุดใดก็ได้ในขณะที่เอกสารเปิดอยู่ ไม่ใช่เฉพาะในระหว่างการโหลดตอนเริ่มต้นเท่านั้น
ทำไมคอลแบ็กถึงเป็น static function
คอลแบ็กการอ่านที่ PDFium เก็บไว้ใน m_GetBlock เป็นฟังก์ชันพอยน์เตอร์ C แบบธรรมดาที่มีข้อกำหนดการเรียกแบบ cdecl เมธอดของ Delphi ไม่สามารถนำมาใช้โดยตรงได้ เพราะเมธอดนั้นจะแฝงอาร์กิวเมนต์ Self ที่ซ่อนอยู่ ซึ่งผู้เรียกฝั่ง C จะไม่มีทางรู้เรื่องนี้เลยและจะไม่ส่งอาร์กิวเมนต์นี้เข้ามา อแดปเตอร์จึงต้องประกาศให้คอลแบ็กเป็น class function ที่ทำเครื่องหมายไว้ว่า cdecl; static ซึ่งจะถูกคอมไพล์ให้เป็นฟังก์ชันแบบสแตนด์อะโลน (free-standing) ที่มีเค้าโครงเฟรมแบบ C ตามที่ PDFium คาดหวัง และไม่มีการแฝงอาร์กิวเมนต์ Self เอาไว้
สิ่งนี้ช่วยแก้ปัญหาข้อกำหนดในการเรียกใช้งานได้ แต่นำมาซึ่งคำถามที่สอง: เมื่อไม่มี Self แล้ว คอลแบ็กจะเข้าถึงสตรีมเฉพาะที่มันควรจะอ่านข้อมูลมาได้อย่างไร? คำตอบคือพารามิเตอร์ของผู้ใช้แบบทึบแสง (opaque) เมื่ออแดปเตอร์สร้างระเบียนขึ้นมา มันจะเก็บตัวชี้ (pointer) อินสแตนซ์ของตัวเองไว้ใน m_Param PDFium จะส่งตัวชี้เดียวกันนั้นกลับมาเป็นอาร์กิวเมนต์ตัวแรกของทุกคอลแบ็ก static function จะแปลงค่ากลับไปเป็น TPdfStreamAdapter และทำการจัดส่งการอ่านไปยังสตรีมของอินสแตนซ์นั้น นี่คือแทรมโพลีน (trampoline) มาตรฐานสำหรับการส่งบริบทของอ็อบเจ็กต์ข้ามพรมแดนของภาษา C ซึ่งไม่มีแนวคิดเรื่องอ็อบเจ็กต์
// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
param : Pointer;
position: FPDF_DWORD;
pBuf : PByte;
size : FPDF_DWORD): Integer; cdecl;
var
Adapter: TPdfStreamAdapter;
begin
Result := 0;
if (param = nil) or (pBuf = nil) or (size = 0) then
Exit;
Adapter := TPdfStreamAdapter(param); // recover the instance from m_Param
if Adapter.FStream = nil then
Exit;
try
Adapter.FStream.Position := Int64(position);
Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
Result := 1;
except
Result := 0; // report failure by return value, never by raising
end;
end;
เพดาน 4 GiB และเหตุใดจึงต้องมีการป้องกัน
นี่คือที่มาของขอบเขตที่ระบุไว้ในตอนต้น ฟิลด์ความยาว m_FileLen ใน FPDF_FILEACCESS เป็นค่า unsigned 32-บิต ความยาวที่มากที่สุดที่มันสามารถนำเสนอได้คือหนึ่งไบต์ก่อนที่จะถึง 4 GiB TStream จะรายงานขนาดของมันเป็นค่า Int64 ดังนั้นสตรีมจึงสามารถอธิบายจำนวนไบต์ได้มากกว่าที่ฟิลด์จะรองรับได้ ทันทีที่ขนาดของสตรีมเกินเพดานนั้น ก็จะไม่มีวิธีที่ซื่อสัตย์ในการบอก PDFium ว่าไฟล์นั้นมีความยาวเท่าใด
การตอบสนองที่ผิดคือการกำหนดขนาดเข้าไปแล้วปล่อยให้มันพันรอบตัวเอง (wrap) การตัดความยาวระดับ 5 GiB ลงในฟิลด์ 32-บิต จะทำให้เกิดตัวเลขขนาดเล็กที่ดูเหมือนจะเป็นไปได้ และ PDFium ก็จะแยกวิเคราะห์ไฟล์โดยเชื่อว่ามันสิ้นสุดที่ประมาณหนึ่งกิกะไบต์ ส่วนท้าย (trailer) และตาราง cross-reference นั้นอยู่ที่ปลายทางจริงๆ ของไฟล์ ซึ่งเลยจากความยาวที่ถูกตัดไปมาก ดังนั้นการแยกวิเคราะห์จึงล้มเหลวในลักษณะที่ไม่เกี่ยวข้องกับสาเหตุที่แท้จริงเลย คุณจะต้องมานั่งดีบักข้อผิดพลาดของ cross-reference บนไฟล์ที่สมบูรณ์แบบทุกประการ โดยไม่มีเบาะแสใดๆ บอกเลยว่าตัวเลขจำนวนเต็ม (integer) นั้นพันรอบตัวมันเองอยู่ลึกลงไปถึงสองระดับ
อแดปเตอร์จึงเลือกที่จะปฏิเสธอินพุตนั้นแทน คอนสตรัคเตอร์จะนำขนาดของสตรีมมาเปรียบเทียบกับ High(FPDF_DWORD) และแสดง EPdfError ขึ้นมาทันทีเมื่อสตรีมมีขนาดใหญ่เกินกว่าจะอธิบายได้ ข้อผิดพลาดที่แจ้งออกมาทันทีอย่างชัดเจนจะเป็นการชี้ให้เห็นถึงปัญหาที่แท้จริง ณ จุดที่ทำการสร้าง การถูกตัดทอนอย่างเงียบๆ จะซ่อนปัญหาไว้เบื้องหลังอาการชวนสับสน ซึ่งจะทำให้คุณต้องวิ่งไล่ตามในภายหลัง ขีดจำกัด 4 GiB เป็นข้อจำกัดที่แท้จริงของเส้นทางการโหลดนี้ และสิ่งที่ควรทำอย่างตรงไปตรงมาคือการแสดงมันออกมาดังๆ แทนที่จะใช้คณิตศาสตร์ที่บังเอิญคอมไพล์ผ่านเข้ามาปกปิดมันไว้ เมื่อคลังข้อมูลมีขนาดข้ามเส้นนั้นไปจริงๆ วิธีแก้ปัญหาตามที่ได้สัญญาไว้ด้านบนจะอยู่นอก API นี้: ให้แยกการสแกนออกเป็นไฟล์ย่อยๆ ตามโวลุ่มที่แต่ละไฟล์ไม่เกินเพดานที่กำหนด หรือทิ้งเอกสารไว้บนดิสก์และให้บริการมันผ่านการออกแบบเพื่อการเข้าถึงโดยตรงที่สร้างขึ้นบน 64-bit ออฟเซ็ต แทนที่จะผ่าน FPDF_FILEACCESS
ความล้มเหลวจะต้องไม่ข้ามพรมแดน
การอ่านสามารถล้มเหลวได้ สตรีมอาจเป็นอ็อบเจ็กต์ที่ทำงานผ่านเครือข่ายและหมดเวลา (times out), เป็นแฮนเดิล blob ที่ถูกปิดทิ้งไปโดยที่คุณไม่รู้ตัว, หรือไฟล์ที่ถูกตัดทอนหลังจากเปิดเอกสารแล้ว ข้อตกลงของ PDFium สำหรับคอลแบ็กการอ่านคือค่าตอบกลับ: ค่าที่ไม่ใช่ศูนย์เพื่อแสดงความสำเร็จ, ค่าศูนย์เพื่อแสดงความล้มเหลว มันเป็นเฟรมของภาษา C และไม่มีกลไกในการตรวจจับหรือส่งผ่าน (propagate) ข้อผิดพลาดของภาษา Pascal ไปได้
นี่คือเหตุผลที่ตัวส่งผ่าน (trampoline) ครอบการค้นหาและการอ่านไว้ใน try/except ที่จะกลืนข้อผิดพลาดทั้งหมดและส่งค่าศูนย์กลับมา หากปล่อยให้ข้อผิดพลาดของ Delphi เล็ดลอดออกไปจากคอลแบ็ก มันก็จะถูกคลายผ่าน cdecl stack frames ของ PDFium ซึ่งไม่เคยถูกสร้างขึ้นมาเพื่อให้กลไกจัดการข้อผิดพลาดของ Pascal เป็นผู้คลาย ผลลัพธ์ที่ดีที่สุดก็คือเกิดพฤติกรรมที่ไม่สามารถนิยามได้ (undefined behavior) และเลวร้ายที่สุดก็คือระบบล่ม (hard crash) ในส่วนลึกของเครื่องมือแยกวิเคราะห์ PDF โดยไม่มี stack ให้ใช้งานได้ การส่งคืนค่าศูนย์จะช่วยเก็บความล้มเหลวให้อยู่ภายในกรอบข้อตกลง PDFium จะเห็นว่าการอ่านบล็อกล้มเหลว มันจะยกเลิกการทำงานอย่างหมดจด และ FPDF_LoadCustomDocument จะรายงานว่าไม่สามารถโหลดเอกสารได้ ซึ่งคอมโพเนนต์จะนำไปแสดงเป็น EPdfError บนฝั่งของ Pascal อันเป็นที่ที่เหมาะสมของมัน
การเปิดเอกสารด้วยวิธีนี้
เมธอดคอมโพเนนต์ที่ใช้ขับเคลื่อนเส้นทางสตรีมมิ่งคือ LoadCustomDocument มันถูกประกาศเป็นเมธอดที่แยกต่างหาก แทนที่จะเป็นอีก LoadDocument โอเวอร์โหลดหนึ่ง เพื่อที่การส่ง TMemoryStream จะได้ไม่พลัดหลงไปอยู่ในเส้นทางที่มีการบัฟเฟอร์โดยไม่ได้ตั้งใจ มันทำหน้าที่สร้างอแดปเตอร์, เรียก FPDF_LoadCustomDocument, และเก็บอแดปเตอร์ให้ทำงานตลอดช่วงอายุการใช้งานของเอกสารที่โหลดเข้ามา
var
Pdf: TPdf;
FileStream: TFileStream;
begin
Pdf := TPdf.Create(nil);
FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
try
// Hand stream ownership to Pdf: it frees FileStream when the document closes.
Pdf.LoadCustomDocument(FileStream, True);
// PDFium has read only the trailer and catalog so far.
// Rendering a page pulls just that page's bytes through the callback.
// ... render or inspect pages here ...
finally
Pdf.Free; // closes the document, which frees the adapter and the stream
end;
end;
การเรียกในรูปแบบเดียวกันก็สามารถใช้ได้กับ TMemoryStream, สตรีม blob จากชุดข้อมูลของฐานข้อมูล, หรือ TStream ที่สืบทอดมาจากคลาสที่สร้างขึ้นเอง การโหลดตามความต้องการจะคุ้มค่ากับความพยายามก็ต่อเมื่อไฟล์มีขนาดใหญ่และคุณต้องการอ่านเนื้อหาเพียงแค่บางส่วนเท่านั้น: โปรแกรมแสดงคลังข้อมูลเอกสาร, โปรแกรมสร้างภาพตัวอย่างขนาดเล็กที่ทำการสุ่มตรวจเพียงไม่กี่หน้า, ดัชนีการค้นหาที่ดึงข้อมูลทีละหน้า เมื่อไฟล์มีขนาดเล็ก หรือคุณมีแผนที่จะอ่านทั้งหมดอยู่แล้ว การโหลดแบบบัฟเฟอร์จะง่ายกว่าและกลไกสตรีมมิ่งก็ไม่ได้ช่วยอะไรเลย ปัจจัยตัดสินก็คือสัดส่วนระหว่างไบต์ที่คุณจะนำมาใช้งานจริงเมื่อเทียบกับจำนวนไบต์ทั้งหมดที่ไฟล์นั้นมี
เมื่อหน้ากระดาษถูกสตรีมเข้ามาตามความต้องการ ข้อกังวลถัดไปคือการรักษาการเรนเดอร์หน้ากระดาษให้ตอบสนองได้ทันใจเมื่อผู้ใช้ซูมและเลื่อนหน้า ซึ่งครอบคลุมไว้ในบันทึกของเราเกี่ยวกับแคชการเรนเดอร์และประสิทธิภาพการซูม เมื่อเอกสารที่ถูกสตรีมเป็นเอกสารที่โปรแกรมแสดงไฟล์ควรเปิดให้ดูแต่ไม่อนุญาตให้ผู้ใช้ส่งออกหรือแก้ไข เทคนิคในหัวข้อบทสรุปการดูตัวอย่าง PDF แบบปลอดภัย จะจับคู่เข้ากับเส้นทางการโหลดนี้ได้อย่างเป็นธรรมชาติ ทั้งสองหัวข้อล้วนสร้างขึ้นบนโครงสร้างการโหลดแบบสตรีมมิ่งที่อธิบายไว้ในที่นี้ ซึ่งจัดส่งมาให้เป็นส่วนหนึ่งของ PDFium Component สำหรับ Delphi และ C++Builder ร่วมกับ API สำหรับการเรนเดอร์, การดึงข้อความ, และคำอธิบายประกอบ ที่ครอบคลุมเนื้อหาในส่วนอื่นๆ ของบล็อกนี้