คำสั่งที่ใช้วางข้อความบนหน้า PDF นั้นตรงไปตรงมา คุณส่งสตริง ฟอนต์ ขนาด และตำแหน่งให้กับ AddText แล้วตัวอักขระก็จะปรากฏขึ้น แต่สิ่งที่มันไม่ได้ทำคือการบอกคุณว่าสตริงนั้นจะมีความกว้างเท่าใดเมื่อวาดเสร็จแล้ว และมันไม่ได้ตัดสตริงยาวๆ ออกเป็นหลายบรรทัด การเรียกหนึ่งครั้งจะวาดข้อความหนึ่งชุดที่ตำแหน่งหนึ่ง หากข้อความนั้นกว้างกว่าคอลัมน์ที่คุณต้องการให้พอดี มันก็จะเลยขอบออกไป และจะไม่มีสิ่งใดในคำสั่งวาดที่เตือนคุณ เมื่อคุณต้องการสร้างย่อหน้าแทนที่จะเป็นเพียงป้ายกำกับเดียว สิ่งที่ขาดหายไปคือความกว้างของสตริงในฟอนต์และขนาดที่เลือก ซึ่งจะต้องวัดก่อนที่คุณจะเขียนลงบนหน้ากระดาษ
นี่คือปัญหาคลาสสิกของการจัดวาง ในการตัดคำของย่อหน้าลงในคอลัมน์ คุณต้องรู้แบบคำต่อคำว่าแต่ละบรรทัดที่เป็นไปได้จะใช้พื้นที่แนวนอนเท่าใด และคุณต้องรู้ล่วงหน้าก่อนที่จะวาดสิ่งใดลงไป การตัดคำคือลูปของการวัดขนาดที่ครอบคลุมคำสั่งวาด และ binding ที่วาดได้อย่างเดียวจะให้เพียงแค่ส่วนที่สองเท่านั้น การรองรับการวัดขนาดข้อความในคอมโพเนนต์ PDFium ช่วยปิดช่องโหว่นั้นด้วยสองฟังก์ชัน ได้แก่ MeasureText และ MeasureTextWidth ซึ่งจะรายงานขนาดพื้นที่การเรนเดอร์ของสตริงโดยไม่ต้องสร้างร่องรอยใดๆ บนหน้ากระดาษ
ทำไมการวัดขนาดถึงเป็น class helper ไม่ใช่เมธอดใหม่ของ TPdf
การรองรับการวัดขนาดมาในรูปแบบของ Delphi class helper สำหรับ TPdf ซึ่งอยู่ในยูนิตของตัวเอง แทนที่จะเป็นเมธอดใหม่ที่ถูกยึดติดเข้าไปในคลาส TPdf Class helper เป็นฟีเจอร์ของภาษาที่ช่วยให้คุณสามารถแนบเมธอดเข้ากับชนิดข้อมูลที่มีอยู่จากภายนอกการประกาศ เมื่อยูนิตนั้นอยู่ในขอบเขต (scope) เมธอดใหม่จะถูกเรียกใช้งานเหมือนกับว่ามันเป็นส่วนหนึ่งของคลาส ดังนั้น helper เมธอดจึงถูกอ่านในรูปแบบ Pdf.MeasureTextWidth(...) โดยไม่ต้องสร้างหรือส่งผ่านอ็อบเจ็กต์แยกต่างหาก
เหตุผลในการจัดเลเยอร์แบบนี้คือการแยกส่วน ประเภทหลักของ TPdf ยังคงเหมือนเดิม โดยไม่มีการเพิ่มฟิลด์หรือแก้ไขโครงสร้าง (signature) ใดๆ ที่มีอยู่ ดังนั้นโปรเจกต์ที่ไม่เคยต้องการการจัดวางหน้าจะไม่ต้องนำเอาโค้ดส่วนการวัดขนาดไปใช้งาน โปรเจกต์ที่ต้องการใช้งานเพียงแค่เพิ่มหนึ่งยูนิตลงในส่วน uses และเมธอดต่างๆ ก็จะพร้อมใช้งาน ความสามารถนี้กลายเป็นตัวเลือกแบบ opt-in ในระดับของแต่ละยูนิต ซึ่งเป็นวิธีที่สะอาดที่สุดในการขยายชนิดข้อมูลที่คุณไม่ได้เป็นเจ้าของหรือไม่ต้องการรบกวน
uses
PDFium, FPdfView, FPdfEdit,
FPdfMeasure; // the helper unit; brings MeasureText into scope on TPdf
// With the unit in scope the methods read as members of TPdf:
var
W, H: Double;
begin
Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
// W and H are now the rendered width and height in PDF user units
end;
การวัดขนาดโดยไม่ต้องแตะหน้ากระดาษ
การวัดขนาดต้องไม่มีผลข้างเคียงใดๆ มันต้องรายงานความกว้างโดยไม่ทิ้งสิ่งใดไว้เบื้องหลัง เพราะคุณต้องเรียกใช้งานหลายครั้งในขณะที่ตัดสินใจเลือกรูปแบบการจัดวาง และหน้ากระดาษต้องดูเหมือนเดิมทุกประการราวกับว่าคุณไม่เคยวัดขนาดเลย เทคนิคที่ทำให้สิ่งนี้เป็นไปได้คือการสร้างอ็อบเจ็กต์ข้อความขึ้นมา สอบถามขนาดของมัน แล้วโยนมันทิ้งไปก่อนที่มันจะถูกนำไปวางบนหน้ากระดาษ
ลำดับขั้นตอนประกอบด้วยการเรียกใช้ PDFium 4 ครั้ง FPDFPageObj_NewTextObj สร้างอ็อบเจ็กต์ข้อความสำหรับเอกสาร โดยระบุชื่อฟอนต์และขนาด FPDFText_SetText ตั้งค่าสตริงที่อ็อบเจ็กต์นั้นเก็บไว้ FPDFPageObj_GetBounds อ่านค่า bounding box (กรอบล้อมรอบ) ของอ็อบเจ็กต์กลับมา FPDFPageObj_Destroy ทำการคืนหน่วยความจำของอ็อบเจ็กต์ สิ่งสำคัญคือไม่มีสิ่งใดในลำดับขั้นตอนนี้ที่เรียกใช้ API สำหรับการแทรกหน้ากระดาษ อ็อบเจ็กต์ถูกสร้างขึ้น ถูกตรวจสอบ และถูกทำลายอย่างโดดเดี่ยว ดังนั้นเอกสารจะไม่เปลี่ยนแปลงเมื่อฟังก์ชันส่งค่ากลับ มันเป็นเครื่องมือตรวจสอบแบบใช้แล้วทิ้ง ซึ่งมีผลลัพธ์เพียงอย่างเดียวคือตัวเลขสี่ตัวของ bounding box
นี่เป็นวิธีที่แข็งแกร่งในการทำงาน เนื่องจาก PDFium ไม่ได้เปิดเผยความกว้างของแต่ละตัวอักขระให้คุณนำไปรวมกันเองได้อย่างสะดวก ตัววัดระยะของอักขระจะขึ้นอยู่กับโปรแกรมฟอนต์ การเข้ารหัส และวิธีที่ PDFium โหลดรูปแบบตัวอักษร และไม่มีการเรียกใช้แบบสาธารณะใดๆ ที่จะให้ข้อมูลความกว้างของตัวอักษรแต่ละตัวในสตริง ในทางกลับกัน bounding box ของอ็อบเจ็กต์ข้อความจริงจะถูกคำนวณโดยกลไกเดียวกับที่จะใช้จัดวางอักขระเพื่อการวาด ดังนั้นมันจึงสะท้อนขนาดพื้นที่การเรนเดอร์ที่แท้จริงแทนที่จะเป็นการประมาณการ การสร้างอ็อบเจ็กต์แบบใช้ครั้งเดียวทิ้งและอ่านค่าขอบเขตของมันเป็นการวัดขนาดที่น่าเชื่อถือที่สุดที่ไลบรารีจะให้ได้
// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
FontSize: Single; out Width, Height: Double);
var
TextObject: FPDF_PAGEOBJECT;
L, B, R, T: Single;
begin
Width := 0;
Height := 0;
if Self.Document = nil then
Exit;
TextObject := FPDFPageObj_NewTextObj(Self.Document,
FPDF_BYTESTRING(AnsiString(Font)), FontSize);
if TextObject = nil then
Exit;
try
if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
Exit;
if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
begin
Width := R - L;
Height := T - B;
end;
finally
FPDFPageObj_Destroy(TextObject); // probe discarded, page untouched
end;
end;
พิกัดและหน่วยของผลลัพธ์
bounding box จะถูกส่งกลับมาเป็นขอบทั้งสี่ด้าน ได้แก่ ซ้าย, ล่าง, ขวา และ บน และขนาดทั้งสองมิติจะถูกคำนวณโดยการลบ ความกว้างคือขวาลบซ้าย และความสูงคือบนลบล่าง ทั้งสองค่าจะแสดงในหน่วยผู้ใช้ PDF (PDF user units) ซึ่งหนึ่งหน่วยเท่ากับเศษหนึ่งส่วนเจ็ดสิบสองนิ้ว ซึ่งเป็นพื้นที่พิกัดเดียวกับที่คุณใช้กำหนดตำแหน่งข้อความบนหน้ากระดาษ ไม่มีหน่วยอุปกรณ์ที่ซ่อนอยู่และไม่มีพิกเซลเข้ามาเกี่ยวข้องในขั้นตอนนี้ ความกว้าง 36 หมายถึงครึ่งนิ้วของหน้ากระดาษ ไม่ว่าความละเอียดในการเรนเดอร์ขั้นสุดท้ายจะเป็นเท่าใดก็ตาม
แกนตั้งทำงานในแบบที่ PDF กำหนด โดยที่ค่า Y จะเพิ่มขึ้นเมื่อเลื่อนขึ้นด้านบน นี่คือเหตุผลที่ว่าทำไมความสูงจึงเป็นบนลบล่างแทนที่จะกลับกัน รายละเอียดนั้นมีความสำคัญเมื่อคุณเลื่อนเคอร์เซอร์ลงมาตามคอลัมน์ คุณทำการวัดความสูงของบรรทัด จากนั้นนำไปลบออกจากเส้นฐาน (baseline) ปัจจุบันเพื่อหาตำแหน่งของบรรทัดถัดไป เนื่องจากความหมายของการเลื่อนลงหน้ากระดาษคือการเลื่อนเข้าหาค่า Y ที่น้อยลง หากปลายทางของคุณคือหน้าจอแทนที่จะเป็นกระดาษ คุณสามารถแปลงหน่วยผู้ใช้เป็นพิกเซลของอุปกรณ์ได้ด้วยความละเอียดของจอแสดงผล: ค่าในหน่วยผู้ใช้คูณด้วย DPI และหารด้วย 72 จะให้ผลลัพธ์เป็นพิกเซล ดังนั้นความกว้างของคอลัมน์ที่คุณกำหนดเป็นจุดจะสามารถนำมาเทียบกับข้อความที่วัดได้ ก่อนที่คุณจะตัดสินใจว่าจะทำการตัดคำตรงไหน
เกิดอะไรขึ้นเมื่อข้อมูลอินพุตไม่สมบูรณ์
ฟังก์ชันต่างๆ ถูกเขียนขึ้นให้ล้มเหลวแบบเงียบๆ หากไม่มีเอกสารที่เปิดอยู่ หรือหากไม่สามารถสร้างอ็อบเจ็กต์ข้อความได้ ผลลัพธ์จะเป็นศูนย์แทนที่จะแสดงข้อผิดพลาด (exception) ความกว้างและความสูงจะถูกกำหนดค่าเริ่มต้นให้เป็นศูนย์ที่ด้านบน และจะถูกเขียนทับก็ต่อเมื่อสามารถอ่าน bounding box กลับมาได้สำเร็จเท่านั้น สตริงว่าง เอกสารที่หายไป หรือฟอนต์ที่ไลบรารีไม่สามารถแปลงเป็นอ็อบเจ็กต์ได้ สิ่งเหล่านี้แต่ละอย่างจะส่งคืนค่าศูนย์แทนที่จะแสดงข้อผิดพลาด
ตัวเลือกนี้ช่วยให้ลูปของการวัดขนาดเรียบง่าย เพราะลูปที่รันข้อมูลคำนับพันๆ คำไม่ใช่สถานที่สำหรับการจัดการข้อผิดพลาดในทุกๆ รอบการทำงาน สิ่งที่ต้องแลกมาก็คือผู้เรียกใช้งานจะต้องเป็นผู้รับหน้าที่ตรวจสอบเอง ความกว้างที่เป็นศูนย์คือค่าที่ใช้เพื่อการส่งสัญญาณเตือน (sentinel) ไม่ใช่ข้อเท็จจริงเกี่ยวกับข้อความ ดังนั้นโค้ดที่ทำการหารด้วยความกว้างที่วัดได้ หรืออนุมานว่าเป็นค่าบวก จะต้องป้องกันไม่ให้เป็นศูนย์ก่อนที่จะเชื่อถือค่าเหล่านั้น ถือว่าค่าศูนย์หมายถึง "ไม่สามารถวัดได้" และข้อตกลงก็จะชัดเจน หากละเลยมันไป อินพุตที่ไม่สมบูรณ์ก็จะกลายเป็นรูปแบบที่มีการวางตัวอักขระซ้อนทับกันไปอย่างเงียบๆ
การสร้างการตัดคำแบบตะกละ (greedy word wrap) ที่อิงตามการวัดขนาด
เมื่อมีฟังก์ชันความกว้างอยู่ในมือ การตัดคำก็จะเป็นลูปแบบตะกละสั้นๆ คุณแยกย่อหน้าออกเป็นคำๆ เก็บข้อมูลบรรทัดปัจจุบันไว้ และสำหรับแต่ละคำ คุณจะต้องวัดขนาดว่าบรรทัดนั้นจะเป็นอย่างไรหากคุณนำคำนั้นไปต่อท้าย ตราบใดที่บรรทัดทดลองยังคงอยู่ในกรอบความกว้างของคอลัมน์ คุณก็ยังคงเพิ่มต่อไปเรื่อยๆ เมื่อมันล้น คุณก็นำบรรทัดปัจจุบันไปแสดงด้วย AddText และเริ่มบรรทัดใหม่ด้วยคำที่ไม่สามารถใส่ลงไปได้ การสะสมค่าทั้งหมดนี้ทำได้โดยใช้ MeasureTextWidth และสิ่งเดียวที่จะไปถึงหน้ากระดาษคือบรรทัดที่คุณยืนยันแล้วว่าสามารถใส่ได้พอดี
procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
Words: TArray<string>;
Line, Trial: WideString;
I: Integer;
Y: Double;
begin
Words := string(Para).Split([' ']);
Line := '';
Y := TopY;
for I := 0 to High(Words) do
begin
if Line = '' then
Trial := Words[I]
else
Trial := Line + ' ' + Words[I];
// Measure the candidate line before drawing anything.
if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
begin
Pdf.AddText(Line, Font, FontSize, X, Y); // flush the line that fit
Y := Y - LineHeight; // Y decreases going down
Line := Words[I]; // overflowing word starts next line
end
else
Line := Trial;
end;
if Line <> '' then
Pdf.AddText(Line, Font, FontSize, X, Y); // flush the final line
end;
ลูปจะทำการวัดขนาดของบรรทัดทดลองแทนที่จะวัดแต่ละคำแล้วนำมารวมกัน เพราะความกว้างของบรรทัดไม่ใช่ผลรวมความกว้างของคำในบรรทัดนั้น ช่องว่างระหว่างคำก็มีส่วนด้วย และการวัดขนาดทั้งหมดในรวดเดียวจะเก็บค่าสิ่งนี้ได้โดยตรง กฎของความตะกละ คือการใส่คำให้ได้มากที่สุดเท่าที่คอลัมน์จะอนุญาตและตัดคำที่คำสุดท้ายที่สามารถใส่ได้ กฎเดียวกันนี้ช่วยเติมเต็มช่องว่างระหว่างการเรียกใช้ AddText แบบดิบๆ กับย่อหน้าจริง การเรียกคำสั่งวาดนั้นไม่ใช่เรื่องยาก แต่การวัดขนาดที่ต้องทำก่อนหน้านั้นต่างหากที่ยาก และนั่นก็คือสิ่งที่ตัวช่วยนี้มีให้
จะนำไปใช้ในส่วนไหน
Measurement is the layer between generating content and rendering it, so it pairs naturally with the rest of a from-scratch document workflow. If you are assembling pages and placing text in the first place, the groundwork is in creating PDF documents from scratch with the PDFium component in Delphi, where AddText and page setup are covered in full. When the font you are measuring matters as much as the string, because metrics depend on the face, analyzing PDF font properties with the PDFium component in Delphi shows how the library reports the font information that drives those bounding boxes. Both build on the same binding, the PDFium Component for Delphi and Lazarus, where the measurement helper ships alongside the document, page, and text APIs described across this blog