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

Flexbox, CSS Grid และเชิงอรรถใน PDF จาก Delphi

PDF Library for Delphi เรนเดอร์ HTML ลงในหน้า PDF ด้วยเลย์เอาต์สองมิติจริง display: flex และ display: grid ถูกวัดขนาดและวางตำแหน่งจริง แทนที่จะลดรูปลงเป็นบล็อกซ้อนกัน และเชิงอรรถถูกจองพื้นที่ไว้ที่ท้ายกล่องที่พกการอ้างอิงของมัน โดยการนับเลขยังคงต่อเนื่องกันข้ามคอลัมน์และหน้า จุดเข้าถึงคือจุดที่คุ้นเคยกันดี คือ DrawHTMLTextBox สำหรับกล่องเดียว และ DrawHTMLStory สำหรับการไหลแบบหลายคอลัมน์

เรื่องนี้สำคัญเพราะ HTML คือวิธีที่เนื้อหารายงานส่วนใหญ่มาถึงในปัจจุบัน เทมเพลตถูกเขียนขึ้นโดยคนที่เขียน CSS แดชบอร์ดถูกออกแบบเป็นการ์ด และตัวเรนเดอร์ที่ยุบแถว flex ลงเป็นบล็อกซ้อนกันสี่บล็อกอย่างเงียบๆ จะสร้างเอกสารที่ไม่เหมือนกับการออกแบบเลย จนกระทั่งความสามารถนี้มีอยู่ คอนเทนเนอร์สองมิติเพียงแบบเดียวที่เอนจินวัดขนาดได้คือตาราง ดังนั้นเลย์เอาต์การ์ดทุกแบบจึงต้องถูกเขียนใหม่เป็นตารางด้วยมือ

อะไรเปลี่ยนไปในแบบจำลองเลย์เอาต์

ลูปหลักเดิมรักษากล่องบรรทัดเดียวและเดินหน้าลงไปตามหน้า แบบจำลองนั้นจัดการเนื้อหาแบบอินไลน์และบล็อกซ้อนกันได้อย่างสมบูรณ์แบบ แต่ไม่สามารถแสดงคอนเทนเนอร์ที่ลูกของมันมีขนาดสัมพันธ์กันได้ ตารางเป็นข้อยกเว้นเพียงอย่างเดียว ซึ่งมีการวัดขนาดแบบสองรอบเป็นของตัวเอง

flex และ grid แต่ละอย่างเพิ่มรอบการวัดขนาดที่มีขอบเขตจำกัดเหนือลูกของคอนเทนเนอร์ และคำสำคัญคือ "มีขอบเขตจำกัด" คอนเทนเนอร์ flex วัดขนาดลูกโดยตรงได้สูงสุด 256 ตัวลงในอาร์เรย์ขนาดคงที่ grid ใช้เมทริกซ์การครอบครองขนาดไม่เกิน 64 คูณ 64 เซลล์ สำหรับการวางตำแหน่งอัตโนมัติที่กำหนดผลลัพธ์ได้แน่นอน เพดานเหล่านี้มีอยู่เพื่อไม่ให้สไตล์ชีตที่ประสงค์ร้ายหรือสร้างขึ้นมาสามารถขับเคลื่อนการเรียกซ้ำแบบไม่มีขอบเขตหรือหน่วยความจำการวางตำแหน่งแบบกำลังสองได้ ซึ่งเป็นข้อกังวลจริงเมื่อ HTML มาจากเทมเพลตที่ลูกค้าแก้ไขเอง

รายการ flex ได้รับขนาดของตัวเองอย่างไร

ในทิศทางแถว คอนเทนเนอร์จะรวมค่าฐานของแต่ละรายการเข้ากับน้ำหนักการขยายและการหดตัวของมัน แล้วจึงกระจายพื้นที่ที่เหลืออยู่ ไม่ว่าจะเป็นบวกหรือลบ ตามน้ำหนักเหล่านั้น เมื่อใช้ flex-wrap แต่ละบรรทัดจะถูกแก้ไขอย่างอิสระต่อกัน ดังนั้นแถวที่แตกออกเป็นสองบรรทัดจะกำหนดพื้นที่ว่างต่อบรรทัด แทนที่จะกำหนดข้ามทั้งคอนเทนเนอร์ ในทิศทางคอลัมน์ การกระจายตามแกนหลักแบบเดียวกันนี้จะทำงานเทียบกับความสูงที่ระบุไว้อย่างชัดเจนหรือความสูงของเนื้อหา

justify-content, align-items, gap และทิศทางย้อนกลับทำงานบนเรขาคณิตที่วัดขนาดไว้แล้ว มันย้ายกล่อง แต่ไม่เคยกระตุ้นการวัดขนาดเนื้อหาของรายการซ้ำ การแยกส่วนนี้เองที่ป้องกันไม่ให้แดชบอร์ดที่ซับซ้อนวัดขนาดลูกของมันซ้ำหลายครั้ง

uses
  PDFlibrary;

