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

แถวซ้ำใน ODS เป็น row-height run ใน HotXLS สำหรับ Delphi

HotXLS Delphi Component เก็บแถว ODS ที่พก table:number-rows-repeated พร้อมความสูงแถวเป็น record TXLSXRowHeightRun เดียว — แถวแรก, แถวสุดท้าย, ความสูงหนึ่งค่า — แทนที่จะเป็น entry ความสูงต่อหนึ่งแถวที่ซ้ำ และพับ style ของเซลล์ว่างที่แถวเหล่านั้นสืบทอดมาให้เป็น interval style overlay ตัวเดียว นั่นคือเหตุผลทั้งหมดที่ HotXLS 2.382.2 เปิดสเปรดชีตที่ท้ายตารางซ้ำแถวว่าง 1,048,530 แถวได้ใน 0.02 วินาที ขณะที่ 2.382.1 หมดเวลา และเป็นเหตุผลที่ไฟล์เดิม save กลับเป็น ODS ได้โดย repeat count ยังอยู่ครบ ไม่กลายเป็นแถวจริงเป็นล้าน

ไฟล์ที่ว่านี้ธรรมดามาก LibreOffice Calc เขียนชีตสิบสี่คอลัมน์ที่มีข้อมูล 45 แถว แล้วบรรยายทุกอย่างที่อยู่ใต้ลงไปด้วย element เดียว: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row> style ro1 ตั้ง style:row-height="0.452cm" และ <table:table-column> แต่ละตัวพก table:default-cell-style-name ที่ทุกเซลล์ว่างในช่วงนั้นสืบทอดไป content.xml ทั้งไฟล์มีขนาด 103 KB ไม่มีอะไรในไฟล์บอกว่า "แพง" ความแพงเป็นของเราทั้งหมด

วิธีที่ HotXLS เปลี่ยนแถว ODS ที่ซ้ำกันหนึ่งแถวให้เป็น state กะทัดรัด: element ใน content.xml ที่มี table:number-rows-repeated 1048530 กับ style ro1 map ไปเป็น record TXLSXRowHeightRun ตัวเดียวที่ครอบแถว 46 ถึง 1048575 ที่ 12.81 pt บวก entry StyleOverlays หนึ่งตัวต่อคอลัมน์ ขณะที่เวอร์ชัน 2.382.1 ขยาย element เดียวกันออกเป็น entry SetRowHeight และออบเจ็กต์เซลล์เป็นล้านตัว
repeat count, ความสูงแถวของ ro1 และ default style ของคอลัมน์บรรยายทุกแถวว่างใต้แถว 45 ไว้แล้ว importer จึงสร้าง record run หนึ่งตัวกับ overlay รายคอลัมน์ได้โดยไม่ต้องแตะพิกัดเป็นล้านตัว

ทำไมแถวที่ซ้ำแถวเดียวถึงทำให้การ import ODS หมดเวลา

เพราะ importer เคยขยายมันออก ใน 2.382.1 row finisher วน SetRowHeight(RowIndex + i, RowHeight) หนึ่งครั้งต่อหนึ่งแถวที่ซ้ำ โดยเขียนความสูงแต่ละค่าลงใน string list แบบ Name=Value ที่คีย์ด้วยหมายเลขแถว ทุกการ insert ลงลิสต์นั้นรันการค้น IndexOfName บนทุกอย่างที่มีอยู่ในลิสต์แล้ว ความสูงล้านค่าจึงคิดเป็นล้านการสแกนเชิงเส้น — อาการค้นหาแบบ quadratic ในลิสต์ที่ HXLS-005 ถูกเปิดไว้ ในเวลาเดียวกัน OdsCommitRow ก็สร้างออบเจ็กต์เซลล์ให้ทุกคอลัมน์ที่สืบทอด style บนทุกแถวที่ซ้ำ เพราะเซลล์ว่างที่มี style ก็ยังนับเป็นเซลล์อยู่ดี

