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

กริด rowspan และหัวตารางที่ซ้ำข้ามหน้าใน HotPDF

HotPDF เรนเดอร์ตาราง HTML ผ่านโปรไฟล์ HTML5 paged-media ของตัวเอง โดยใช้ occupancy grid จริง ๆ สำหรับ rowspan กับ colspan, ความสูงแถวที่วัดมาจริงแทนการเดาจากจำนวนตัวอักษร และแถวหัวตารางที่ซ้ำบนทุกหน้าต่อเนื่อง มีสองสถานการณ์ที่มันจะปฏิเสธไม่ซ้ำหัวตาราง รู้ทั้งสองกรณีไว้ก่อนถูกกว่ามานั่งดีบักเซลล์ที่ดับเบิลขึ้นมาทีหลัง

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

ทำไมความสามารถเรื่องตารางจึงย้ายเข้าไปอยู่ใน HTML renderer

เพราะทางเลือกแบบอื่นเสีย rich text ไป และ rich text ก็คือเหตุผลที่เนื้อหาเป็น HTML ตั้งแต่แรก แผนที่ดูดีที่สุดคือการ reuse โดยที่ HotPDF มี layout DOM table object ที่มีกริดครบอยู่แล้ว งั้นเชื่อม HTML parser เข้าไปก็ได้เรื่อง spanning ฟรี ๆ ปัญหาคืออะไรที่ table object ตัวนั้นวาดด้วย เซลล์ของมันพกแค่ข้อความกับสไตล์ และเส้นทางการวาดปล่อย output เป็น plain text อะไรที่ HTML มีจริงเกินกว่าฟอนต์กับสี ทั้งลิงก์ ตัวห้อย การเปลี่ยนขนาด inline และสีราย run จึงหายวับไปก่อนถึงหน้ากระดาษ

ทิศทางที่รอดจากการปะทะกับเอกสารจริงคือทางกลับกัน ย้ายความสามารถของเครื่องยนต์ตาราง ทั้ง occupancy grid, การวัดจริง, การซ้ำหัวตาราง และการถ่วงน้ำหนักคอลัมน์ เข้าไปใน HTML renderer แล้วปล่อยการเรนเดอร์ rich text ให้อยู่ที่เดิมที่มันทำงานอยู่แล้ว นั่นเป็นการเปลี่ยนแปลงที่ใหญ่กว่าการทำบริดจ์ และเป็นการเปลี่ยนที่ทำให้ hyperlink ในเซลล์ตารางยังคงเป็น hyperlink

rowspan โดยไม่ต้องมี union-find

เซลล์แบบ span สร้างกลุ่มแถวแบบ atomic แต่การปิดวงเหนือกลุ่มพวกนี้ไม่ต้องใช้โครงสร้าง disjoint-set ทั่วไป เพราะ occupancy เป็นช่วงต่อเนื่องเสมอ เซลล์ที่มี rowspan="3" เริ่มที่แถว K ครอบแถว K ถึง K+2 เท่านั้น ข้อมูลกลุ่มจึงยุบเหลือเป็น marker บอกจุดจบต่อหนึ่งแถว

อัลกอริทึมมีสองบรรทัดของความตั้งใจ เมื่อวางเซลล์ span ที่เริ่มที่ K และจบที่ E ให้บันทึก GroupEnd[K] := Max(GroupEnd[K], E) แล้วเดินแถวย้อนกลับหนึ่งรอบและใช้ G[R] := G[G[R]] ซึ่งไล่ปลายแถวของแต่ละแถวย้อนผ่าน span ที่ทับกัน ให้ transitive closure ใน single pass สิ่งที่ได้คือสำหรับทุกแถว แถวสุดท้ายที่ต้องอยู่หน้าเดียวกับมัน ซึ่งเป๊ะกับสิ่งที่ขั้นตอนแบ่งหน้าต้องใช้ตัดสินว่าจุดตัดจะตกได้ตรงไหน

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

กริดตาราง HTML ของ HotPDF ที่เซลล์หนึ่งซึ่งมี rowspan 3 เริ่มที่แถว 2 ครอบแถว 2 ถึง 4 เป็นสี่เหลี่ยม atomic เดียว วางคู่กับค่า group end ต่อแถว G ของ R ที่ได้จากการเดินย้อนหนึ่งรอบ แสดงว่าแถว 2, 3 และ 4 ผูกติดอยู่หน้าเดียวกัน
occupancy ของ span เป็นช่วงต่อเนื่องเสมอ marker บอกจุดจบต่อแถวกับการเดินย้อนหนึ่งรอบจึงแทน union-find ได้ และบอกการแบ่งหน้าได้ตรงเป๊ะว่าจุดตัดตกได้ตรงไหน
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Stats: THPDFHTMLImportStatistics;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'audit-report.pdf';
    Pdf.BeginDoc;
    Importer := THPDFHTMLImporter.Create(Pdf);
    try
      Importer.Margin := 48;
      Importer.BaseFontName := 'Arial';
      Importer.BaseFontSize := 10;
      Importer.MaxDOMNodes := 200000;
      Importer.MaxLayoutOperations := 2000000;
      if Importer.RenderHTML5(SourceHtml, PrintStyleSheet) then
      begin
        Stats := Importer.Statistics;
        Writeln('tables ', Stats.TableCount,
                '  page breaks ', Stats.PageBreakCount);
      end;
    finally
      Importer.Free;
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

