Artikel Teknis

Round-Trip Pivot Table ODS di Delphi: Scope Namespace XML

HotXLS Delphi Excel Component mempertahankan data pilot table OpenDocument melewati siklus open-and-save ODS dengan menangkap subtree <table:data-pilot-tables> di content.xml apa adanya saat open lalu me-replay-nya saat save, sejak v2.382.0. Sejak v2.382.1 fragmen itu juga membawa setiap binding namespace XML yang dideklarasikan leluhurnya, sehingga definisi pivot yang tersimpan tetap well-formed bagi consumer mana pun, bukan hanya bagi HotXLS

Bug yang memaksa kedua perubahan itu muncul dari jalannya korpus yang ketat. Sampel official-pivot.ods, yang ditulis build pengembangan LibreOffice 6.1, memuat satu pivot bernama DataPilot1 yang membaca Sheet1.A2:E30 dan menaruh hasilnya di Sheet1.G6:J18. Buka dengan HotXLS, simpan tanpa perubahan, hitung elemen <table:data-pilot-table> di output-nya: masuk satu, keluar nol, di Win32 maupun Win64. Tidak ada apa pun di uji itu yang menyentuh pivot-nya. Ronde pemeriksaan pertama hanya membandingkan konstanta sel dan lolos; assertion struktural-lah yang membeberkan kehilangannya, sebuah pengingat bahwa "nilai cocok" adalah definisi yang lemah untuk kesetiaan round-trip

Kenapa pivot table ODS hilang setelah save lewat library?

Pivot table ODS hilang karena HotXLS tidak punya model in-memory untuk data pilot table OpenDocument, dan writer ODS membangun content.xml sepenuhnya dari model. Writer-nya merakit automatic style, satu <table:table> per worksheet, <table:content-validations>, <table:named-expressions>, dan <table:database-ranges>, masing-masing dihasilkan dari objek yang benar-benar dipegang workbook. Definisi pivot — ODF 1.3 Part 3 §9.6, sebuah container <table:data-pilot-tables> dengan satu <table:data-pilot-table> per pivot, membawa table:source-cell-range-nya, anak-anak table:data-pilot-field-nya, table:target-range-address dan table:buttons-nya — tidak punya objek tempat ia hidup, jadi part hasil regenerasi sekadar menghilangkannya

Kontras dengan XLSX memang disengaja. HotXLS mem-parse pivot cache dan pivot table SpreadsheetML ke model sungguhan yang bisa Anda bangun, perluas dengan calculated field, dan refresh dari Delphi, sehingga keduanya bertahan melewati save karena ditulis ulang, bukan disalin. Pivot ODS jauh lebih jarang diminta, dan memodelkan kosakata data pilot ODF hanya demi round-trip akan menjadi banyak kode yang tidak diedit siapa pun. Jawaban pragmatisnya sama dengan yang sudah diterapkan HotXLS pada blok extLst tak dikenal di XLSX: simpan apa yang tidak Anda modelkan, byte per byte jika bisa, event per event jika tidak

Apa yang salah pada penangkapan berbasis Pos yang pertama?

Penangkapan di v2.382.0 mengiris definisi pivot dari content.xml sebagai string biasa, dan irisan itu kehilangan deklarasi namespace yang membuatnya bermakna. Implementasinya sesingkat kedengarannya — dekode part-nya ke WideString, cari tag pembukanya dengan Pos, cari tag penutupnya setelahnya, salin rentangnya ke FRawOdsDataPilotTablesXml di workbook:

// HotXLS v2.382.0 -- digantikan satu rilis kemudian
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);   // seluruh content.xml ada di memori
  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 hitungannya jadi hijau, dan perbaikannya dirilis. Yang menangkapnya adalah pemeriksaan kedua yang lebih ketat yang ditambahkan hari yang sama: setiap part XML di package hasil save disuapkan ke parser namespace-aware independen di luar HotXLS, dan parser itu menolak content.xml yang baru dengan galat prefix tak terikat. Pivot dari LibreOffice membawa atribut ekstensi produsen — loext:ignore-selected-page="true" pada sebuah page field, calcext:repeat-item-labels="false" pada setiap level — dan string hasil irisan memuat atribut-atribut itu tapi tidak memuat deklarasi xmlns:loext dan xmlns:calcext yang mengikatnya. Deklarasi-deklarasi itu berada di root <office:document-content> file sumber, tiga puluh lima jumlahnya, dua ribu karakter jauhnya dari pivot-nya

