Bài viết kỹ thuật

Pivot ODS round-trip trong Delphi: phạm vi namespace

HotXLS Delphi Excel Component giữ được bảng data pilot của OpenDocument qua một vòng mở rồi lưu tệp ODS bằng cách chụp nguyên văn cây con <table:data-pilot-tables> trong content.xml lúc mở rồi phát lại khi lưu, kể từ v2.382.0. Từ v2.382.1, mảnh này còn mang theo mọi binding namespace XML mà các phần tử tổ tiên của nó khai báo, nên định nghĩa pivot được lưu vẫn well-formed với mọi bên tiêu thụ, không chỉ với HotXLS

Lỗi buộc phải làm cả hai thay đổi lộ ra từ một lần chạy corpus nghiêm ngặt. Mẫu official-pivot.ods, do một bản dựng phát triển của LibreOffice 6.1 ghi, chứa một pivot tên DataPilot1 đọc Sheet1.A2:E30 và đặt kết quả vào Sheet1.G6:J18. Mở nó bằng HotXLS, lưu nguyên trạng, rồi đếm số phần tử <table:data-pilot-table> trong đầu ra: một vào, không ra, trên cả Win32 lẫn Win64. Không có gì trong bài test đụng vào pivot. Vòng thăm dò đầu tiên chỉ so các hằng số trong ô và đã cho qua; chính assertion về cấu trúc mới phơi ra mất mát đó, một lời nhắc rằng “giá trị khớp nhau” là một định nghĩa yếu về độ trung thực round-trip

Vì sao một bảng pivot ODS biến mất sau khi thư viện lưu?

Một bảng pivot ODS biến mất vì HotXLS không có model trong bộ nhớ cho bảng data pilot của OpenDocument, còn writer ODS lại dựng content.xml hoàn toàn từ model. Writer lắp các automatic style, mỗi worksheet một <table:table>, rồi <table:content-validations>, <table:named-expressions> và <table:database-ranges>, tất cả đều sinh từ các object mà workbook thật sự chứa. Một định nghĩa pivot — ODF 1.3 Phần 3 §9.6, một container <table:data-pilot-tables> với mỗi pivot một <table:data-pilot-table>, mang table:source-cell-range, các con table:data-pilot-field, table:target-range-address và table:buttons — không có object nào để trú ngụ, nên part được sinh lại đơn giản là bỏ nó đi

Sự tương phản với XLSX là có chủ ý. HotXLS parse pivot cache và pivot table của SpreadsheetML thành một model thật mà bạn có thể dựng, mở rộng bằng calculated field và refresh từ Delphi, nên chúng sống sót qua lần lưu vì được ghi lại chứ không phải sao chép. Pivot ODS là nhu cầu hiếm hơn nhiều, và mô hình hóa cả từ vựng data pilot của ODF chỉ để phục vụ round-trip sẽ là một đống code không ai sửa. Câu trả lời thực dụng cũng là câu HotXLS đã áp cho các khối extLst không biết trong XLSX: giữ những gì bạn không model, từng byte nếu làm được, từng event nếu không

Cách chụp bằng Pos đầu tiên sai ở đâu?

Bản chụp v2.382.0 cắt định nghĩa pivot ra khỏi content.xml như một chuỗi trần, và lát cắt đó thiếu đúng các khai báo namespace khiến nó có nghĩa. Cách làm ngắn đúng như tên gọi — giải mã part thành một WideString, tìm thẻ mở bằng Pos, tìm thẻ đóng phía sau nó, chép đoạn đó vào FRawOdsDataPilotTablesXml trên workbook:

// HotXLS v2.382.0 -- bị thay thế một bản phát hành sau đó
function OdsCaptureDataPilotTablesXml(Stream: TStream): WideString;
const
  OpenTag: WideString = '<table:data-pilot-tables';
  CloseTag: WideString = '</table:data-pilot-tables>';
var
  Text: WideString;
  StartPos, ClosePos: Integer;
begin
  Result := '';
  Text := LoadPartAsWideString(Stream);   // cả content.xml nằm trong bộ nhớ
  StartPos := Pos(OpenTag, Text);
  if StartPos = 0 then Exit;
  ClosePos := Pos(CloseTag, Copy(Text, StartPos, MaxInt));
  if ClosePos = 0 then Exit;
  Result := Copy(Text, StartPos, ClosePos + Length(CloseTag) - 1);
end;

