บทความเทคนิค

การเย็บหน้าและการใช้เทมเพลตลายน้ำใน PDF ของ Delphi

การเย็บหน้า (Page stitching) เป็นการรวม เนื้อหา (content) ของหน้าเดิมที่มีอยู่เข้าด้วยกันบนผืนผ้าใบ (canvas) ร่วมกัน ซึ่งเป็นการทำงานที่แตกต่างจากการรวมไฟล์ (merging files) losLab PDF Library ได้นำการเย็บหน้าแบบ Form XObject และเทมเพลตลายน้ำที่จัดวางประกอบได้มาใช้สำหรับ Delphi: StitchPageOverlay, StitchPagesSideBySide และ StitchPagesVertically จะวางหน้ากระดาษที่จับภาพมาได้ที่ระดับการปรับสเกลใดๆ ด้วยความละเอียดแม่นยำของเวกเตอร์อย่างสมบูรณ์ และ TPDFlibCompositeWatermark จะซ้อนลายน้ำข้อความและรูปภาพตามช่วงหน้ากระดาษที่กำหนดได้ในการเรียกเพียงครั้งเดียว หากสิ่งที่คุณต้องการจริงๆ คือการต่อหน้าทั้งหมดจากเอกสารหลายฉบับเข้าด้วยกันเป็นไฟล์เดียว นั่นคือตระกูลการควบรวม (merge family) ซึ่งครอบคลุมไว้ในบทความเกี่ยวกับการรวม PDF ระดับไบต์อย่างรวดเร็ว ส่วนบทความนี้จะเป็นเรื่องเกี่ยวกับการรวมเนื้อหาหน้าเว็บภายในพื้นที่พิกัดเดียว

ความต้องการที่นำมาสู่เรื่องนี้เป็นเรื่องปกติที่พบได้บ่อยๆ ตัวอย่างเช่น โรงเรียนแห่งหนึ่งต้องการรวมหน้าข้อสอบสองหน้าเข้าด้วยกันบนกระดาษแนวนอนแผ่นเดียวเพื่อลดการใช้กระดาษลงครึ่งหนึ่ง ผู้ตรวจสอบการแปลต้องการให้หน้าภาษาอังกฤษและคำแปลแสดงอยู่เคียงข้างกันในหน้าเดียวเพื่อเลื่อนดูไปพร้อมกัน ทีมกฎหมายต้องการให้เอกสารร่างทุกฉบับประทับตราสามชั้นพร้อมกัน ได้แก่ โลโก้ที่มุมกระดาษ, คำว่า CONFIDENTIAL ในแนวทะแยงมุมตรงกลาง และบรรทัดท้ายกระดาษขนาดเล็ก โดยทำเฉพาะหน้า 1 ถึง 3 เท่านั้น ทั้งสามประการนี้เป็นปัญหาพื้นฐานเดียวกัน นั่นคือการจัดวางเนื้อหาของหน้าที่ผ่านการจัดรูปแบบเรียบร้อยแล้วไปไว้ที่อื่นในขนาดอื่นโดยไม่ลดทอนคุณภาพไปเป็นบิตแมป

ทำไมการเชื่อมต่อสตรีมเนื้อหา PDF (Content Streams) จึงล้มเหลว?

การเย็บหน้าแบบธรรมดาๆ โดยการคัดลอกสตรีม /Contents ของหน้าต้นทางไปยังหน้าปลายทาง จะล้มเหลวด้วยเหตุผลทางโครงสร้าง 3 ประการ ประการแรก เกิดการชนกันของพิกัด: สตรีมเนื้อหาทั้งสองสตรีมกำหนดจุดเริ่มต้นของตนเองไว้ที่มุมล่างซ้ายของ MediaBox ของตนเอง ดังนั้นเนื้อหาที่วางจึงไปทับเนื้อหาเดิมในตำแหน่งที่ผิด ประการที่สอง เกิดการชนกันของชื่อทรัพยากร: แต่ละหน้าจะดึงข้อมูลตัวดำเนินการเช่น /F1 Tf และ /Im0 Do เทียบกับพจนานุกรม /Resources ของตนเอง และหน้าสองหน้ามักจะผูกชื่อเดียวกันเข้ากับฟอนต์หรือรูปภาพที่ต่างกัน ส่งผลให้อีกหน้าหนึ่งแสดงผลด้วยทรัพยากรที่ผิดพลาดไปโดยไม่แจ้งเตือน ประการที่สาม การคัดลอกสตรีมดิบทำให้คุณไม่สามารถปรับสเกลขนาดได้เลย สิ่งที่ดีที่สุดที่คุณทำได้คือการย้ายพิกัด (translate) เท่านั้น