W3C Namespaces in XML 1.0 §6.1 mendefinisikan aturan yang membuat ini kegagalan keras, bukan sekadar kosmetik: deklarasi namespace berada dalam scope sejak start tag elemen tempat ia muncul hingga end tag elemen itu, dan setiap nama berprefix di dalam scope tersebut diresolusi terhadapnya. Potong sebuah subtree dari dokumennya dan Anda memotongnya dari scope-nya. HotXLS menulis root <office:document-content>-nya sendiri dengan sebelas deklarasi — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — jadi calcext: kebetulan teresolusi, table: kebetulan teresolusi, dan loext: tidak. Parser namespace-aware memperlakukan prefix tak terikat sebagai pelanggaran well-formedness, yang berarti seluruh part-nya tidak terbaca, bukan sekadar satu atribut

Apa yang luput dari penangkapan berbasis Pos atas official-pivot.ods di HotXLS: subtree pivot membawa atribut ekstensi loext dan calcext sementara deklarasi xmlns yang mengikatnya berada di root office:document-content, tiga puluh lima binding jauhnya, sehingga fragmen hasil irisan meninggalkan setiap prefix yang dipakainya tak terikat dan parser namespace-aware menolak seluruh content.xml
Deklarasi namespace berada dalam scope sejak start tag hingga end tag-nya, dan memotong subtree dari dokumennya berarti memotongnya dari scope itu, yang mengubah satu atribut menjadi part yang tidak terbaca

Bagaimana HotXLS membawa binding xmlns leluhur ke fragmennya?

HotXLS v2.382.1 mengganti irisan string itu dengan pass atas content.xml lewat TXMLReader streaming miliknya sendiri, memelihara stack binding namespace yang ditandai kedalaman tempat masing-masing dideklarasikan, lalu menyalin binding yang masih berlaku ke elemen root fragmennya begitu targetnya tercapai. Reader-nya berjalan dengan PreserveWhitespaceText aktif agar text node kembali persis seperti ditulis, dan tag yang dibangun ulang memakai TXMLReader.RawName serta TXMLReader.Attribute[I].RawName — ejaan prefix dari filenya — bukan nama kanonik yang biasanya diserahkan reader ke parser part. Berikut inti loop-nya:

Bagaimana HotXLS v2.382.1 menangkap subtree data pilot beserta scope namespace-nya: pass TXMLReader streaming memelihara stack binding xmlns yang ditandai kedalaman deklarasinya, menelusurinya dari yang paling dalam saat mencapai target table:data-pilot-tables, menghormati shadowing lewat set Seen, melewati prefix yang dideklarasikan elemen itu sendiri dan mem-pop binding pada end tag maupun pada empty element
Mencocokkan target lewat nama kanonik reader membuat produsen yang mengeja ulang prefix table tetap berfungsi, dan subtree yang tidak pernah tertutup memunculkan exception alih-alih menuliskan fragmen separuh saat save
// Namespaces: TStringList berisi 'xmlns:p=uri' dengan kedalaman deklarasinya di Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // element, text, CDATA, komentar
  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);   // pangkas dulu '>' atau '/>' di belakangnya
      ...
      // Bawa binding leluhur yang efektif ke root fragmen.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // binding paling dalam yang menang
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // sudah dideklarasikan di sini? lewati
          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 tertutup
  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);                   // keluar dari scope
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Tiga detail di loop itu menopang kebenarannya. Menelusuri stack dari binding paling dalam ke luar dan mengingat setiap prefix di Seen mengimplementasikan shadowing: jika leluhur yang lebih dekat mengikat ulang xmlns:table, nilai yang lebih dekat yang menang, persis seperti yang dikatakan §6.1. Melewati prefix yang sudah dideklarasikan elemen itu sendiri menghindari pengeluaran atribut yang sama dua kali, yang akan menjadi pelanggaran well-formedness yang berbeda. Dan aturan pop-nya menyala pada end tag dan pada empty element, karena <x/> tidak pernah menghasilkan event EndElement — jebakan self-closing yang sama yang harus dipelajari penangkapan extLst XLSX. Mencocokkan target lewat Reader.Name alih-alih RawName adalah kemenangan yang lebih senyap: reader menkanonikalisasi URI namespace table ODF ke prefix table, sehingga produsen yang mengejanya t:data-pilot-tables tetap cocok, sementara fragmen yang dikeluarkan mempertahankan prefix apa pun yang dipakai produsennya

