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

รายงาน Delphi PDF ด้วย HotPDF: TextOut, ฟอนต์ และรูปภาพ

การสร้างรายงานท้ายที่สุดแล้วคือการวางสามสิ่งลงบนหน้ากระดาษแล้วทำให้มันตกลงกันได้ว่าจะอยู่ตรงไหน: ข้อความที่พิกัดที่รู้แน่ชัด ฟอนต์ที่แสดงผลเหมือนกันทั้งบนเซิร์ฟเวอร์และบนเดสก์ท็อปของคุณ และรูปภาพที่ปรับขนาดให้พอดี ทุกอย่างที่เหลือที่ไลบรารีรายงานทำล้วนจัดวางอยู่รอบสามสิ่งนี้ HotPDF ซึ่งเป็นไลบรารีสร้าง PDF ของ losLab สำหรับ Delphi และ C++Builder มอบแต่ละอย่างนี้ให้คุณเป็นการเรียกโดยตรงบน page object และแรงเสียดทานที่แท้จริงเพียงอย่างเดียวคือระบบพิกัดที่อยู่เบื้องล่าง ซึ่งวิ่งไปในทิศทางตรงข้ามกับ VCL canvas ที่คุณคุ้นเคย จัดการทำความเข้าใจทิศทางนั้นให้เรียบร้อยก่อน แล้วงานเลย์เอาต์ที่เหลือจะเลิกฝืนคุณ

การจัดวางข้อความและจุดกำเนิดที่มุมล่างซ้าย

รายงานฉบับแรกของแทบทุกคนออกมากลับหัว ชื่อเรื่องตกไปอยู่ใกล้ขอบล่าง และแต่ละบรรทัดที่อยู่ใต้มันก็ไต่ขึ้นไปทางด้านบน ไม่มีอะไรทำงานผิดพลาดเลย PDF user space ที่นิยามไว้ใน ISO 32000-1 §8.3 วางจุดกำเนิดไว้ที่มุมล่างซ้ายโดยแกน Y เพิ่มขึ้นไปทางบน ซึ่งเป็นภาพสะท้อนกลับของ GDI canvas ที่แกน Y เพิ่มลงมาจากมุมบนซ้าย ใช้เวลาห้านาทีทำความเข้าใจให้เข้ากันได้กับสิ่งนี้ จะช่วยประหยัดเลย์เอาต์ที่คุณจะต้องเขียนใหม่ตอนที่ตัวเลขเริ่มไม่สมเหตุสมผล

แผนภาพ HotPDF เปรียบเทียบจุดกำเนิดพิกัดซ้ายบนของ VCL กับจุดกำเนิดซ้ายล่างของ PDF ที่ TextOut วางหัวเรื่องห่างจากด้านบนของหน้า Letter 50 พอยต์ที่ Y เท่ากับ 792 ลบ 50
PDF user space สะท้อนกับแคนวาส VCL ชื่อเรื่องที่ห่างจากด้านบนของหน้า Letter 50pt จึงคือ TextOut(50, 792 - 50, 0, 'INVOICE') และการแปลงแบบเดียวกันนี้ทำให้พิกัดรายงานทุกจุดเข้าใจง่าย