ฝั่ง save มีปัญหาในแบบของตัวเอง ไฟล์จาก LibreOffice จบด้วยแถว ro1 อีกหนึ่งแถวหลังการซ้ำก้อนใหญ่ แถวที่มี style สูงสุดจึงนั่งอยู่ก้นสุดของชีต และ OdsBuildTableXml ก็เดินทุกแถวขึ้นไปถึงมันโดย emit element <table:table-row> ทีละแถว ต่อให้เวิร์กบุ๊กถูก import อย่างถูก ๆ ก็ยังจะเขียนออกมาแพง ๆ การแก้ฝั่ง import โดยไม่แก้ฝั่ง export จะแค่ย้ายจุดที่หมดเวลา ไม่ได้กำจัดมัน

row-height run ใน HotXLS คืออะไร

run คือสิ่งเล็กที่สุดที่บรรยายว่า "แถว 46 ถึง 1,048,575 สูง 12.81 point ทั้งหมด" ได้โดยไม่ต้องพูดซ้ำ 1,048,530 ครั้ง TXLSXRowHeightRun เป็น record ของ FirstRow, LastRow และ Height ส่วน TXLSXRowHeightRuns เป็น dynamic array ของ record นั้น และ TXLSXWorksheet แต่ละตัวเก็บอาร์เรย์หนึ่งชุดไว้ใน FRowHeightRuns เคียงกับลิสต์ความสูงรายแถวที่มีอยู่เดิม ตอน import ODS row finisher แยกทางตาม repeat count แล้ว: count เป็น 1 ก็ยังเรียก SetRowHeight อะไรที่มากกว่านั้นเรียก XlsxAssignRowHeightRun ครั้งเดียวสำหรับทั้งช่วง ช่วงนั้นถูก clamp ไว้ที่ XlsxMaxRow ซึ่งคือ 1,048,576 ดังนั้น repeat count ที่เกินชีตจะถูกตัดให้สั้นลง ไม่ใช่ถูกปฏิเสธ

XlsxAssignRowHeightRun เป็น writer เดียวของอาร์เรย์นั้น และมันทำให้ run ต่าง ๆ ไม่ทับกันโดยโครงสร้าง เมื่อได้ช่วงใหม่มา มันจะคัดลอกทุกระยะ run ที่อยู่นอกช่วงนั้นทั้งหมด แยก run ใดที่คาบเกี่ยวกับช่วงนั้นออกเป็นส่วนก่อนกับส่วนหลัง จากนั้นต่อช่วงใหม่เข้าไปเมื่อ Present เป็น true — หรือไม่ต่ออะไรเลยเมื่อ Present เป็น false ซึ่งก็คือวิธีที่ ClearRowHeight เจาะรูหนึ่งแถว ผลตามมาสองข้อ อาร์เรย์ไม่เคยมีช่วงที่ทับกัน การค้นจึงหยุดที่ตัวแรกที่เจอได้ และอาร์เรย์ไม่เคยถูกแก้ในที่เดิม สำเนาชุดใหม่ถูกสร้างทุกครั้งที่เรียก ซึ่งไม่คิดต้นทุนอะไรที่ขนาดเท่านี้ และตัด bug เรื่อง aliasing ไปได้ทั้งชั้น

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // ชีตที่แถวท้าย ๆ ซ้ำ 1,048,530 ครั้งภายใต้ row style ตัวเดียว
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // การอ่านทั้งสองครั้ง resolve ผ่าน run เดียวกัน ไม่มีอะไรถูกขยาย
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // override แถวเดียวบดบัง run โดยไม่แยกมัน
    Sheet.RowHeight[500000] := 36;
    // การ clear หนึ่งแถวใน run ตัด run ออกเป็นสองชิ้น
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // ยังเป็นความสูงของ run อยู่
  finally
    Workbook.Free;
  end;
end;