Form XObjects สามารถแก้ปัญหาทั้งสามประการนี้ได้พร้อมกัน และนั่นคือสาเหตุที่ losLab PDF Library สร้างการเย็บหน้าขึ้นบนพื้นฐาน of Form XObject ข้อกำหนด ISO 32000-1 §8.10 นิยาม Form XObject ว่าเป็นสตรีมเนื้อหาแบบเบ็ดเสร็จในตัวที่มีพจนานุกรม /Resources และพื้นที่พิกัดของตนเอง ซึ่งสามารถนำไปวางในหน้าเอกสารได้โดยการเรียกใช้ชื่อของมันภายใต้เมทริกซ์การแปลงข้อมูล (transformation matrix) ปัจจุบันที่กำลังใช้งานอยู่ ฟังก์ชัน CapturePage จะรวมหน้าเอกสารเดิมทั้งหมด ทั้งเนื้อหาและทรัพยากร เข้าไว้ใน XObject ประเภท /Subtype /Form จากนั้นฟังก์ชันเย็บหน้าจะจัดตำแหน่งด้วยเมทริกซ์ cm เพื่อย่อขยาย, ย้ายพิกัด หรือหมุนตามต้องการ เนื่องจากต้นทางยังคงเป็นเวกเตอร์ ข้อความจึงยังคงเป็นข้อความและเส้นทาง (paths) ยังคงเป็นเส้นทางในทุกระดับการซูม คุณสมบัติเดียวกันนี้ทำให้กราฟิกแบบเวกเตอร์และรูปแบบการไล่โทนสี (shading patterns) ไม่ขึ้นกับความละเอียด ไม่มีสิ่งใดถูกแปลงเป็นพิกเซล และพจนานุกรมทรัพยากรที่แยกเป็นเอกเทศหมายความว่าหน้าที่จับภาพได้จะไม่ขัดแย้งกับหน้าปลายทางในเรื่องการใช้ชื่อ /F1

PDF Library for Delphi: การคัดลอก content stream แบบง่ายล้มเหลวด้วยพิกัดชนกัน ชื่อ resource /F1 ชนกัน และไม่มีการย่อขยาย ขณะที่ CapturePage ห่อแต่ละหน้าเป็น Form XObject ที่สมบูรณ์ในตัว ซึ่งเมทริกซ์ cm เพียงตัวเดียววางลงหน้าเป้าหมายที่กว้างกว่าได้ พร้อม resource แยกขาดและความคมชัดเวกเตอร์เต็มรูปแบบ
การคัดลอกสตรีมดิบล้มเหลวพร้อมกันสามทาง ขณะที่การจับแต่ละหน้าเป็น Form XObject ให้พื้นที่พิกัดและ resource dictionary ที่แยกเดี่ยวซึ่งเมทริกซ์ cm เพียงตัวเดียววางตำแหน่ง

จะรวมหน้า PDF สองหน้าเข้าไว้ในหน้าเดียวกันได้อย่างไร?