ฟังก์ชันเรียกหลักของ page object คือ TextOut(X, Y, Angle, Text) X และ Y ระบุตำแหน่งข้อความเป็นหน่วยพอยต์จากมุมล่างซ้าย และ Angle หมุนมันเป็นองศา ซึ่งเป็นวิธีที่ตราประทับแบบเฉียง DRAFT หรือ COPY ถูกวาดขึ้นมาโดยไม่ต้องมีการรองรับพิเศษใด ๆ เทคนิคที่ช่วยให้สัญชาตญาณที่ฝึกมาจาก VCL ยังคงใช้ได้อยู่คือการแสดง Y เป็นความสูงของหน้าลบด้วยระยะที่คุณต้องการจากด้านบน:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice-0001.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [fsBold], 16);
    Pdf.CurrentPage.TextOut(50, 792 - 50, 0, 'INVOICE');       // 50pt จากด้านบนของกระดาษ Letter
    Pdf.CurrentPage.SetFont('Arial', [], 10);
    Pdf.CurrentPage.TextOut(50, 792 - 70, 0, 'Date: 2026-06-11');
    Pdf.CurrentPage.TextOut(300, 400, 45, 'COPY');              // ตราประทับแบบหมุน
    Pdf.AddPage;                                                // ตอนนี้ CurrentPage ชี้มาที่นี่
    Pdf.CurrentPage.SetFont('Arial', [], 10);                   // สถานะฟอนต์ไม่ถูกส่งต่อมาด้วย
    Pdf.CurrentPage.TextOut(50, 742, 0, 'Page 2 detail rows');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

พฤติกรรมที่มีสถานะสองอย่างในโค้ดนี้เป็นสาเหตุของบั๊กส่วนใหญ่ที่ปรากฏขึ้นเฉพาะในหน้าที่สองเท่านั้น AddPage จะชี้ CurrentPage ไปยังหน้าที่มันเพิ่งสร้างขึ้นมาใหม่ ดังนั้น page reference ที่คุณแคชไว้ก่อนหน้านี้จะไม่วาดในตำแหน่งที่คุณคาดหวังอีกต่อไป การเลือกฟอนต์ก็เป็นแบบต่อหน้า ไม่ใช่ต่อเอกสารเช่นกัน ถ้าคุณข้าม SetFont หลัง AddPage ไป TextOut แรกบนหน้าใหม่จะย้อนกลับไปใช้ค่าเริ่มต้นอะไรก็ตามที่หน้านั้นเริ่มต้นไว้ ไม่ใช่ฟอนต์หัวข้อตัวหนาที่คุณตั้งไว้เมื่อสามหน้าก่อน นิสัยที่ปลอดภัยคือปฏิบัติต่อ "เริ่มหน้าใหม่" และ "ตั้งค่าสถานะข้อความใหม่" เป็นขั้นตอนเดียวที่แยกออกจากกันไม่ได้ใน loop ของรายงาน

ฟอนต์ที่มีอยู่บนเซิร์ฟเวอร์ ไม่ใช่แค่บนเดสก์ท็อปของคุณ

ปัญหาฟอนต์ส่วนใหญ่ที่จริงแล้วคือปัญหาการ deploy ที่แค่ปลอมตัวมา เครื่อง development ของคุณติดตั้งฟอนต์ของบริษัทไว้แล้ว ดังนั้นรายงานจึงดูถูกต้องบนหน้าจอของคุณและถูกส่งออกไป โฮสต์ production รันงานภายใต้ service account ที่ไม่เคยติดตั้งฟอนต์นั้นเลย ตัว renderer จะเงียบ ๆ แทนที่ด้วยอะไรก็ตามที่มันหาเจอ และสิ่งแรกที่ใครสักคนจะได้ยินเกี่ยวกับเรื่องนี้คือลูกค้าถามว่าทำไม letterhead ถึงเปลี่ยนไป ทางออกคือเลิกเชื่อใจไดเรกทอรีฟอนต์ของ OS แล้วโหลดฟอนต์จากไฟล์ที่ตัวติดตั้งของคุณวางไว้บนดิสก์แทน ฟังก์ชันลงทะเบียน Unicode ของ HotPDF รับ path เข้ามาและทำสิ่งนั้นได้อย่างตรงจุด:

แผนภาพปัญหาการปรับใช้ฟอนต์ PDF ใน Delphi: เซิร์ฟเวอร์ production แทนฟอนต์ที่หายไปอย่างเงียบๆ ขณะที่ RegisterUnicodeTTF โหลด TTF จากไฟล์ที่ deploy ไว้แล้วฝังลงใน PDF
การพึ่งพาไดเรกทอรีฟอนต์ของ OS พังเมื่อบัญชีเซอร์วิสของระบบผลิตไม่มีฟอนต์นั้น ขณะที่การโหลด TTF จากไฟล์ที่ deploy ฝังกลิฟลงไป ทุกโฮสต์จึงเรนเดอร์ได้เหมือนกันหมด
Pdf.RegisterUnicodeTTF('C:\ProgramData\MyApp\Fonts\NotoSans.ttf');
Pdf.CurrentPage.SetFont('NotoSans', [], 12);
Pdf.CurrentPage.TextOut(50, 700, 0, WideString('Łódź - Ünïcode test ✓'));

TextOut รับ WideString เข้ามาโดยตรง ซึ่งสำคัญกว่าที่เห็นตอนแรกมาก ชื่อลูกค้าที่มีเครื่องหมายเน้นเสียง ถนนในเยอรมนี เมืองในโปแลนด์: สิ่งเหล่านี้ไม่ใช่กรณีขอบ ๆ แต่เป็นเนื้อหาปกติของตารางลูกค้า และมันผ่านฟังก์ชันเรียกเดียวกันกับป้ายกำกับ ASCII ที่คุณ hard-code ไว้ ตราบใดที่ฟอนต์ที่ลงทะเบียนไว้มี glyph นั้นจริง ๆ มีข้อจำกัดด้านเวอร์ชันหนึ่งที่มาพร้อมกับฟอนต์ที่ฝังไว้: เอกสารต้องเป็น PDF 1.5 หรือใหม่กว่า ดังนั้นถ้ามีข้อกำหนดอื่นที่ไม่เกี่ยวข้องกันตรึงคุณไว้กับเวอร์ชันเก่ากว่านั้น นั่นคือสิ่งที่จะพังอย่างเงียบ ๆ สคริปต์แบบขวาไปซ้าย เช่น อาหรับและฮีบรู ต้องการการจัดรูปทรงตัวอักษรที่แท้จริง ไม่ใช่แค่การค้นหา glyph แบบตรงไปตรงมา และมันมี pipeline เป็นของตัวเอง ดูบทความของเราเรื่องการจัดรูปทรงข้อความสคริปต์ซับซ้อนด้วย HotPDF

เมื่อไม่มีฟอนต์ที่ติดตั้งไว้ตัวไหนแสดงสิ่งที่คุณต้องการได้ ลองนึกถึงอักขระ MICR บนเช็ค หรือชุดสัญลักษณ์เฉพาะของบริษัท Type 3 font จะมาเติมเต็มช่องว่างนั้น คุณนิยาม glyph แต่ละตัวเป็น content stream ขนาดเล็กผ่าน RegisterType3Font และ AddType3Glyph มันเป็นมุมเฉพาะทางของ API และคุณจะแทบไม่ได้เอื้อมไปใช้มันเลย แต่มันสะอาดกว่าการกระจาย bitmap สัญลักษณ์เล็ก ๆ นับร้อยไปทั่วหน้ากระดาษมาก

รูปภาพ: อาร์กิวเมนต์ตรงกลางคือความกว้างและความสูง ไม่ใช่มุม

การจัดการรูปภาพแบ่งออกเป็นสองขั้นตอน และการแยกมันไว้ต่างหากคือประเด็นสำคัญทั้งหมด AddImage รับ TBitmap หรือ TJPEGImage ฝังมันไว้ครั้งเดียว แล้วคืนค่า index กลับมา งานศิลป์แบบ PNG ต้องถูกถอดรหัสเป็น bitmap ก่อนที่จะมาถึงตรงนี้ จากนั้น ShowImage จะวาด index นั้นตรงไหนก็ได้และบ่อยแค่ไหนก็ได้ตามที่คุณต้องการ ลำดับอาร์กิวเมนต์ของ ShowImage เป็นจุดหนึ่งที่คุ้มค่าจะอ่านให้ช้าลงหน่อย:

แผนภาพไปป์ไลน์รูปภาพของ HotPDF ที่ AddImage ฝังบิตแมปหนึ่งครั้งและคืนดัชนี, ShowImage วางด้วยความกว้างกับความสูง และลำดับอาร์กิวเมนต์ไม่ใช่คู่มุมสี่เหลี่ยม
AddImage ฝังพิกเซลครั้งเดียวและการเรียก ShowImage ทุกครั้งใช้ดัชนีนั้นซ้ำ และอาร์กิวเมนต์กลางเป็นค่า width กับ height ไม่ใช่พิกัดของมุมตรงข้าม
var
  Png: TPngImage;
  Logo: TBitmap;
  LogoIdx: Integer;
begin
  Png := TPngImage.Create;
  Logo := TBitmap.Create;
  try
    Png.LoadFromFile('brand-logo.png');
    Logo.Assign(Png);                       // ถอดรหัส PNG เป็น bitmap
    LogoIdx := Pdf.AddImage(Logo, icFlate); // ไม่สูญเสียคุณภาพ เหมาะกับงานศิลป์สีเรียบ
  finally
    Logo.Free;
    Png.Free;
  end;
  // (Index, X, Y, Width, Height, Angle): ไม่ใช่ (X1, Y1, X2, Y2)
  Pdf.CurrentPage.ShowImage(LogoIdx, 50, 700, 120, 40, 0);
end;

ตัวเลขสองตัวหลังตำแหน่งคือความกว้างและความสูง ไม่ใช่พิกัดของมุมตรงข้าม และอาร์กิวเมนต์ตัวสุดท้ายคือมุมหมุนเป็นองศา ถ้าอ่าน signature นี้ราวกับเป็นกล่องแบบ X1/Y1/X2/Y2 โลโก้ขนาด 120 คูณ 40 ที่วางไว้ที่ (50, 700) จะยืดจากตรงนั้นไปถึง (120, 40) แทน แผ่กระจายไปทั่วเกือบทั้งหน้า output จะทำให้ข้อผิดพลาดนั้นชัดเจนมาก ในขณะที่ source code ดูสมเหตุสมผลไปหมด นั่นคือสิ่งที่ทำให้มันเสียเวลาไปทั้งบ่าย KeepImageAspectRatio มีค่าเริ่มต้นเป็น True ดังนั้นกล่องที่มีสัดส่วนผิดจะทำให้รูปภาพมีขอบดำแบบ letterbox แทนที่จะบิดเบี้ยว ให้เปลี่ยนเป็น False เฉพาะเมื่อคุณตั้งใจจะยืดมันจริง ๆ เท่านั้น

การแยกระหว่างการลงทะเบียนกับการวางตำแหน่งจะคุ้มค่ามากในงานยาว ๆ เพราะ AddImage ฝังพิกเซลไว้ครั้งเดียว และทุก ShowImage ที่ใช้ index นั้นจะชี้กลับไปยัง object ที่ฝังไว้ตัวเดียวกัน ตำแหน่งที่คุณเรียก AddImage จึงเป็นตัวกำหนดขนาดไฟล์ ถ้าเรียกมันไว้ข้างในลูปของหน้าสำหรับ statement 500 หน้า โลโก้ตัวเดียวกันจะถูกฝังไว้ถึง 500 ครั้ง แต่ถ้าเรียกมันครั้งเดียวก่อนลูป แล้วเก็บ index ไว้ โลโก้จะถูกจัดเก็บไว้เพียงครั้งเดียวเท่านั้น dictionary เล็ก ๆ ที่ใช้ path ของ asset เป็นคีย์ก็เพียงพอที่จะรับประกันว่ารูปภาพที่แตกต่างกันแต่ละรูปจะถูกลงทะเบียนแค่ครั้งเดียวพอดี