Loop-nya juga menolak menebak. Jika part-nya berakhir sementara penangkapannya masih terbuka — content.xml yang terpotong atau malformed — OdsCaptureDataPilotTablesXml memunculkan exception alih-alih mengembalikan fragmen separuh, karena fragmen separuh akan ditulis kembali saat save dan mengubah input yang rusak menjadi output yang rusak dengan nama library tertera di situ

Di mana fragmen itu mendarat di content.xml hasil save?

HotXLS menulis fragmen yang ditangkap ke dalam <office:spreadsheet> tepat setelah <table:named-expressions> yang ia hasilkan dan sebelum <table:database-ranges>. Model konten ODF 1.3 Part 3 untuk <office:spreadsheet> menetapkan urutan tetap bagi anak-anak di belakang itu, jadi blok verbatim tidak bisa sekadar ditambahkan di mana pun writer-nya kebetulan berada; ia harus dijatuhkan ke slot yang spesifik. Dari sisi pemanggil tidak ada API dan tidak ada yang perlu dikonfigurasi; definisinya ikut terbawa pada open dan save biasa:

Di mana definisi pivot yang ditangkap mendarat pada save ODS HotXLS: anak office:spreadsheet mengikuti urutan ODF tetap mulai dari elemen table yang dihasilkan lewat table:content-validations dan table:named-expressions, fragmen table:data-pilot-tables yang verbatim masuk sebelum table:database-ranges, dan tidak ada API karena definisinya ikut terbawa bersama OpenODS dan SaveAsODS
Blok verbatim tidak bisa ditambahkan di mana pun writer-nya berada, dan salinan binding leluhur yang dibawanya tidak berbahaya karena Namespaces in XML mengizinkan pendeklarasian ulang prefix di scope bersarang
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;   // edit di dalam source range pivot-nya
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml di output-nya masih membawa DataPilot1 beserta
    // source range, field, target range, button dan atribut loext:/calcext:-nya
  finally
    Book.Free;
  end;
end;

Redundansinya disengaja dan layak diketahui. Root fragmennya kini mengulang xmlns:table dan xmlns:calcext walau root dokumen hasil save juga mendeklarasikannya; Namespaces in XML mengizinkan pendeklarasian ulang prefix di scope bersarang, jadi duplikatnya tidak berbahaya. Untuk sampel LibreOffice, set yang dibawa adalah seluruh tiga puluh lima deklarasi root, sekitar dua kilobyte di atas definisi sepanjang 8.357 karakter, karena penangkapannya tidak menganalisis prefix mana yang benar-benar dipakai subtree-nya. Pemindaian prefix terpakai akan memangkasnya, dan mungkin menyusul nanti; kebenaran dulu, kekompakan kemudian

Aturan memotong subtree dari XML untuk replay verbatim