Assertion đếm số chuyển xanh, và bản vá được phát hành. Thứ bắt được nó là một phép kiểm tra thứ hai, nghiêm hơn, thêm vào cùng ngày: mọi part XML của package đã lưu đều được đưa qua một parser độc lập, hiểu namespace, nằm ngoài HotXLS, và parser đó từ chối content.xml mới với lỗi prefix không được bind. Pivot từ LibreOffice mang các attribute mở rộng của nhà sản xuất — loext:ignore-selected-page="true" trên một page field, calcext:repeat-item-labels="false" trên mọi level — và chuỗi bị cắt có chứa các attribute đó nhưng không có khai báo xmlns:loext cùng xmlns:calcext để bind chúng. Những khai báo đó nằm trên gốc <office:document-content> của tệp nguồn, ba mươi lăm cái, cách pivot hai nghìn ký tự

W3C Namespaces in XML 1.0 §6.1 định nghĩa quy tắc khiến đây là lỗi cứng chứ không phải lỗi hình thức: một khai báo namespace có hiệu lực từ thẻ mở của phần tử chứa nó tới thẻ đóng của phần tử đó, và mọi tên có prefix bên trong phạm vi ấy đều phân giải theo nó. Cắt một cây con ra khỏi tài liệu là cắt nó ra khỏi phạm vi đó. HotXLS tự ghi gốc <office:document-content> với mười một khai báo — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — nên calcext: tình cờ phân giải được, table: tình cờ phân giải được, còn loext: thì không. Một parser hiểu namespace coi prefix không được bind là vi phạm tính well-formed, nghĩa là cả part không đọc được, chứ không chỉ một attribute

Điều mà cách chụp bằng Pos trên official-pivot.ods bỏ sót trong HotXLS: cây con pivot mang các attribute mở rộng loext và calcext trong khi các khai báo xmlns bind chúng nằm trên gốc office:document-content cách đó ba mươi lăm binding, nên mảnh bị cắt để lại mọi prefix nó dùng ở trạng thái không bind và một parser hiểu namespace từ chối cả content.xml
Một khai báo namespace có hiệu lực từ thẻ mở tới thẻ đóng của nó, và cắt một cây con ra khỏi tài liệu là cắt nó ra khỏi phạm vi ấy, biến một attribute thành cả một part không đọc được

HotXLS mang các binding xmlns của tổ tiên lên mảnh đó như thế nào?

HotXLS v2.382.1 thay lát cắt chuỗi bằng một lượt duyệt content.xml qua TXMLReader streaming của chính nó, duy trì một stack các binding namespace được gắn nhãn độ sâu nơi mỗi cái được khai báo, và chép những binding còn hiệu lực lên phần tử gốc của mảnh ngay khi chạm tới đích. Reader chạy với PreserveWhitespaceText bật để các text node quay về đúng như đã ghi, và các thẻ được dựng lại dùng TXMLReader.RawName cùng TXMLReader.Attribute[I].RawName — cách viết prefix trong tệp — thay vì tên chuẩn hóa mà reader thường đưa cho các parser part. Đây là phần lõi của vòng lặp:

Cách HotXLS v2.382.1 chụp cây con data pilot cùng phạm vi namespace của nó: một lượt TXMLReader streaming giữ stack các binding xmlns được gắn nhãn độ sâu khai báo, duyệt từ trong cùng ra ở đích table:data-pilot-tables, tôn trọng shadowing qua một tập Seen, bỏ qua các prefix mà chính phần tử khai báo và pop binding ở cả thẻ đóng lẫn phần tử rỗng
Khớp đích theo tên chuẩn hóa của reader giúp các nhà sản xuất viết lại prefix table vẫn chạy được, và một cây con không bao giờ đóng sẽ ném lỗi thay vì ghi lại một mảnh nửa vời khi lưu
// Namespaces: TStringList gồm 'xmlns:p=uri' kèm độ sâu khai báo trong Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // ghi element, text, CDATA, comment
  if Reader.NodeType = xmlntElement then
  begin
    for I := 0 to Reader.AttributeCount - 1 do
    begin
      AttrName := Reader.Attribute[I].RawName;
      if (AttrName = 'xmlns') or (Pos(WideString('xmlns:'), AttrName) = 1) then
        Namespaces.AddObject(String(AttrName) + '=' + String(Reader.Attribute[I].Value),
          TObject(NativeInt(Depth)));
    end;
    if (CaptureDepth < 0) and (Reader.Name = 'table:data-pilot-tables') then
    begin
      Opening := XlsxRawXmlReaderOpenTag(Reader);   // bỏ dấu '>' hoặc '/>' ở cuối trước đã
      ...
      // Mang các binding còn hiệu lực của tổ tiên lên gốc của mảnh.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // binding ở trong cùng thắng
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // đã khai báo ngay ở đây? thì bỏ qua
          Opening := Opening + ' ' + AttrName + '="' +
            XlsxEscapeAttr(WideString(Namespaces.ValueFromIndex[I])) + '"';
      end;
      ...
      CaptureDepth := Depth;
    end;
    if not Reader.IsEmptyElement then Inc(Depth);
  end
  else if Reader.NodeType = xmlntEndElement then
  begin
    Dec(Depth);
    if Depth = CaptureDepth then Exit;                           // cây con đã đóng
  end;
  if (Reader.NodeType = xmlntEndElement) or
     ((Reader.NodeType = xmlntElement) and Reader.IsEmptyElement) then
    while (Namespaces.Count > 0) and
          (NativeInt(Namespaces.Objects[Namespaces.Count - 1]) >= Depth) do
      Namespaces.Delete(Namespaces.Count - 1);                   // rời khỏi phạm vi
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Ba chi tiết trong vòng lặp đó gánh phần đúng đắn. Duyệt stack từ binding trong cùng ra ngoài và ghi nhớ từng prefix trong Seen chính là cài đặt shadowing: nếu một tổ tiên gần hơn rebind xmlns:table, giá trị gần hơn thắng, đúng như §6.1 yêu cầu. Bỏ qua những prefix mà chính phần tử đã khai báo tránh phát cùng một attribute hai lần, vốn là một lỗi well-formedness khác. Và quy tắc pop kích hoạt ở cả thẻ đóng lẫn phần tử rỗng, vì <x/> không bao giờ sinh ra event EndElement — đúng cái bẫy self-closing mà phép chụp extLst trong XLSX cũng phải học. Khớp đích theo Reader.Name thay vì RawName là một thắng lợi kín đáo hơn: reader chuẩn hóa URI namespace table của ODF về prefix table, nên một nhà sản xuất viết t:data-pilot-tables vẫn khớp, trong khi mảnh được phát ra vẫn giữ nguyên prefix mà nhà sản xuất dùng

