การรวมหรือแยกไฟล์ PDF ขนาดสองกิกะไบต์ด้วยวิธีที่นึกออกทันทีทำให้คุณเสียสองอย่างพร้อมกัน คือเวลาจริงและพื้นที่แอดเดรส วิธีที่นึกออกทันทีคือโหลดอินพุตแต่ละไฟล์ ทำงาน แล้วเขียนเอาต์พุต และการโหลดนี่เองคือจุดที่พัง คลังเอกสารสแกนที่ขยับจาก 300 ไปเป็น 600 DPI จะเพิ่มความละเอียดเชิงเส้นเป็นสองเท่าและกินพื้นที่ดิสก์ราวสี่เท่า งานประกอบเอกสารตัวเดิมที่จัดการไฟล์ขนาด 400 MB ได้มาทั้งปีจึงเริ่มกระตุกทันทีที่อินพุตแตะระดับกิกะไบต์ บ่อยครั้งขณะที่ทำเพียงแค่นับจำนวนหน้าเท่านั้น ตัวงานไม่ได้ยากขึ้นเลย ทั้งหมดมีแค่เปิด นับ เลือกช่วง แล้วต่อเข้าด้วยกัน เพียงแต่การโหลดทั้งต้นไม้เลิกเป็นค่าเริ่มต้นที่สมเหตุสมผลไปแล้วที่ขนาดระดับนั้น PDF Library for Delphi ซึ่งเป็นไลบรารี PDF ของ losLab สำหรับ Delphi และ C++Builder ตอบโจทย์นี้ด้วยเลเยอร์ Direct Access คือกลุ่มฟังก์ชันที่ขึ้นต้นด้วย DA ซึ่งมีตัวอ่านแบบสตรีมมิงหนุนอยู่เบื้องหลัง เดินสำรวจตาราง cross-reference ในที่ตั้งของมันแทนที่จะสร้างเอกสารทั้งฉบับขึ้นในหน่วยความจำ
หน่วยความจำหายไปไหนเมื่อโหลดแบบเต็มรูปแบบ
การโหลดไฟล์ PDF "ตามปกติ" หมายถึงการแจง xref แปลง indirect object ทุกตัวให้กลายเป็นต้นไม้ในหน่วยความจำ ถอดรหัส object stream และเชื่อม page tree ฟอนต์ และ annotation เข้าเป็นออบเจ็กต์ที่คุณจัดการต่อได้ สำหรับเวิร์กโฟลว์การแก้ไข นี่คือการแลกที่ถูกต้อง แต่สำหรับงานรวม แยก และตรวจสอบ ส่วนใหญ่มันคือความสูญเปล่า คลังเอกสารสแกน 30,000 หน้าอาจมี indirect object นับล้านตัว ขณะที่งานแยกไฟล์ต้องอ่านเพียงไม่กี่ร้อยตัว ได้แก่โหนดของหน้าในช่วงที่ร้องขอ บวกกับสิ่งที่โหนดเหล่านั้นอ้างถึง
เลเยอร์ Direct Access พลิกโมเดลนี้กลับด้าน DAOpenFile และ DAOpenFileReadOnly แจงส่วน trailer และ xref ซึ่งกินพื้นที่เพียงไม่กี่กิโลไบต์ที่ท้ายไฟล์ แล้วคืน file handle กลับมา ออบเจ็กต์จะถูกดึงแบบ lazy เมื่อมีการเรียกที่ต้องใช้มันจริง ผลในทางปฏิบัติคือการเปิดไฟล์ขนาดหลายกิกะไบต์ใช้เวลาพอ ๆ กับการเปิดไฟล์เล็ก และหน่วยความจำแปรตามสิ่งที่คุณแตะต้อง ไม่ใช่ตามสิ่งที่ไฟล์บรรจุอยู่
การสำรวจไฟล์ขนาดมหึมาโดยไม่ต้องโหลด
รูปแบบด้านล่างมาจากเบนช์มาร์กไฟล์ขนาดใหญ่ของไลบรารีเอง คือเปิดแบบอ่านอย่างเดียว ถามคำถาม แล้วปิด โดยไม่มีต้นไม้เอกสารเกิดขึ้นเลยแม้แต่ครั้งเดียว
var
Lib: TPDFlib;
Handle, Pages: Integer;
begin
Lib := TPDFlib.Create;
try
Handle := Lib.DAOpenFileReadOnly('archive-2025.pdf', '');
if Handle = 0 then
raise Exception.Create('Direct access open failed');
Pages := Lib.DAGetPageCount(Handle);
Writeln('pages : ', Pages);
Writeln('title : ', Lib.DAGetInformation(Handle, 'Title'));
Lib.DACloseFile(Handle);
finally
Lib.Free;
end;
end;
โหมดอ่านอย่างเดียวควรเลือกใช้ทุกครั้งที่ทำได้ เพราะมันยอมให้ขั้นตอนรับไฟล์ทำงานไปพร้อมกับที่กระบวนการอื่นถือไฟล์อยู่ และยังบอกเจตนาไว้ในตัว ขั้นตอนสำรวจที่เผลอเรียกฟังก์ชันซึ่งเปลี่ยนแปลงข้อมูลจะล้มเหลวทันทีแทนที่จะทำให้คลังเอกสารเสียหาย
PageRef คือแฮนเดิลของออบเจ็กต์ ไม่ใช่เลขหน้า
ความผิดพลาดที่พบบ่อยที่สุดเพียงข้อเดียวกับ DA API คือการส่งเลขหน้าเข้าไปในที่ที่ฟังก์ชันต้องการ PageRef การเรียก DA แบบรายหน้าเกือบทุกตัวรับแฮนเดิลอ้างอิงไปยังออบเจ็กต์ของหน้า ไม่ใช่เลขหน้า ทั้ง DAExtractPageText, DARenderPageToFile, DARotatePage และ DACapturePage ล้วนต้องการ ref คุณจะได้มันมาโดยแปลงเลขหน้าที่คนใช้กันผ่าน DAFindPage:
PageRef := Lib.DAFindPage(Handle, 250); // เลขหน้า -> แฮนเดิลออบเจ็กต์
if PageRef <> 0 then
begin
Text := Lib.DAExtractPageText(Handle, PageRef, 0);
Lib.DARenderPageToFile(Handle, PageRef, 5, 150, 'page250.png');
end;
การส่งเลข 250 ดิบ ๆ เข้าไปแทนจะไม่ทำให้เกิดข้อผิดพลาดใด ๆ มันจะไปอ้างถึงออบเจ็กต์อะไรก็ตามที่บังเอิญอยู่หลังค่าแฮนเดิลนั้น ซึ่งถ้าโชคดีก็ล้มเหลวให้เห็นชัด แต่ถ้าโชคร้ายก็ดึงข้อความจากหน้าที่ผิดเข้าไปในเอกสารที่ส่งถึงลูกค้า หากคุณห่อเลเยอร์ DA ไว้ในโค้ดบริการของคุณเอง จงทำให้ขั้นตอนแปลงค่านี้ข้ามไม่ได้ คือรับเลขหน้าที่ขอบเขตด้านนอก เรียก DAFindPage ทันที แล้วส่งต่อเฉพาะ ref ภายในเท่านั้น
การรวมไฟล์หลายร้อยไฟล์ด้วยลิสต์ที่ตั้งชื่อไว้
สำหรับสองไฟล์ MergeFiles(First, Second, Output) ก็เพียงพอแล้ว แต่การประกอบแบบแบตช์ขยายตัวได้ดีกว่าเมื่อใช้ file list คือลงทะเบียนอินพุตไว้ใต้ชื่อลิสต์ แล้วรวมทั้งลิสต์ในรอบเดียว
Lib.AddToFileList('Statements', 'jan.pdf');
Lib.AddToFileList('Statements', 'feb.pdf');
Lib.AddToFileList('Statements', 'mar.pdf');
Lib.MergeFileList('Statements', 'q1-statements.pdf');
// ตรวจผลลัพธ์ด้วยวิธีที่ถูกที่สุด: ใช้ direct access อีกครั้ง
Handle := Lib.DAOpenFileReadOnly('q1-statements.pdf', '');
Writeln('merged pages: ', Lib.DAGetPageCount(Handle));
Lib.DACloseFile(Handle);
ตระกูลฟังก์ชันรวมไฟล์มีสามรูปแบบ และความต่างไม่ได้อยู่ที่ความเร็วเพียงอย่างเดียว MergeFileListFast ข้ามการรักษา structure tree ส่วน MergeFileListStrict บังคับใช้โหมดเข้มงวด และรุ่นที่ไม่มีคำต่อท้ายคือค่าเริ่มต้นที่สมดุล กฎเชิงปฏิบัติที่ตามมาคือ ถ้ามีอินพุตใดเป็น Tagged PDF ที่โครงสร้างการเข้าถึงต้องอยู่รอด ซึ่งกรณีที่เห็นชัดที่สุดคืออะไรก็ตามที่ผลิตขึ้นเพื่อ PDF/UA ให้เลือกรุ่นค่าเริ่มต้นหรือรุ่น Strict เพราะรุ่น Fast จะทิ้ง structure tree ไปอย่างเงียบ ๆ สำหรับคลังเอกสารสแกนล้วนที่ไม่มีการทำแท็ก รุ่น Fast คือประสิทธิภาพที่ได้มาฟรี จงตัดสินใจตามไปป์ไลน์ ไม่ใช่ตามอารมณ์ของนักพัฒนา และบันทึกรุ่นที่ใช้ไว้ในล็อกของงาน
การแยกไฟล์โดยไม่ต้องโหลด: การดึงตามช่วงหน้า
การแยกไฟล์ยึดปรัชญาไม่โหลดแบบเดียวกัน ExtractFilePages(InputFileName, Password, OutputFileName, RangeList) ดึงช่วงหน้าจากไฟล์ไปยังไฟล์โดยตรง ด้วยรายการช่วงอย่างเช่น '1-500', '501-1000' หรือการเลือกที่คั่นด้วยจุลภาค โดยที่ต้นทางไม่เคยกลายเป็นต้นไม้เอกสารเลย เมื่อเอกสารถูกโหลดไว้แล้วด้วยเหตุผลอื่น ExtractPageRanges จะสร้างเอกสารใหม่ในหน่วยความจำจากเอกสารปัจจุบัน ส่วน CopyPageRanges ดึงช่วงหน้าข้ามมาจากเอกสารอีกฉบับที่โหลดไว้โดยอ้างตาม ID สำหรับการแยกสตรีมงานพิมพ์รวมออกเป็นใบแจ้งยอดรายฉบับ รูปแบบไฟล์ต่อไฟล์คือตัวที่กันไม่ให้อินพุตขนาด 4 GB พองขึ้นไปอยู่ใน RAM
ไฟล์ที่โกหกเรื่องผังตำแหน่งภายในของตัวเอง
ไปป์ไลน์ไฟล์ขนาดใหญ่เจอไฟล์เสียหายในอัตราที่ไปป์ไลน์ไฟล์เล็กไม่เคยพบ ด้วยเหตุผลง่าย ๆ ว่าอินพุตผ่านระบบมามากกว่า มีรูปแบบความล้มเหลวสองแบบที่ควรจัดการอย่างชัดเจน
อย่างแรกคือ header ที่เลื่อนตำแหน่ง เมลเกตเวย์และ print spooler บางครั้งเติมไบต์ไว้หน้าไฟล์ PDF ทำให้เครื่องหมาย %PDF ไม่ได้อยู่ที่ offset 0 อีกต่อไป และทุก xref offset ในไฟล์ก็ผิดไปเป็นจำนวนเท่ากันหมด ตัวอ่านแบบสตรีมมิงตรวจจับเรื่องนี้ได้และเปิดเผยออกมา (DAShiftedHeader ที่ระดับ flat และ ShiftedHeader บน TSmartPDFReader) จากนั้นก็ชดเชยให้ระหว่างการอ่าน โค้ดคำนวณ offset ที่เขียนขึ้นเองมักทำไม่ได้ ซึ่งเป็นเหตุผลว่าทำไมอาการคลาสสิกจึงเป็นประโยคที่ว่า "ใช้ได้กับทุกไฟล์ที่เราสร้างเอง แต่พังกับไฟล์จากลูกค้า X"
อย่างที่สองคือตาราง cross-reference ที่เสียหาย DACopyFile(InputFileName, OutputFileName, PageCount) สตรีมทั้งไฟล์ไปยังสำเนาใหม่พร้อมกับสร้าง xref ขึ้นใหม่ และคืนจำนวนหน้ากลับมาเป็นผลพลอยได้ การรันมันเป็นขั้นตอนปรับให้เป็นมาตรฐานก่อนถึงผู้บริโภคปลายทางที่จู้จี้ จะเปลี่ยนความล้มเหลวในการแจงไฟล์แบบเป็น ๆ หาย ๆ ทั้งกลุ่มให้กลายเป็นขั้นตอนซ่อมเพียงขั้นเดียวที่คาดเดาได้ และเมื่อการแก้ไขของคุณเองต้องถูกบันทึก DAAppendFile จะเขียนมันเป็น incremental update คือต่อ revision ใหม่เข้าไปแทนที่จะเขียนข้อมูลนับกิกะไบต์ใหม่ทั้งหมด ซึ่งทำให้ต้นทุนการบันทึกแปรตามขนาดของการเปลี่ยนแปลง ไม่ใช่ตามขนาดของไฟล์
รายละเอียดการส่งมอบ: linearization และการจัดวางองค์ประกอบ
ความสามารถอีกสองอย่างที่อยู่ข้างเคียงช่วยเติมเต็มไปป์ไลน์ไฟล์ขนาดใหญ่ เมื่อเอาต์พุตที่ประกอบเสร็จถูกเสิร์ฟผ่าน HTTP เพื่อเปิดดูในเบราว์เซอร์ LinearizeFile จะจัดระเบียบไฟล์ใหม่ให้รองรับการสตรีมแบบ byte-range หน้าแรกจึงแสดงผลได้ก่อนที่ส่วนที่เหลือของแพ็กเกจขนาด 500 MB จะดาวน์โหลดเสร็จ ให้รันมันเป็นขั้นตอนสุดท้ายหลังการรวมไฟล์ทั้งหมด เพราะการแก้ไขใด ๆ ในภายหลังจะทำให้ไฟล์หลุดสภาพ linearized อีกครั้ง และเมื่อแพ็กเกจต้องการการจัดวางองค์ประกอบมากกว่าการต่อไฟล์ธรรมดา เช่น ใบปะหน้าที่ประทับไว้หลังใบแจ้งยอดทุกฉบับ หรือหน้าต้นทางสองหน้าที่จัดลงบนแผ่นเอาต์พุตเดียว DACapturePage จะเปลี่ยนหน้าใดก็ได้ให้เป็นเทมเพลตที่นำกลับมาใช้ซ้ำได้ แล้ว DADrawCapturedPage ก็วางมันลงบนหน้าปลายทางที่สี่เหลี่ยมตำแหน่งใดก็ได้ โดยยังคงไม่ต้องโหลดเอกสารต้นทางขนาดหลายกิกะไบต์แบบเต็มรูปแบบ
ขีดจำกัด และสิ่งที่ยังคงอ่านได้อย่างเดียว
ตัวรูปแบบไฟล์เองหมดพื้นที่ก่อน Direct Access อยู่มาก ค่า offset เป็น Int64 ตลอดทั้งเลเยอร์ DA เพดานจริงจึงอยู่ที่พื้นที่ดิสก์ที่มีอยู่ และฟิลด์ xref offset ขนาด 10 หลักของตาราง cross-reference แบบคลาสสิกที่ไม่ใช่สตรีม คลังเอกสารสแกนขนาดหลายกิกะไบต์จึงเป็นเรื่องธรรมดาในทางปฏิบัติ และหน่วยความจำยังคงมีขอบเขตจำกัดไม่ว่าไฟล์จะใหญ่แค่ไหน เพราะออบเจ็กต์จะถูกอ่านเฉพาะเมื่อมีการเรียกที่ต้องการมันเท่านั้น
มีคำถามสองข้อที่ถูกถามบ่อยพอจะตอบตรง ๆ ไว้เลย ข้อแรก การรวมไฟล์ผ่านเส้นทางค่าเริ่มต้นจะพาโครงสร้างเอกสารข้ามไปด้วย บุ๊กมาร์กและลิงก์จึงอยู่รอด ส่วนรุ่น Fast คือตัวที่แลก structure tree กับความเร็ว ซึ่งเป็นเหตุผลทั้งหมดที่ควรสงวนมันไว้ใช้กับอินพุตที่ไม่มีแท็ก นิสัยที่ปลอดภัยคือเปิดเอาต์พุตที่รวมเสร็จแล้ว ไล่ดู outline และสุ่มตรวจลิงก์ภายในสักสองสามจุดก่อนส่งมอบ ข้อสองคือเรื่องการแก้ไข มีทางสายกลางที่ใช้ประโยชน์ได้อยู่ระหว่างการสำรวจแบบอ่านอย่างเดียวกับการโหลดเต็มรูปแบบ การดำเนินการระดับหน้าทำงานกับแฮนเดิลได้โดยตรง เช่น DARotatePage, DAMovePage และ DAHidePage รวมถึงการอ่านฟิลด์ฟอร์ม และ DAAppendFile ก็บันทึกการแก้ไขเหล่านั้นไว้เป็น revision แบบ incremental ส่วนการแก้ไขระดับเนื้อหา คือทุกอย่างที่เขียน marking operator ภายในหน้าขึ้นใหม่ ยังคงเป็นงานของเลเยอร์เอกสารแบบเต็ม
บทความที่เกี่ยวข้อง
หากเอาต์พุตที่รวมแล้วต้องคงความสามารถในการเข้าถึงไว้ พื้นฐานเรื่อง structure tree ครอบคลุมไว้ในบทความว่าด้วยการเข้าถึงของ Tagged PDF ซึ่งอธิบายอย่างชัดเจนว่ารุ่นรวมไฟล์แบบ Fast จะทิ้งอะไรไปบ้าง ส่วนการดึงเนื้อหาออกมาจากช่วงหน้าที่คุณแยกไว้ ดูได้ที่คู่มือการดึงข้อความ รูปภาพ และฟอนต์
รายการฟังก์ชัน Direct Access ฉบับเต็มมาพร้อมกับตัวไลบรารี ส่วนรุ่นต่าง ๆ และไฟล์ทดลองใช้อยู่ที่หน้าผลิตภัณฑ์ PDF Library for Delphi