ลำดับการค้นคือส่วนที่คุ้มจะจำ TXLSXWorksheet.GetRowHeight ตรวจลิสต์รายแถวก่อน แล้วจึงไปดู run เฉพาะเมื่อแถวนั้นไม่มี entry ชัดเจน และ HasRowHeight ก็ทำเหมือนกัน ดังนั้น Sheet.RowHeight[500000] := 36 จึงไม่แตะ run เลย — มันเพิ่ม entry หนึ่งตัวลงลิสต์รายแถว และ entry นั้นชนะเพราะถูกค้นเจอก่อน ClearRowHeight ตรงกันข้าม: มันลบ entry รายแถวออกแล้วเรียก XlsxAssignRowHeightRun ด้วย Present = False เพราะแถวที่ถูก clear ต้องอ่านได้ว่า "ไม่มีความสูง" แม้จะมี run ครอบมันอยู่ ClearRowHeights ล้างทั้งสองโครงสร้างพร้อมกันในทีเดียว

การผ่าตัด row-height run ใน HotXLS: หลัง OpenODS มี run หนึ่งตัวครอบแถว 46 ถึง 1048575 ที่ 12.81 pt ขณะที่ entry รายแถวตั้งแถว 500000 เป็น 36 pt และชนะการค้นเพราะ GetRowHeight ตรวจลิสต์รายแถวก่อน และ ClearRowHeight ของแถว 500001 ก็แยก run ออกเป็นสองชิ้นที่ไม่ทับกันรอบรูที่เจาะไว้
XlsxAssignRowHeightRun คัดลอกส่วนที่อยู่นอกช่วงที่ถูก clear และไม่ต่ออะไรเลยสำหรับช่วงนั้นเอง run จึงไม่ทับกันโดยโครงสร้าง และการค้นหยุดที่ตัวแรกที่เจอได้ ขณะที่ override ที่แถว 500000 ไม่ถูกแตะ

style ของเซลล์ว่างที่สืบทอดมาไปอยู่ตรงไหน

ไปอยู่ใน interval style overlay หนึ่งตัวต่อหนึ่งคอลัมน์ ไม่ได้ไปอยู่ในออบเจ็กต์เซลล์ OdsCommitRow ตัดสินเป็นรายค่าของคอลัมน์ว่ามันเป็น blank แบบ compact หรือไม่: แถวนั้นซ้ำมากกว่าหนึ่งครั้ง เซลล์ไม่มีค่า ไม่มีสูตร และไม่มี rich text สำหรับ blank แบบ compact มันสร้างเซลล์จริงเฉพาะบนแถวแรกของ run แล้วใส่ style ที่สืบทอดมาลงไป จากนั้นลงทะเบียนดัชนี style หกตัวเดิม — ฟอนต์, fill, เส้นขอบ, number format, การจัดวาง, การป้องกัน — เป็น StyleOverlays.Add ที่ครอบแถวที่สองจนถึงปลาย run ในคอลัมน์นั้น แถวหลังจากแถวแรกถูกข้ามไปทั้งหมดในลูปการสร้างออบเจ็กต์

เทสต์ regression ทำให้รูปร่างของมันเป็นรูปธรรม หลังจากเปิดชีตที่แถวที่สองซ้ำ 1,048,575 ครั้งภายใต้ column default style ตัวหนา Sheet.Cells.Count ถูก assert ว่าไม่ถึง 10 และ Sheet.Cells[700000, 1].FontIndex ก็ยัง resolve เป็นฟอนต์ตัวหนาได้ — overlay ให้ style ทันทีที่มีใครแตะพิกัดนั้น นี่คือกลไกเดียวกับที่กันไม่ให้คอลัมน์ที่มีรูปแบบแต่ไม่มีข้อมูลคิดราคาเป็นเซลล์ล้านตัวทางฝั่ง XLSX บันทึกเรื่องการเก็บเซลล์แบบ row block และ interval style overlay อธิบายว่า overlay ซ้อนและ resolve กันอย่างไร สิ่งใหม่ตรงนี้คือ importer ของ ODS สร้างมันขึ้นมาเองจาก repeat count แทนที่จะรอให้แอปพลิเคชันมาจัดรูปแบบช่วงข้อมูล