การเลือก codec คืออีกตัวควบคุมขนาดหนึ่ง เนื้อหาที่เป็นภาพถ่าย ไฟล์แนบที่สแกนมาและสิ่งที่คล้ายกัน ควรอยู่ในรูปแบบ JPEG: ส่ง icJpeg ให้ AddImage และลด JpegQuality ลงมาเหลือประมาณ 85 เพราะ property นี้เริ่มต้นที่ 100 และความแตกต่างที่ 85 นั้นมองไม่เห็นเลยบนหน้ากระดาษที่พิมพ์ออกมา งานศิลป์สีเรียบ เช่น โลโก้ แผนภูมิ และเส้นวาด ควรอยู่ใน icFlate ที่การบีบอัดแบบไม่สูญเสียคุณภาพก็กระชับอยู่แล้ว ในขณะที่ JPEG จะทำให้เกิด ringing ที่มองเห็นได้รอบขอบคมชัด งาน statement ที่ดันรูปถ่ายคุณภาพเต็มไว้ทุกหน้าอาจพองตัวขึ้นเป็นหลายกิกะไบต์ได้ เนื้อหาแบบเดียวกันที่ JPEG 85 จะเหลือขนาดประมาณหนึ่งในสิบ และไม่มีผู้อ่านคนไหนบอกความแตกต่างได้เลย

เส้นบรรทัด กล่อง และการแรเงาด้วย path primitive

เส้นแนวนอนใต้หัวตารางและกล่องสีเทาข้างหลังตัวเลขยอดรวมไม่จำเป็นต้องเป็นรูปภาพ วาดมันเป็นเวกเตอร์แล้วมันจะยังคมชัดที่ทุกระดับการซูม พิมพ์ออกมาคมชัด และแทบไม่เพิ่มขนาดไฟล์เลย HotPDF ทำตามโมเดลเดียวกับที่ raw PDF content stream ใช้: สร้าง path ขึ้นมา แล้วเรียก operator ที่วาดมัน

// เส้นแนวนอนใต้หัวตาราง
Pdf.CurrentPage.SetLineWidth(0.75);
Pdf.CurrentPage.MoveTo(50, 660);
Pdf.CurrentPage.LineTo(545, 660);
Pdf.CurrentPage.Stroke;

// กล่องยอดรวมแบบแรเงา: X, Y, width, height
Pdf.CurrentPage.SetRGBFillColor(RGB(235, 235, 235));
Pdf.CurrentPage.Rectangle(395, 120, 150, 40);
Pdf.CurrentPage.Fill;

ลำดับนี้ไม่ใช่เรื่องเลือกได้: ตั้งสถานะการวาดสี สร้าง path แล้วจึงเรียก Stroke หรือ Fill path ที่คุณสร้างขึ้นแต่ไม่เคยวาดจะไม่ส่งผลอะไรกับหน้ากระดาษเลย ซึ่งแทบจะเป็นคำตอบเสมอเมื่อเส้นบรรทัด "ไม่ปรากฏขึ้นมา" SetRGBFillColor รับ TColor ตัวเดียว ดังนั้นค่าคงที่ VCL ที่คุ้นเคยอย่าง clNavy และ clBlack จึงใส่เข้าไปได้ตรง ๆ และ Rectangle ใช้อาร์กิวเมนต์ความกว้างและความสูงแบบเดียวกับการวางรูปภาพ แทนที่จะเป็นสองมุม มีข้อควรระวังหนึ่งเกี่ยวกับเส้นบาง ๆ: อะไรก็ตามที่บางกว่าประมาณครึ่งพอยต์อาจดูสวยงามบนจอมอนิเตอร์ แล้วหายไปเลยบนเครื่องพิมพ์สำนักงานความละเอียด 600 dpi ดังนั้น 0.75pt จึงเป็นเกณฑ์ขั้นต่ำที่สมเหตุสมผลสำหรับเส้นบรรทัดใด ๆ ที่ต้องรอดผ่านการพิมพ์ออกมาได้

