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

เซลล์ที่ผสานของ HotXLS และเทมเพลตรายงานแบบกำหนดเค้าโครงใน Delphi

ลอง iterate เซลล์ของ report template ที่เพิ่งเปิดใหม่ ๆ แล้วชื่อเรื่องที่ merge ไว้จะทำตัวเหมือน leak คุณอ่าน A1 แล้วได้ "Quarterly Statement" คุณอ่าน B1 ถึง F1 ซึ่งดูเหมือนอยู่ใต้แบนเนอร์เดียวกันเห็นชัด ๆ แล้วคุณกลับไม่ได้อะไรเลย เขียนค่าลงใน C1 เพื่อแพตช์หัวเรื่อง แล้วมันจะไม่ปรากฏบนหน้าจอเลย grid ไม่ได้ทำข้อมูลของคุณหายไปไหน มันกำลังทำสิ่งที่ merge หมายถึงพอดี: ทั้งใน XLS และ XLSX สี่เหลี่ยมที่ merge ไว้จะ render แค่เนื้อหาของเซลล์เดียว คือ anchor ที่มุมซ้ายบน แล้วปฏิบัติต่อส่วนที่เหลือเป็นพื้นที่ที่ถูกครอบไว้ ซึ่งเก็บค่าได้แต่ไม่เคยแสดงออกมา ผู้ใช้ Excel ซึมซับสิ่งนี้ผ่านการลองผิดลองถูก แต่ตัวสร้างรายงานต้องเข้ารหัสมันเป็นกฎ เพราะในโค้ดที่สร้างขึ้นอาการนี้คือพื้นที่ว่างเปล่าที่ไม่มี exception ให้ตามรอยได้เลย HotXLS ไลบรารี Object Pascal แบบ native ที่อ่านและเขียน Excel ทั้งสองฟอร์แมตจาก Delphi และ C++Builder เปิดตาราง merge ออกมาชัดเจนพอที่คุณจะเขียนโค้ดตามกฎนั้นได้ แทนที่จะต้องมาค้นพบมันใหม่ใน support ticket

หนึ่งค่า หนึ่ง anchor

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

แผนภาพแบนเนอร์ควบของ HotXLS ที่เซลล์ที่ถูกครอบยังเก็บช่องของตัวเอง ขณะที่การอ่าน resolve ไปที่ anchor A1 ในสเปรดชีตของ Delphi
HotXLS เก็บเซลล์ที่ถูกปกคลุมทุกเซลล์เป็นช่องจริงและวาดใหม่เฉพาะ anchor การอ่านจึงอ้างอิงผ่าน A1 ในขณะที่การเขียนลงพื้นที่ปกคลุมยังคงมองไม่เห็นจนกว่าจะ unmerge

ฝั่ง XLSX ตารางนั้นเป็น object ระดับหนึ่งอย่างเต็มตัว Sheet.MergedCells พก Add('A1:C1'), FindAt(Row, Col), DeleteAt และ Items และ call ที่คุณจะหยิบใช้บ่อยที่สุดคือ FindAt: ส่งพิกัดใดก็ได้ให้มัน แล้วมันจะคืนพื้นที่ merge ที่ครอบเซลล์นั้น หรือคืน nil เมื่อเซลล์นั้นยืนอยู่เดี่ยว ๆ การค้นหาครั้งเดียวนั้นแหละคือรากฐานสำหรับทั้งสองด้านของการจัดการ merge ที่ถูกต้อง คือการอ่านอย่างปลอดภัยและการป้องกันการเขียน ทั้งสองด้านจะปรากฏขึ้นภายหลัง

สองส่วนหน้า สองสำนวนของการ merge

HotXLS เก็บเครื่องยนต์ BIFF8 .xls แบบคลาสสิกและเครื่องยนต์ OOXML .xlsx ไว้เป็น object model แยกจากกัน และมันสะกดคำว่า merge ต่างกันเพราะสืบทอดมาจากธรรมเนียมคนละแบบ ส่วนหน้าของ XLS เดินตามสำนวน Excel COM: คุณเอา range มาจาก indexed property สอง argument แล้วเรียก Merge ด้วย OleVariant ที่ค่าของมันเป็นตัวตัดสินรูปทรงที่คุณจะได้

var
  Book: IXLSWorkbook;   // นับ reference ผ่าน interface: ห้าม Free เอง
  Sh: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  Sh := Book.Sheets[1];                 // collection ของ sheet ฝั่ง XLS เริ่มนับที่ 1
  Sh.Range['A1', 'F1'].Merge(False);    // False = merge เป็นบล็อกเดียว
  Sh.Cells.Item[1, 1].Value := 'Quarterly Statement';
  Sh.Range['A3', 'F4'].Merge(True);     // True = merge across: หนึ่ง merge ต่อหนึ่งแถว
  Book.SaveAs('layout.xls');