SaveAsODS เขียน repeat count กลับอย่างไร

ด้วยการแบ่งท้ายที่ว่างของชีตเฉพาะตรงที่อะไรบางอย่างเปลี่ยนจริง ๆ OdsBuildTableXml ตอนนี้ติดตามสองขอบเขต: contentMaxRow ซึ่งเป็นแถวสุดท้ายที่มีค่า, สูตร, hyperlink หรือการขึ้นหน้าใหม่ด้วยมือ และ maxRow ซึ่งขยายต่อผ่านเซลล์ว่างที่มีแต่ style, ความสูงรายแถว, LastRow ของทุก run และขอบล่างของทุก overlay เซลล์ว่างที่มีแต่ style ไม่นับเป็นเนื้อหาอีกต่อไป — TXLSXCells.IsStyleOnlyBlank คือตัวที่กันมันออก — แถวท้าย ๆ ที่มี style ในไฟล์ LibreOffice จึงเลิกฉุดขอบเขตเนื้อหาลงไปที่ก้นชีต

เหนือ contentMaxRow ขึ้นไป แถวถูกเขียนทีละแถวเหมือนเดิมทุกประการ ใต้ลงมา writer คำนวณ nextRow เป็นค่าที่น้อยที่สุดในบรรดา: FirstRow ของ run ถัดไป, LastRow + 1 ของ run ปัจจุบัน, entry ความสูงรายแถวตัวถัดไป, ขอบ overlay ตัวถัดไป และเซลล์ที่ถูกสร้างเป็นออบเจ็กต์ตัวถัดไป จากนั้นทุกอย่างตั้งแต่แถวปัจจุบันถึง nextRow - 1 จะถูก emit เป็น <table:table-row> ตัวเดียวโดยตั้ง table:number-rows-repeated เป็นผลต่าง พร้อม <table:table-cell/> หนึ่งตัวต่อคอลัมน์ที่ใช้ชื่อ style ซึ่ง resolve ผ่าน overlay เมื่อมี overlay ครอบคอลัมน์นั้น ตัว row style เองมาจาก TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec) ซึ่งตอนนี้พับข้อความความสูง — 12.81pt เป็นต้น — เข้าไปในคีย์ deduplication ของมันพร้อมกับธง hidden และ page-break ทุกแถวใน run จึงแชร์ style ro<N> ตัวเดียวที่มี property style:row-height อันเดียว

สิ่งที่ SaveAsODS เขียนสำหรับชีตที่หนุนด้วย run: contentMaxRow หยุดที่แถว 45 ตรงที่ค่าสิ้นสุด ขณะที่ maxRow ขยายผ่าน height run และ override ของมัน, แถวที่อยู่เหนือขอบเขตถูกเขียนทีละแถว และท้ายตารางถูก emit เป็น element table-row ที่ซ้ำกันซึ่ง row style มาจาก RowStyleFor และ style เซลล์ resolve ผ่าน overlay
element ที่ซ้ำแต่ละตัวครอบช่วงที่สม่ำเสมอหนึ่งช่วงและหยุดที่ขอบ run ถัดไป, entry ความสูง, ขอบ overlay หรือเซลล์ที่ถูกสร้างเป็นออบเจ็กต์ตัวถัดไป ชีตที่ไม่มี validation จึง save ออกมาเป็น element ไม่กี่ตัว ขณะที่ validation หรือการ export เป็น XLSX ต้องจ่ายเป็นรายแถว
var
  Workbook, Reopened: TXLSXWorkbook;
  Saved: TMemoryStream;
