Technical Article

การวัดข้อความ PDF สำหรับเค้าโครงและการตัดคำใน Delphi

การเรียกใช้งาน (call) ที่ใช้ใส่ข้อความลงบนหน้า PDF นั้นตรงไปตรงมา คุณส่งมอบสตริง ฟอนต์ ขนาด และตำแหน่งให้กับ AddText แล้วสัญลักษณ์ (glyphs) ก็จะปรากฏขึ้น สิ่งที่มันไม่ได้ทำคือการบอกคุณว่าสตริงนั้นจะมีความกว้างเท่าใดเมื่อวาดเสร็จ และมันจะไม่แบ่งสตริงที่ยาวเหยียดออกเป็นหลายบรรทัด การเรียกเพียงครั้งเดียวจะวาดข้อความหนึ่งชุดในตำแหน่งเดียว หากข้อความชุดนั้นกว้างกว่าคอลัมน์ที่คุณตั้งใจจะใส่ มันก็แค่แล่นทะลุขอบออกไป และจะไม่มีอะไรในการเรียกใช้งานการวาดนั้นที่จะเตือนคุณ ช่วงเวลาที่คุณต้องการสร้างเป็นย่อหน้า (paragraph) แทนที่จะเป็นแค่ป้ายกำกับ (label) เดี่ยวๆ ชิ้นส่วนที่ขาดหายไปคือความกว้างของสตริงในฟอนต์และขนาดที่เลือก ซึ่งจะต้องวัดไว้ก่อนที่คุณจะส่งมอบมันลงบนหน้ากระดาษ

นี่คือปัญหาคลาสสิกของการจัดวางเค้าโครง (layout problem) หากต้องการตัดย่อหน้าลงในคอลัมน์ คุณจำเป็นต้องรู้ไปทีละคำว่าแต่ละบรรทัดที่มีสิทธิ์จะต้องใช้พื้นที่แนวนอนไปเท่าใด และคุณจะต้องรู้ล่วงหน้าก่อนที่จะวาดอะไรลงไป การตัดคำ (word wrap) คือลูปการวัดที่ห่อหุ้มอยู่รอบๆ การเรียกใช้งานการวาด และไบน์ดิง (binding) ที่ทำได้แค่วาดจะให้มาแค่ครึ่งหลัง การรองรับการวัดข้อความใน PDFium component ได้ปิดช่องโหว่นั้นด้วยสองฟังก์ชันคือ MeasureText และ MeasureTextWidth ซึ่งจะรายงานขอบเขตของการเรนเดอร์สตริงออกมาโดยที่ไม่ต้องไปทิ้งร่องรอย (mark) ใดๆ ไว้บนหน้ากระดาษเลย

เหตุใดการวัดจึงเป็นตัวช่วยคลาส (class helper) ไม่ใช่เมธอดใหม่บน TPdf

การรองรับการวัดมาถึงในรูปแบบของตัวช่วยคลาส (class helper) สำหรับ Delphi ของ TPdf โดยอาศัยอยู่ในยูนิต (unit) ของตัวเอง แทนที่จะเป็นเมธอดใหม่ที่ถูกยึดติดเข้าไปในคลาส TPdf ตัวช่วยคลาสคือฟีเจอร์ของภาษาที่ช่วยให้คุณสามารถแนบเมธอดเข้าไปยังประเภทที่มีอยู่เดิมจากภายนอกการประกาศของมันได้ เมื่อยูนิตนั้นเข้ามาอยู่ในขอบเขต (scope) เมธอดใหม่จะถูกเรียกใช้ราวกับว่ามันเป็นของคลาสอย่างแท้จริง ดังนั้นตัวช่วยเมธอด (helper method) จึงสามารถอ่านเป็น Pdf.MeasureTextWidth(...) โดยไม่มีออบเจ็กต์แยกต่างหากที่ต้องถูกสร้างขึ้นหรือส่งผ่านไปมา

เหตุผลที่ต้องแบ่งชั้น (layer) ด้วยวิธีนี้คือเรื่องของการแยกส่วน (separation) ประเภท TPdf หลักจะยังคงเป็นเหมือนเดิม โดยไม่มีการเพิ่มฟิลด์และไม่มีการแตะต้องลายเซ็น (signature) ที่มีอยู่ ดังนั้นโปรเจกต์ที่ไม่เคยต้องการจัดวางเค้าโครง (layout) ก็จะไม่เคยต้องพกพาโค้ดการวัดไปไหนมาไหนด้วย โปรเจกต์ที่ต้องการใช้งาน ก็เพียงแค่เพิ่มหนึ่งยูนิตลงไปในประโยค (clause) 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;

การวัดโดยไม่ต้องแตะหน้ากระดาษ