การแบ่งหน้าด้วยข้อมูลจริง ไม่ใช่ข้อมูลตัวอย่าง

มีรายละเอียดหนึ่งที่ต้องทำให้ถูกต้องก่อนที่เลย์เอาต์จะลงตัว: คอลัมน์ตัวเลขควรจัดชิดขอบขวา และวิธีทำคือวัดความกว้างที่แสดงผลจริงของแต่ละค่าแล้วจัดตำแหน่งย้อนกลับจากขอบคอลัมน์ ไม่ใช่เติมช่องว่างนำหน้าเข้าไปในสตริง การเติมช่องว่างจะเรียงตรงกันได้ก็ต่อเมื่อใช้ฟอนต์แบบ monospace เท่านั้น และไม่มีใครตั้งรายงานการเงินด้วยฟอนต์แบบ monospace ให้รันค่าผ่านรูทีนที่รู้จัก locale ของ Delphi เช่น FormatFloat ก่อน เพื่อให้ตัวคั่นหลักพันที่คุณวัดความกว้างเป็นตัวเดียวกับที่ locale ของลูกค้าจะแสดงผลจริง

อันตรายของการแบ่งหน้าคือคุณมักเขียนมันขึ้นมาโดยเทียบกับชุดข้อมูลตัวอย่าง ที่มีแถวสั้น ๆ สิบแถวพอดีกับหนึ่งหน้า และลูปไม่เคยต้องแบ่งเลย แต่การใช้งานจริงจะส่งลูกค้าที่มีชื่อบริษัทยาว 140 ตัวอักษรและ statement ที่มี 4,000 รายการมาให้ ทีนี้ลูปก็ต้องแบ่งให้ถูกต้องทุกครั้งไป รูปแบบที่ใช้ได้ผลดีคือ Y cursor ตัวเดียวที่เลื่อนลงมาเรื่อย ๆ ขณะที่คุณลบความสูงของแต่ละแถวออก และการตรวจสอบที่จะเริ่มหน้าใหม่ทันทีที่ cursor จะข้ามขอบล่าง คำว่าเลื่อนลงในที่นี้หมายถึงค่า Y ลดลง ซึ่งเป็นจุดเดียวที่จุดกำเนิดมุมล่างซ้ายยังคงขัดกับสัญชาตญาณ เก็บทั้งหมดนี้ไว้ในรูทีนเดียวที่เรียก SetFont ใหม่ด้วยและวาด header ที่วิ่งซ้ำบนหน้าใหม่ด้วย แล้วบั๊กแบบเลื่อนหน้าผิดไปหนึ่งหน้าจะไม่มีวันเกิดขึ้นได้เลย เมื่อรายงานชุดเดียวกันนี้ยังต้องเป็นไปตามกฎด้านการเก็บถาวรหรือการเข้าถึงได้ด้วย ทางเลือกที่คุณตัดสินใจตรงนี้ ฟอนต์ไหนที่คุณฝังไว้ output ถูกแท็กไว้หรือไม่ color space ไหนที่คุณใช้ ล้วนเป็นสิ่งที่มาตรฐานเหล่านั้นตรวจสอบ คู่มือ PDF/A, PDF/X และ PDF/UA ของ HotPDF จึงคุ้มค่าที่จะอ่านก่อนที่ template จะลงตัวแน่นหนา

ทุกฟังก์ชันเรียกที่แสดงในบทความนี้ ทั้งการวางตำแหน่งข้อความ การลงทะเบียนฟอนต์ การฝังรูปภาพ และการวาด path มาพร้อมกับHotPDF Delphi Componentสำหรับ Delphi และ C++Builder ซึ่งเอกสารอ้างอิงครอบคลุม output API ฉบับเต็ม พร้อมกับฟีเจอร์ด้าน form, encryption และ signing ที่อยู่เคียงข้างกัน