var
  Lib: TPDFlib;
  Html, Remainder: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;
    Lib.SetPageSize('A4');
    Lib.NewPage;

    Html :=
      '<div style="display:flex; gap:12px;">' +
      '  <div style="flex:2 1 0; background:#f4f6f8; padding:8px;">' +
      '    <b>Revenue</b><br/>EUR 4,182,300</div>' +
      '  <div style="flex:1 1 0; background:#f4f6f8; padding:8px;">' +
      '    <b>Margin</b><br/>18.4%</div>' +
      '  <div style="flex:1 1 0; background:#f4f6f8; padding:8px;">' +
      '    <b>Backlog</b><br/>92 days</div>' +
      '</div>';

    Remainder := Lib.DrawHTMLTextBox(40, 40, 515, 120, Html);
    if Remainder <> '' then
      Log('content did not fit - carry the remainder to the next box');

    Lib.SaveToFile('dashboard.pdf');
  finally
    Lib.Free;
  end;
end;

ค่าที่คืนกลับมาคือสตริงการต่อเนื่อง ซึ่งเป็นวิธีที่จุดเข้าถึงการวาด HTML ทุกจุดรายงานสิ่งที่ไม่พอดี ส่งมันไปยังกล่องถัดไปหรือหน้าถัดไป แล้วการไหลจะดำเนินต่อจากจุดที่หยุดไว้

การวางตำแหน่งกริด และแทร็กสามารถเป็นอะไรได้บ้าง

แทร็กกริดรับความยาวคงที่ เปอร์เซ็นต์ หน่วย fr นิพจน์ repeat() แบบง่าย และ minmax() การวางตำแหน่งอัตโนมัติเติมเมทริกซ์การครอบครองอย่างกำหนดผลลัพธ์ได้แน่นอน ดังนั้น HTML เดียวกันจึงให้การจัดเรียงเดียวกันเสมอ พิกัดที่ระบุอย่างชัดเจนได้รับอนุญาตให้ซ้อนทับกันได้ ซึ่งเป็นการจงใจ การออกแบบที่ซ้อนป้ายทับการ์ดกำลังแสดงความตั้งใจ ไม่ใช่ข้อผิดพลาด เมื่อระบุเพียงแกนเดียวอย่างชัดเจน การวางตำแหน่งจะค้นหาเฉพาะแกนที่เหลือเท่านั้น

รายการที่ครอบคลุมหลายแถวจะส่งความสูงที่วัดได้กลับไปยังแถวที่มันครอบคลุม โดยเฉลี่ยไว้ในแถวเหล่านั้น ซึ่งป้องกันไม่ให้รายการที่ครอบคลุมสูงบีบแถวเดียวจนแคบในขณะที่ทิ้งแถวข้างเคียงไว้สั้น:

Html :=
  '<div style="display:grid; grid-template-columns:repeat(3, 1fr); ' +
  '            gap:10px;">' +
  '  <div style="grid-row:span 2; background:#eef;">Site plan</div>' +
  '  <div>Inspector</div>' +
  '  <div>Date</div>' +
  '  <div style="grid-column:2 / span 2;">Findings summary</div>' +
  '</div>';

Remainder := Lib.DrawHTMLTextBox(40, 180, 515, 260, Html);

ลูกของ flex และ grid ถูกเรนเดอร์ผ่านตัวเรนเดอร์ HTML เดียวกันกับสิ่งอื่นทั้งหมด ซึ่งเป็นคุณสมบัติที่ทำให้ฟีเจอร์นี้ใช้งานได้จริง แทนที่จะเป็นโลกแยกต่างหาก ฟอนต์ การซ้อนทับของ CSS ลิงก์ ภาพ ตาราง และคอนเทนเนอร์ flex หรือ grid ที่ซ้อนกันอีกชั้น ล้วนมีพฤติกรรมภายในรายการ flex เหมือนกับที่มันมีในระดับบนสุดทุกประการ และแผนเลย์เอาต์ภายนอกบันทึกคำสั่งข้อความและสี่เหลี่ยมสุดท้ายไว้ ทำให้การวาดซ้ำใช้แคชการวัดขนาดที่มีอยู่แล้วซ้ำได้

เหตุใดเชิงอรรถจึงเป็นปัญหาการแบ่งหน้า

เชิงอรรถไม่ใช่เนื้อหาที่ไหลตามหลังย่อหน้าที่มีการอ้างอิงถึงมัน แต่เป็นเนื้อหาที่ต้องปรากฏที่ท้ายกล่องเดียวกันกับการอ้างอิงของมัน นั่นกลับลำดับการวัดขนาดตามปกติ เพราะพื้นที่ที่มีให้สำหรับข้อความเนื้อหาตอนนี้ขึ้นอยู่กับเนื้อหาที่ยังไม่ถูกจัดเลย์เอาต์เลย