end;

argument ของ Merge คือส่วนที่คนมักเข้าใจผิด บน range สองแถว Merge(True) จะผลิต merge แบบหนึ่งแถวที่เป็นอิสระจากกันสองอัน ซึ่งคือ "Merge Across" ของ Excel และเป็นสิ่งที่คุณต้องการพอดีสำหรับแถบหัวเรื่องแบบซ้อนที่ควรแยกแถวออกจากกันได้ Merge(False) หลอมทั้งสี่เหลี่ยมให้เป็นบล็อกเดียว range ยังรายงาน MergeCells เป็น flag สถานะ คืนพื้นที่ที่ครอบคลุมผ่าน MergeArea และยุบตัวเองด้วย Unmerge ได้ด้วย ส่วนหน้าของ XLSX เปิด operation เดียวกันภายใต้ชื่อที่ต่างกัน: Sheet.MergeCells(Row1, Col1, Row2, Col2) รับขอบเขตแบบจำนวนเต็ม TXLSXRange.Merge รับตัวแปร Across ที่เทียบเท่ากัน และ collection MergedCells เก็บผลลัพธ์ไว้

template ที่โตไปพร้อมข้อมูลของมัน

report template จริง ๆ ไม่ใช่ grid ที่ตายตัว หัวเรื่องกับผลรวมตายตัว แต่ส่วนรายละเอียดระหว่างกลางยืดออกไปตามอะไรก็ตามที่ query คืนกลับมา รูปแบบที่ใช้ได้จริงคือเก็บแถวรายละเอียดที่มีสไตล์ครบหนึ่งแถวไว้ใน template โคลนมันทีละครั้งต่อหนึ่ง record แล้วเปิดช่องว่างไว้ข้างหน้าบล็อกผลรวม เพื่อให้ทุกอย่างที่ยึดอยู่ข้างล่างเลื่อนลงโดยไม่เสีย formatting ไป

เทมเพลตรายงานของ HotXLS ขยายตัวใน Delphi: แถวรายละเอียดที่มีสไตล์ถูกโคลนต่อเรกคอร์ด และ InsertRows เปิดช่องให้บล็อกรวมยอดเลื่อนลงโดยการควบยังคงอยู่ครบ
การโคลนแถวรายละเอียดที่มีสไตล์จะพาสไตล์และสูตรของมันไปยังทุกสำเนา และ InsertRows จะเลื่อนแถบผลรวมลงโดยยังคง merge และรูปแบบครบถ้วน
Sheet.Range['A1:F1'].Merge;
Sheet.Cells[1, 1].Value := 'INVOICE #2026-0611';    // ค่าลงไปที่ anchor คือ A1
Sheet.RowHeight[1] := 28;
TitleFont := Book.Fonts.Add('Calibri', 16, True, False);
Sheet.Cells[1, 1].FontIndex := TitleFont + 1;        // index ของ pool เริ่มนับที่ 0, ฝั่งเซลล์เริ่มนับที่ 1

// แถวที่ 5 คือบรรทัด template รายละเอียดที่มีสไตล์แล้ว
for I := 0 to ItemCount - 1 do
  Sheet.CopyRange(5, 1, 5, 6, 6 + I, 1);             // สไตล์และสูตรเดินทางไปพร้อมกัน

// เปิดช่องว่างเหนือบล็อกผลรวม; เนื้อหาข้างล่างจะเลื่อนลง
Sheet.InsertRows(6 + ItemCount, 1);
Sheet.Range['A1:F1'].SetBorders(xlsxEdgeOutline, xlsxBorderMedium);

มีสองบรรทัดที่คุ้มค่าจะมองซ้ำอีกครั้ง การกำหนดฟอนต์พก off-by-one ที่กัดคุณอย่างเงียบ ๆ: Fonts.Add คืนตำแหน่ง pool แบบเริ่มนับที่ 0 ในขณะที่เซลล์เก็บ reference ฟอนต์แบบเริ่มนับที่ 1 ซึ่ง 0 หมายถึงฟอนต์ default ดังนั้นการตัด + 1 ทิ้งไปจะไม่ raise อะไรเลย มันแค่จัดสไตล์ชื่อเรื่องของคุณด้วยฟอนต์ที่ผิดเท่านั้น อีกบรรทัดคือ CopyRange ซึ่งย้าย formatting และสูตรไปพร้อมกับค่า นั่นคือเหตุผลทั้งหมดที่ควรโคลนแถว template ที่สร้างด้วยมือ แทนที่จะสร้างรูปลักษณ์ของมันขึ้นใหม่ในโค้ด นักออกแบบเป็นเจ้าของรูปลักษณ์แค่ครั้งเดียวใน template ตัว generator แค่เทข้อมูลลงในสำเนาของมันเท่านั้น

