CollateDocumentsEx ใน PDFlibPas ไลบรารี PDF สำหรับ Delphi รวมเอกสารที่เปิดอยู่หลายไฟล์เข้าเป็นไฟล์เดียวแบบสอดใบ ฟังก์ชันนี้จะต่อหน้าจำนวน GroupSize จากแต่ละต้นฉบับในแต่ละรอบ รับรายการช่วงหน้าต่อแต่ละต้นฉบับ และถือว่าช่วงแบบเรียงลดลง เช่น 3-1 คือการกลับลำดับของต้นฉบับนั้น เรียกครั้งเดียวก็แปลงกองหน้าปกติกับกองหน้าหลังที่กลับด้านให้เป็นลำดับอ่านที่ถูกต้อง
สถานการณ์ที่อยู่เบื้องหลัง API นี้เป็นเรื่องธรรมดาและพบได้บ่อยมาก เครื่องสแกนแบบป้อนกระดาษที่สแกนได้ด้านเดียวจะสแกนทั้งกองคว่ำหน้าลง จากนั้นผู้ใช้งานพลิกกองกระดาษแล้วสแกนซ้ำอีกครั้ง ผลลัพธ์ที่ได้คือไฟล์ PDF สองไฟล์ ไฟล์หน้าเรียงลำดับปกติ ไฟล์หลังเรียงลำดับกลับด้าน สิ่งที่ผู้ใช้ต้องการคือไฟล์เดียว หน้า 1 ด้านหน้า หน้า 1 ด้านหลัง หน้า 2 ด้านหน้า เรียงต่อกันไปแบบนี้ บทความนี้พูดถึงปัญหาการเรียงลำดับและกับดักการซ้ำซ้อนของ resource ที่ซ่อนอยู่เบื้องหลัง ถ้าสนใจเรื่องความเร็วของการต่อไฟล์แบบดิบ ดูได้ที่ การรวม PDF ความเร็วสูงด้วยการเลื่อน reference ระดับไบต์ ถ้าไฟล์ต้นฉบับใหญ่เกินกว่าจะโหลดเข้าหน่วยความจำได้ทั้งหมด ดูได้ที่ รวมและแยก PDF ขนาดกิกะไบต์ด้วย direct access
เครื่องสแกนสร้างกองไฟล์สองกอง โดยกองหนึ่งกลับด้าน
การสอดใบ (Collation) ไม่ใช่การรวมไฟล์ (Merge) การรวมไฟล์คือการต่อช่วงหน้าเข้าด้วยกัน ส่วนการสอดใบคือการสลับสอดหน้าเข้าด้วยกัน และรูปแบบการสอดใบนั้นขึ้นอยู่กับคุณสมบัติของอุปกรณ์จริงที่สร้างไฟล์ต้นฉบับ ถ้ารูปแบบผิด ไฟล์จะไม่ใช่แค่ผิดเล็กน้อย แต่จะอ่านไม่ได้เลย เพราะทุกหน้าที่สองเป็นของแผ่นกระดาษคนละแผ่นกัน มีตัวแปรสามตัวที่อธิบายเกือบทุกกรณีจริง คือมีต้นฉบับกี่ตัวในการหมุนเวียน แต่ละต้นฉบับให้กี่หน้าต่อรอบ และมีต้นฉบับใดต้องอ่านย้อนกลับหรือไม่ CollateDocuments ครอบคลุมสองข้อแรกด้วย array ธรรมดาของ document handle และตัวเลข GroupSize ส่วน CollateDocumentsEx เพิ่มข้อที่สามโดยรับรายการช่วงหน้าคั่นด้วยเซมิโคลอน หนึ่งส่วนต่อหนึ่งต้นฉบับ โดยส่วนที่ว่างหมายถึงทุกหน้าของต้นฉบับนั้น และช่วงแบบลดลงหมายถึงกลับลำดับ ทั้งสองฟังก์ชันจะต่อท้ายเข้ากับเอกสารที่เลือกอยู่ในปัจจุบัน และคืนค่า 1 เมื่อสำเร็จ 0 เมื่อถูกปฏิเสธไม่ว่ากรณีใด
ทำไมการสอดใบแบบไร้เดียงสาถึงทำให้ขนาดไฟล์เพิ่มขึ้นหลายเท่า
เพราะ import map ที่แม็ป object number จากต้นฉบับไปยัง object number ปลายทางถูกสร้างขึ้นใหม่ทุกครั้งที่เรียกฟังก์ชันคัดลอก และทุกสิ่งที่เข้าถึงได้จากมากกว่าหนึ่งชิ้นส่วนจะถูก import ซ้ำต่อทุกชิ้นส่วน ภายใน PDFlibPas TPDFDocument.CopyPagesFromDoc จะรีเซ็ต NewIndObjList ทุกครั้งที่ถูกเรียก รายการนั้นเป็นความจำเดียวที่ตัวคัดลอกมีเกี่ยวกับสิ่งที่มันเคยนำข้ามมาแล้ว เรียกครั้งเดียวด้วยช่วงสิบหน้า ฟอนต์ที่ใช้ร่วมกันในทั้งสิบหน้าจะถูกฝังเพียงครั้งเดียว แต่ถ้าเรียกสิบครั้งด้วยทีละหน้า ฟอนต์ตัวเดียวกันนั้นจะถูกฝังสิบครั้ง เรื่องนี้สำคัญกับไฟล์สแกนมากกว่างานเอกสารข้อความทั่วไป เพราะหน้าที่สแกนคือ image XObject ขนาดใหญ่ชิ้นเดียว และวัตถุที่ใช้ร่วมกันคือวัตถุที่มีน้ำหนักจริง เช่น ICC profile ที่ฝังไว้ สาย /DecodeParms ที่ใช้ร่วมกัน form XObject ของตราประทับหรือลายน้ำที่ใช้กับทุกแผ่น ฟอนต์ของชั้นข้อความ OCR วิธีเขียนลูปสอดใบแบบวนรอบที่ดูตรงไปตรงมาที่สุดคือลูปวนรอบ และลูปแบบนั้นคือกรณีที่แย่ที่สุดพอดี
// Do not do this. Each CopyPageRanges call rebuilds the import map,
// so anything the two sources share internally is imported once per
// round instead of once per source.
var
RoundIndex: Integer;
begin
for RoundIndex := 1 to 12 do
begin
PDF.CopyPageRanges(Fronts, IntToStr(RoundIndex));
PDF.CopyPageRanges(Backs, IntToStr(13 - RoundIndex));
end;
end;
สิบสองรอบ สองต้นฉบับ กลายเป็น import map ยี่สิบสี่ชุด ไม่มีอะไรเตือนเลย ลำดับหน้าถูกต้อง ทุกหน้าเรนเดอร์ได้ปกติ อาการเดียวที่บอกได้คือไฟล์ใหญ่กว่าผลรวมของต้นฉบับหลายเท่า ในงานแบทช์ 300 หน้า ตัวคูณนี้ไม่ใช่แค่ความคลาดเคลื่อนเล็กน้อย แต่เป็นความต่างระหว่างไฟล์เก็บถาวรที่พอดีกับงบเก็บรักษา กับไฟล์ที่เกินงบไปไกล
Import ครั้งเดียว แล้วค่อยจัดลำดับ page tree ใหม่
ทางแก้คือแยกสองเรื่องที่ลูปแบบไร้เดียงสาผสมรวมกันไว้ให้ออกจากกัน การคัดลอกตัดสินว่าจะมีวัตถุอะไรอยู่ในปลายทาง ส่วนการจัดลำดับตัดสินว่าหน้าจะอยู่ตรงไหนใน page tree CollateDocumentsEx คัดลอกแต่ละต้นฉบับเพียงครั้งเดียวในการเรียก CopyPagesFromDoc ครั้งเดียวสำหรับช่วงเต็มของต้นฉบับนั้น ดังนั้นแต่ละต้นฉบับได้ import map ชุดเดียว และ resource ที่ใช้ร่วมกันจะถูกเขียนเพียงครั้งเดียว หลังจากทุกต้นฉบับถูกนำเข้ามาแล้วเท่านั้นการสอดใบจึงเกิดขึ้น และเกิดขึ้นทั้งหมดผ่าน TPDFPageTree.MovePage
การย้ายหน้าแทบไม่มีต้นทุนในแง่ที่สำคัญตรงนี้ ISO 32000-1 §7.7.3 กำหนดให้ page tree เป็นโครงสร้างสมดุลของ node dictionary ที่ array /Kids ของแต่ละโหนดเก็บ indirect reference ไว้ พร้อมค่า /Count ที่รวมจำนวน leaf ในแต่ละโหนด การย้ายหน้าหมายถึงการถอด indirect reference หนึ่งตัวออกจาก /Kids array หนึ่งชุด แล้วแทรกเข้าไปในอีกชุดหนึ่ง ปรับค่า /Count ทั้งสองด้าน และชี้ /Parent ของหน้านั้นใหม่ ไม่มีการแตะ content stream ไม่มี resource ใดถูกทำซ้ำ ไม่มี object ใหม่ถูกสร้างขึ้น object ของหน้ายังคง object number เดิม ซึ่งเป็นเหตุผลเดียวกับที่ object number ยังคงเสถียรตามที่กล่าวไว้ใน การแทนที่หน้าโดยรักษา object number ยังมีอีกจุดที่การย้ายหน้าแบบไร้เดียงสามักทำพลาด แต่ MovePage ไม่พลาด ISO 32000-1 §7.7.3.4 อนุญาตให้ /Resources, /MediaBox, /CropBox และ /Rotate ถูกสืบทอดจากโหนดบรรพบุรุษแทนที่จะระบุไว้ที่หน้าเอง หน้าที่สืบทอด resource มาจากโหนด A แล้วถูกย้ายไปอยู่ใต้โหนด B จะสืบทอดสิ่งที่ต่างออกไปโดยไม่มีการแจ้งเตือน หรือไม่สืบทอดอะไรเลย MovePage จึงแก้ปัญหานี้ด้วยการคำนวณค่าที่สืบทอดมาแล้วเขียนลงใน page dictionary ก่อนการย้าย เพื่อให้หน้านั้นพกพาคุณสมบัติของตัวเองไปด้วยตลอดการย้าย
ขั้นตอนการจัดลำดับใหม่ทำอะไรจริง ๆ
มันรัน selection sort เทียบกับความหมายแบบ insert-at ลำดับที่ต้องการเทียบกับบล็อกจะถูกคำนวณก่อน โดยไล่ไปตามต้นฉบับตามรอบหมุนเวียน หยิบไม่เกิน GroupSize ดัชนีจากแต่ละต้นฉบับ ข้ามต้นฉบับที่หมดแล้ว ทำซ้ำจนวางหน้าครบทุกหน้า สิ่งนี้จะได้ permutation เหนือบล็อกที่ต่อเข้ามา การนำไปใช้เป็นส่วนที่ยุ่งยาก เพราะ MovePage คือการแทรก ไม่ใช่การสลับ ดังนั้นทุกครั้งที่ย้ายจะเลื่อนทุกอย่างระหว่างตำแหน่งเดิมกับตำแหน่งใหม่ไปทีละหนึ่ง
ในการทำงานจริงจะเก็บ array ชื่อ Current ที่จำลองว่าหน้าที่ต่อเข้ามาแต่ละหน้าอยู่ตรงไหนในขณะนั้น สแกนไปข้างหน้าจากตำแหน่ง K เพื่อหาหน้าที่ควรอยู่ตำแหน่ง K ออกคำสั่งย้าย แล้วเลื่อน entry ใน array ให้สะท้อนสิ่งที่การย้ายทำกับ tree จริง มันเป็น O(n squared) ในแง่การดำเนินการกับ array และเป็นศูนย์ในแง่การคัดลอก object ซึ่งเป็นการแลกเปลี่ยนที่ถูกต้องสำหรับงานลักษณะนี้ การสอดใบ 500 หน้าคือการสลับตัวเลขจำนวนหนึ่งในสี่ล้านครั้ง และไม่มีข้อมูลภาพซ้ำแม้แต่ไบต์เดียว ช่วงที่กลับด้านและหน้าที่ซ้ำกันไม่ต้องการการจัดการพิเศษในขั้นตอนนี้ เพราะ PLParsePageRangeList ถูกเรียกโดยปิดการเรียงลำดับและอนุญาตให้ซ้ำได้ ดังนั้นลำดับที่ร้องขอจึงรอดจากขั้นตอนการ parse โดยไม่เปลี่ยนแปลง
ช่วงแบบกลับด้านและการรวม Duplex ในการเรียกครั้งเดียว
เมื่อการกลับด้านถูกแสดงเป็นช่วง กรณี double-pass ของเครื่องสแกนแบบแท่นเรียบจะยุบเหลือการเรียกครั้งเดียว ด้านหน้าต้องการลำดับปกติ และด้านหลังต้องการ 12-1 และส่วนแรกที่ว่างเปล่าก่อนเซมิโคลอนหมายความว่าต้นฉบับแรกให้ทุกหน้าของมัน
var
PDF: TPDFlib;
Target, Fronts, Backs: Integer;
begin
PDF := TPDFlib.Create;
try
Target := PDF.NewDocument;
if PDF.LoadFromFile('fronts.pdf', '') <> 1 then
Exit;
Fronts := PDF.SelectedDocument;
if PDF.LoadFromFile('backs.pdf', '') <> 1 then
Exit;
Backs := PDF.SelectedDocument;
PDF.SelectDocument(Target);
// fronts 1..12 in order, backs scanned in reverse: F1 B12 F2 B11 ...
if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 1 then
PDF.SaveToFile('duplex.pdf');
finally
PDF.Free;
end;
end;
มีพฤติกรรมสองอย่างในโค้ดตัวอย่างนี้ที่ควรพูดถึงให้ชัด หน้าที่ผ่านการสอดใบจะถูกต่อท้ายเข้ากับเอกสารที่เลือกอยู่ ดังนั้นเอกสารที่สร้างด้วย NewDocument จะมีหน้าเปล่าเริ่มต้นอยู่ก่อนหน้าเหล่านั้น และควรลบทิ้งถ้าไม่ต้องการ และต้นฉบับอาจมีจำนวนหน้าไม่เท่ากันได้ เมื่อ GroupSize เป็น 2 กับต้นฉบับสามหน้าและห้าหน้า รอบต่าง ๆ จะออกมาเป็น A1 A2 B1 B2 จากนั้น A3 B3 B4 เมื่อ A ใกล้หมด แล้วจึง B5 เดี่ยว ๆ เพราะต้นฉบับที่หมดแล้วจะถูกข้ามไปเฉย ๆ ไม่มีการเติมช่องว่าง
การย้อนกลับ ฟิลด์ฟอร์ม และสิ่งที่ไม่ตามมาด้วย
ทุกอาร์กิวเมนต์จะถูกตรวจสอบก่อนที่ปลายทางจะถูกแตะต้อง handle เอกสารที่ไม่มีอยู่ เอกสารที่เลือกอยู่ถูกระบุเป็นต้นฉบับของตัวเอง GroupSize ต่ำกว่าหนึ่ง จำนวนส่วนที่ไม่ตรงกับจำนวนต้นฉบับ ช่วงที่ระบุหน้าที่ต้นฉบับไม่มี ทั้งหมดนี้จะคืนค่า 0 โดยที่ปลายทางไม่ถูกเปลี่ยนแปลง การล้มเหลวระหว่างการคัดลอกเป็นกรณีที่ยากกว่า และถูกจัดการผ่าน DeletePages สาธารณะแทนที่จะเป็น PageTree.DeletePages ดิบ ๆ เหตุผลนั้นเจาะจงมาก การคัดลอกทำงานโดยเปิดใช้ MergeFormData ดังนั้นฟิลด์ฟอร์มของต้นฉบับจึงถูกต่อเข้าไปใน array /AcroForm /Fields ของปลายทางไปแล้วก่อนที่ต้นฉบับถัดมาจะล้มเหลว การลบหน้าที่ระดับ page-tree จะทำให้หน้า widget หลุดหายไปและปล่อยให้ reference ของฟิลด์เหล่านั้นค้างอยู่ ทางที่เป็นสาธารณะจะยกเลิกการเชื่อมโยง reference ของฟิลด์ outline และ article-thread พร้อมกับหน้า
if PDF.CollateDocumentsEx([Fronts, Backs], ';12-1', 1) = 0 then
// Nothing was appended and the target is byte-identical to before.
// 412 is the copy failure; 0 means the arguments were rejected
// during validation, before any page was touched.
Log(Format('collate rejected, LastErrorCode=%d', [PDF.LastErrorCode]));
ควรบอกลูกค้าของคุณอย่างตรงไปตรงมาเกี่ยวกับขอบเขต การสอดใบพา page annotation และฟิลด์ฟอร์มไปด้วย และรวม field list ของ AcroForm, calculation order array และ default resources dictionary เข้าด้วยกัน แต่ไม่พาบุ๊กมาร์กของต้นฉบับไปด้วย outline tree ของกองสแกนหน้าแทบจะว่างเปล่าเสมอ ดังนั้นในกรณี duplex จะไม่สูญเสียอะไร แต่ถ้าสอดใบเอกสารที่มีคนเขียนขึ้นสองไฟล์ outline ของทั้งสองจะไม่ถูกนำมาด้วย และคุณต้องสร้างระบบนำทางขึ้นเอง named destination ที่มีอยู่แค่ใน catalogue ของต้นฉบับก็อยู่ในสถานการณ์เดียวกัน ควรวางแผนเรื่องนี้ไว้ก่อนที่จะสัญญากับลูกค้าว่าการสอดใบจะไม่สูญเสียข้อมูลใด ๆ
PDFlibPas จัดส่งฟังก์ชันการสอดใบมาพร้อมกับ API การประกอบหน้าอื่น ๆ ดังนั้นเวิร์กโฟลว์ของเครื่องสแกน การดึงข้อมูลตามช่วง และเส้นทางไฟล์ขนาดใหญ่ทั้งหมดอยู่ใน component เดียวสำหรับ Delphi และ C++Builder เอกสารอ้างอิง API ฉบับเต็มและรุ่นทดลองอยู่ที่หน้าผลิตภัณฑ์ losLab Delphi PDF library