StitchPagesSideBySide(Page1, Page2, Gap, TargetHeight) คือคำตอบโดยตรง: ฟังก์ชันนี้จะจับภาพหน้าต้นทางทั้งสองหน้า สร้างหน้าใหม่ที่กว้างขึ้น และวางภาพที่จับได้ทั้งสองฝั่งซ้ายและขวาโดยมีระยะห่าง (gutter) ระหว่างกลาง โดยแต่ละหน้าจะถูกปรับขนาดให้พอดีกับ TargetHeight ฟังก์ชันนี้จะส่งกลับหมายเลขหน้าใหม่ (เริ่มจาก 1) หรือส่งกลับ 0 เมื่อล้มเหลว และหน้าต้นทางทั้งสองหน้าจะถูกจับภาพและซ่อนไว้เป็นส่วนหนึ่งของการทำงาน กรณีการสอบแบบสองหน้าต่อแผ่นกระดาษจึงใช้โค้ดเพียงสามบรรทัดเท่านั้น

var
  Lib: TPDFlib;
  NewPage: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('exam.pdf', '');
    // หน้า 1 และ 2 อยู่บนหน้าใหม่ที่กว้างขึ้นหน้าเดียว มีร่องกลาง 18pt
    // ทั้งสองหน้าถูกปรับสเกลให้ความสูงเอาต์พุตเป็น 595pt (A4 แนวนอน)
    NewPage := Lib.StitchPagesSideBySide(1, 2, 18, 595);
    if NewPage > 0 then
      Lib.SaveToFile('exam-2up.pdf');
  finally
    Lib.Free;
  end;
end;

สำหรับการประสานหน้าในแนวตั้ง ฟังก์ชัน StitchPagesVertically(PageRanges, Gap, TargetWidth) จะซ้อนช่วงหน้าทั้งหมดลงในหน้ากระดาษยาวแผ่นเดียวสไตล์โปสเตอร์ PageRanges จะใช้ไวยากรณ์ช่วงมาตรฐาน เช่น '1-3,5' หน้าต้นทางแต่ละหน้าจะถูกปรับขนาดเพื่อให้ความกว้างสอดคล้องกับ TargetWidth ดังนั้นความสูงของแต่ละหน้าที่จะปรากฏคือความสูงของตัวมันเองคูณด้วยอัตราการปรับสเกลของมัน และความสูงของหน้ากระดาษผลลัพธ์คือผลรวมของความสูงเหล่านั้นบวกกับค่า Gap ระหว่างหน้าแต่ละคู่ นี่คือรูปแบบที่คุณต้องการสำหรับเอกสารการตรวจสอบแบบเลื่อนต่อเนื่อง หรือใบเสร็จยาวๆ ที่ประกอบขึ้นจากแหล่งข้อมูลขนาด Letter

สามโหมดเย็บหน้า PDF ใน Delphi: StitchPagesSideBySide วางสองหน้าที่จับมาบนแผ่นกว้างขึ้นโดยเว้นช่อง 18 พอยต์, StitchPagesVertically ซ้อนช่วง 1-3,5 เป็นหน้าเดียวสูงยาว, StitchPageOverlay วาดหน้าต้นฉบับทับปกที่ความทึบ 0.45 พร้อมคำเตือนว่าทุกการจับซ่อนต้นฉบับของมันและเลื่อนเลขหน้าที่สูงกว่าลงหนึ่ง
การเรียก stitch ทั้งสามใช้เรขาคณิตเดียวกัน: หน้าที่จับมาถูกวางเคียงข้าง ซ้อนกัน หรือวาดลงบนหน้าที่มีอยู่ และการจับแต่ละครั้งใช้แหล่งที่มาไป หมายเลขหน้าที่สูงกว่าทุกหน้าจึงเลื่อนลง

จะซ้อนทับหน้าหนึ่งลงบนอีกหน้าหนึ่งได้อย่างไร?

