Bài viết kỹ thuật

Dòng lặp ODS thành row-height run trong HotXLS Delphi

HotXLS Delphi Component lưu một dòng ODS mang table:number-rows-repeated cùng chiều cao dòng thành một record TXLSXRowHeightRun duy nhất — dòng đầu, dòng cuối, một chiều cao — thay vì một entry chiều cao cho mỗi dòng lặp, và gộp style của các ô trống mà những dòng đó kế thừa vào một interval style overlay duy nhất. Đó là toàn bộ lý do HotXLS 2.382.2 mở một bảng tính có phần đuôi lặp 1.048.530 dòng trống trong 0,02 giây trong khi 2.382.1 bị timeout, và cũng là lý do cùng tệp đó được lưu ngược về ODS với số lần lặp còn nguyên thay vì thành một triệu dòng theo nghĩa đen

Tệp đang nói tới thì bình thường. LibreOffice Calc ghi một sheet mười bốn cột với 45 dòng dữ liệu, rồi mô tả mọi thứ bên dưới chúng bằng đúng một phần tử: <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 đặt style:row-height="0.452cm", và mỗi <table:table-column> mang một table:default-cell-style-name mà mọi ô trống trong run đều kế thừa. Cả content.xml nặng 103 KB. Không có gì trong tệp nói rằng nó “đắt”; phần đắt hoàn toàn là của chúng tôi

Cách HotXLS biến một dòng lặp ODS thành trạng thái gọn: phần tử content.xml với table:number-rows-repeated 1048530 và style ro1 ánh xạ thành một record TXLSXRowHeightRun duy nhất trải từ dòng 46 tới 1048575 ở 12,81 pt cộng một entry StyleOverlays cho mỗi cột, trong khi bản 2.382.1 mở rộng cùng phần tử đó thành một triệu entry SetRowHeight và cell object
Số lần lặp, chiều cao dòng ro1 và default style của cột mô tả mọi dòng trống bên dưới dòng 45, nên bộ import có thể dựng một record run và các overlay theo cột mà không phải chạm tới một triệu tọa độ

Vì sao một dòng lặp làm timeout một lần import ODS?

Vì bộ import từng mở rộng nó ra. Ở 2.382.1, phần kết thúc dòng lặp SetRowHeight(RowIndex + i, RowHeight) một lần cho mỗi dòng lặp, ghi từng chiều cao vào một string list Name=Value khóa theo số dòng. Mỗi lần chèn vào list đó lại chạy một phép tra IndexOfName trên mọi thứ đã có trong đó, nên một triệu chiều cao tốn một triệu lượt quét tuyến tính — đúng kiểu tìm kiếm bậc hai trên list mà HXLS-005 được mở để xử lý. Cùng lúc đó OdsCommitRow vật chất hóa một cell object cho mọi cột kế thừa style, trên mỗi dòng lặp, vì một ô trống có style vẫn bị tính là một ô

Phía lưu cũng có phiên bản vấn đề của riêng nó. Tệp LibreOffice kết thúc bằng một dòng ro1 nữa sau lần lặp lớn, nên dòng có style cao nhất nằm tận đáy sheet, và OdsBuildTableXml duyệt từng dòng cho tới đó và phát ra các phần tử <table:table-row> mỗi lần một cái. Kể cả một workbook được import rẻ cũng sẽ ghi đắt. Vá phần import mà không vá phần export thì chỉ dời chỗ timeout đi chứ không xóa nó

Row-height run trong HotXLS là gì?

Một run là thứ nhỏ nhất có thể mô tả “các dòng 46 tới 1.048.575 đều cao 12,81 point” mà không phải nói điều đó 1.048.530 lần. TXLSXRowHeightRun là một record gồm FirstRow, LastRow và Height; TXLSXRowHeightRuns là một mảng động của chúng, và mỗi TXLSXWorksheet giữ một mảng trong FRowHeightRuns bên cạnh danh sách chiều cao theo từng dòng đã có. Khi import ODS, phần kết thúc dòng giờ rẽ nhánh theo số lần lặp: số lần bằng 1 vẫn gọi SetRowHeight, còn lớn hơn thì gọi XlsxAssignRowHeightRun một lần cho cả dải. Dải được kẹp vào XlsxMaxRow, tức 1.048.576, nên một số lần lặp vượt quá sheet sẽ bị cắt bớt chứ không bị từ chối

