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

ODS Pivot Table Round-Trip ใน Delphi: ขอบเขต XML namespace

HotXLS Delphi Excel Component เก็บ data pilot table ของ OpenDocument ให้รอดผ่านรอบ open-and-save ของ ODS ด้วยการเก็บ subtree <table:data-pilot-tables> ของ content.xml ไว้แบบ verbatim ตอนเปิด แล้ว replay มันตอน save ซึ่งทำมาตั้งแต่ v2.382.0 และตั้งแต่ v2.382.1 fragment นั้นยังพก XML namespace binding ทุกตัวที่ ancestor ของมันประกาศไว้มาด้วย นิยาม pivot ที่ save ออกมาจึงยัง well-formed สำหรับ consumer ตัวไหนก็ตาม ไม่ใช่แค่สำหรับ HotXLS

bug ที่บังคับให้ต้องแก้ทั้งสองอย่างโผล่มาจากการรัน corpus แบบเข้มงวด ตัวอย่าง official-pivot.ods ที่เขียนโดย LibreOffice 6.1 development build มี pivot หนึ่งตัวชื่อ DataPilot1 ที่อ่าน Sheet1.A2:E30 แล้ววางผลไว้ที่ Sheet1.G6:J18 เปิดมันด้วย HotXLS, save โดยไม่แก้อะไร, นับ element <table:data-pilot-table> ใน output: เข้าหนึ่งตัว ออกศูนย์ตัว ทั้งบน Win32 และ Win64 เหมือนกัน ไม่มีอะไรในเทสต์ไปแตะ pivot เลย รอบตรวจแรกเทียบแค่ค่าคงที่ในเซลล์และผ่านไปได้ structural assertion ต่างหากที่เปิดเผยการสูญหาย ซึ่งเตือนให้รู้ว่า "ค่าตรงกัน" เป็นนิยามที่อ่อนมากของ round-trip fidelity

ทำไม ODS pivot table ถึงหายไปหลัง save ด้วยไลบรารี

ODS pivot table หายไปเพราะ HotXLS ไม่มี model ในหน่วยความจำสำหรับ data pilot table ของ OpenDocument และ writer ของ ODS ก็สร้าง content.xml จาก model ล้วน ๆ writer ประกอบ automatic style, <table:table> หนึ่งตัวต่อหนึ่งเวิร์กชีต, <table:content-validations>, <table:named-expressions> และ <table:database-ranges> โดยแต่ละส่วนสร้างจากออบเจ็กต์ที่เวิร์กบุ๊กมีอยู่จริง นิยาม pivot — ODF 1.3 Part 3 §9.6 ซึ่งเป็น container <table:data-pilot-tables> ที่มี <table:data-pilot-table> หนึ่งตัวต่อหนึ่ง pivot พร้อม table:source-cell-range, ลูก ๆ อย่าง table:data-pilot-field, table:target-range-address และ table:buttons — ไม่มีออบเจ็กต์ให้อยู่ ส่วนที่สร้างใหม่จึงตัดมันทิ้งไปเฉย ๆ

การเทียบกับ XLSX เป็นเรื่องที่ตั้งใจ HotXLS parse pivot cache และ pivot table ของ SpreadsheetML เข้าเป็น model จริงที่คุณสร้าง, ต่อเติมด้วย calculated field และ refresh ได้จาก Delphi พวกมันจึงรอดผ่านการ save เพราะถูกเขียนใหม่ ไม่ใช่ถูกคัดลอก ส่วน pivot ของ ODS เป็นความต้องการที่หายากกว่ามาก และการไป model คำศัพท์ data pilot ของ ODF เพื่อ round-trip อย่างเดียวก็จะเป็นโค้ดก้อนใหญ่ที่ไม่มีใครได้แก้ คำตอบแบบปฏิบัติจริงก็คือคำตอบเดียวกับที่ HotXLS ใช้อยู่แล้วกับblock extLst ที่ไม่รู้จักใน XLSX: เก็บสิ่งที่คุณไม่ได้ model ไว้ ให้ครบ byte ถ้าทำได้ ให้ครบ event ถ้าทำไม่ได้

การเก็บด้วย Pos รอบแรกพลาดอะไรไป