begin
  Workbook := TXLSXWorkbook.Create;
  Reopened := TXLSXWorkbook.Create;
  Saved := TMemoryStream.Create;
  try
    Workbook.OpenODS('conditional-formatting.ods');
    Workbook.Sheets[1].RowHeight[500000] := 36;
    Workbook.Sheets[1].ClearRowHeight(500001);
    // ท้ายที่ว่างถูกเขียนเป็นแถวซ้ำไม่กี่ตัว ไม่ใช่เป็นล้าน
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // override, รู และ run ทั้งหมดรอดผ่าน round trip
    Writeln(Reopened.Sheets[1].RowHeight[500000]:0:2);   // 36.00
    Writeln(Reopened.Sheets[1].HasRowHeight(500001));    // False
    Writeln(Reopened.Sheets[1].RowHeight[500002]:0:2);   // ความสูงของ run
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

เทสต์ที่ตอกพฤติกรรมนี้ไว้ assert ว่า stream ที่ save มีขนาดต่ำกว่า 64 KB สำหรับชีตที่ height run ครอบ 1,048,575 แถวโดยมี override และรูเจาะอยู่ตรงกลาง มีขอบเขตที่ต้องพูดตรง ๆ สองข้อที่ควรอยู่ข้างตัวเลขนั้น ข้อแรก เวิร์กชีตที่มี data validation ใด ๆ จะตั้ง contentMaxRow เท่ากับ maxRow ดังนั้น validation จะปิดการบีบท้ายตารางบนชีตนั้น แล้วมันก็ถูกเขียนทีละแถวอีกครั้ง ข้อสอง XLSX ไม่มี attribute สำหรับการซ้ำ — <row> ของ SpreadsheetML บรรยายหนึ่งแถว — การ export ชีตที่หนุนด้วย run ไปเป็น .xlsx จึงแจกแจงแถวที่ run ครอบอยู่แล้วเขียน attribute ht ลงในแต่ละแถว model ยังกะทัดรัดในหน่วยความจำ แต่ฟอร์แมตไฟล์เป็นตัวตัดสินว่าไฟล์จะออกมาหน้าตาอย่างไร

การแก้ที่เปลี่ยนหมายเลขแถวทุกครั้งติดค้างอะไรกับ run บ้าง

การดูแลรักษา ตัวแทนข้อมูลชุดใหม่ของ metadata แถวจะถูกก็ต่อเมื่อทุก operation ที่เปลี่ยนหมายเลขแถวพามันเคลื่อนไปพร้อมลิสต์รายแถวที่มันนั่งอยู่ข้าง ๆ และคอมมิตนี้ก็แตะทุก operation เหล่านั้น InsertRows กับ DeleteRows ผ่าน XlsxShiftRowHeightRuns ซึ่งสร้างอาร์เรย์ใหม่โดยเก็บส่วนของแต่ละ run ที่อยู่ก่อนจุดแก้, ทิ้งอะไรก็ตามที่ตกอยู่ในหน้าต่างการลบ และใส่ส่วนที่เหลือกลับโดยเลื่อนตาม delta — run ที่คร่อมการแทรกจึงกลายเป็นสอง run ที่มีช่องว่างระหว่างกัน และ run ที่คร่อมการลบก็หดสั้นลง TileRangeAxisMetadata ล้าง run ทั้งช่วงที่ tile แล้วลงทะเบียน run ต้นทางใหม่ครั้งหนึ่งต่อสำเนา ณ offset ของมัน TXLSXWorksheet.CopyFrom กับ TXLSXSheets.AddCopy ใช้ Copy() ของอาร์เรย์แทนที่จะ assign ตรง ๆ นั่นคือเหตุผลที่เทสต์สามารถ clear ความสูงทั้งหมดบนตัว clone แล้วยังพบว่าชีตต้นฉบับครบถ้วนอยู่ที่แถว 1,048,576

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // แทรกสองแถวที่ 500000: override เลื่อนไปที่ 500002 รูเลื่อนไปที่ 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // ลบมันออกอีกครั้ง: ทุกอย่างเลื่อนกลับ
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // tile แถว 2..4 สองครั้งลงไปในชีต; ความสูงของ run ตามแต่ละสำเนาไป
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // ความสูงของ run
end;

