ใน PDFlibPas ซึ่งเป็น Delphi PDF library หน้าที่ย้ายด้วย MovePage เคยได้รับ MediaBox CropBox และ Resources object เดิมเป๊ะ ๆ ที่ node Pages เก่าของมันถืออยู่ การ SetPageBox หรือ DrawText บนหน้าที่ย้ายแล้วจึงเขียนทับ node ตัวนั้นและหน้าพี่น้องที่ยังสืบทอดจากมันแบบเงียบ ๆ ตั้งแต่ v3.539.36 หน้าที่ย้ายได้สำเนาของตัวเอง และ indirect reference ก็ยังคงเป็น reference release เดียวกันปิดเส้นทางที่เกี่ยวเนื่องอีกสองทาง: SetPageBox บนกล่อง indirect ที่หลายหน้าแชร์กัน และ CopyPageRanges ที่ปล่อยหน้าของเอกสารต้นทางผูกค้างกับ node Pages ของมัน พร้อม CropBox ที่ผูกอยู่กับ MediaBox
รายงานที่พาเรื่องนี้มาถึงไม่เคยพูดถึง object identity เลย พวกมันบอกว่า "ผม crop หน้า 7 แล้วหน้า 8 ถึง 12 โดน crop ตามไปด้วย" หรือ "ผมหด CropBox แล้ว MediaBox เลื่อนตามไปด้วย" หรือตัวที่งงที่สุด "ผม copy หน้าเข้าเอกสารใหม่ แล้วไฟล์ต้นฉบับเปลี่ยนไป" ไม่มีอะไร crash ไม่มีอะไรรั่ว ไฟล์ที่ save ออกมาก็เป็น PDF ที่ถูกต้องสมบูรณ์ มันแค่มี geometry ที่ไม่มีใครสั่งไว้
ทำไม SetPageBox บนหน้าหนึ่งถึงปรับขนาดหน้าพี่น้องตามไปด้วย
SetPageBox กระทบพี่น้องเพราะ entry สองตัวใน page tree ชี้ไปที่ array ก้อนเดียวกันในหน่วยความจำ และ SetPageBox แก้ array เป้าหมายของมันในที่เดิม หน้าหรือ node Pages ตัวไหนที่ถือ instance เดียวกันอยู่จะเห็นการแก้นั้นทันที เส้นทางโค้ดสามทางใน PDFlibPas ผลิตการแชร์แบบนี้ไว้ก่อน v3.539.36:
MovePageทำ materialization ของ attribute ที่สืบทอดได้ลงบนหน้าก่อนดึงมันออกจาก parent แต่มันแนบ object ของบรรพบุรุษไปเลย ไม่ใช่สำเนา หน้าที่ย้ายกับพี่น้องเดิมจึงแชร์ array กล่องและ dictionary ของ Resources ร่วมกันSetPageBoxไล่ตาม indirect reference แล้วแก้ array ที่ถูกอ้าง ไฟล์ที่หลายหน้าชี้ไปที่ object/MediaBox 11 0 Rตัวเดียวจึงโดนปรับขนาดทุกหน้าด้วย call เดียว ไม่ว่าMovePageจะเกี่ยวหรือไม่ก็ตามCopyPageRangesทำ materialization ของค่าที่สืบทอดมาลงหน้าต้นทางก่อนโคลนมันเข้าเอกสารปลายทาง และมันแนบ instance ของ node Pages ไปติดกับหน้าต้นทาง บวกกับ instance ของ MediaBox เองเป็น CropBox เริ่มต้น
เคสของ MovePage มีประวัติสั้น ๆ ให้เล่า ก่อน v3.539.27 MovePage หอบข้ามไปแค่ /Resources หน้าที่ย้ายไปอยู่ใต้ parent ใหม่จึงเงียบ ๆ ใช้ขนาดกับ rotation ของ parent นั้นไป v3.539.27 แก้ MediaBox, CropBox กับ Rotate ที่หายไป ซึ่งก็คือสิ่งที่ CollateDocumentsEx อาศัยตอนจัดเรียงหน้าใหม่ แต่มันแนบค่าของบรรพบุรุษไปเป็น instance ที่แชร์กัน นั่นคือช่องที่ v3.539.36 ปิดลง ส่วนเส้นทางของ SetPageBox กับ CopyPageRanges เก่ากว่านั้น build ไหนที่เก่ากว่า v3.539.36 มีพวกมันครบ
ค่า direct, indirect reference และการสืบทอด attribute ของหน้า
การ copy ที่ถูกต้องของ attribute หน้าที่สืบทอดมาต้องทำสำเนาค่า direct และคง indirect reference ไว้เป็น reference เพราะนั่นคือเส้นแบ่งที่ ISO 32000-1 กำหนดเอง object direct อย่าง [0 0 400 300] ที่เขียนข้างใน dictionary เป็นของ dictionary นั้นตัวเดียว ส่วน object indirect ที่นิยามครั้งเดียวเป็น 11 0 obj และถูกอ้างเป็น 11 0 R ถูกออกแบบให้แชร์: ISO 32000-1 §7.3.10 ทำให้มันเข้าถึงได้จากที่ไหนก็ได้ในไฟล์ และ 11 0 R ทุกตัวหมายถึง object เดียวกัน
การสืบทอด attribute ของหน้า ตาม ISO 32000-1 §7.7.3.4 เพิ่มกรณีที่สาม Resources MediaBox CropBox และ Rotate อาจวางอยู่บน node Pages แล้วมีผลกับหน้าลูกทุกหน้าที่ไม่ได้นิยามค่าของตัวเอง หน้าไม่ได้ถือค่านั้น มันไปค้นค่าผ่าน /Parent ห่วงโซ่ค้นหานี้ขาดทันทีที่หน้าเปลี่ยน parent นั่นเหตุผลที่ MovePage กับ BalancePageTree ต้องเขียนค่าที่มีผลจริงลงบนหน้าเสียก่อน เหลือแค่คำถามว่าจะเขียนอย่างไร
ทำไม object pool เลยเห็นความผิดพลาดไม่ขึ้น
ใน PDFlibPas object PDF ทุกตัวที่ parse หรือสร้างขึ้นเป็นของ pool TPDFStructure ของเอกสาร และ dictionary กับ array เก็บ pointer ธรรมดาชี้ไปยัง entry ของมัน TPDFDictionary.Add จด pointer ไว้อย่างเดียว การเอา instance หนึ่งตัวไปใส่สอง container แม่จึงถูกกฎหมายทุกชั้นที่ runtime ตรวจได้: ไม่มี double free ตอน teardown ไม่มี reference count ให้เพี้ยน ไม่มี exception การ serialize ก็ใจกว้างเท่ากัน เพราะแต่ละ container เขียนค่าปัจจุบันของ instance ที่แชร์ลงไป inline และก่อนการแก้ใด ๆ output ที่ได้คือไบต์ต่อไบต์เท่ากับสิ่งที่สำเนาที่ถูกต้องจะให้
aliasing ปรากฏตัวเมื่อมีคนมา mutate instance ที่แชร์กันในที่เดิม SetPageBox ทำพอดีแบบนั้นผ่าน rectangle wrapper ครอบ array เดิม และการวาดรูปบนหน้าก็ทำแบบเดียวกันกับ dictionary ของ Resources ตอน font หรือ image ถูก register การแก้ตกลงเงียบ ๆ ในทุก container อื่นที่ถือ pointer เดียวกัน
PDFlibPas v3.539.36 copy แทนการแชร์อย่างไร
PDFlibPas v3.539.36 แก้ปัญหาทั้งสองปลาย: materialization ตอนนี้แนบสำเนาไป และการเขียนกล่องตอนนี้แก้เฉพาะ array ที่หน้าเป็นเจ้าของ การแก้แต่ละฝั่งครอบคลุมเคสที่อีกฝั่งคลอบไม่ถึง
helper ทำ materialization คือ PLInheritPageAttributes ตอนนี้แนบ Page.Owner.Decode(Value.Output) แทนที่จะแนบ Value การวนผ่าน serializer ไปกลับเป็นวิธีหยาบแต่แม่นยำที่ได้ semantics ของ PDF มาฟรี ๆ array หรือ dictionary แบบ direct serialize เป็นข้อความ literal ของมันแล้ว decode กลับมาเป็น instance ใหม่ที่เป็นอิสระ ส่วน indirect reference serialize เป็น 11 0 R แล้ว decode เป็น reference object ใหม่ที่ยังชี้ไป object 11 ตัวเดิม หน้าจึงยังอ้างถึง object ที่แชร์แทนที่จะได้สำเนาแปะติดมา ซึ่งคงพฤติกรรม reference ที่ v3.539.27 เริ่มใช้ ความลึกของสำเนาลึกพอดีกับโครงสร้าง direct: อะไรที่ถึงผ่าน reference ข้างใน dictionary ที่ถูก copy ยังคงถูกแชร์ต่อ ตามที่ฟอร์แมตไฟล์ตั้งใจ BalancePageTree เรียก helper ตัวเดียวกันกับทุกหน้าที่มันจัด parent ใหม่ หน้าที่ถูกทำ materialization ตรงนั้นจึงได้ instance แยกด้วย
copy อย่างเดียวยังไม่พอ เพราะเคส reference ยังชี้ไปที่ object ที่แชร์อยู่ดี ถ้า SetPageBox ไล่ตาม reference นั้นไปแก้ object 11 หน้าที่ย้ายก็จะปรับขนาด parent เก่ากับลูกอื่น ๆ ของมันซ้ำอีก ตัวเขียนกล่องจึงใช้ copy-on-write: แก้ในที่เดิมเมื่อ entry ของหน้าเองเป็น array direct เท่านั้น และแทนที่กล่องแบบ indirect หรือที่หายไปด้วย array direct ใหม่ object 11 ถูกปล่อยให้เหมือนเดิมสำหรับทุกหน้าอื่นที่อ้างมัน
| เส้นทางโค้ด | ก่อน v3.539.36 | ตั้งแต่ v3.539.36 |
|---|---|---|
materialization ของ MovePage | หน้าถือ instance direct ของบรรพบุรุษเอง | หน้าถือสำเนาที่ถอดรหัสแล้ว reference ยังเป็น reference |
SetPageBox | ไล่ตาม reference แล้วแก้ array ที่แชร์ | แก้เฉพาะ array direct บนหน้า ไม่งั้นเขียนใหม่ทั้งตัว |
หน้าต้นทางของ CopyPageRanges | แชร์กล่องของ node Pages CropBox คือ instance ของ MediaBox | ค่าที่ถูกทำ materialization ทุกตัวบนหน้าต้นทางเป็นสำเนา |
| กล่องเริ่มต้นตอนโคลน resources ของหน้า | CropBox, BleedBox, TrimBox และ ArtBox แชร์ array เดียว | กล่องเริ่มต้นแต่ละตัวได้ array ของตัวเอง |
แถวสุดท้ายคือเคสที่ซุ่มซ่อน เมื่อ library โคลน resources ของหน้าเพื่อ page capture หรือการ merge มันจะเติม entry ของ CropBox, BleedBox, TrimBox และ ArtBox ที่หายไป และพวกมันเคยเป็น array instance เดียวกัน ไม่มี caller ปัจจุบันปล่อยให้ alias นี้รอดนานพอจะโดนแก้ แต่ caller ตัวถัดไปก็อาจเป็น วิธีเลือกค่ากล่องเริ่มต้นพวกนี้เป็นหัวข้อของตัวเอง ดูได้ในคู่มือ TrimBox, BleedBox และ CropBox defaults ของ PDFlibPas
จำลอง aliasing ของ MovePage ด้วย PDF ที่ปั้นเอง
วิธีเร็วที่สุดในการเช็ค build ไหนของ PDFlibPas คือ PDF เล็ก ๆ ที่เขียนมือแล้วโหลดด้วย LoadFromString ที่ทุกหมายเลข object รู้ล่วงหน้า helper ด้านล่างเขียนตาราง cross-reference แบบคลาสสิกพร้อม byte offset ที่คำนวณถูกต้อง การทดสอบจึงไม่พึ่งพาพฤติกรรมกู้ไฟล์เสียหายของ parser
uses
System.SysUtils, PDFlibrary;
function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
Offsets: array of Integer;
I, XRefPos: Integer;
begin
Result := '%PDF-1.4'#10;
SetLength(Offsets, Length(Objects));
for I := 0 to High(Objects) do
begin
Offsets[I] := Length(Result); // byte offset แบบนับจากศูนย์ของ "N 0 obj"
Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
Objects[I] + #10'endobj'#10;
end;
XRefPos := Length(Result);
Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
#10'0000000000 65535 f '#10;
for I := 0 to High(Offsets) do // แต่ละ entry ยาวพอดี 20 ไบต์
Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
Result := Result + 'trailer'#10'<< /Size ' +
AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;
function StreamObj(const Content: AnsiString): AnsiString;
begin
Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
' >>'#10'stream'#10 + Content + #10'endstream';
end;
เอกสารทดสอบมี node Pages ระดับกลางสองตัว node 3 ถือ MediaBox แบบ indirect (object 11 ขนาด 400 คูณ 300 พอยต์) CropBox แบบ direct และ dictionary ของ Resources แบบ direct พร้อมเป็นเจ้าของหน้าสองหน้า node 4 มี MediaBox ขนาด Letter และเป็นเจ้าของหน้าที่สาม การย้ายหน้า 1 ไปตำแหน่ง 3 จัด parent ใหม่ให้อยู่ใต้ node 4 ซึ่งเป็นการย้ายที่ต้องทำ materialization พอดี ถ้าไม่ทำหน้านั้นจะกลายเป็นหน้า Letter
procedure Check(Condition: Boolean; const Msg: string);
begin
if not Condition then
raise Exception.Create(Msg);
end;
procedure CheckMovedPageIsIsolated;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
'<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
'/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
'<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
'/MediaBox [0 0 612 792] >>',
'<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
'<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
'<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
'[0 0 400 300]']), '') = 1, 'load failed');
Lib.SelectPage(1);
Check(Lib.MovePage(3) = 1, 'MovePage failed');
Lib.SelectPage(3); // หน้าที่เพิ่งย้ายไป
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');
Lib.SetPageBox(1, 0, 200, 200, 200); // MediaBox 200 x 200
Lib.SetPageBox(2, 0, 100, 100, 100); // CropBox 100 x 100
FontID := Lib.AddStandardFont(4); // Helvetica
Lib.SelectFont(FontID);
Lib.SetTextSize(12);
Lib.DrawText(20, 20, 'MOVED');
// ส่องดู parent เดิมก่อน SelectPage ไปหน้าอื่น (เหตุผลด้านล่าง)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // หน้า 2 เดิม ยังอยู่ใต้ node 3
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
'shared object 11 was rewritten');
finally
Lib.Free;
end;
end;
GetPageBox(BoxType, Dimension) รับ box type 1 หมายถึง MediaBox กับ 2 หมายถึง CropBox และ dimension 2 หมายถึงความกว้าง ด้วย origin เริ่มต้นที่มุมล่างซ้าย SetPageBox(1, 0, 200, 200, 200) หมายถึงซ้าย 0 บน 200 กว้าง 200 และสูง 200 บน build ระหว่าง v3.539.27 ถึง v3.539.35 การเช็คฝั่งพี่น้องจะ fail การแก้ CropBox ตกลงใน array direct ของ node 3 และการแก้ MediaBox เขียนทับ object 11 ผ่าน reference
CopyPageRanges เปลี่ยนเอกสารต้นทางหรือเปล่า
ตั้งแต่ v3.539.36 CopyPageRanges ยังเขียนลงบนหน้าต้นทางเหมือนเดิม แต่ค่าทุกตัวที่มันเขียนเป็นสำเนาแยกของตัวเอง การแก้ทีหลังบนต้นทางจึงอยู่เฉพาะหน้าที่คุณแก้ การเขียนนั้นตั้งใจแบบนั้น: หน้าต้นทางต้องมี MediaBox, CropBox, Rotate และ Resources แบบชัดเจนก่อน dictionary ของมันจะถูกโคลนเข้าปลายทาง ไม่งั้นสำเนาจะเสียทุกอย่างที่มันสืบทอดมา การรีนัมเบอร์กับการ copy หน้าเข้าปลายทางเล่าไว้ในการ deep copy object ข้ามเอกสารใน PDFlibPas บั๊กตัวนี้ซุ่มอยู่ฝั่งต้นทาง ซึ่งคนส่วนใหญ่สมมติว่าการ copy แค่อ่านเท่านั้น
output ไม่เคยเปิดเผยเรื่องนี้เลย ไม่ว่าจะแชร์หรือ copy ค่าที่ถูกทำ materialization serialize เหมือนกัน ทั้งสองเอกสารจึง save ออกมาไบต์ต่อไบต์เท่ากันก่อนและหลังการแก้ มีแค่การแก้เอกสารต้นทางหลังการ copy เท่านั้นที่เปิดโปง alias:
procedure CheckSourceSurvivesCopy;
var
Lib: TPDFlib;
SourceID, TargetID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
'/MediaBox [0 0 400 300] /Resources << >> >>',
'<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
SourceID := Lib.SelectedDocument;
TargetID := Lib.NewDocument; // กลายเป็น document ที่ถูกเลือกอยู่
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // หดแค่ CropBox ตัวเดียว
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
Lib.SetPageBox(1, 0, 200, 200, 200);
Lib.SelectPage(2);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');
Lib.SelectDocument(TargetID); // สำเนาคงขนาดเดิมของมันไว้
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
ก่อน v3.539.36 ทั้งสองหน้าตรงนี้สืบทอด MediaBox แบบ direct ของ node ราก สำเนาแนบ instance นั้นไปกับหน้า 1 ของต้นทาง แล้วแนบซ้ำอีกรอบเป็น CropBox ของหน้า 1 การหด CropBox จึงหด MediaBox ตามไปด้วย และการปรับขนาด MediaBox ก็ปรับขนาดหน้า 2 ผ่าน node ราก workflow ที่ copy หน้าออกไปแล้วยังแก้ต้นทางต่อ อย่างการ collate สแกน duplex รวมเป็น PDF เดียวก่อนตัดแต่งไฟล์ต้นฉบับ คือที่ ๆ เรื่องนี้โผล่มา
ทำไม instance aliasing ถึงทดสอบยาก
instance aliasing ทดสอบยากเพราะผลที่มองเห็นได้ต้องการสามขั้นเรียงลำดับเฉพาะ: สร้าง alias mutate ฝั่งหนึ่ง แล้วค่อยส่องดูอีกฝั่งก่อนที่อะไรจะไปแตะมัน การทดสอบส่วนใหญ่ทำแค่ขั้นแรกแล้วเทียบ output ที่ save ซึ่งเหมือนกันเป๊ะไม่ว่า alias จะมีอยู่หรือเปล่า
กับดักลำดับใน PDFlibPas คือ SelectPage การเลือกหน้าจะ re-apply font ปัจจุบันผ่าน SelectFont ซึ่ง register font ตัวนั้นลง resources ของหน้า หน้าที่ไม่มี /Resources ของตัวเองจะ resolve ไปที่ dictionary ของ parent แค่เลือกหน้าแบบนี้ก็เพิ่ม /Font เข้า node Pages อย่างถูกต้องตามกติกา ใน test ของ MovePage ด้านบน การเลือกหน้า 2 เดิมจะเพิ่ม entry ของ Helvetica เข้า node 3 ซึ่งเป็นพฤติกรรมที่ถูกต้อง ไม่ใช่การรั่ว นั่นเหตุผลที่เช็ค GetObjectToString(3) รันก่อน SelectPage(1) สลับสองอันนี้แล้ว test จะ fail แม้บน build ที่แก้แล้ว
กฎเดียวกันนี้ยังบอกด้วยว่าอะไรที่ v3.539.36 ตั้งใจปล่อยไว้ การเขียน resource ลงหน้าที่สืบทอด dictionary ของ Resources เขียนลง dictionary ของบรรพบุรุษ และพี่น้องทุกหน้าเห็น entry ใหม่ นั่นคือ inheritance ทำงานตามสเปก ไม่ใช่การแชร์ instance และไม่เป็นอันตราย เพราะการเพิ่มชื่อ font หรือ image เข้า dictionary ที่แชร์ไม่เปลี่ยนวิธีที่หน้าอื่น render ถ้าต้องการให้หน้าหยุดสืบทอด ก็ให้ dictionary ของ Resources เป็นของตัวเองเสียก่อน
เช็คลิสต์สำหรับโค้ด object model ของ PDF
บทเรียนพวกนี้ใช้กว้างไปถึง object model ของ PDF แบบไหนก็ตามที่สร้างบน pool กับ container แบบ pointer ไม่ว่าจะใน Delphi หรือที่อื่น:
- ตอนทำ materialization ของ attribute ที่สืบทอดตาม ISO 32000-1 §7.7.3.4 ให้ deep-copy ค่า direct และคง indirect reference เป็น reference ใหม่ที่ชี้ object เดิม
- ห้าม
Addinstance ที่มีอยู่แล้วเข้า container ที่สอง เว้นแต่การแชร์นั้นตั้งใจและจดไว้ในเอกสาร การเป็นของ pool แปลว่า runtime จะไม่บ่นให้ฟังเด็ดขาด - แก้ในที่เดิมเฉพาะสิ่งที่ node ปัจจุบันเป็นเจ้าของแบบ direct object ส่วนค่าแบบ indirect หรือที่สืบทอดมาให้แทนที่ด้วย direct object ใหม่ (copy-on-write)
- ค่าเริ่มต้นที่ derive จาก entry อื่น อย่าง CropBox ที่มาจาก MediaBox ต้องมี instance ของตัวเอง
- ทดสอบ aliasing ด้วยลำดับ mutate แล้วส่องดูฝั่งที่ถือร่วมอีกตัว และเช็คลำดับของ call ที่อาจเขียนอย่างถูกกฎหมายแทรกเข้ามาตรงกลางด้วย
- การเทียบ output ที่ save พิสูจน์อะไรไม่ได้ในเรื่องนี้ ค่าที่แชร์กับค่าที่ copy serialize เหมือนกันจนกว่าการแก้ครั้งแรก
- บน PDFlibPas อัปเกรดเป็น v3.539.36 ขึ้นไปถ้าคุณเรียก
MovePage,CollateDocumentsEx,BalancePageTreeหรือCopyPageRangesแล้วแก้ page box หรือวาดรูปบนหน้าต่อ
PDFlibPas เปิดให้แก้ page tree, copy ข้ามเอกสารและควบคุม page box ผ่านคลาส TPDFlib ตัวเดียวสำหรับ Delphi, C++Builder และ Free Pascal ดูหน้า product page ของ PDFlibPas Delphi PDF library สำหรับ edition, platform ที่รองรับและ API reference ฉบับเต็ม