การแบ่งแยกนั้นขยายผลไปได้ไกลขึ้นเมื่อ layout ที่ใช้ซ้ำได้อาศัยอยู่ใน workbook ของตัวเอง เช่น sheet ของแถบหัวและท้ายที่ใช้ร่วมกันข้ามรายงาน CopyRangeTo ทำการโคลนแบบเดียวกันข้ามขอบเขตของ worksheet ได้ โดยรับ sheet ปลายทางบวกพิกัดปลายทาง ดังนั้น generator จึงเก็บ sheet template ที่สะอาดหมดจดไว้หนึ่งแผ่น แล้วประทับพื้นที่ของมันลงบน output sheet ได้มากเท่าที่งานต้องการ ทางเลือกอื่นคือการแก้ template ตรง ๆ แล้วพยายามคืนสภาพมันภายหลัง ซึ่งเป็นสิ่งที่ใช้ได้จนถึงวันที่การรันครั้งหนึ่งล้มเลิกไปกลางทาง

สิ่งที่ InsertRows ย้าย และสิ่งที่มันไม่ย้าย

รูปแบบ template-ที่-โตได้ ใช้งานได้ก็เพราะ InsertRows ของ XLSX เป็นการแก้ไขเชิงโครงสร้าง ไม่ใช่แค่การสับเปลี่ยนเซลล์ เมื่อมันเปิดช่องว่าง มันย้ายพื้นที่ merge ความสูงแถว hyperlink comment frozen pane ช่วง autofilter conditional format data validation ตาราง defined name จุดยึดรูปภาพ และจุดยึดแผนภูมิที่อยู่ใต้จุดแทรกด้วย ไม่ใช่แค่ค่าเซลล์เท่านั้น นั่นคือสิ่งที่ทำให้บล็อกผลรวมมาถึงแถวใหม่ของมันโดยที่ merge และรูปแบบตัวเลขยังครบถ้วน แทนที่จะมาถึงแบบถูกลอกออก

ข้อจำกัดสองอย่างที่มีเอกสารรองรับของมันคือสิ่งที่ควรออกแบบให้รอบคอบ การปรับสูตรถูกจำกัดขอบเขตไว้แค่ sheet ที่กำลังถูกแก้ไข: reference ภายใน sheet นั้นถูกเขียนใหม่ และสูตรบน sheet อื่นที่ชี้เข้าไปในพื้นที่ที่ถูกเลื่อนก็ถูกเขียนใหม่เช่นกัน แต่การปรับนั้นตามแค่ reference ที่เล็งไปที่ sheet ที่ถูกแก้ไขเท่านั้น ดังนั้นแผนการ reference ข้าม workbook ใด ๆ ก็สมควรได้รับการตรวจสอบของตัวเอง แทนที่จะเชื่อมันตาบอด ข้อจำกัดที่สองชัดเจนกว่านั้น และอยู่ฝั่ง XLS pivot table รอดจากรอบเปิด-save ในฐานะ record ที่ถูกเก็บรักษาไว้แบบดิบ ๆ ไม่ใช่ object ที่ถูกสร้างเป็นโมเดลที่ HotXLS ย้ายได้ ดังนั้นการแทรกแถวจะไม่ย้ายพื้นที่ของ pivot เลย template ใดก็ตามที่คุณสร้างสำหรับฟอร์แมต .xls ควรจอดพื้นที่ pivot ของมันไว้ให้ห่างจากแถบไหนก็ตามที่โตได้

การปฏิเสธเขียนข้อมูลลงในพื้นที่ layout

ความล้มเหลวของเซลล์ merge ที่มาถึง production จริง ๆ ไม่ใช่แบบที่แค่เสียความสวยงาม มันคือความล้มเหลวเชิงโครงสร้าง: แถวรายละเอียดหลุดเข้าไปในแถบ layout ที่ merge ไว้ ค่าของมันตกลงในเซลล์ที่ถูกครอบแล้วกลายเป็นล่องหน และผลรวมของคอลัมน์ก็เลิกตรงกับสิ่งที่ใครก็ตามอ่าน sheet เห็นอย่างเงียบ ๆ เพราะ FindAt ตอบคำถามเรื่องพื้นที่ครอบสำหรับพิกัดใดก็ได้ generator จึงปฏิเสธการเขียนนั้นได้ตั้งแต่จุดที่มันกำลังจะเกิดขึ้น แทนที่จะส่งมอบรายงานที่นับยอดต่ำกว่าจริงอย่างเงียบ ๆ