Vòng lặp cũng từ chối đoán mò. Nếu part kết thúc khi phép chụp vẫn còn mở — một content.xml bị cắt hay sai định dạng — OdsCaptureDataPilotTablesXml ném lỗi thay vì trả về một mảnh nửa vời, vì mảnh nửa vời sẽ được ghi ngược lại khi lưu và biến một đầu vào hỏng thành một đầu ra hỏng mang tên thư viện

Mảnh đó nằm ở đâu trong content.xml đã lưu?

HotXLS ghi mảnh đã chụp vào <office:spreadsheet> ngay sau <table:named-expressions> mà nó sinh ra và trước <table:database-ranges>. Model nội dung của <office:spreadsheet> trong ODF 1.3 Phần 3 quy định một thứ tự cố định cho các phần tử con ở cuối đó, nên một khối nguyên văn không thể cứ nối vào chỗ writer đang đứng; nó phải được đặt vào đúng một khe nhất định. Từ phía người gọi thì không có API nào và cũng không có gì để cấu hình; định nghĩa đi kèm một lần mở rồi lưu bình thường:

Mảnh định nghĩa pivot đã chụp nằm ở đâu trong một lần lưu ODS của HotXLS: các con của office:spreadsheet đi theo thứ tự cố định của ODF từ các phần tử table được sinh ra qua table:content-validations và table:named-expressions, khối table:data-pilot-tables nguyên văn được đặt vào trước table:database-ranges, và không có API nào vì định nghĩa đi kèm OpenODS cùng SaveAsODS
Một khối nguyên văn không thể nối vào chỗ writer đang đứng, và các bản sao binding của tổ tiên mà nó mang theo là vô hại vì Namespaces in XML cho phép khai báo lại một prefix trong phạm vi lồng nhau
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS('official-pivot.ods') <> 1 then
      raise Exception.Create('open failed');
    Book.Sheets[0].Cells[2, 5].Value := 1250.0;   // sửa một ô nằm trong vùng nguồn của pivot
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml trong đầu ra vẫn mang DataPilot1 cùng
    // vùng nguồn, các field, vùng đích, các button và attribute loext:/calcext:
  finally
    Book.Free;
  end;
end;

Việc dư thừa là cố ý và đáng để biết. Gốc của mảnh giờ lặp lại xmlns:table và xmlns:calcext dù gốc tài liệu đã lưu cũng khai báo chúng; Namespaces in XML cho phép khai báo lại một prefix trong một phạm vi lồng nhau, nên các bản trùng đó vô hại. Với mẫu LibreOffice, tập được mang theo là cả ba mươi lăm khai báo ở gốc, khoảng hai kilobyte cộng thêm vào định nghĩa dài 8.357 ký tự, vì phép chụp không phân tích xem cây con thật sự dùng những prefix nào. Một lượt quét prefix được dùng sẽ cắt bớt phần đó, và có thể sẽ làm sau; đúng đắn trước, gọn gàng sau

Một quy tắc để cắt cây con khỏi XML cho việc phát lại nguyên văn