XlsxAssignRowHeightRun là writer duy nhất của mảng này, và nó giữ các run rời nhau ngay từ cách dựng. Nhận một khoảng mới, nó chép mọi run hiện có nằm hoàn toàn ngoài khoảng đó, cắt bất kỳ run nào chồng lên thành phần trước và phần sau, rồi nối khoảng mới vào khi Present là true — hoặc không nối gì khi Present là false, và đó là cách ClearRowHeight đục một lỗ một dòng. Hai hệ quả đi kèm. Mảng không bao giờ chứa các khoảng chồng nhau, nên một phép tra có thể dừng ngay ở kết quả đầu tiên. Và mảng không bao giờ bị sửa tại chỗ; một bản sao mới được dựng ở mọi lần gọi, tốn không đáng kể ở các kích thước liên quan mà lại xóa sạch cả một lớp bug aliasing

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Một sheet có dòng đuôi lặp 1.048.530 lần dưới một row style
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // Cả hai lần đọc đều phân giải qua cùng một run; không có gì bị mở rộng
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // Một override một dòng che run mà không cắt nó
    Sheet.RowHeight[500000] := 36;
    // Xóa một dòng bên trong run sẽ cắt run thành hai mảnh
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // vẫn là chiều cao của run
  finally
    Workbook.Free;
  end;
end;

Thứ tự tra cứu là phần đáng nhớ. TXLSXWorksheet.GetRowHeight kiểm tra danh sách theo dòng trước và chỉ hỏi các run khi dòng đó không có entry tường minh, và HasRowHeight cũng vậy. Nên Sheet.RowHeight[500000] := 36 không hề đụng tới run — nó thêm một entry vào danh sách theo dòng, và entry đó thắng vì được tra trước. ClearRowHeight thì ngược lại: nó bỏ mọi entry theo dòng rồi gọi XlsxAssignRowHeightRun với Present = False, vì một dòng đã xóa phải đọc ra là “không có chiều cao” kể cả khi một run phủ lên nó. ClearRowHeights làm rỗng cả hai cấu trúc cùng lúc

Phẫu thuật row-height run trong HotXLS: sau OpenODS một run phủ các dòng 46 tới 1048575 ở 12,81 pt trong khi một entry theo dòng đặt dòng 500000 thành 36 pt và thắng phép tra vì GetRowHeight kiểm tra danh sách theo dòng trước, và ClearRowHeight dòng 500001 cắt run thành hai mảnh rời nhau quanh cái lỗ
XlsxAssignRowHeightRun chép các mảnh nằm ngoài khoảng bị xóa và không nối gì cho chính khoảng đó, nên các run rời nhau ngay từ cách dựng và một phép tra có thể dừng ở kết quả đầu tiên trong khi override tại dòng 500000 vẫn nguyên vẹn

Các style ô trống được kế thừa đi đâu?

Vào một interval style overlay cho mỗi cột, chứ không vào các cell object. OdsCommitRow quyết định theo từng cột xem đó có phải một ô trống gọn: dòng lặp nhiều hơn một lần, ô không có giá trị, không có công thức và không có rich text. Với một ô trống gọn, nó chỉ tạo một ô thật ở dòng đầu của run, áp style kế thừa vào đó, rồi đăng ký sáu style index y hệt — font, fill, border, number format, alignment, protection — dưới dạng một StyleOverlays.Add phủ từ dòng thứ hai tới hết run ở cột đó. Các dòng sau dòng đầu bị bỏ qua hoàn toàn trong vòng lặp vật chất hóa