การวัดจะต้องไม่มีผลข้างเคียง (side effects) มันจะต้องรายงานความกว้างโดยไม่ทิ้งร่องรอยใดๆ ไว้เบื้องหลัง เนื่องจากคุณต้องเรียกมันหลายต่อหลายครั้งในขณะที่ตัดสินใจเลือกเค้าโครง และหน้ากระดาษจะต้องดูเหมือนกับว่าคุณไม่เคยทำการวัดใดๆ มาก่อนเลยอย่างพอดิบพอดี เทคนิคที่ทำให้สิ่งนี้เป็นไปได้ก็คือการสร้างออบเจ็กต์ข้อความ (text object) เพื่อสอบถามขนาดของมัน แล้วโยนมันทิ้งไปก่อนที่มันจะถูกนำไปแนบติดกับหน้ากระดาษ

ลำดับดังกล่าวคือการเรียกใช้งาน PDFium จำนวนสี่ครั้ง FPDFPageObj_NewTextObj สร้างออบเจ็กต์ข้อความบนเอกสารโดยระบุชื่อฟอนต์และขนาด FPDFText_SetText ตั้งค่าสตริงที่ออบเจ็กต์นั้นบรรทุก FPDFPageObj_GetBounds อ่านกล่องล้อมรอบ (bounding box) ของออบเจ็กต์กลับมา FPDFPageObj_Destroy ปลดปล่อยออบเจ็กต์ สิ่งสำคัญคือไม่มีสิ่งใดในลำดับนั้นที่เรียกใช้ API เพื่อแทรกหน้า (page-insertion API) ออบเจ็กต์ถูกสร้างขึ้น ถูกสืบค้น (queried) และถูกทำลายโดยแยกเป็นอิสระ เอกสารจึงไม่ถูกเปลี่ยนแปลงเมื่อฟังก์ชันส่งคืน มันเป็นเพียงเครื่องตรวจวัดแบบใช้แล้วทิ้ง (throwaway probe) ซึ่งผลลัพธ์เพียงอย่างเดียวของมันก็คือตัวเลขสี่ตัวจากกล่องล้อมรอบ

นี่คือวิธีที่แข็งแกร่ง (robust way) ในการทำเช่นนั้น เพราะ PDFium จะไม่เปิดเผยความกว้างของการเว้นระยะ (advance width) แบบต่อหนึ่งสัญลักษณ์ (per-glyph) อันแสนสะดวกสบายเพื่อให้คุณสามารถนำไปรวมกันได้ด้วยตัวเอง เมตริก (metrics) ของสัญลักษณ์นั้นขึ้นอยู่กับโปรแกรมฟอนต์ การเข้ารหัส (encoding) และวิธีการที่ PDFium ทำการโหลดรูปแบบหน้า (face) และยังไม่มีการเรียกใช้งานแบบสาธารณะที่จะส่งมอบระยะการเว้นสำหรับตัวอักษรแต่ละตัวในสตริงให้กับคุณ ในทางกลับกัน กล่องล้อมรอบของออบเจ็กต์ข้อความจริงๆ นั้นจะถูกคำนวณโดยใช้กลไกแบบเดียวกับที่จะใช้จัดวางเลย์เอาต์สัญลักษณ์เหล่านั้นเพื่อทำการวาด ดังนั้นมันจึงสะท้อนให้เห็นขอบเขตของการเรนเดอร์ตามความเป็นจริงแทนที่จะเป็นการประมาณการ การสร้างออบเจ็กต์แบบใช้แล้วทิ้ง (disposable object) ขึ้นมาหนึ่งชิ้นและอ่านกล่องล้อมรอบของมัน คือการวัดผลที่เชื่อถือได้มากที่สุดเท่าที่ไลบรารีจะให้ได้

// 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;

พิกัดและหน่วยของผลลัพธ์

กล่องล้อมรอบจะกลับมาในรูปแบบขอบทั้งสี่ด้าน ได้แก่ ซ้าย ล่าง ขวา และบน และมิติทั้งสอง (two dimensions) จะร่วงหล่นลงมาจากการลบ ความกว้างคือขวาลบซ้าย และความสูงคือบนลบล่าง ทั้งคู่แสดงออกมาในรูปแบบของหน่วยผู้ใช้ (user units) PDF ซึ่งหนึ่งหน่วยจะเท่ากับหนึ่งในเจ็ดสิบสองของนิ้ว ซึ่งเป็นพื้นที่พิกัดเดียวกับที่คุณใช้จัดตำแหน่งข้อความบนหน้ากระดาษ ไม่มีหน่วยของอุปกรณ์ที่ถูกซ่อนไว้และไม่มีพิกเซลมาเกี่ยวข้องในระยะนี้ ความกว้างเท่ากับ 36 หมายถึงครึ่งนิ้วของหน้ากระดาษ ไม่ว่าความละเอียดในการเรนเดอร์ในท้ายที่สุดจะเป็นเท่าใดก็ตาม