// ปฏิเสธการเขียนข้อมูลรายละเอียดลงในพื้นที่ layout ที่ merge ไว้
if Sheet.MergedCells.FindAt(Row, 1) <> nil then
  raise Exception.CreateFmt('row %d overlaps a merged layout region', [Row]);
Sheet.Cells[Row, 1].Value := Detail.Description;

การตรวจสอบขอบเขตแบบเดียวกันนี้ควรอยู่ทุกที่ที่ผู้ใช้จะ sort หรือ filter output ในภายหลัง range ที่มี merge อยู่ข้างในไม่สามารถ sort ได้อย่างสะอาด เพราะการ sort ย้ายแถวแบบอิสระจากกัน และ merge ที่ครอบหลายแถวไม่มีแถวเดียวให้เดินทางไปด้วย Excel จะตอบกลับด้วย error หรือ layout ที่ปนกันยุ่งเหยิง วินัยที่รักษารายงานให้ถูกต้องคือเรื่องภูมิศาสตร์ จำกัด merge ไว้แค่แถบหัวเรื่อง ตัวแบ่งส่วน และบล็อกลายเซ็น แล้วเก็บส่วนกลางแบบตารางของ sheet ให้แบนราบ บทความเรื่องการสร้างรายงานจาก template พัฒนาการแบ่งแยก layout-กับ-ข้อมูลนี้ให้เป็น workflow แบบขับเคลื่อนด้วย placeholder เต็มรูปแบบ และ บทความเรื่อง conditional formatting และ rich text ครอบคลุมการจัดสไตล์แถบข้อมูลแบนนั้น

merge เสื่อมสภาพลงอย่างไรตอนออกไป

merge คือแนวคิดระดับ workbook และแต่ละ format สำหรับ export ที่เน้นข้อความเคารพมันในระดับที่ต่างกัน การรู้พฤติกรรมทั้งสามแบบไว้ก่อนช่วยประหยัดรอบ QA ไปได้หนึ่งรอบ การ export HTML สร้าง merge ขึ้นมาใหม่อย่างซื่อสัตย์ ปล่อย colspan และ rowspan ออกมาบนตารางเดียว ดังนั้นรายงานที่ผูกกับ browser จึงรักษาลุคแบบมีแถบไว้ได้ การ export RTF ไม่ครอบคลุมคอลัมน์เลย: ข้อความ anchor ลงในเซลล์ของตัวเอง และความกว้างที่เหลือของ merge จะออกมาเป็นเซลล์ว่างเปล่า ซึ่งทำให้ชื่อเรื่องกว้าง ๆ ดูเหมือนถูกดันไปทางซ้ายใน word processor CSV ไม่มีแนวคิดเรื่อง merge เลย ดังนั้นค่าของ anchor จะครองฟิลด์เดียว และทุกเซลล์ที่ถูกครอบจะออกมาเป็นฟิลด์ว่างเปล่า ข้อสรุปสำหรับ workbook ที่ป้อนการ export แบบมีตัวคั่นด้วยคือให้เก็บอะไรก็ตามที่รับน้ำหนักสำคัญไว้นอกเรขาคณิตของ merge บทความเรื่องการ export CSV, TSV และ HTML เดินผ่านแต่ละ format อย่างละเอียด

หัวเรื่องแบบควบของ HotXLS ส่งออกจาก Delphi เป็น HTML พร้อม colspan กับ rowspan, เป็น RTF ที่ไม่มี span และเป็น CSV ในรูปฟิลด์แบน
หัวเรื่องที่ merge ไว้ชุดเดียวกันรอดการส่งออก HTML ผ่าน colspan และ rowspan เสื่อมลงใน RTF เป็นเซลล์เดียวที่ล็อกซ้าย และแผ่แบนใน CSV เป็นค่าหนึ่งค่าบวกฟิลด์ว่าง

มีความมั่นใจหนึ่งอย่างสำหรับใครก็ตามที่กำลังชั่งน้ำหนักเรื่องนี้กับขนาดไฟล์: merge แทบไม่มีต้นทุนอะไรเลยในระดับรายงาน ตาราง merge เล็กจิ๋วเมื่อเทียบกับข้อมูลเซลล์ และการอ่านเซลล์ที่ถูกครอบยังคงผ่าน FindAt แทนที่จะสแกน แรงกดดันด้านประสิทธิภาพบน workbook ขนาดใหญ่มาจากที่อื่น ส่วนใหญ่คือการโตของ style pool และหน่วยความจำที่เส้นทาง save ถืออยู่ ซึ่ง บทความเรื่องประสิทธิภาพของ workbook ขนาดใหญ่ พูดถึงโดยตรง ทั้ง API ของ merge, operation การแก้ไขเชิงโครงสร้าง และ demo ของ template มาพร้อมกับ HotXLS Delphi Component