การต่อ PDF เข้าด้วยกันดูเหมือนควรจะเป็นงานที่เบาอยู่แล้ว เนื้อหาของหน้าได้จัดวางเสร็จ ฟอนต์ฝังไว้แล้ว รูปภาพบีบอัดแล้ว ตามหลักแล้วการ merge ก็เป็นเพียงงานธุรการ คือเปลี่ยนหมายเลขอ็อบเจ็กต์ให้พื้นที่หมายเลขของไฟล์สองชุดไม่ชนกัน เชื่อม page tree เข้าด้วยกัน แก้ตาราง cross-reference แล้วเขียนออกมา แต่ในทางปฏิบัติ โค้ด merge ส่วนใหญ่ทิ้งความเบานั้นไปหมด สำหรับทุกอ็อบเจ็กต์ในทุกไฟล์เข้า มันจะ parse เต็มรูปแบบเป็น object tree แบบ tokenized แก้ indirect reference สองสามจุด แล้ว serialize tree กลับเป็นไบต์ การ parse และการ serialize ใหม่คือสองส่วนที่กินเวลา และสำหรับอ็อบเจ็กต์ส่วนใหญ่ ผลลัพธ์ที่ได้แทบเหมือนไบต์เดิมทุกตัว
PDFlibPas เป็น PDF engine แบบ Object Pascal ที่ทำงานแบบ native สำหรับ Delphi และ C++Builder และ fast merge path ของมันมีไว้เพื่อข้ามรอบนั้นเมื่อพิสูจน์ได้ว่าปลอดภัย แนวคิดนี้แคบ แต่ให้ผลกับทั้งชุดเอกสาร: สำหรับอ็อบเจ็กต์ที่ไม่ได้ถูกแก้และไม่ใช่ stream ให้ใช้ไบต์ต้นฉบับแบบตรงตัว แล้วเขียน indirect reference ที่มีอยู่ด้วยการ rewrite ระดับไบต์ครั้งเดียว โดยเปลี่ยนทุก N G R เป็น (N+Offset) G R ไม่ต้อง tokenizer, ไม่ต้อง object tree, ไม่ต้อง serializer บทความนี้จะไล่ตั้งแต่จุดที่ทางลัดนี้ใช้ได้ เครื่องสถานะของ parser ที่ทำการ rewrite ไบต์โดยไม่ทำให้ข้อมูลเสีย ทำไมการ merge bookmark ต้องใช้กลไกอีกแบบ และวิธีที่ ordinary merge path ถูกสร้างใหม่จาก quadratic ให้เป็น linear ในเวลาเดียวกัน
ทำไมการเปลี่ยนหมายเลขอ็อบเจ็กต์จึงเป็นต้นทุนจริงของการ merge
PDF แต่ละไฟล์มีพื้นที่หมายเลขอ็อบเจ็กต์ของตัวเอง ไฟล์ A มีอ็อบเจ็กต์ 1, อ็อบเจ็กต์ 2 ไปเรื่อย ๆ ไฟล์ B ก็มีอ็อบเจ็กต์ 1, อ็อบเจ็กต์ 2 ของตัวเอง คุณไม่สามารถโยนอ็อบเจ็กต์ของ B เข้าไปในไฟล์ A แบบไม่เปลี่ยนอะไรเลยได้ เพราะหมายเลขจะชนกัน และ indirect reference ทุกตัวภายใน B จะชี้ไปยังอ็อบเจ็กต์ผิดตัว วิธีแก้คือใช้ค่า offset ถ้า A จบที่จำนวนอ็อบเจ็กต์ Offset อ็อบเจ็กต์ N ของ B ก็จะกลายเป็นอ็อบเจ็กต์ N+Offset ในผลลัพธ์ และทุก reference N G R ที่ปรากฏอยู่ในอ็อบเจ็กต์ของ B ต้องถูกเลื่อนไปเป็น (N+Offset) G R ให้ตรงกัน
การเลื่อนนั้นคือหน้าที่เชิงความหมายทั้งหมดของการ merge ส่วน body การแก้ page tree และการ merge AcroForm เป็นเพียงการแก้เล็ก ๆ ที่มีขอบเขตชัดบนอ็อบเจ็กต์ไม่กี่ตัว งานหนักจริงคือการเขียน reference ใหม่ข้ามอ็อบเจ็กต์นับพันตัว และวิธีแบบง่ายสุดที่จะทำคือต้อง parse แต่ละอ็อบเจ็กต์ก่อนเพื่อหา reference ในเชิงโครงสร้าง MergeFileListFast ของ PDFlibPas เลือกมุมมองตรงข้าม คือ reference ก็หาได้จากไบต์ดิบเช่นกัน ถ้าคุณระวังบริบทที่ลำดับ digit-space-digit-space-R ไม่ได้เป็น reference ข้ามการ parse แล้วเลื่อนในตำแหน่งเดิม ค่าใช้จ่ายต่ออ็อบเจ็กต์ก็ยุบเหลือแค่การสแกนไบต์แบบเส้นตรงรอบเดียวที่เดิมทีคุณก็ต้องคัดลอกอยู่แล้ว
เมื่อการนำไบต์ต้นฉบับกลับมาใช้ซ้ำถือว่าปลอดภัยอย่างพิสูจน์ได้
เส้นทางแบบไบต์จะถูกใช้ก็ต่อเมื่อเงื่อนไขทั้งสามข้อเป็นจริงสำหรับอ็อบเจ็กต์ที่กำลังคัดลอกจากเอกสารตัวถัดไป ถ้าเงื่อนไขข้อใดข้อหนึ่งล้มเหลว อ็อบเจ็กต์นั้นจะถูกส่งกลับไปทาง decode และ serialize ใหม่แบบเต็มเสมอ ความถูกต้องจึงชนะความเร็วทุกครั้ง:
Doc2.IsChangedObject(X)เป็น False ถ้า merge engine ได้แก้ไขอ็อบเจ็กต์นั้นในหน่วยความจำไปแล้ว เช่นอ็อบเจ็กต์ของหน้าเพจที่ถูกชี้/Parentใหม่ ต้นฉบับในหน่วยความจำก็คือแหล่งความจริง และไบต์เดิมย่อมล้าสมัย มีแค่อ็อบเจ็กต์ที่ยังไม่ถูกแตะต้องเท่านั้นที่ผ่านเกณฑ์- ไบต์ต้นฉบับไม่มีคำสำคัญ
streamเนื้อหาของอ็อบเจ็กต์แบบ stream เป็นไบนารีทึบที่ถูกห่อด้วยstream/endstreamและการสแกน reference แบบง่าย ๆ บนข้อมูล stream ที่บีบอัดหรือเข้ารหัสแล้วอาจไป "เจอ" และทำลายลวดลายไบต์ที่หน้าตาเหมือน reference ได้ อ็อบเจ็กต์แบบ stream จึงยังคงใช้เส้นทางที่รับรู้ stream ตามเดิม - ไบต์ต้นฉบับไม่มีทั้ง
/StructTreeRootและ/StructElemในโปรไฟล์ fast ต้นไม้โครงสร้างของ tagged-PDF จะถูกตัดทิ้งแทนที่จะ merge ดังนั้นอ็อบเจ็กต์เหล่านั้นต้องผ่านเส้นทาง decode เพื่อให้ engine ตั้งค่าเป็นค่าว่างได้อย่างตั้งใจ
การตัดสินใจนี้เกิดขึ้นในลูปคัดลอกอ็อบเจ็กต์ทีละตัว เมื่อทั้งสามเงื่อนไขผ่าน ไบต์ของอ็อบเจ็กต์จะถูกส่งตรงไปยัง ShiftIndRefsInSource แล้วต่อไปยัง writer ทันที มิฉะนั้นไบต์จะถูกทิ้งและอ็อบเจ็กต์จะถูกสร้างใหม่ด้วย GetObject เลื่อนด้วย ShiftIndRef แล้วค่อย serialize โครงสร้างของสาขานี้ควรดูไว้ เพราะลำดับการตรวจคือสิ่งที่ทำให้มันปลอดภัย:
ObjectData := '';
if not Doc2.IsChangedObject(X) then
begin
ObjectData := FastMergeObjectSource(Reader2, X);
if (PLPos('stream', ObjectData) > 0) or
((not PreserveStructTree) and (PLPos('/StructTreeRoot', ObjectData) > 0)) or
((not PreserveStructTree) and (PLPos('/StructElem', ObjectData) > 0)) then
ObjectData := '' // fall back to decode
else
ObjectData := ShiftIndRefsInSource(ObjectData, Offset);
end;
if ObjectData <> '' then
Writer.AddObject(X + Offset, Doc2.GetGenNum(X), ObjectData)
else
begin
Obj := Doc2.GetObject(X, TempStruct); // full parse path
// ... null out struct-tree objects, ShiftIndRef, Obj.Output ...
end;
An empty ObjectData is the signal that the byte path declined the object. That single sentinel keeps the fast and slow routes from drifting apart: there is exactly one place that decides, and exactly one fallback
เครื่องสถานะสำหรับเลื่อนอ้างอิงและกรณีขอบ
การ rewrite indirect reference แบบระดับไบต์เป็นงานที่ดูเหมือนง่ายแต่พลาดได้ง่าย เพราะ R และชุดตัวเลขปรากฏอยู่เต็ม PDF object ในบริบทที่ไม่ใช่ reference เลย ShiftIndRefsInSource คือ scanner ขนาดเล็กที่เขียนด้วยมือซึ่งกวาดไบต์เพียงรอบเดียว และจะ rewrite ตัวเลขก็ต่อเมื่อหลังจากมันมีตัวเลขอีกชุดหนึ่งคั่นด้วย whitespace แบบ PDF แล้วตามด้วยตัวคั่น R ลำดับทางลัดมาเป็นอันดับแรก ถ้า offset เป็นศูนย์หรือ source ว่าง ไบต์จะถูกส่งกลับโดยไม่เข้าสแกนเนอร์เลย
ความถูกต้องของ scanner พึ่งพาการรู้จำบริบทที่ลำดับหน้าตาเหมือน reference ต้องปล่อยผ่าน นี่คือขอบเขตที่พลาดได้ง่ายที่สุด และแต่ละข้อถูกจัดการไว้ชัดเจน:
- สตริงลิเทอรัล ที่คั่นด้วย
(และ)จะถูกคัดลอกแบบตรงตัว โดยติดตามความลึกของการซ้อนและให้เกียรติการ escape ด้วย backslash เพื่อให้วงเล็บที่ถูก escape ไม่ทำให้การนับความลึกเพี้ยน สตริงอย่าง(see object 3 0 R for details)มีแพตเทิร์น reference แบบตำราอยู่ข้างใน แต่จริง ๆ แล้วเป็นเพียงข้อความ และต้องรอดออกไปแบบ byte-for-byte - สตริงเลขฐานสิบหก ที่คั่นด้วย
<และ>จะถูกส่งผ่านโดยไม่ตีความ ไบต์52ภายใน hex string คือรหัส ASCII ของRและ scanner ที่ปฏิบัติต่อ payload แบบ hex เหมือนข้อความอาจสร้าง reference ผีขึ้นมาได้ การเจอ<<ที่เปิด dictionary จึงถูกตรวจจับก่อน เพื่อไม่ให้ dictionary ถูกเข้าใจผิดว่าเป็น hex string - name object ที่เริ่มด้วย
/จะถูกกินทั้งก้อน ตั้งแต่เครื่องหมายทับไปจนถึง whitespace หรือ delimiter ถัดไป ถ้าไม่มีส่วนนี้ ชื่ออย่าง/Rซึ่งเป็น resource key ที่พบบ่อย อาจถูกอ่านเป็นRของ reference ได้ - คอมเมนต์ ที่ขึ้นต้นด้วย
%จะวิ่งไปจนจบบรรทัด และถูกข้ามในฐานะข้อความทึบ - การทดสอบเลขก่อนแล้วค่อย R นั้นเข้มงวด reference จะถูกรู้จักก็ต่อเมื่อเป็น
NwhitespaceGwhitespaceRโดยมีRปิดท้ายด้วย whitespace, delimiter, หรือจบอินพุต ถ้าขาด generation number ไป หรือมีตัวอักษรตามหลังRตัวเลขจะถูกส่งออกไปเหมือนเดิม นี่คือสิ่งที่ป้องกันไม่ให้จำนวนเต็มใน/Length 1234และตัวเลขสี่ตัวของMediaBoxถูกเพิ่มขึ้นแบบเงียบ ๆ
แกนของการทดสอบที่เข้มงวดนี้อ่านแทบตรงกับประโยคสเปกเลย:
if (P <= N) and (Source[P] = 'R') and
((P = N) or PLIsPdfWhite(Source[P + 1]) or PLIsPdfDelimiter(Source[P + 1])) then
Obj1 := PLStrToIntDef(PLCopy(Source, I, E1 - I), -1);
if Obj1 >= 0 then
begin
AppendStr(PLIntToStr(Obj1 + Offset)); // shifted object number
AppendBytes(E1, P - E1); // original whitespace + generation
AppendBytes(P, 1); // the 'R'
end;
มีการ rewrite เฉพาะหมายเลขอ็อบเจ็กต์เท่านั้น generation number และ whitespace ดั้งเดิมที่แน่นอนระหว่าง token จะถูกคัดลอกผ่านไปทั้งหมด ดังนั้นผลลัพธ์จึงเหมือนไบต์กับอินพุตทุกประการ ยกเว้นจำนวนเต็มตัวเดียวที่ต้องเปลี่ยน ความแม่นยำนี้คือจุดสำคัญทั้งหมด มันทำให้การนำไบต์ต้นฉบับกลับมาใช้ซ้ำเทียบเท่ากับการ serialize ใหม่ทั้งก้อน ไม่ใช่แค่ใกล้เคียงเท่านั้น พฤติกรรมนี้มีชุด unit test แบบเจาะจงคอยครอบคลุม ทั้ง reference เปล่า ๆ, reference ที่อยู่ใน array, ตัวเลขที่ไม่ใช่ reference, สตริงลิเทอรัล, hex string และ generation number ที่ไม่เป็นศูนย์พร้อม offset ที่ถูกนำไปใช้
ทำไม bookmark จึงใช้ AppendOutline ซ้ำไม่ได้
การ merge bookmark ของหลายเอกสารให้กลายเป็น outline tree เดียวดูเหมือนเป็นงานของ helper AppendOutline ที่มีอยู่แล้ว เพราะมันรู้วิธี graft bookmark ระดับบนสุดของเอกสารหนึ่งเข้าไปอีกเอกสารได้อยู่แล้ว แต่ที่นี่มันเป็นเครื่องมือผิดตัว และเหตุผลคือความไม่ตรงกันแบบละเอียดในชั้นสถาปัตยกรรม AppendOutline หา bookmark ระดับบนสุดตัวสุดท้ายปัจจุบันโดยให้ reader ไล่ผ่านไบต์ของไฟล์ต้นฉบับ ทว่า fast merge เก็บการแก้ไขไว้ในบัฟเฟอร์อ็อบเจ็กต์ใหม่ผ่าน ChangeObject ซึ่ง reader ไม่เคยเห็นการแก้เหล่านั้นเลย ถ้าต่อเอกสารสามฉบับขึ้นไป ทุกครั้งที่ append จะไปชี้ bookmark สุดท้ายเดิมของเอกสารแรกไปยังเอกสารล่าสุด ทำให้ bookmark ของเอกสารตรงกลางหลุดออกจากโซ่ไปหมด เหลือแค่ /Count สะสมที่ยังถูกต้องอยู่ ซึ่งทำให้บั๊กนี้สังเกตยากจนกว่าจะมีคนเปิดแผง bookmark
fast path แก้ปัญหานี้ด้วยการ inject แบบสองช่วงที่ขับเคลื่อนด้วย metadata และไม่ย้อนอ่าน reader ซ้ำ ช่วงแรกจะอ่านอินพุตทั้งหมดเพื่อเก็บข้อมูลต่อเอกสาร ได้แก่ object ต้นตอของ outline root และ generation number, หมายเลข bookmark ระดับบนสุดตัวแรกและตัวสุดท้าย, และ /Count ของ root จากสรุปนั้น โค้ดจะคำนวณหมายเลขอ็อบเจ็กต์แบบ global ของทุกลิงก์ที่ต้องสร้างขึ้นมาใหม่ คือ /Parent ระดับบนสุดของแต่ละเอกสารไปยัง root ที่ใช้ร่วมกัน, /Prev ของ bookmark แรกไปยัง bookmark สุดท้ายของเอกสารก่อนหน้า, และ /Next ของ bookmark สุดท้ายไปยัง bookmark แรกของเอกสารถัดไป โดยใช้คณิตศาสตร์ของหมายเลขอ็อบเจ็กต์ล้วน ๆ เบื้องหลังนี้มีข้อจำกัดด้านลำดับการเขียนอยู่ด้วย คืออ็อบเจ็กต์ของเอกสารแรกถูกเขียนออกไปก่อนที่เอกสารถัดไปจะถูกเปิดเสียอีก ดังนั้นการแก้ outline ทั้งหมดของเอกสารแรก เช่น root /Count กับ /Last และ /Next ของ bookmark สุดท้ายตัวเก่า ต้องเขียนให้อยู่ในรูปคณิตศาสตร์ที่ไม่ต้องพึ่งเอกสารถัดไปเลย ส่วนการแก้ของเอกสารถัดไปแต่ละฉบับจะถูกนำไปใส่ในที่เดิมหลังจากเปิดแล้วแต่ก่อนเขียนออก จึงไหลออกไปผ่านเส้นทาง change-object เดียวกัน
อินวาเรียนต์ของการจัดแนว offset ที่เชื่อมทุกอย่างไว้
ทั้งการเลื่อน reference และการ inject bookmark พึ่งพาอินวาเรียนต์ทางคณิตศาสตร์ข้อเดียว และนี่คือสมมติฐานที่เปราะที่สุดของการออกแบบทั้งหมด reference ที่ inject เข้าไปในเอกสารถัดไปจะถูกเขียนเป็น หมายเลขอ็อบเจ็กต์ global เป้าหมายลบด้วย Offset ของเอกสารนั้น เพื่อให้เมื่ออ็อบเจ็กต์นั้นถูกเลื่อนภายหลังด้วย ShiftIndRef(Offset) ค่านั้นจะไปตกที่หมายเลข global ที่ตั้งใจไว้ เอกสารแรกได้ Offset = 0 และใช้หมายเลข global โดยตรง เพื่อให้การลบนั้นถูกต้อง ลำดับ offset ที่ใช้งานระหว่าง inject ต้องตรงกับลำดับ offset ที่ใช้ตอนอ็อบเจ็กต์ถูกเขียนออกจริง
มันตรงกัน เพราะลักษณะการทำงานของการ merge หน้าและฟอร์มเป็นแบบนั้นเอง AddPages, AddFields, และ AddFieldFonts แก้เฉพาะอ็อบเจ็กต์ที่มีอยู่แล้วของเอกสารแรกเท่านั้น พวกมันไม่เคยเพิ่มอ็อบเจ็กต์ใหม่เลย ดังนั้นจำนวนอ็อบเจ็กต์ของเอกสารแรกจึงไม่เปลี่ยนตลอดช่วง page-merge และ offset ของเอกสารถัดไปแต่ละฉบับ ซึ่งก็คือผลรวมของจำนวนอ็อบเจ็กต์ของเอกสารก่อนหน้าทั้งหมด ยังคงนิ่งตั้งแต่ช่วง inject จนถึงช่วงเขียนออก ถ้าทำลายเงื่อนไขนี้ขึ้นมา เช่นมีขั้นตอนที่สร้างอ็อบเจ็กต์ใหม่กลางทาง reference ของทุกหน้าและทุก bookmark ที่ตามมาจะเพี้ยนไปตามจำนวนอ็อบเจ็กต์ที่เพิ่มเข้าไป อินวาเรียนต์นี้เงียบ แต่เป็นตัวรับน้ำหนักจริง ๆ
สามจุดเข้าใช้งานบนเอ็นจินเดียว
fast path ไม่ใช่การแยกกิ่งของโค้ด merge ในงานเดียวกันนี้ เอ็นจินระดับไบต์ถูกแยกออกมาเป็น routine ภายในตัวเดียว MergeFileListInternal(ListName, OutputFileName, PreserveStructTree, StrictMode) และ public API กลายเป็น wrapper บาง ๆ ที่เลือกสองแฟลก:
MergeFileListFastเรียกเอ็นจินโดยปิดการเก็บ structure tree เป็นเส้นทางที่เบาที่สุด ตัดต้นไม้ของ tagged-PDF ทิ้งเพื่อให้เส้นทางระดับไบต์ใช้ได้กับอ็อบเจ็กต์จำนวนมากที่สุดMergeFileListเรียกโดยเปิดการเก็บไว้ ดังนั้น structure tree จึงอยู่รอดและผลลัพธ์ยังเป็น tagged PDF ที่ใช้งานได้ เส้นทางปกตินี้ยังรับการ merge bookmark และฟอร์มแบบหลายเอกสารมาด้วยMergeFileListStrictเปิด strict mode ช่วง metadata pass แรกจะหยุดทันทีที่เจออินพุตแรกที่ไม่รายงานการ merge แบบสะอาด ดังนั้นจะรวมเฉพาะเอกสารที่เก็บได้ก่อนถึงไฟล์เสีย แทนที่จะข้ามไฟล์เสียแล้วทำต่อ
การรวมเส้นทางเข้าด้วยกันยังทำให้ merge แบบปกติถูกสร้างใหม่จากลูปแบบจับคู่ทีละสองไฟล์ที่มีความซับซ้อน O(N²) คือ merge ไฟล์หนึ่งกับสอง เอาผลลัพธ์นั้นไป merge กับสาม ทำต่อไปเรื่อย ๆ และ parse ตัวสะสมที่โตขึ้นทุกขั้น ให้กลายเป็น pass เชิงเส้นเพียงครั้งเดียวที่เปิดอินพุตแต่ละไฟล์แค่รอบเดียว จุดเข้าใช้งานเก่าแบบสองไฟล์และสองสตรีม MergeFiles และ MergeStreams ยังไม่ถูกแตะและยังคงมีไว้สำหรับผู้เรียกที่ต้องการ merge แบบจับคู่จริง ๆ
มีข้อสังเกตที่ตรงไปตรงมาเกี่ยวกับพฤติกรรมของ structure tree เพราะมันเคยทำให้ test suite สะดุด การ "drop" ของ fast path ไม่ได้หายไปทั้งหมด มันลบการอ้างอิงของ catalog ในเอกสารแรกไปยัง /StructTreeRoot แต่ตัว structure-tree object เองยังถูกเขียนออกมาในสภาพ orphan อยู่ ดังนั้นไบต์ผลลัพธ์ของ fast path ยังมีสตริง /StructTreeRoot อยู่ และคุณจึงแยก fast กับ ordinary output ไม่ได้ด้วยการค้นหาสตริงนั้น ความต่างจริงคือ catalog ยังเข้าถึง structure tree อยู่หรือไม่ ซึ่งเป็นตัวกำหนดว่าไฟล์นั้นยังเป็น tagged PDF ที่นำทางได้หรือเปล่า
เมื่อไรควรเลือกเส้นทางไหน
เส้นทางระดับไบต์เป็นการปรับ throughput สำหรับการประกอบเอกสารจำนวนมากในกรณีที่คุณไม่จำเป็นต้องคง structure tree ของ tagged-PDF ไว้ เช่น การรวมรายงาน, การสร้างชุด statement, การต่อไฟล์แบบ batch เมื่อวัดซ้ำกับชุดอินพุตขนาดกลางถึงใหญ่ การใช้ไบต์ซ้ำช่วยลดเวลา wall-clock ลงราวสี่ถึงสิบสามเปอร์เซ็นต์ขึ้นกับส่วนผสมของอ็อบเจ็กต์ โดยไม่มี failure ใหม่กับอินพุตขนาดเล็กหรืออินพุตที่มีรูปแบบผิด เพราะอ็อบเจ็กต์ใดก็ตามที่ scanner พิสูจน์ว่าไม่ปลอดภัยไม่ได้จะถูกส่งกลับไป parse แบบเต็ม ถ้าคุณต้องการให้ structure tree อยู่ครบเพื่อการเข้าถึง ให้ใช้เส้นทาง merge แบบ tagged-PDF ปกติซึ่งคงมันไว้ และถ้าคุณกำลังทำงานกับไฟล์เดี่ยวที่ใหญ่มากแทนที่จะเป็นอินพุตจำนวนมาก เทคนิคคัดลอกไบต์ที่อธิบายในบทความคู่หูเกี่ยวกับ การ merge และ split ไฟล์ PDF ขนาดใหญ่ด้วย direct file access ก็ใช้ปรัชญาเดียวกัน คือ "คัดลอกไบต์ หลีกเลี่ยง object tree แบบเต็ม" ในระดับทั้งไฟล์
รูทีน merge และตัวแปร fast กับ strict ของมันเป็นส่วนหนึ่งของ PDFlibPas Delphi PDF Library ซึ่งเอกสารของมันมี reference ครบสำหรับ file-list API และตัวเลือก merge ที่อธิบายไว้ที่นี่