Bài regression làm hình dạng đó cụ thể. Sau khi mở một sheet có dòng thứ hai lặp 1.048.575 lần dưới một default style cột in đậm, Sheet.Cells.Count được assert là dưới 10, và Sheet.Cells[700000, 1].FontIndex vẫn phân giải ra font đậm — overlay cung cấp style ngay khi tọa độ đó bị chạm tới. Đây cũng chính là cơ chế giữ cho một cột có định dạng nhưng rỗng không tốn một triệu ô ở phía XLSX; bài về lưu trữ ô theo row-block và interval style overlay trình bày cách các overlay xếp lớp và phân giải. Điểm mới ở đây là bộ import ODS tự tạo chúng, từ số lần lặp, chứ không chờ ứng dụng định dạng một vùng

SaveAsODS ghi số lần lặp trở lại như thế nào?

Bằng cách chỉ cắt phần đuôi rỗng của sheet ở những chỗ thật sự có thay đổi. OdsBuildTableXml giờ theo dõi hai biên: contentMaxRow, dòng cuối cùng chứa giá trị, công thức, hyperlink hay ngắt dòng thủ công, và maxRow, biên này còn kéo dài qua các ô trống chỉ có style, các chiều cao một dòng, LastRow của mọi run và mép dưới của mọi overlay. Một ô trống chỉ có style không còn bị tính là nội dung — TXLSXCells.IsStyleOnlyBlank là thứ loại nó ra — nên dòng có style nằm ở cuối tệp LibreOffice không còn kéo biên nội dung xuống tận đáy sheet

Phía trên contentMaxRow, các dòng vẫn được ghi từng cái một y như trước. Phía dưới, writer tính nextRow là giá trị nhỏ nhất trong: FirstRow của run kế tiếp, LastRow + 1 của run hiện tại, entry chiều cao một dòng kế tiếp, mép overlay kế tiếp, và ô được vật chất hóa kế tiếp. Toàn bộ từ dòng hiện tại tới nextRow - 1 sau đó được phát ra thành một <table:table-row> với table:number-rows-repeated đặt bằng hiệu số, mang mỗi cột một <table:table-cell/> cùng tên style đã phân giải qua overlay khi có overlay phủ cột đó. Bản thân row style đến từ TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), thứ giờ gộp cả đoạn text chiều cao — ví dụ 12.81pt — vào key khử trùng lặp của nó bên cạnh cờ hidden và page-break, nên mọi dòng trong run dùng chung một style ro<N> với đúng một thuộc tính style:row-height

Điều SaveAsODS ghi cho một sheet dựa trên run: contentMaxRow dừng ở dòng 45 nơi các giá trị kết thúc trong khi maxRow kéo dài qua height run và các override của nó, các dòng phía trên biên được ghi từng cái một, và phần đuôi được phát ra thành các phần tử table-row lặp có row style đến từ RowStyleFor và style ô phân giải qua các overlay
Mỗi phần tử lặp trải đúng một đoạn đồng nhất và dừng ở mép run, entry chiều cao, mép overlay hay ô được vật chất hóa kế tiếp, nên một sheet không có validation lưu thành vài phần tử còn validation hoặc export XLSX thì trả giá theo từng dòng
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);
    // Phần đuôi rỗng được ghi thành một vài dòng lặp, không phải một triệu
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // Override, lỗ đục và run đều sống sót qua vòng 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);   // chiều cao của run
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

Bài test ghim hành vi này assert rằng stream đã lưu dưới 64 KB với một sheet có height run trải 1.048.575 dòng kèm một override và một lỗ đục ở giữa. Hai ranh giới trung thực cần nói ngay bên cạnh con số đó. Thứ nhất, một worksheet có bất kỳ data validation nào sẽ đặt contentMaxRow bằng maxRow, nên validation tắt việc nén đuôi trên sheet đó và nó lại được ghi từng dòng. Thứ hai, XLSX không có attribute repeat — một <row> của SpreadsheetML mô tả đúng một dòng — nên export một sheet dựa trên run sang .xlsx sẽ liệt kê các dòng mà run phủ và ghi một attribute ht trên mỗi dòng. Model vẫn gọn trong bộ nhớ; định dạng tệp quyết định tệp trông thế nào

Mọi thao tác đánh số lại dòng giờ nợ các run điều gì?