StitchPageOverlay(SourcePage, TargetPage, Left, Top, Width, Height, Opacity) จะวาดหน้าเอกสารที่มีอยู่หน้าหนึ่งลงบนอีกหน้าหนึ่ง แทนการสร้างหน้าใหม่ หน้าต้นทางจะถูกจับภาพเป็น Form XObject และวาดลงบนหน้าปลายทาง ณ สี่เหลี่ยมผืนผ้าที่คุณระบุ พร้อมกำหนดค่าความทึบแสง Opacity ตั้งแต่ 0 ถึง 1 (1 คือทึบแสง) ความกว้างและความสูงเป็นพารามิเตอร์อิสระ ดังนั้นภาพที่วางลงไปอาจยืดหดได้หากสัดส่วนของคุณแตกต่างจากอัตราส่วนภาพดั้งเดิมของหน้าต้นทาง ให้ส่งคู่พิกัดที่เป็นสัดส่วนกันหากคุณต้องการรูปภาพตัวอย่าง (thumbnail) ของต้นทางโดยไม่มีการบิดเบี้ยว การใช้งานทั่วไปคือเอกสารที่แผงข้อมูลการอนุมัติถูกแยกไว้เป็นหน้าของตนเองและต้องการปั๊มตราลงบนหน้าปก

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('report.pdf', '');
    // หน้า 5 เก็บแผงข้อมูลการอนุมัติ ให้วางลงบนหน้า 1
    // ในกล่องขนาด 260 x 180 ที่ความทึบแสง 45% หน้า 5 จะถูกใช้ไป:
    // มันถูกจับภาพและซ่อนไว้ ดังนั้นหน้า 6..n จะกลายเป็น 5..n-1
    if Lib.StitchPageOverlay(5, 1, 300, 500, 260, 180, 0.45) = 1 then
      Lib.SaveToFile('report-approved.pdf');
  finally
    Lib.Free;
  end;
end;

เกิดอะไรขึ้นกับหมายเลขหน้าหลังการจับภาพ?

CapturePage จะใช้หน้าต้นทางไปเลย: เนื้อหาของหน้ากระดาษจะถูกย้ายเข้าไปใน XObject และหน้าที่ว่างเปล่าในตอนนี้จะถูกซ่อนไว้ ดังนั้นทุกหน้าที่อยู่ถัดจากหน้าต้นทางจะเลื่อนขึ้นมาแทนที่ (หมายเลขลดลงหนึ่งหน้า) ทันที นี่คือความหมายที่สำคัญที่สุดใน stitching API ทั้งหมด losLab PDF Library จะจัดการการคำนวณภายในแต่ละการเรียกเย็บหน้า ฟังก์ชัน StitchPageOverlay จะปรับเปลี่ยนดัชนีปลายทางเมื่อปลายทางอยู่สูงกว่าต้นทาง และ StitchPagesSideBySide จะจับภาพหน้าที่ตัวเลขสูงกว่าก่อน เพื่อให้ดัชนีของการจับภาพที่สองยังคงถูกต้อง แต่โค้ดแวดล้อมของคุณต้องคำนึงถึงการเลื่อนหมายเลขหน้าระหว่างการเรียกด้วย กฎสองข้อที่จะรักษาความถูกต้องของโค้ดการเย็บหลายหน้าคือ: ให้บันทึกขนาดหน้าหรือหมายเลขหน้าใดๆ ที่คุณจำเป็นต้องใช้ ก่อน การจับภาพครั้งแรก เนื่องจากสิ่งเหล่านั้นจะเปลี่ยนไปในภายหลัง และเมื่อคุณจับหลายหน้าด้วยตัวเองในลูป ให้ทำงานแบบย้อนกลับ (เรียงจากเลขหน้ามากไปน้อย) เพื่อให้การซ่อนหน้าที่มีเลขมากกว่าจะไม่ส่งผลกระทบต่อดัชนีของหน้าที่น้อยกว่าซึ่งคุณยังไม่ได้จับภาพ

จะใช้ลายน้ำหลายชั้นกับช่วงของหน้าเอกสารได้อย่างไร?

