การประทับลายน้ำหรือโลโก้ลงบนทุกหน้าของเอกสารอาจดูเหมือนงานที่ใช้เวลาเพียงห้านาที จนกระทั่งคุณเปิดดูผลลัพธ์ในเครื่องมือตรวจสอบขนาดไฟล์ แนวทางที่เห็นได้ชัดคือการวนซ้ำไปตามหน้าต่างๆ และในแต่ละหน้าก็สร้างอ็อบเจ็กต์ข้อความหรือรูปภาพเดิมซ้ำอีกครั้ง วิธีนั้นได้ผลในแง่ของสายตา แต่มันสิ้นเปลืองอย่างมหาศาล ลายน้ำ "DRAFT" ทแยงมุมที่วาดลงบนรายงานความยาวหนึ่งร้อยหน้าโดยตรง หมายถึงการทำสำเนาเส้นทาง (path) และข้อมูลข้อความเดิมหนึ่งร้อยชุดที่ฝังตัวอยู่ในสตรีมเนื้อหา และไฟล์ที่บันทึกไว้ก็จะเก็บข้อมูลเหล่านั้นไว้ทุกตัว
Form XObject คือโครงสร้างที่ PDF มีไว้ให้เพื่อหลีกเลี่ยงเหตุการณ์เช่นนี้ มันจะห่อหุ้มชิ้นส่วนของเนื้อหาที่สามารถนำกลับมาใช้ใหม่ได้ ไม่ว่าจะเป็นทั้งหน้าหรือเทมเพลตขนาดเล็ก ให้กลายเป็นอ็อบเจ็กต์ที่มีชื่อเพียงตัวเดียวซึ่งสามารถนำไปวาดได้หลายครั้งในหลายๆ ตำแหน่ง เนื้อหานั้นจะคงอยู่ในไฟล์เพียงครั้งเดียว แต่ละหน้าที่ต้องการประทับตราจะเก็บคำสั่งสั้นๆ ที่บอกว่า "วาด XObject N ที่นี่ ด้วยการแปลงข้อมูลนี้" ลายน้ำที่มีอยู่ร้อยหน้าจึงเป็นการเพิ่มอ็อบเจ็กต์เนื้อหาเพียงตัวเดียวลงในไฟล์แทนที่จะเป็นร้อยตัว และนั่นคือความแตกต่างระหว่างเอกสารที่ขนาดเพิ่มขึ้นเป็นเส้นตรงตามจำนวนหน้ากับเอกสารที่ไม่เป็นเช่นนั้น ลายน้ำ, ตรายางโลโก้, เทมเพลตหมายเลขหน้า, และตราประทับล้วนเป็นปัญหาในรูปแบบเดียวกัน และ Form XObject คือเครื่องมือที่เหมาะสมสำหรับทุกเรื่องที่กล่าวมา
ทำไมอ็อบเจ็กต์ที่จัดเก็บเพียงหนึ่งชิ้นจึงดีกว่าการวาดใหม่เป็นร้อยครั้ง
การประหยัดนี้เป็นเรื่องของโครงสร้าง ไม่ใช่เรื่องความสวยงาม หน้า PDF จะแสดงผลด้วยการรันสตรีมเนื้อหา ซึ่งเป็นลำดับของตัวดำเนินการวาดภาพ เมื่อคุณวาดตรายางใหม่ต่อหน้า คุณกำลังต่อท้ายลำดับตัวดำเนินการแบบเต็มของตรายางนั้นๆ ลงในสตรีมของทุกหน้า และไบต์ข้อมูลเหล่านั้นจะถูกคัดลอกซ้ำไปเรื่อยๆ เท่ากับจำนวนหน้ากระดาษที่คุณมี Form XObject จะย้ายตัวดำเนินการเหล่านั้นไปยังสตรีมที่ถูกจัดเก็บเพียงครั้งเดียวในเอกสาร การอ้างอิงที่แต่ละหน้าเก็บไว้นั้นมีขนาดเล็ก: มันจะทำการผลักเมทริกซ์การแปลง เรียกใช้ XObject และคืนสถานะเดิม จำนวนหน้าจะไม่เป็นตัวคูณต้นทุนของชิ้นงานศิลปะอีกต่อไป
สิ่งนี้มีความสำคัญอย่างยิ่งเมื่อตราประทับมีขนาดใหญ่ ตราประทับเวกเตอร์ที่มีส่วนของเส้นทางนับร้อยเส้น หรือบิตแมปของโลโก้ ล้วนเป็นสิ่งที่มีต้นทุนสูงในการจัดเก็บ หากจัดเก็บเพียงครั้งเดียวและใช้การอ้างอิงถึง ส่วนที่หนักที่สุดก็จะจ่ายเพียงครั้งเดียว และค่าใช้จ่ายต่อหน้า (overhead) จะใช้เพียงไม่กี่ไบต์สำหรับการเรียกใช้งาน ผลลัพธ์ทางสายตาบนหน้ากระดาษจะเหมือนกับการวาดใหม่โดยตรง ซึ่งเป็นประเด็นสำคัญ ผู้อ่านจะไม่สามารถบอกความแตกต่างได้ แต่ขนาดไฟล์สามารถบอกได้อย่างชัดเจน
การจับภาพหน้ากระดาษไปเป็น XObject
PDFium สร้างอ็อบเจ็กต์ที่นำกลับมาใช้ใหม่ได้จากหน้ากระดาษที่มีอยู่ แหล่งที่มาคือหน้ากระดาษในเอกสารบางไฟล์ที่คุณเปิดไว้ อาจจะเป็นไฟล์ PDF ขนาดเล็กที่มีหน้าเดียวซึ่งไม่มีอะไรเลยนอกจากงานศิลปะสำหรับลายน้ำของคุณ หรืออาจเป็นหน้าเฉพาะของไฟล์ที่ใหญ่กว่า CreateXObjectFromPage ทำหน้าที่ดึงเนื้อหาของหน้าต้นฉบับนั้นเข้าสู่แฮนเดิลที่สามารถนำกลับมาใช้ใหม่ได้ ซึ่งเป็นของเอกสารปลายทาง นั่นคือเอกสารที่คุณกำลังประทับตราอยู่
var
Dest, Stamp: TPdf;
XObject: TPdfXObject;
begin
Dest := TPdf.Create(nil);
Stamp := TPdf.Create(nil);
try
Dest.FileName := 'Report.pdf';
Dest.Active := True;
Stamp.FileName := 'Watermark.pdf'; // one page of artwork
Stamp.Active := True;
if not (Dest.Active and Stamp.Active) then
raise Exception.Create('Could not open the input documents');
// Capture page 0 of the stamp document into a reusable handle that
// is owned by Dest. Source must be Active; the index is zero-based.
XObject := Dest.CreateXObjectFromPage(Stamp, 0);
if XObject = nil then
raise Exception.Create('Could not build the stamp XObject');
// ... place it, then free it before closing Stamp (see below) ...
รูปแบบคำสั่ง (signature) คือ CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject เมธอดนี้จะแสดงข้อผิดพลาดหากเอกสารต้นฉบับไม่ได้ Active และมันจะส่งคืนค่า nil แทนการแสดงข้อผิดพลาดเมื่อ PDFium ไม่สามารถสร้างอ็อบเจ็กต์ได้ ดังนั้นการตรวจสอบที่ชัดเจนด้านบนจึงไม่ใช่ทางเลือก แฮนเดิลที่ส่งกลับมาคือ TPdfXObject ที่คุณเป็นเจ้าของ และข้อจำกัดด้านอายุการใช้งานสองข้อที่แนบมาด้วยนั้นเป็นส่วนของการฝึกฝนทั้งหมดนี้ที่ผู้คนมักจะพลาด ดังนั้นจึงมีส่วนอธิบายเพิ่มเติมแยกต่างหากที่ด้านล่าง
การวางตราประทับบนหน้ากระดาษ
XObject ที่ถูกบันทึกไว้จะไม่ทำอะไรเลยด้วยตัวมันเอง หากต้องการให้มันปรากฏ คุณจะต้องแทรกสำเนาของมันลงบนหน้าปัจจุบันของเอกสาร ซึ่งเป็นหน้าที่เลือกโดยคุณสมบัติ PageNumber ที่อิงหลัก 1-based ด้วย InsertFormObjectFromXObject การเรียกใช้นั้นจะส่งคืนอ็อบเจ็กต์หน้าพื้นฐาน (an FPDF_PAGEOBJECT) และแฮนเดิลที่ส่งคืนคือวิธีที่คุณจะกำหนดตำแหน่งการวาง หากไม่มีการแปลงค่า ตราประทับจะลงจอดที่จุดกำเนิดในพิกัดของหน้าต้นฉบับเอง ซึ่งมักจะไม่ใช่ตำแหน่งที่คุณต้องการ
เนื่องจาก InsertFormObjectFromXObject จะแทรกสำเนาหนึ่งฉบับต่อการเรียกใช้และส่งอ็อบเจ็กต์หน้าใหม่กลับมาทุกครั้ง คุณจึงสามารถวาด XObject เดียวกันได้หลายครั้งในหน้าเดียวด้วยการแปลงที่แตกต่างกัน และเนื้อหาที่จัดเก็บไว้จะยังคงถูกนับเพียงครั้งเดียวในไฟล์ โลโก้ที่มุมกระดาษและลายน้ำจางๆ แบบเต็มหน้าสามารถมาจากอ็อบเจ็กต์ที่จับภาพไว้ตัวเดียวกันได้
var
PageObj: FPDF_PAGEOBJECT;
M: TPdfMatrix;
RawM: FS_MATRIX;
begin
// The current page of Dest receives one copy of the XObject.
PageObj := Dest.InsertFormObjectFromXObject(XObject);
if PageObj = nil then
raise Exception.Create('Insert failed on this page');
// Position it: move 200 units right, 500 up, at 70% scale.
M := TPdfMatrix.Create;
try
M.Scale(0.7, 0.7);
M.Translate(200, 500);
RawM := M.Handle;
if FPDFPageObj_SetMatrix(PageObj, RawM) = 0 then
raise Exception.Create('Cannot assign the stamp matrix');
finally
M.Free;
end;
Dest.UpdatePage; // commit this page's edits to its content stream
// if not Dest.SaveAs(...) then ... when every page is done.
end;
รายละเอียดเกี่ยวกับการจัดการดูแลรักษาความเรียบร้อยสองประการทำให้การดำเนินการนี้ปลอดภัย ประการแรก เมื่อแทรกไปแล้ว อ็อบเจ็กต์หน้าจะเป็นของหน้านั้น ไม่ใช่ของ XObject การปลดปล่อย (freeing) XObject ในภายหลังจะไม่ทำให้การจัดวางที่คุณทำไว้แล้วเป็นโมฆะ นั่นคือสิ่งที่ทำให้ลำดับการสร้าง-การจัดวาง-การปลดปล่อย (create-place-free ordering) ตามที่อธิบายไว้ด้านล่างสามารถทำงานได้ ประการที่สอง การแทรกและการกำหนดตำแหน่งจะเปลี่ยนเพียงรายชื่ออ็อบเจ็กต์ของหน้าในหน่วยความจำเท่านั้น UpdatePage คือสิ่งที่จะแปลงชุดข้อมูล (serialises) ของรายการนั้นกลับเข้าสู่สตรีมเนื้อหาของหน้า ดังนั้นหน้ากระดาษที่คุณแก้ไขโดยไม่มีการเรียกใช้งานฟังก์ชันนี้ก็จะถูกบันทึกราวกับว่าตราประทับนั้นไม่เคยถูกวางลงไปเลย
กฎอายุการใช้งานแฮนเดิลที่ทำให้คนเจ็บปวด
ข้อจำกัดสองประการที่ควบคุมแฮนเดิลของ XObject และการเพิกเฉยต่อข้อใดข้อหนึ่งจะก่อให้เกิดความล้มเหลวที่ดูเหมือนไม่เกี่ยวข้องกับสาเหตุเลย ประการแรก เอกสารต้นฉบับต้องเปิดใช้งาน (active) ในขณะที่คุณเรียก CreateXObjectFromPage การดึงข้อมูลจะอ่านเนื้อหาของหน้าต้นฉบับจากเอกสารต้นฉบับแบบสด ดังนั้นเอกสารและหน้านั้นจะต้องเปิดอยู่และใช้งานได้เมื่อมีการสร้างแฮนเดิล ประการที่สอง และนี่คือสิ่งที่ทำให้ผู้คนประหลาดใจ แฮนเดิลจะต้องถูกปลดปล่อยก่อนที่จะปิดหน้าต้นฉบับ และในทางปฏิบัติ ต้องทำก่อนที่คุณจะปิดหรือปลดปล่อยเอกสารต้นฉบับที่มันดึงมา
เหตุผลคือ XObject เป็นการอ้างอิงเข้าไปในโครงสร้างที่เอกสารต้นฉบับยังคงเป็นเจ้าของอยู่ มันไม่ใช่สำเนาแยกต่างหากและครบถ้วนในตัวที่คุณสามารถนำติดตัวไปได้หลังจากที่ต้นฉบับหายไป หากปิดต้นฉบับไปก่อน แฮนเดิลจะถูกทิ้งให้ชี้ไปยังเนื้อหาที่ถูกทำลายทิ้งไปแล้ว ดังนั้นการปล่อยแฮนเดิลในภายหลัง หรือการใช้งานใดๆ กับมัน จะทำงานอยู่บนหน่วยความจำที่ไม่ถูกต้องอีกต่อไป อาการดังกล่าวเป็นรูปแบบดั้งเดิมของ dangling handle: มีการละเมิดการเข้าถึงขณะปิดระบบ หรือข้อมูลเสียหายเป็นระยะๆ ซึ่งจะย้ายไปมาตามลำดับการจัดสรรหน่วยความจำ โดยมี stack ที่ชี้ไปที่โค้ดการล้างข้อมูล แทนที่จะชี้ไปที่บรรทัดของโค้ดที่ก่อให้เกิดปัญหาจริงๆ การแก้ไขต้องทำที่การเรียงลำดับ ไม่ใช่การเขียนโค้ดเชิงรับ (defensive coding) สร้าง XObject, นำมันไปแทรกไว้ในทุกหน้าที่ต้องการ, ปลดปล่อย XObject, และหลังจากนั้นจึงค่อยปิดเอกสารต้นฉบับ destructor ของ TPdfXObject จะทำหน้าที่คืนแฮนเดิลของ PDFium ให้คุณ ดังนั้นการปล่อยตัวห่อหุ้มให้ถูกเวลาคือความรับผิดชอบทั้งหมดของคุณ
เมทริกซ์, และความหมายของตัวเลขทั้งหกของมัน
การจัดตำแหน่งคือการแปลงรูปแบบอัฟฟีน 2 มิติ (2D affine transform) แบบเดียวกับที่ PDF ใช้สำหรับกำหนดตำแหน่งเนื้อหาอยู่ทุกที่ (ISO 32000-1, section 8.3.4) ซึ่งประกอบด้วยตัวเลขหกตัว เขียนแทนด้วย a, b, c, d, e, f และ PDFium จะเปิดเผยตัวเลขเหล่านี้ในรูปแบบของระเบียน FS_MATRIX พวกมันจะจับคู่ (map) จุดจากพื้นที่ของอ็อบเจ็กต์เองไปยังพื้นที่หน้ากระดาษ:
// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d : horizontal and vertical scale
// b, c : the shear / rotation terms
// e, f : translation (where the origin lands on the page)
คุณสามารถใส่ค่าทั้งหกนี้ด้วยตัวเอง แต่การประกอบด้วยมือจะเป็นจุดที่การหมุนมักจะผิดพลาด เนื่องจากหมุนต้องผสมทั้งสี่ค่า a, b, c, d เข้าด้วยกัน ตัวห่อหุ้ม TPdfMatrix จากยูนิต FPdfMatrix ได้รวบรวมการทำงานทั่วไปไว้ให้คุณแล้วและทำคูณแบบ post-multiplies ไปในตัว ดังนั้น Translate, Scale, และ Rotate จะเรียงร้อยต่อเนื่องกันในลำดับที่คุณเรียกใช้งาน ลายน้ำแนวทแยงจะเป็นการหมุน ตามด้วยการเลื่อนตำแหน่ง (translate) เพื่อจัดให้อยู่ตรงกลางอีกครั้ง โลโก้ที่มุมกระดาษจะเป็นการปรับขนาด (scale) ตามด้วยการเลื่อนตำแหน่ง เมื่อเตรียมเมทริกซ์พร้อมแล้ว ให้คัดลอกค่าดิบของมัน ซึ่งก็คือคุณสมบัติ Handle ชนิด FS_MATRIX นำไปใส่ในตัวแปรโลคัล (local variable) แล้วส่งตัวแปรนั้นไปยัง FPDFPageObj_SetMatrix; ส่วนนำเข้าประกาศให้เมทริกซ์เป็นพารามิเตอร์แบบ var ดังนั้นคุณสมบัติจึงไม่สามารถส่งไปยังตัวแปรนี้ได้โดยตรง และผลลัพธ์ของมันจะเป็น 0 ในกรณีที่เกิดความล้มเหลว FPDFPageObj_Transform ในระดับที่ต่ำกว่า ซึ่งรับค่าทั้งหกค่าโดยตรงในรูปของทศนิยม (doubles) นั้นจะมีให้ใช้งาน เมื่อคุณต้องการจะส่งตัวเลขผ่านไปแทนที่จะสร้างตัวห่อหุ้มขึ้นมา
การประทับทุกหน้ากระดาษ ตามลำดับที่ถูกต้อง
รูปแบบการทำงานเต็มรูปแบบจะเป็นการนำชิ้นส่วนต่างๆ มารวมกันด้วยการจัดลำดับที่กฎของอายุการใช้งานระบุไว้ เปิดเอกสารทั้งสองไฟล์, จับภาพของตรายางเพียงครั้งเดียว, เดินตามหน้าปลายทางด้วยการตั้งค่า PageNumber แบบ 1-based ตามลำดับ แล้วแทรกพร้อมกับกำหนดตำแหน่งสำเนา, ยืนยันการเปลี่ยนแปลงแต่ละหน้าด้วย UpdatePage, จากนั้นปลดปล่อย XObject, แล้วบันทึกไฟล์ด้วย SaveAs, และปล่อยให้เอกสารต้นฉบับปิดเป็นอันดับสุดท้าย
procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
Dest, Stamp: TPdf;
XObject: TPdfXObject;
PageObj: FPDF_PAGEOBJECT;
M: TPdfMatrix;
RawM: FS_MATRIX;
I: Integer;
begin
Dest := TPdf.Create(nil);
Stamp := TPdf.Create(nil);
try
Dest.FileName := ASource;
Dest.Active := True;
Stamp.FileName := AStamp;
Stamp.Active := True;
if not (Dest.Active and Stamp.Active) then
raise Exception.Create('Could not open the input documents');
// 1. Capture the artwork once. Stamp is Active here.
XObject := Dest.CreateXObjectFromPage(Stamp, 0);
if XObject = nil then
raise Exception.Create('Could not capture the stamp page');
try
// 2. Place a copy on every page of Dest. PageNumber is 1-based.
for I := 1 to Dest.PageCount do
begin
Dest.PageNumber := I; // make page I current
PageObj := Dest.InsertFormObjectFromXObject(XObject);
if PageObj = nil then
Continue;
M := TPdfMatrix.Create;
try
M.Rotate(45); // diagonal watermark
M.Translate(150, 100); // nudge into position
RawM := M.Handle;
FPDFPageObj_SetMatrix(PageObj, RawM);
finally
M.Free;
end;
Dest.UpdatePage; // commit this page's edits
end;
finally
XObject.Free; // 3. free BEFORE Stamp closes
end;
// 4. Write the result while Dest is still open.
if not Dest.SaveAs(AOutput) then
raise Exception.Create('Could not save ' + AOutput);
finally
Stamp.Free; // source closes last
Dest.Free;
end;
end;
โครงสร้างของบล็อก try เป็นส่วนที่ทำงานจริงๆ โดย finally ด้านในจะปลดปล่อย XObject ก่อนที่การควบคุมจะเข้าถึง finally ด้านนอกที่ปลดปล่อย Stamp ดังนั้นแฮนเดิลจะถูกคลายออกในขณะที่ต้นฉบับของมันยังคงอยู่เสมอ แม้ว่าจะมีข้อผิดพลาดเกิดขึ้นระหว่างการวนลูปก็ตาม การทำโครงสร้างซ้อนให้ถูกต้องจะทำให้กฎอายุการใช้งานดูแลตัวมันเอง
Stamping is one corner of a larger toolkit for building and editing page content. If your stamp is itself an image rather than a captured page, converting images to PDF documents with PDFium covers getting that bitmap into a document first. And when the thing you want to carry alongside the visible stamp is a file rather than ink on the page, working with PDF attachments in Delphi shows the embedded-file side. All of it ships with the PDFium Component for Delphi and C++Builder, alongside the rendering, editing, and document APIs covered elsewhere on this blog