Việc bảo trì. Một cách biểu diễn mới cho metadata dòng chỉ đúng nếu mọi thao tác làm đổi số dòng đều di chuyển nó cùng các danh sách theo dòng nằm cạnh, và commit này chạm vào từng thao tác như vậy. InsertRows và DeleteRows đi qua XlsxShiftRowHeightRuns, thứ dựng lại mảng bằng cách giữ phần của mỗi run nằm trước điểm sửa, bỏ phần rơi vào cửa sổ xóa, và thêm lại phần còn lại đã dịch theo delta — nên một run vắt qua điểm chèn biến thành hai run có khe hở, còn một run vắt qua vùng xóa thì co lại. TileRangeAxisMetadata xóa các run trên cả dải được tile, rồi đăng ký lại từng run nguồn một lần cho mỗi bản sao tại offset của nó. TXLSXWorksheet.CopyFrom và TXLSXSheets.AddCopy lấy một Copy() của mảng chứ không gán thẳng, và đó là lý do bài test có thể xóa sạch chiều cao trên một bản clone mà vẫn thấy sheet gốc nguyên vẹn ở dòng 1.048.576

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // Chèn hai dòng tại 500000: override dời sang 500002, lỗ đục sang 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // Xóa chúng lần nữa: mọi thứ dịch về như cũ
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // Tile các dòng 2..4 hai lần xuống dưới sheet; chiều cao của run đi theo mỗi bản sao
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // chiều cao của run
end;

Các biên ở phía đọc cũng có nghĩa vụ tương tự. GetUsedRange nâng mép dưới của nó tới FirstRow và LastRow của mỗi run, còn BuildRowMajorCellOrder kéo dòng tối đa tính cả metadata của nó qua mọi run để writer XLSX vẫn ghé qua những dòng chỉ có chiều cao. Nếu bạn có thêm một cấu trúc khóa theo dòng của riêng mình trên object model HotXLS, đây là danh sách kiểm tra: chèn, xóa, tile, copy, used range, và mọi serializer. Bỏ sót một cái là lỗi âm thầm — chiều cao lệch đi đúng bằng số dòng chèn, và không có gì ném exception cả

Cái gì vẫn theo từng dòng, và các con số giờ ra sao

Cờ hidden, outline level và trạng thái collapsed vẫn bị mở rộng. Phần kết thúc dòng lặp SetRowHidden và SetRowOutlineLevel một lần cho mỗi dòng lặp, nên một sheet ẩn cả phần đuôi một triệu dòng, hay lồng nó trong một table:table-row-group, vẫn trả một entry cho mỗi dòng cho từng attribute đó. Thay đổi ở 2.382.2 chỉ nhắm vào đúng hai thứ mà HXLS-005 thật sự đo — chiều cao và style ô trống kế thừa — và cùng kỹ thuật run đó sẽ áp được cho các thứ khác nếu có tệp nào đòi hỏi. Reader ODS cũng không hành động theo style:use-optimal-row-height; một row style nói “optimal” mà lại cho kèm chiều cao thì được import với chính chiều cao đó

So với corpus, conditional-formatting.ods giờ hoàn tất chu trình mở, assert, lưu, mở lại và assert lại trong 0,178 giây trên Win32 và 0,158 giây trên Win64, với riêng giai đoạn mở là 0,020 giây, nằm gọn trong ngân sách 60 giây mà trước đây nó từng đốt hết. Các interface cấp workbook mà định dạng này đi qua được mô tả trong bài hướng dẫn mở và lưu tệp ODS, còn tập đòn bẩy rộng hơn cho tệp lớn nằm ở hiệu năng workbook lớn; bản thân phần tử row của ODF, với các attribute repeat và style của nó, được quy định ở ODF 1.3 Phần 3 §9.1.4

HotXLS đọc và ghi XLS, XLSX cùng ODS từ code Delphi và C++Builder thuần, không cần cài Excel hay LibreOffice, và đó là lý do một dòng lặp hàng triệu lần là thứ thư viện phải model cho tử tế chứ không thể giao cho một tiến trình bên ngoài — trang component bảng tính HotXLS cho Delphi liệt kê các định dạng hỗ trợ và phiên bản RAD Studio