Pelajaran umumnya adalah sebuah subtree baru mandiri setelah Anda membuatnya begitu, dan scope namespace adalah hal pertama yang rusak ketika Anda lupa. Daftar periksa yang kini diterapkan HotXLS pada setiap penangkapan "simpan apa yang tidak kami modelkan":

  • Telusuri dokumen dengan reader sungguhan dan lacak binding yang ada di scope. Pencarian string dengan Pos tidak bisa melihat scope sama sekali, dan ia juga salah cocok pada elemen bersarang yang namanya sama, pada string yang cocok di dalam komentar atau section CDATA, serta pada nilai atribut yang kebetulan memuat teks tag-nya
  • Salin binding yang efektif ke root fragmen, dari yang paling dalam lebih dulu, sekali per prefix, lewati yang sudah dideklarasikan root-nya
  • Pertahankan ejaan prefix mentah di tag yang dikeluarkan; cocokkan targetnya lewat namespace yang sudah teresolusi, bukan lewat prefix literal
  • Pertahankan text node berisi whitespace, dan ingat bahwa empty element menutup scope-nya sendiri tanpa event end tag
  • Validasi part hasil save dengan parser yang bukan library yang sedang diuji. Library akan dengan senang hati membaca ulang output-nya sendiri lewat jalur kode longgar yang sama yang menulisnya

Poin terakhir itulah yang benar-benar menemukan HXLS-003 untuk kedua kalinya. Pemeriksaan penerimaan v2.382.0 adalah ekspresi reguler yang menghitung start tag data-pilot-table di content.xml hasil save, dan ekspresi reguler melihat tag, bukan dokumen — ia buta terhadap apakah prefix di tag itu terikat atau tidak. Runner korpus yang ketat, yang ditambahkan di v2.382.1, mem-parse setiap part XML dan .rels di package hasil save dengan parser namespace-aware lalu membandingkan pohon pivot-nya — tag, atribut terurut, teks, anak, secara rekursif — dengan aslinya. Perbandingan itu namespace-expanded, jadi pengejaan ulang prefix akan tetap lolos sementara prefix tak terikat tidak bisa

Di mana jaminan verbatim berakhir

Replay verbatim mempertahankan definisi; ia tidak memahaminya, dan batas-batasnya mengikuti dari situ. HotXLS tidak memaparkan API untuk membaca, mengedit, atau me-refresh pivot ODS, jadi FRawOdsDataPilotTablesXml adalah field internal dan satu-satunya perilaku yang teramati adalah definisinya bertahan. Fragmennya diserialisasi ulang dari event reader, bukan disalin sebagai byte: kutipan atribut dan bentuk self-closing dinormalkan, sementara teks dan whitespace dipertahankan. XML yang ditangkap hanya dikeluarkan oleh writer konten ODS, jadi workbook yang dibuka dari .ods lalu disimpan sebagai .xlsx kehilangan pivot-nya, dan workbook yang dibuka dari .xlsx tidak punya apa pun untuk di-replay ke save .ods — asimetri jalur import dan export ODS berlaku di sini seperti di tempat lain. Dan karena definisinya opak, ia tidak bisa mengikuti editan Anda: ganti nama Sheet1 atau pindahkan data sumbernya di HotXLS dan pivot yang tersimpan tetap menunjuk Sheet1.A2:E30, membiarkan consumer-nya melaporkan range yang rusak saat ia refresh berikutnya. Satu catatan urutan juga perlu disebut di sini: HotXLS mengeluarkan range AutoFilter sebagai <table:database-ranges> setelah fragmen pivot-nya, dan sampel korpusnya tidak membawa database range, jadi workbook yang punya filter sekaligus pivot sebaiknya dilewatkan ke validator skema ODF sebelum Anda mengandalkan urutan relatif kedua elemen itu

Ujilah dengan file produsen Anda sendiri, bukan hanya sampel korpusnya. Pembawaan namespace-nya menangani prefix apa pun yang dideklarasikan produsen di leluhurnya, tapi dokumen yang mendeklarasikan prefix di elemen pivot itu sendiri, atau yang memakai default namespace untuk kosakata table, akan menguji cabang skip dan shadowing yang tidak diuji sampel LibreOffice. Keduanya sudah diimplementasikan; belum ada sampelnya di korpus, dan pembedaan itulah yang justru sering dikaburkan oleh satu entri changelog

Penangkapan data pilot verbatim di v2.382.0 dan perbaikan scope namespace di v2.382.1 sudah ada di HotXLS Delphi Excel Component terkini, yang halaman produknya mencantumkan cakupan baca-tulis ODS, XLSX, dan XLS lengkap untuk Delphi dan C++Builder