RenderHTML5 รับ author style sheet เสริมได้เป็นอาร์กิวเมนต์ตัวที่สอง จุดนั้นแหละคือที่สำหรับกฎฝั่งพิมพ์ อย่าเอา screen style sheet ทิ้งไว้ในนั้น โปรไฟล์ถูกทำเวอร์ชัน และ HTML5ProfileMilestones รายงานว่าบิลด์ปัจจุบันทำกลุ่มความสามารถไหนได้แล้ว ทั้ง himParserCascade, himPagedLayout, himTablesForms และ himBoundedResources แอปพลิเคชันจึงถอยระดับอย่างตั้งใจได้ แทนที่จะไปเจอช่องว่างเฉพาะหน้างานจริง

การวัดต้องตรงกับการวาด แบบเป๊ะสุด

ความสูงแถวถูกต้องก็ต่อเมื่อโค้ดที่วัดบรรทัดที่ตัดขึ้นใหม่ตัดด้วยกฎเดียวกับโค้ดที่วาดมัน ฟังดูชัดเจน แต่เป็นแหล่งปัญหาอันดับหนึ่งของตารางที่เส้นขอบไม่ตรงกับเนื้อหาในนั้น HotPDF วัดด้วยตัวนับบรรทัดแบบ greedy และตัวนับนี้ต้องตรงกับ semantic การตัดคำของเส้นทาง rich-text ในสามประเด็นเจาะจง: ตัดได้ที่ช่องว่างเท่านั้น ไม่สับคำใด ๆ และคำที่กว้างกว่าคอลัมน์จะได้บรรทัดเป็นของตัวเอง

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

ทำตรงนี้ได้แล้วจะเปลี่ยนสิ่งที่คุณ assert ได้ในเทสต์ ผลที่สังเกตได้ของการวัดที่แม่นคือระยะห่างระหว่างบรรทัด ไม่ใช่จำนวน glyph: แถวบรรทัดเดียวสูงราว 20 จุด ขณะที่การเดาจากจำนวนตัวอักษรกับเนื้อหาเดียวกันบอกสองบรรทัดและราว 35 จุด ให้ assert ที่ระยะทางแนวตั้งระหว่างแถว และอย่าลืมว่า user space ของ PDF มีแกน Y เพิ่มขึ้นด้านบน หัวตารางที่นั่งอยู่เหนือแถวเนื้อหาแปลว่าค่า Y ของหัวตารางมากกว่า ซึ่งตรงข้ามกับสัญชาตญาณพิกัดหน้าจอที่มือจะพิมพ์ออกมา

เมื่อไรที่ HotPDF ปฏิเสธไม่ซ้ำหัวตาราง

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

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

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

// ค่าน้ำหนักคอลัมน์มาจาก markup จุดควบคุมจึงอยู่ที่ print style sheet
// ความกว้างถูกถือเป็นน้ำหนัก ไม่ใช่พิกเซล
const
  PrintStyleSheet =
    'table { width: 100%; }' +
    'thead th { font-weight: bold; background: #eee; }' +
    'td.amount { text-align: right; }';

// แถวหัวตารางที่พก rowspan ล้ำเข้าไปในเนื้อหาจะยับยั้งการซ้ำหัวตาราง
// เก็บ span ให้อยู่ใน section เดียวกัน:
//   <thead><tr><th rowspan="2">Item</th>...</tr></thead>  ใช้ได้
//   <tr><th rowspan="3">Item</th>...  ล้ำเข้า tbody ไม่ซ้ำหัวตาราง

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

ชิ้นส่วนนี้วางอยู่ตรงไหนของ pipeline เอกสาร

งานตารางนั่งอยู่ในโปรไฟล์ paged-media ที่กว้างกว่า กฎการแบ่งหน้า งบประมาณ resource และการจัดการ CSS ที่อธิบายไว้ใน เส้นทางนำเข้า HTML5 paged-media ใช้กับเอกสารที่มีตารางได้แบบไม่เปลี่ยนแปลง ถ้าข้อมูลของคุณไม่ได้เริ่มจาก HTML เส้นทางสร้างตรงใน การประกอบตารางลง PDF โดยตรง ตัดชั้น parsing ออกทั้งหมดแล้วให้พฤติกรรมกริดชุดเดียวกันผ่าน API และเพราะความสูงแถวในที่สุดพึ่งจุดที่บรรทัดถูกตัด วงเล็บเรื่องการวัดใน การจัด justify และการตัดบรรทัด จึงเป็นคู่หูของใครที่กำลังจูน output ตารางแน่น ๆ

บทเรียนที่เอาไปใช้ซ้ำได้จากเรื่องนี้ไม่ใช่เรื่องตารางเลย เมื่อซับซิสเต็มใหม่ต้องการความสามารถที่ซับซิสเต็มเก่ามีอยู่แล้ว ให้ถามว่าฝั่งไหนเป็นเจ้าของสิ่งที่ทำซ้ำยากที่สุด เลขคณิตของกริดคือไม่กี่สิบบรรทัด ย้ายง่าย ส่วนการเรนเดอร์ rich text ที่มีลิงก์ inline, ตัวห้อย และสไตล์ราย run นั้นไม่ใช่ กริดจึงย้ายและข้อความอยู่เดิม HotPDF ส่งทั้งสองเส้นทางมาพร้อมกันใน HotPDF Delphi PDF component การเลือกระหว่าง input แบบ HTML กับการสร้างตรงจึงเป็นการตัดสินใจระดับโปรเจกต์ ไม่ใช่ระดับไลบรารี