ตัวเรนเดอร์จึงวัดขนาดเชิงอรรถเมื่อพบการอ้างอิง และหักพื้นที่เชิงอรรถออกจากงบประมาณความสูงเนื้อหาของกล่องที่มีขอบเขตปัจจุบัน หากการอ้างอิง ข้อความเนื้อหาที่มีอยู่ และเชิงอรรถ ไม่สามารถใส่พอดีได้ทั้งหมด เครื่องหมายเชิงอรรถและทุกอย่างหลังจากนั้นจะย้ายเข้าไปในสตริงการต่อเนื่องพร้อมกัน กฎนี้เองที่ป้องกันความล้มเหลวคลาสสิกสองแบบ คือเชิงอรรถพิมพ์ทับข้อความเนื้อหา และเชิงอรรถติดค้างอยู่บนหน้าที่การอ้างอิงของมันอยู่บนหน้าก่อนหน้า

ในกล่องที่มีขอบเขต พื้นที่เชิงอรรถถูกตรึงไว้ที่ด้านล่างพร้อมเส้นคั่นอยู่เหนือมัน ในการวัดขนาดแบบไม่มีขอบเขต ซึ่งไม่มีความสูงกล่องให้ตรึงไว้ พื้นที่เชิงอรรถจะตามหลังเนื้อหาทันที การนับเลขถูกพกไว้ในฟิลด์ส่วนขยายบนสแตกการต่อเนื่อง ดังนั้น DrawHTMLTextBox และ DrawHTMLStory จึงรักษาลำดับให้ดำเนินต่อไปข้ามคอลัมน์และหน้า และสตริงการต่อเนื่องที่สร้างขึ้นก่อนที่ฟิลด์นั้นจะมีอยู่ก็ยังคงดำเนินต่อได้อย่างถูกต้อง

// เชิงอรรถภายในเรื่องแบบหลายคอลัมน์รักษาลำดับที่ดำเนินต่อเนื่องเดียว
Html := LoadTemplate('chapter.html');    // ใช้เครื่องหมาย float:footnote
Remainder := Lib.DrawHTMLStory(40, 40, 515, 700,
  2,        // คอลัมน์
  16,       // ร่องกลางเป็นหน่วยพอยต์
  20,       // จำนวนหน้าสูงสุดสำหรับเรื่องนี้
  Html);
if Remainder <> '' then
  Log('story exceeded its page budget');

คำแนะนำเชิงปฏิบัติสำหรับผู้เขียนเทมเพลต

ออกแบบภายในเพดานที่บันทึกไว้ คอนเทนเนอร์ flex ที่มีลูกโดยตรงมากกว่า 256 ตัวมักจะเป็นตารางข้อมูลที่สวมชุด flex อยู่เกือบทุกครั้ง และเส้นทางตารางก็วัดขนาดมันได้ดีกว่าอยู่ดี grid ที่ใหญ่กว่า 64 คูณ 64 คือสเปรดชีต และคำแนะนำเดียวกันนี้ใช้ได้เช่นกัน สำหรับข้อความเนื้อหาแบบหลายคอลัมน์ พฤติกรรมคอลัมน์และการแบ่งพยางค์ที่อธิบายไว้ในการแบ่งพยางค์และคอลัมน์ข้อความแบบสมดุล ควบคุมว่าการไหลมีหน้าตาอย่างไรภายในแต่ละคอลัมน์

วัดขนาดก่อนวาดเมื่อเลย์เอาต์ต้องพอดี GetHTMLTextHeight รายงานความสูงที่ความกว้างที่กำหนดจะต้องการ ซึ่งเป็นวิธีที่ประหยัดในการตัดสินใจระหว่างเลย์เอาต์แบบหนึ่งกับอีกแบบก่อนที่จะลงมือจริง และปฏิบัติต่อสตริงการต่อเนื่องที่ไม่ว่างเปล่าว่าเป็นเรื่องปกติ ไม่ใช่ข้อยกเว้น มันคือกลไกที่เนื้อหายาวใช้แบ่งหน้า ไม่ใช่สัญญาณข้อผิดพลาด

เมื่อ HTML มาจากเอนจินสร้างรายงานแทนที่จะมาจากเทมเพลตที่เขียนด้วยมือ เส้นทางที่ขับเคลื่อนด้วยชุดข้อมูลในเอนจินสร้างรายงานจากชุดข้อมูล ประกอบเข้ากันได้ดีกับสิ่งนี้ โดยสร้างมาร์กอัปที่ flex และ grid นำไปจัดเรียงต่อ และเมื่อเนื้อหาเดียวกันต้องออกจาก PDF อีกครั้งด้วย เส้นทางการส่งออกเชิงความหมายในการส่งออก PDF เป็น Markdown และ DOCX จะปิดวงจรการไปกลับให้สมบูรณ์

เลย์เอาต์ HTML การสร้างรายงาน และการส่งออกเชิงความหมาย เป็นส่วนหนึ่งของไลบรารีเดียวกันสำหรับ Delphi, C++Builder และ Free Pascal รายการคุณสมบัติทั้งหมดอยู่ที่หน้า PDF Library for Delphi