เลเยอร์เทมเพลตลายน้ำใน PDFlibWatermark.pas จะแปลงการทำงานประเภท "นิยามครั้งเดียว นำไปใช้ได้กับหลายหน้า" ให้กลายเป็นอ็อบเจกต์ TPDFlibWatermarkBase จะมีข้อมูลที่ลายน้ำทุกอันต้องใช้ร่วมกัน ได้แก่ ตำแหน่ง 9 จุด Position (wpTopLeft ไปจนถึง wpBottomRight บวกกับ wpCustom ซึ่งพิกัด X/Y ดิบ), มุม Angle เป็นองศา, ความทึบแสง Opacity ตั้งแต่ 0 ถึง 1 และสตริง PageRange ที่ค่าว่างจะหมายถึงทุกหน้า TPDFlibTextWatermark จะระบุข้อความ, ID ฟอนต์ (ค่าเริ่มต้นคือ Helvetica), ขนาด และสี RGB; TPDFlibImageWatermark จะระบุ ID ของรูปภาพที่เพิ่มเข้าไปก่อนหน้านี้ รวมถึงความกว้างและความสูงในการวาง TPDFlibCompositeWatermark จะเก็บรายการของลายน้ำย่อยและนำไปใช้ในลักษณะซ้ำซ้อน (recursively) และ TPDFlib.ApplyWatermark เป็นจุดเข้าใช้งานในการเรียกเพียงครั้งเดียวที่สอดคล้องกับช่วงหน้าของเทมเพลตและส่งคืนจำนวนหน้าที่ถูกประทับตรา ตัวอย่างการประทับตราทางกฎหมายสามชั้นมีลักษณะดังนี้

var
  Lib: TPDFlib;
  Logo: TPDFlibImageWatermark;
  Diagonal, Footer: TPDFlibTextWatermark;
  Stack: TPDFlibCompositeWatermark;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('draft.pdf', '');
    Stack := TPDFlibCompositeWatermark.Create;
    try
      Logo := TPDFlibImageWatermark.Create;
      Logo.ImageID := Lib.AddImageFromFile('logo.png', 0);
      Logo.Position := wpTopRight;
      Logo.Width := 90;
      Logo.Height := 32;
      Stack.Add(Logo);

      Diagonal := TPDFlibTextWatermark.Create;
      Diagonal.Text := 'CONFIDENTIAL';
      Diagonal.Position := wpCenter;
      Diagonal.Angle := 45;
      Diagonal.FontSize := 60;
      Diagonal.Opacity := 0.15;
      Stack.Add(Diagonal);

      Footer := TPDFlibTextWatermark.Create;
      Footer.Text := 'Internal review copy';
      Footer.Position := wpBottomCenter;
      Footer.FontSize := 9;
      Footer.Opacity := 0.6;
      Stack.Add(Footer);

      Stack.PageRange := '1-3';   // ลายน้ำย่อยจะสืบทอดช่วงหน้านี้
      Lib.ApplyWatermark(Stack);
      Lib.SaveToFile('draft-marked.pdf');
    finally
      Stack.Free;                 // composite จะ free ลายน้ำย่อยของตัวเองไปด้วย
    end;
  finally
    Lib.Free;
  end;
end;

รายละเอียดการประกอบสองประการที่ควรทำความเข้าใจเพิ่มเติม ประการแรก การสืบทอดช่วงเป็นแบบทิศทางเดียวและไม่มีการลบข้อมูลทำลาย: วัตถุลูกที่มีค่า PageRange ว่างเปล่าจะรับสืบทอดช่วงของวัตถุแม่ที่เป็นคอมโพสิตชั่วคราวระหว่างการใช้งาน Apply ในขณะที่วัตถุลูกที่ตั้งค่าช่วงของตัวเองไว้จะรักษาค่าเดิมไว้ ดังนั้นคุณจึงสามารถตั้งค่าส่วนท้ายไว้ที่ทุกหน้าได้ ในขณะที่ประทับตราแนวทะแยงมุมเฉพาะบนส่วนของเอกสารร่างเท่านั้น ประการที่สอง ความเป็นเจ้าของจะเป็นไปตามคอมโพสิต: TPDFlibCompositeWatermark.Destroy จะล้างหน่วยความจำของอ็อบเจกต์ลูกทั้งหมด ดังนั้นคุณเพียงแค่ล้างหน่วยความจำของคอมโพสิตเพียงครั้งเดียวและไม่ต้องกังวลสิ่งอื่นใด ดังเช่นในตัวอย่างข้างต้น