ขอบเขตฝั่งอ่านมีพันธะเดียวกัน GetUsedRange ดันขอบล่างของมันไปที่ FirstRow และ LastRow ของทุก run และ BuildRowMajorCellOrder ขยายแถวสูงสุดที่รวม metadata ของมันผ่านทุก run เพื่อให้ writer ของ XLSX ยังไปเยี่ยมแถวที่มีแต่ความสูง ถ้าคุณเพิ่มโครงสร้างที่คีย์ด้วยแถวของคุณเองทับบน object model ของ HotXLS สักวัน นี่คือ checklist: insert, delete, tile, copy, used range และ serializer ทุกตัว พลาดข้อเดียวความล้มเหลวก็เงียบ — ความสูงเพี้ยนไปตามจำนวนที่แทรก และไม่มีอะไร throw

อะไรที่ยังเป็นรายแถว และตัวเลขตอนนี้เป็นอย่างไร

ธง hidden, ระดับ outline และสถานะ collapsed ยังถูกขยายอยู่ row finisher วน SetRowHidden และ SetRowOutlineLevel หนึ่งครั้งต่อหนึ่งแถวที่ซ้ำ ชีตที่ซ่อนท้ายตารางล้านแถว หรือซ้อนมันไว้ใน table:table-row-group จึงจ่าย entry ต่อแถวให้กับ attribute เหล่านั้นแต่ละตัว การเปลี่ยนแปลงใน 2.382.2 จำกัดขอบเขตอยู่ที่สองสิ่งที่ HXLS-005 วัดจริง — ความสูงกับ style ว่างที่สืบทอดมา — และเทคนิค run แบบเดียวกันก็ใช้กับตัวอื่นได้ถ้ามีไฟล์เรียกร้องขึ้นมาสักวัน reader ของ ODS ก็ไม่ทำตาม style:use-optimal-row-height ด้วย row style ที่บอกว่า "optimal" แล้วให้ความสูงมาด้วย จะถูก import พร้อมความสูงนั้น

เทียบกับ corpus ตอนนี้ conditional-formatting.ods ทำรอบ open, assert, save, reopen และ re-assert จบใน 0.178 วินาทีบน Win32 และ 0.158 วินาทีบน Win64 โดยขั้น open เองใช้ 0.020 วินาที ซึ่งอยู่ในงบ 60 วินาทีที่ก่อนหน้านี้มันใช้จนหมด อินเทอร์เฟซระดับเวิร์กบุ๊กที่ฟอร์แมตนี้ไหลผ่านอธิบายไว้ในคำแนะนำแบบเดินทีละขั้นเรื่องการเปิดและบันทึกไฟล์ ODS และคันโยกอีกชุดที่กว้างกว่าสำหรับไฟล์ใหญ่มีอยู่ในประสิทธิภาพกับเวิร์กบุ๊กขนาดใหญ่ ส่วน element แถวของ ODF เองพร้อม attribute repeat และ style ระบุไว้ใน ODF 1.3 Part 3 §9.1.4

HotXLS อ่านและเขียน XLS, XLSX และ ODS จากโค้ด Delphi และ C++Builder แบบเนทีฟโดยไม่ต้องติดตั้ง Excel หรือ LibreOffice นั่นคือเหตุผลที่การซ้ำระดับล้านแถวเป็นเรื่องที่ไลบรารีต้อง model ให้ดี แทนที่จะโยนให้โปรเซสนอก — หน้า HotXLS Delphi spreadsheet component ลิสต์ฟอร์แมตที่รองรับและเวอร์ชัน RAD Studio