การเก็บใน v2.382.0 ตัดนิยาม pivot ออกจาก content.xml เป็นสตริงธรรมดา และชิ้นที่ตัดมาได้ก็ขาด namespace declaration ที่ทำให้มันมีความหมาย โค้ดสั้นอย่างที่ชื่อมันบอก — decode part เป็น WideString, หา open tag ด้วย Pos, หา close tag หลังจากนั้น, คัดลอกช่วงนั้นลง FRawOdsDataPilotTablesXml บนเวิร์กบุ๊ก:

// HotXLS v2.382.0 -- ถูกแทนที่ในอีกรุ่นถัดมา
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);   // content.xml ทั้งไฟล์ในหน่วยความจำ
  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 นับจำนวนผ่านเป็นสีเขียว และการแก้ก็ออกไป สิ่งที่จับได้คือการตรวจชุดที่สองที่เข้มกว่าซึ่งเพิ่มเข้ามาวันเดียวกัน: XML part ทุกตัวของ package ที่ save แล้วถูกป้อนให้ parser ที่รู้จัก namespace แบบอิสระนอก HotXLS และ parser นั้นก็ปฏิเสธ content.xml ตัวใหม่ด้วย error เรื่อง prefix ที่ไม่ได้ผูกไว้ pivot จาก LibreOffice พก attribute extension ของผู้ผลิต — loext:ignore-selected-page="true" บน page field, calcext:repeat-item-labels="false" บนทุกระดับ — และสตริงที่ตัดมาได้ก็มี attribute เหล่านั้นแต่ไม่มี declaration xmlns:loext กับ xmlns:calcext ที่ผูกมันไว้ declaration เหล่านั้นนั่งอยู่บนราก <office:document-content> ของไฟล์ต้นทาง สามสิบห้าตัว ห่างจาก pivot สองพันตัวอักษร

W3C Namespaces in XML 1.0 §6.1 กำหนดกฎที่ทำให้เรื่องนี้เป็นความล้มเหลวเต็มตัวไม่ใช่แค่เรื่องผิวเผิน: namespace declaration มีผลตั้งแต่ start tag ของ element ที่มันปรากฏจนถึง end tag ของ element นั้น และชื่อที่มี prefix ทุกชื่อภายในขอบเขตนั้น resolve กับมัน พอตัด subtree ออกจากเอกสาร คุณก็ตัดมันออกจากขอบเขตนั้นด้วย HotXLS เขียนราก <office:document-content> ของตัวเองพร้อม declaration สิบเอ็ดตัว — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — calcext: จึงบังเอิญ resolve ได้, table: บังเอิญ resolve ได้ และ loext: resolve ไม่ได้ parser ที่รู้จัก namespace ถือว่า prefix ที่ไม่ถูกผูกเป็น well-formedness violation ซึ่งหมายความว่าทั้ง part อ่านไม่ได้ ไม่ใช่แค่ attribute ตัวเดียว

สิ่งที่การเก็บด้วย Pos ของ official-pivot.ods พลาดไปใน HotXLS: subtree ของ pivot พก attribute extension loext และ calcext ขณะที่ declaration xmlns ที่ผูกมันไว้นั่งอยู่บนราก office:document-content ห่างออกไปสามสิบห้า binding ชิ้นที่ตัดมาจึงทิ้งทุก prefix ที่มันใช้ไว้โดยไม่ถูกผูก และ parser ที่รู้จัก namespace ก็ปฏิเสธ content.xml ทั้งไฟล์
namespace declaration มีผลตั้งแต่ start tag จนถึง end tag ของมัน และการตัด subtree ออกจากเอกสารก็ตัดมันออกจากขอบเขตนั้นด้วย ซึ่งเปลี่ยน attribute ตัวเดียวให้กลายเป็น part ที่อ่านไม่ได้

HotXLS พา binding xmlns ของ ancestor มาที่ fragment ได้อย่างไร

HotXLS v2.382.1 เปลี่ยนจากการตัดสตริงมาเป็นการเดินผ่าน content.xml ด้วย TXMLReader แบบ streaming ของตัวเอง โดยถือ stack ของ namespace binding ที่ติดความลึกที่แต่ละตัวถูกประกาศไว้ และคัดลอก binding ที่ยังมีผลอยู่ลงบน element รากของ fragment ทันทีที่ไปถึงเป้าหมาย reader รันโดยเปิด PreserveWhitespaceText ไว้ เพื่อให้ text node กลับมาตรงตามที่เขียนไว้ และแท็กที่สร้างใหม่ใช้ TXMLReader.RawName กับ TXMLReader.Attribute[I].RawName ซึ่งเป็นรูป prefix ที่มาจากไฟล์ ไม่ใช่ชื่อ canonical ที่ reader ปกติส่งให้ part parser นี่คือแกนของลูป:

วิธีที่ HotXLS v2.382.1 เก็บ subtree ของ data pilot พร้อมขอบเขต namespace ของมัน: การเดิน TXMLReader แบบ streaming ถือ stack ของ binding xmlns ที่ติดความลึกที่ประกาศไว้, เดินจากชั้นในสุดก่อนที่เป้าหมาย table:data-pilot-tables, เคารพการ shadowing ผ่านเซต Seen, ข้าม prefix ที่ element ประกาศเอง และ pop binding ทั้งบน end tag และบน empty element เหมือนกัน
การจับเป้าหมายด้วยชื่อ canonical ของ reader ทำให้ผู้ผลิตที่เขียน prefix ของตารางใหม่ยังใช้ได้ และ subtree ที่ไม่เคยปิดจะ raise แทนที่จะเขียน fragment ครึ่งเดียวกลับตอน save
// Namespaces: TStringList ของ 'xmlns:p=uri' โดยเก็บความลึกที่ประกาศไว้ใน Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // 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);   // ตัด '>' หรือ '/>' ที่ต่อท้ายออกก่อน
      ...
      // พา binding ของ ancestor ที่ยังมีผลมาลงบนรากของ fragment
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // binding ที่อยู่ชั้นในสุดชนะ
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // ประกาศตรงนี้อยู่แล้ว? ข้ามไป
          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;                           // ปิด subtree แล้ว
  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);                   // ออกจากขอบเขต
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

มีสามรายละเอียดในลูปนั้นที่พาความถูกต้องมา การเดิน stack จาก binding ชั้นในสุดออกมาข้างนอกแล้วจำแต่ละ prefix ไว้ใน Seen คือการทำ shadowing: ถ้า ancestor ที่ใกล้กว่าประกาศ xmlns:table ใหม่ ค่าที่ใกล้กว่าก็ชนะ ตรงตามที่ §6.1 บอกว่าต้องเป็น การข้าม prefix ที่ element ประกาศเองอยู่แล้วหลีกเลี่ยงการ emit attribute เดิมซ้ำ ซึ่งจะเป็น well-formedness error คนละแบบ และกฎการ pop ทำงานทั้งบน end tag และ บน empty element เพราะ <x/> ไม่เคยสร้าง event EndElement — กับดัก self-closing อันเดียวกับที่การเก็บ extLst ใน XLSX ต้องเรียนรู้ การจับเป้าหมายด้วย Reader.Name แทน RawName เป็นชัยชนะที่เงียบกว่า: reader ทำ namespace URI ของตาราง ODF ให้เป็น prefix table ตามมาตรฐาน ผู้ผลิตที่เขียนมันเป็น t:data-pilot-tables จึงยังจับได้ ขณะที่ fragment ที่ emit ออกมายังคง prefix ที่ผู้ผลิตใช้ไว้

ลูปนี้ยังปฏิเสธที่จะเดาด้วย ถ้า part จบลงในขณะที่การเก็บยังเปิดอยู่ — content.xml ที่ถูกตัดขาดหรือผิดรูป — OdsCaptureDataPilotTablesXml จะ raise แทนที่จะคืน fragment ครึ่ง ๆ กลาง ๆ เพราะ fragment ครึ่ง ๆ กลาง ๆ จะถูกเขียนกลับตอน save แล้วเปลี่ยน input ที่เสียให้กลายเป็น output ที่เสียโดยมีชื่อไลบรารีติดอยู่

fragment ไปลงตรงไหนใน content.xml ที่ save แล้ว

HotXLS เขียน fragment ที่เก็บได้ลงใน <office:spreadsheet> ต่อจาก <table:named-expressions> ที่มันสร้างทันที และก่อน <table:database-ranges> content model ของ <office:spreadsheet> ใน ODF 1.3 Part 3 กำหนดลำดับตายตัวสำหรับ child พวกที่อยู่ท้าย ๆ เหล่านั้น บล็อก verbatim จึงแค่ต่อท้ายตรงที่ writer บังเอิญอยู่ไม่ได้ มันต้องถูกหยอดลงในช่องที่เจาะจง จากฝั่งผู้เรียกไม่มี API และไม่มีอะไรต้องตั้งค่า นิยามนั้นติดไปกับการ open และ save ธรรมดา:

ที่ที่นิยาม pivot ที่เก็บได้ไปลงในการ save ODS ของ HotXLS: ลูกของ office:spreadsheet เรียงตามลำดับตายตัวของ ODF ตั้งแต่ element ตารางที่สร้างขึ้น ผ่าน table:content-validations และ table:named-expressions แล้ว fragment table:data-pilot-tables แบบ verbatim ก็ถูกหยอดก่อน table:database-ranges และไม่มี API เพราะนิยามนั้นติดไปกับ OpenODS และ SaveAsODS
บล็อก verbatim ต่อท้ายตรงที่ writer บังเอิญอยู่ไม่ได้ และสำเนา binding ของ ancestor ที่มันพกมาก็ไม่เป็นอันตรายเพราะ Namespaces in XML ยอมให้ประกาศ prefix ซ้ำในขอบเขตที่ซ้อนกัน
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;   // แก้ค่าภายในช่วงต้นทางของ pivot
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml ใน output ยังพก DataPilot1 พร้อม
    // source range, field, target range, button และ attribute loext:/calcext: อยู่
  finally
    Book.Free;
  end;
end;

ความซ้ำซ้อนนี้ตั้งใจและคุ้มที่จะรู้ไว้ รากของ fragment ตอนนี้ประกาศ xmlns:table กับ xmlns:calcext ซ้ำอีกครั้งแม้รากเอกสารที่ save ก็ประกาศไว้แล้ว Namespaces in XML ยอมให้ประกาศ prefix ซ้ำในขอบเขตที่ซ้อนกัน ตัวซ้ำจึงไม่เป็นอันตราย สำหรับตัวอย่างจาก LibreOffice ชุดที่พกมาคือ declaration ทั้งสามสิบห้าตัวจากราก ประมาณสองกิโลไบต์ทับบนนิยามความยาว 8,357 ตัวอักษร เพราะการเก็บไม่ได้วิเคราะห์ว่า subtree ใช้ prefix ตัวไหนจริง ๆ การสแกนหา prefix ที่ใช้จริงจะตัดส่วนเกินนั้นได้ และอาจตามมาทีหลัง ความถูกต้องมาก่อน ความกระชับทีหลัง

กฎสำหรับการตัด subtree ออกจาก XML เพื่อ replay แบบ verbatim

บทเรียนทั่วไปคือ subtree จะพึ่งพาตัวเองได้ก็ต่อเมื่อคุณทำให้มันเป็นแบบนั้น และ namespace scope คือสิ่งแรกที่พังเมื่อคุณลืม นี่คือ checklist ที่ HotXLS ใช้กับการเก็บแบบ "เก็บสิ่งที่เราไม่ได้ model" ทุกกรณี:

  • เดินเอกสารด้วย reader จริงและติดตาม binding ที่อยู่ในขอบเขต การค้นสตริงด้วย Pos มองไม่เห็นขอบเขตเลย และยังจับผิดพลาดกับ element ที่ซ้อนกันและมีชื่อเดียวกัน, กับสตริงที่ตรงกันในคอมเมนต์หรือ section CDATA และกับค่า attribute ที่บังเอิญมีข้อความของแท็กอยู่
  • คัดลอก binding ที่ยังมีผลลงบนรากของ fragment โดยเริ่มจากชั้นในสุด ครั้งเดียวต่อหนึ่ง prefix และข้ามสิ่งที่รากประกาศไว้แล้ว
  • เก็บรูป prefix แบบดิบไว้ในแท็กที่ emit; จับเป้าหมายด้วย namespace ที่ resolve แล้ว ไม่ใช่ด้วย prefix ตามตัวอักษร
  • รักษา whitespace text node ไว้ และจำไว้ว่า empty element ปิดขอบเขตของตัวเองโดยไม่มี event ของ end tag
  • validate part ที่ save ด้วย parser ที่ไม่ใช่ไลบรารีที่กำลังทดสอบ ไลบรารีจะอ่าน output ของตัวเองซ้ำผ่านเส้นทางโค้ดผ่อนปรนตัวเดียวกับที่เขียนมันได้อย่างสบายใจ