แกนตั้งจะทำงานไปในทิศทางที่ PDF กำหนดไว้ โดยแกน Y จะมีค่าเพิ่มขึ้นไปทางด้านบน ซึ่งนั่นเป็นเหตุผลว่าทำไมความสูงจึงต้องเป็นบนลบล่างแทนที่จะกลับกัน รายละเอียดนั้นมีความสำคัญเมื่อคุณทำการขยับเคอร์เซอร์เลื่อนลงไปตามคอลัมน์ คุณทำการวัดความสูงของบรรทัดหนึ่ง จากนั้นจึงลบออกจากเส้นบรรทัดฐาน (baseline) ในปัจจุบันเพื่อทำการค้นหาบรรทัดถัดไป เนื่องจากความหมายของการขยับลงไปตามหน้ากระดาษก็คือการขยับเข้าหาแกน Y ที่มีขนาดเล็กกว่า หากปลายทางของคุณเป็นหน้าจอ (screen) แทนที่จะเป็นแผ่นกระดาษ คุณก็เพียงแค่ทำการแปลงหน่วยผู้ใช้ให้เป็นพิกเซลของอุปกรณ์ (device pixels) โดยใช้ความละเอียดของการแสดงผล: ค่าที่เป็นหน่วยผู้ใช้นำไปคูณกับ DPI และหารด้วย 72 ก็จะได้ค่าพิกเซลออกมา ดังนั้นความกว้างของคอลัมน์ที่คุณตั้งค่าเป็นพอยต์ (points) จึงสามารถถูกนำไปจับคู่กับการเรียกใช้งานเพื่อการวัดผล (measured run) ได้ก่อนที่คุณจะทำการตัดสินใจว่าจะทำการตัดคำ (break) ตรงที่ใด

เกิดอะไรขึ้นเมื่ออินพุตเสื่อมถอย (degenerate input)

ฟังก์ชันต่างๆ ถูกเขียนขึ้นให้ล้มเหลวแบบเงียบๆ หากไม่มีการเปิดเอกสารไว้ หรือหากไม่สามารถสร้างออบเจ็กต์ข้อความได้ ผลลัพธ์ก็จะเป็นขอบเขตศูนย์แทนที่จะเป็นการแจ้งข้อยกเว้น (raised exception) ความกว้างและความสูงจะถูกกำหนดค่าเริ่มต้น (initialised) ไว้ที่ศูนย์ทางด้านบน และจะถูกเขียนทับ (overwritten) ก็ต่อเมื่อกล่องล้อมรอบได้ถูกอ่านกลับมาได้สำเร็จ สตริงที่ว่างเปล่า เอกสารที่หายไป ฟอนต์ที่ไลบรารีไม่สามารถทำการแก้ (resolve) ให้เป็นออบเจ็กต์ได้ สิ่งเหล่านี้แต่ละอย่างจะส่งคืนค่าศูนย์แทนที่จะทำการโยนออก (throwing)

ตัวเลือกนั้นทำให้ลูปการวัดผลยังคงความเรียบง่ายเอาไว้ได้ เพราะลูปที่ทำงาน (runs) ผ่านถ้อยคำนับพันคำไม่ใช่สถานที่สำหรับการจัดการกับข้อยกเว้นในทุกๆ รอบ ต้นทุนก็คือผู้ใช้ที่มีหน้าที่เรียกใช้งาน (caller) จะต้องเป็นผู้รับภาระในการตรวจสอบ ความกว้างที่เป็นศูนย์คือตัวเฝ้าระวัง (sentinel) ไม่ใช่ข้อเท็จจริงเกี่ยวกับข้อความนั้น ดังนั้นโค้ดที่หารด้วยความกว้างที่วัดได้ หรือสันนิษฐานว่ามีค่าเป็นบวก (positive value) จึงต้องทำการป้องกันศูนย์เอาไว้ก่อนที่จะเชื่อใจมัน จงปฏิบัติต่อเลขศูนย์เสมือนว่าเป็น "ไม่สามารถทำการวัดได้" และข้อตกลงก็จะมีความชัดเจน; หากเพิกเฉยต่อมัน อินพุตที่เสื่อมถอยก็จะกลายไปเป็นเค้าโครงที่มีคอลัมน์ของสัญลักษณ์ซ้อนทับกันอย่างเงียบๆ

การตัดคำแบบละโมบ (greedy word wrap) ที่สร้างขึ้นบนการวัด