เทมเพลตลายน้ำแบบผสมใน Delphi: TPDFlibWatermarkBase เก็บ anchor ตำแหน่งเก้าจุด มุม ความทึบ และช่วงหน้า, TPDFlibCompositeWatermark ซ้อนโลโก้รูปภาพ, ชั้นข้อความ CONFIDENTIAL ที่หมุน และชั้นข้อความท้ายหน้า, ApplyWatermark ประทับทั้งสามชั้นลงหน้า 1-3 และการคืนหน่วยความจำของคอมโพสิตจะคืนหน่วยความจำลูกทั้งหมดด้วย
composite เก็บเลเยอร์รูปภาพ เลเยอร์ข้อความทแยง และเลเยอร์ข้อความท้ายกระดาษไว้เหนือคุณสมบัติฐานที่ใช้ร่วมกัน เรียกใช้ทั้งหมดแบบ recursive บน PageRange ของมัน และปล่อยลูกทั้งหมดเมื่อถูกทำลาย

ขอบเขตที่ควรทราบ

มีข้อจำกัดที่จำเป็นต้องชี้แจงสองประการ ซึ่งทั้งสองประการเป็นผลมาจากหลักการทำงานของกลไกนี้ ประการแรก การจับภาพจะครอบคลุมเฉพาะสตรีมเนื้อหาและทรัพยากรของหน้าเท่านั้น คำอธิบายประกอบ, ลิงก์, ฟิลด์ฟอร์ม และข้อคิดเห็นจะถูกเก็บเป็นวัตถุแยกต่างหากในพจนานุกรมของหน้า ไม่ได้อยู่ในสตรีมเนื้อหา ดังนั้นพวกมันจึงไม่ได้ย้ายเข้าไปใน XObject ด้วย เลย์เอาต์ที่เย็บหน้าแล้วจะนำเนื้อหาของหน้าที่มองเห็นได้ไปด้วย แต่ไม่มีเลเยอร์แบบโต้ตอบ (interactive layer) ของต้นฉบับ ประการที่สอง การระบุตำแหน่งข้อความ 9 จุดเป็นการประมาณค่า: หากไม่มีเลย์เอาต์เอนจินแบบเต็มรูปแบบ ไลบรารีจะไม่สามารถทราบความกว้างที่แท้จริงของการเรนเดอร์สตริงได้ ดังนั้นจึงประมาณการกล่องข้อความไว้ที่ 40% ของความกว้างหน้าเมื่อต้องแปลตำแหน่งอย่างเช่น wpCenter วิธีนี้มีความถูกต้องเพียงพอสำหรับการจัดกึ่งกลางป้ายข้อความสั้นๆ แต่สำหรับการวางพิกัดที่แม่นยำในระดับพิกเซลของสตริงที่ยาว ให้สลับไปใช้ wpCustom และคำนวณพิกัดด้วยตัวคุณเอง

มาตราส่วนไม่ใช่ข้อจำกัดในเชิงปฏิบัติของที่นี่ เนื่องจากการจับภาพและการเย็บหน้าจะเป็นการดำเนินการกับจุดอ้างอิงอ็อบเจกต์ (object references) แทนที่จะเป็นการเข้ารหัสเนื้อหาของหน้าใหม่ หากแหล่งข้อมูลของคุณมีขนาดหลายร้อยเมกะไบต์ ให้ใช้ระเบียบปฏิบัติเดียวกันกับที่อธิบายไว้ในคู่มือการรวมและการแยกไฟล์ขนาดใหญ่ด้วยการเข้าถึงโดยตรง ก่อนที่คุณจะเย็บหน้า ฟังก์ชันเย็บหน้าและคลาสเทมเพลตลายน้ำที่แสดงไว้ที่นี่มีมาพร้อมกับ losLab PDF Library ปัจจุบันสำหรับ Delphi, C# และ VB.NET ควบคู่ไปกับคำสั่งระดับล่างเช่น CapturePage, DrawCapturedPage และ DrawCapturedPageMatrix เมื่อคุณต้องการเลย์เอาต์ที่การเรียกเย็บหน้าสำเร็จรูปทั้งสามวิธีไม่ครอบคลุม