ข้อสุดท้ายคือข้อที่จับ HXLS-003 ได้จริงในรอบที่สอง การตรวจ acceptance ของ v2.382.0 เป็น regular expression ที่นับ start tag ของ data-pilot-table ใน content.xml ที่ save แล้ว และ regular expression เห็นแค่แท็ก ไม่เห็นเอกสาร — มันมองไม่ออกว่า prefix บนแท็กนั้นถูกผูกไว้หรือไม่ corpus runner แบบเข้มงวดที่เพิ่มใน v2.382.1 parse XML และ part .rels ทุกตัวของ package ที่ save แล้วด้วย parser ที่รู้จัก namespace จากนั้นเทียบต้นไม้ pivot — แท็ก, attribute ที่เรียงแล้ว, ข้อความ, ลูก — แบบเรียกซ้ำ กับต้นฉบับ การเทียบนั้นขยาย namespace ก่อน ดังนั้นการเขียน prefix ใหม่ยังผ่านได้ แต่ prefix ที่ไม่ถูกผูกผ่านไม่ได้

การรับประกันแบบ verbatim สิ้นสุดตรงไหน

การ replay แบบ verbatim รักษานิยามไว้ แต่มันไม่เข้าใจนิยามนั้น และขอบเขตก็ตามมาจากตรงนั้น HotXLS ไม่เปิด API ให้อ่าน แก้ หรือ refresh pivot ของ ODS เลย FRawOdsDataPilotTablesXml จึงเป็นฟิลด์ภายใน และพฤติกรรมเดียวที่สังเกตได้คือนิยามนั้นรอดไปได้ fragment ถูก serialize ใหม่จาก event ของ reader ไม่ได้คัดลอกเป็น byte: การใส่เครื่องหมายคำพูดใน attribute และรูป self-closing ถูก normalize ขณะที่ข้อความและ whitespace ถูกเก็บไว้ XML ที่เก็บได้ถูก emit โดย writer ของเนื้อหา ODS เท่านั้น เวิร์กบุ๊กที่เปิดจาก .ods แล้ว save เป็น .xlsx จึงเสีย pivot ไป และเวิร์กบุ๊กที่เปิดจาก .xlsx ก็ไม่มีอะไรให้ replay ลงในการ save เป็น .ods — ความไม่สมมาตรของเส้นทาง import และ export ของ ODS ใช้ตรงนี้เหมือนที่อื่นทุกแห่ง และเพราะนิยามนั้นเป็นของทึบ มันจึงตามการแก้ไขของคุณไม่ได้: เปลี่ยนชื่อ Sheet1 หรือย้ายข้อมูลต้นทางใน HotXLS แล้ว pivot ที่ save ไว้ก็ยังชี้ไปที่ Sheet1.A2:E30 อยู่ ปล่อยให้ consumer รายงานช่วงที่พังตอน refresh ครั้งถัดไป มีข้อควรระวังเรื่องลำดับอีกข้อที่ควรพูดตรงนี้ด้วย: HotXLS emit ช่วง AutoFilter เป็น <table:database-ranges> ต่อจาก fragment ของ pivot และตัวอย่างใน corpus ไม่มี database range เลย เวิร์กบุ๊กที่มีทั้ง filter และ pivot จึงควรผ่าน ODF schema validator ก่อนที่คุณจะพึ่งลำดับสัมพัทธ์ของสอง element นั้น

ทดสอบกับไฟล์จากผู้ผลิตของคุณเอง ไม่ใช่แค่ตัวอย่างใน corpus การพา namespace มาด้วยจัดการ prefix ใดก็ตามที่ผู้ผลิตประกาศบน ancestor ได้ แต่เอกสารที่ประกาศ prefix บน element pivot เอง หรือที่ใช้ default namespace กับคำศัพท์ของตาราง จะไปแตะ branch การข้ามและการ shadowing ที่ตัวอย่างจาก LibreOffice ไม่ได้แตะ ทั้งสองอย่างถูก implement ไว้แล้ว แต่ยังไม่มีตัวอย่างไหนใน corpus และความต่างตรงนี้ก็เป็นสิ่งประเภทที่ changelog มักทำให้เลือนไป

การเก็บ data pilot แบบ verbatim ใน v2.382.0 และการแก้ namespace scope ใน v2.382.1 อยู่ในHotXLS Delphi Excel Component รุ่นปัจจุบัน ซึ่งหน้าผลิตภัณฑ์ลิสต์ความครอบคลุมการอ่านและเขียน ODS, XLSX และ XLS ครบถ้วนสำหรับ Delphi และ C++Builder