เมื่อมีฟังก์ชันความกว้างอยู่ในมือ การตัดคำก็คือลูประยะสั้นแบบละโมบ (greedy loop) คุณเพียงแค่แยกย่อหน้าออกเป็นคำๆ คอยเก็บรักษายังบรรทัดปัจจุบันเอาไว้ และสำหรับแต่ละคำ คุณก็แค่ทำการวัดดูว่าบรรทัดนั้นจะมีสภาพเป็นเช่นไรถ้าคุณนำคำนั้นไปต่อท้าย ในขณะที่บรรทัดรุ่นทดลอง (trial line) ยังคงมีขนาดพอดีกับความกว้างของคอลัมน์ คุณก็ทำการเพิ่มคำเข้าไปเรื่อยๆ; เมื่อใดที่มันกำลังจะล้น คุณก็เพียงแค่ล้าง (flush) บรรทัดปัจจุบันด้วย AddText และทำการเริ่มต้นบรรทัดใหม่ด้วยคำที่มีขนาดไม่พอดี การสะสม (accumulation) ดังกล่าวจะถูกดำเนินการด้วย MeasureTextWidth เพียงอย่างเดียว และสิ่งเดียวที่จะไปถึงหน้ากระดาษได้ก็คือบรรทัดที่คุณได้ยืนยันแล้วว่ามีขนาดพอดี

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<WideString>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := WideString(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(X, Y, Font, FontSize, Line);   // 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(X, Y, Font, FontSize, Line);      // flush the final line
end;

ลูปจะทำการวัดบรรทัดรุ่นทดลองแทนที่จะทำการวัดแต่ละคำแล้วนำไปบวกกัน เพราะความกว้างของบรรทัดหนึ่งๆ ไม่ใช่ผลรวมจากความกว้างของคำต่างๆ ช่องว่าง (spaces) ระหว่างคำมีส่วนร่วมด้วย และการเรียกใช้งานเพื่อการวัดผล (measured run) จะจับภาพนั้นเอาไว้โดยตรง กฎแบบละโมบที่ว่า "ใส่คำเข้าไปให้ได้มากที่สุดเท่าที่คอลัมน์จะอนุญาตและทำการตัดคำในตำแหน่งของคำสุดท้ายที่ยังพอดีอยู่" ก็คือกฎเดียวกันกับที่ใช้ในการเติมเต็มช่องว่างระหว่าง AddText แบบดิบๆ กับย่อหน้าที่เป็นจริง การเรียกใช้งานเพื่อการวาดไม่เคยเป็นส่วนที่ยากเลย สิ่งที่ยากคือการวัดผลที่ต้องทำมาก่อนหน้า และนั่นก็คือสิ่งที่ตัวช่วยได้จัดเตรียมไว้ให้อย่างพอดิบพอดี

ชิ้นส่วนนี้เหมาะสมตรงที่ใด

การวัดผลคือเลเยอร์ (layer) ที่อยู่คั่นกลางระหว่างการสร้างเนื้อหากับการเรนเดอร์มัน ดังนั้นมันจึงจับคู่กับส่วนที่เหลือของเวิร์กโฟลว์ (workflow) เอกสารที่เริ่มต้นจากศูนย์ (from-scratch) ได้อย่างเป็นธรรมชาติ หากคุณกำลังประกอบหน้ากระดาษและวางข้อความลงไปตั้งแต่แรก งานพื้นฐาน (groundwork) ก็คือ การสร้างเอกสาร PDF จากศูนย์ด้วย PDFium component ใน Delphi ซึ่งจะมีการครอบคลุมถึง AddText และการตั้งค่าหน้ากระดาษเอาไว้อย่างครบถ้วน เมื่อฟอนต์ที่คุณทำการวัดมีความสำคัญเทียบเท่ากับสตริง เพราะเมตริกต่างๆ นั้นต้องพึ่งพารูปแบบหน้า (face) การวิเคราะห์คุณสมบัติของฟอนต์ PDF ด้วย PDFium component ใน Delphi จะแสดงให้เห็นว่าไลบรารีทำการรายงานข้อมูลของฟอนต์ซึ่งขับเคลื่อนกล่องล้อมรอบเหล่านั้นออกมาได้อย่างไร ทั้งคู่จะถูกสร้างขึ้นมาบนไบน์ดิง (binding) ตัวเดียวกัน นั่นก็คือ PDFium Component สำหรับ Delphi และ Lazarus ซึ่งมีตัวช่วยการวัดผลจัดส่งมาให้ควบคู่ไปกับเอกสาร หน้ากระดาษ และข้อความ API ที่อธิบายเอาไว้ทั่วทั้งบล็อกนี้