Bài học chung là một cây con chỉ tự đứng vững sau khi bạn làm cho nó tự đứng vững, và phạm vi namespace là thứ đầu tiên vỡ khi bạn quên. Danh sách kiểm tra mà HotXLS giờ áp cho mọi phép chụp kiểu “giữ những gì ta không model”:

  • Duyệt tài liệu bằng một reader thật và theo dõi các binding đang có hiệu lực. Tìm chuỗi bằng Pos hoàn toàn không nhìn thấy phạm vi, và nó còn khớp sai ở các phần tử lồng nhau cùng tên, ở một chuỗi khớp nằm trong comment hay khối CDATA, và ở những giá trị attribute tình cờ chứa đoạn text của thẻ
  • Chép các binding còn hiệu lực lên gốc của mảnh, trong cùng trước, mỗi prefix một lần, bỏ qua những gì gốc đã khai báo
  • Giữ nguyên cách viết prefix thô trong các thẻ được phát ra; khớp đích theo namespace đã phân giải, không theo prefix nguyên văn
  • Giữ nguyên các text node chỉ chứa khoảng trắng, và nhớ rằng một phần tử rỗng tự đóng phạm vi của nó mà không có event thẻ đóng
  • Kiểm tra part đã lưu bằng một parser không phải chính thư viện đang được test. Thư viện sẽ vui vẻ đọc lại đầu ra của chính nó qua đúng đường code dễ dãi đã ghi ra nó

Điểm cuối mới là thứ thật sự tìm ra HXLS-003 trong lần thứ hai. Phép kiểm tra nghiệm thu của v2.382.0 là một regular expression đếm thẻ mở data-pilot-table trong content.xml đã lưu, mà regular expression chỉ thấy một cái thẻ chứ không thấy một tài liệu — nó mù trước việc các prefix trên thẻ đó có được bind hay không. Bộ chạy corpus nghiêm ngặt thêm ở v2.382.1 parse mọi part XML và .rels của package đã lưu bằng một parser hiểu namespace rồi so cây pivot — thẻ, attribute đã sắp, text, các con, đệ quy — với bản gốc. Phép so đó có mở rộng namespace, nên viết lại prefix theo cách khác vẫn qua được còn prefix không bind thì không

Bảo đảm nguyên văn dừng ở đâu

Phát lại nguyên văn giữ được một định nghĩa; nó không hiểu định nghĩa đó, và các ranh giới đều suy ra từ đó. HotXLS không phơi API nào để đọc, sửa hay refresh một pivot ODS, nên FRawOdsDataPilotTablesXml là một trường nội bộ và hành vi quan sát được duy nhất là định nghĩa đó sống sót. Mảnh này được tuần tự hóa lại từ các event của reader chứ không được sao chép theo byte: cách đặt dấu ngoặc cho attribute và dạng tự đóng được chuẩn hóa, còn text và khoảng trắng giữ nguyên. XML đã chụp chỉ được phát ra bởi writer nội dung ODS, nên một workbook mở từ .ods rồi lưu thành .xlsx sẽ mất pivot, và một workbook mở từ .xlsx thì không có gì để phát lại vào lần lưu .ods — những bất đối xứng giữa đường import và export ODS áp dụng ở đây như ở mọi nơi. Và vì định nghĩa là bất khả tri, nó không thể đi theo các sửa đổi của bạn: đổi tên Sheet1 hay di chuyển dữ liệu nguồn trong HotXLS thì pivot đã lưu vẫn trỏ vào Sheet1.A2:E30, để bên tiêu thụ báo một vùng hỏng ở lần refresh kế tiếp. Một lưu ý về thứ tự cũng thuộc về đây: HotXLS phát các vùng AutoFilter dưới dạng <table:database-ranges> sau mảnh pivot, mà mẫu trong corpus lại không mang database range nào, nên một workbook có cả filter lẫn pivot nên được chạy qua một ODF schema validator trước khi bạn tin vào thứ tự tương đối của hai phần tử đó

Hãy test bằng tệp của chính nhà sản xuất bạn dùng, chứ không chỉ bằng mẫu trong corpus. Việc mang namespace theo xử lý được mọi prefix mà một nhà sản xuất khai báo trên một phần tử tổ tiên, nhưng một tài liệu khai báo prefix ngay trên chính phần tử pivot, hoặc dùng default namespace cho từ vựng table, sẽ chạm vào các nhánh bỏ qua và shadowing mà mẫu LibreOffice không chạm tới. Cả hai nhánh đều đã được cài đặt; chưa nhánh nào có mẫu trong corpus, và sự phân biệt đó đúng là kiểu thứ mà một dòng changelog hay làm mờ đi

Phép chụp data pilot nguyên văn ở v2.382.0 và bản vá phạm vi namespace ở v2.382.1 đều có trong HotXLS Delphi Excel Component hiện tại, với trang sản phẩm liệt kê đầy đủ phạm vi đọc ghi ODS, XLSX và XLS cho Delphi cùng C++Builder