Sebuah xlsx yang valid tidak harus mengandung xl/worksheets/sheet1.xml. HotXLS, komponen spreadsheet Excel native untuk Delphi dan C++Builder, menemukan setiap part lewat OPC relationship graph, bukan dengan menebak nama, karena ISO/IEC 29500-2 hanya menjamin bahwa part-part bisa dijangkau dari _rels/.rels, tidak pernah menjamin bahwa mereka berada pada path konvensional
Kenapa parser saya gagal pada xlsx yang valid?
Karena nama part yang Anda hafal adalah konvensi satu produsen, bukan syarat dari formatnya. Setiap path yang pernah Anda hardcode, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, adalah apa yang kebetulan diterbitkan writer Excel desktop. Sebuah paket yang conforming boleh saja menempatkan workbook-nya di office/book.xml dan worksheet pertamanya di xl/custom/data-sheet.xml dan tetap sah sebagai SpreadsheetML, selama relationship-nya menunjuk ke sana. Inilah alasan paling umum kenapa sebuah reader buatan sendiri melaporkan "tidak bisa menemukan sheet1.xml" pada sebuah berkas yang dibuka tanpa keluhan oleh Excel, LibreOffice, dan Numbers
Produsen yang melakukan ini bukan hal yang eksotis. Report generator sisi-server memakai ulang sebuah paket template dan mempertahankan layout aslinya. Export pipeline yang menggabungkan dua workbook menomori ulang sheet dan meninggalkan celah, sehingga sebuah workbook lima-sheet punya sheet1, sheet2, sheet4, sheet7, dan sheet9. Tool yang membuang sebuah sheet tidak selalu menomori ulang yang tersisa. Pada setiap kasus itu, tebakan berbasis-indeks xl/worksheets/sheet + IntToStr(i + 1) + .xml diam-diam membaca sheet yang salah atau tidak membaca apa pun, yang lebih buruk daripada sebuah exception karena workbook-nya tetap termuat sementara angkanya salah. Paket minimal di bawah ini menjalankan seluruh masalah ini, dan itu adalah bentuk yang dipakai HotXLS untuk regression-test-nya
<!-- _rels/.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
Target="office/book.xml"/>
</Relationships>
<!-- office/_rels/book.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId42"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
Target="../xl/custom/data-sheet.xml"/>
</Relationships>
<!-- xl/custom/_rels/data-sheet.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="note7"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
Target="../notes/review.xml"/>
</Relationships>
Apa yang sebenarnya dijamin ISO/IEC 29500-2?
Ia menjamin keterjangkauan, bukan lokasi. ISO/IEC 29500-2 adalah bagian Open Packaging Conventions dari standar ini, dan klausul relationship-nya mendefinisikan persis satu entry point tetap: package relationship part di _rels/.rels. Dari sana Anda mengikuti relationship yang Type-nya http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument untuk mencapai workbook part-nya, dan setiap part lain ditemukan dengan membaca relationship part milik part itu sendiri dan mengikuti edge bertipe ke arah luar
Dua aturan lagi dari standar yang sama melakukan pekerjaan sesungguhnya. Klausul part-naming mematok di mana sebuah relationship part berada: untuk sebuah part di <folder>/<name>, relationship-nya berada di <folder>/_rels/<name>.rels, dan untuk sebuah part di root paket, foldernya sekadar _rels/. Klausul relationship markup menyatakan bahwa Target adalah sebuah URI reference yang diresolusi terhadap URI dari source part-nya, dalam pengertian RFC 3986 yang biasa, kecuali TargetMode="External" menandainya sebagai menunjuk ke luar paket. Resolusi relatif-terhadap-source adalah langkah yang dilewatkan semua orang, dan itulah sebabnya literal yang sama ../notes/review.xml berarti satu hal di dalam xl/custom/_rels/data-sheet.xml.rels dan sesuatu yang sama sekali berbeda di dalam sebuah rels file satu folder lebih dalam. Satu kerutan terakhir berada di antara model logis dan byte di disk: nama part dalam model logis bersifat absolut dan dimulai dengan sebuah forward slash, tetapi klausul ZIP physical mapping membuang slash itu ketika mengubah sebuah part name menjadi sebuah ZIP item name, sehingga sebuah resolver yang lupa akan hal itu mencari /xl/sharedStrings.xml dalam arsip dan tidak menemukan apa-apa
Di dalam XlsxResolveRelationshipTarget
HotXLS memusatkan seluruh aturan resolusinya dalam satu fungsi, XlsxResolveRelationshipTarget, dideklarasikan di lxHandleX.pas sebagai function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Fungsi ini mengambil ZIP item name dari source part-nya dan atribut Target mentah, dan mengembalikan sebuah ZIP item name tanpa slash di depan, siap diserahkan langsung ke arsip. Meneruskan sebuah OwnerPartName kosong meresolusi terhadap root paket, yang persis yang dibutuhkan package relationship part. Urutan operasinya lebih penting daripada langkah-langkah individualnya: backslash dinormalisasi menjadi forward slash lebih dulu, karena beberapa produsen menulis separator gaya Windows ke dalam Target; fragment apa pun yang diperkenalkan oleh # dipotong sebelum penanganan path, sehingga ../charts/chart1.xml#Sheet1 meresolusi menjadi sebuah part name alih-alih menjadi sebuah entri arsip yang tidak ada; baru setelah itu fungsi ini memisahkan absolut dari relatif
// Normalization core, as implemented in lxHandleX.pas.
combined := StringReplace(Target, '\', '/', [rfReplaceAll]);
p := Pos('#', combined);
if p > 0 then
combined := Copy(combined, 1, p - 1);
if (combined <> '') and (combined[1] = '/') then
Delete(combined, 1, 1) // package-absolute: strip the slash only
else
begin
p := LastDelimiter('/', String(OwnerPartName));
if p > 0 then
baseName := Copy(OwnerPartName, 1, p)
else
baseName := '';
combined := baseName + combined; // relative to the source part folder
end;
source.StrictDelimiter := True; // '/' only, no quote or space handling
source.Delimiter := '/';
source.DelimitedText := String(combined);
for i := 0 to source.Count - 1 do
begin
segment := WideString(source[i]);
if (segment = '') or (segment = '.') then
Continue; // empty and dot segments vanish
if segment = '..' then
begin
if parts.Count > 0 then
parts.Delete(parts.Count - 1); // pop, and never below the root
end
else
parts.Add(String(segment));
end;
Loop segmen ini adalah sebuah stack walk sederhana: segmen kosong dan . dibuang, .. memunculkan (pop) satu level, dan sebuah .. yang akan lolos keluar dari root paket diserap alih-alih menghasilkan sebuah indeks negatif atau sebuah nama yang dimulai dengan ../. Penetapan StrictDelimiter := True bukan sekadar kosmetik. Tanpanya, sebuah TStringList Delphi memperlakukan spasi sebagai delimiter dan menghormati karakter kutip, yang merusak part name apa pun yang mengandung spasi, dan part name dengan spasi itu sah
Menelusuri graph: workbook, worksheet, drawing
HotXLS menelusuri tiga tingkatan relationship part pada jalur TXLSXWorkbook.Open. Tingkatan paket ditangani oleh XlsxFindOfficeDocumentPart, yang membaca _rels/.rels dan mengembalikan target officeDocument. Tingkatan workbook membaca workbook relationship part dan membangun dua map sekaligus: sebuah identifier map untuk lookup r:id dan sebuah type map untuk part singleton. Tingkatan worksheet dan drawing mengulangi pola ini dengan ParseWorksheetRelsXml dan ParseDrawingRelsXml, masing-masing meneruskan part name-nya sendiri sebagai basis resolusi sehingga sebuah drawing yang mereferensikan ../media/image3.png mendarat pada blob yang benar
// Tier 1: the only fixed name in the whole format.
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
WorkbookPartName := 'xl/workbook.xml'; // legacy fallback
if not zip.Exists(WorkbookPartName) then
Exit;
// Tier 2: <folder>/_rels/<name>.rels for the workbook part itself.
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
relsStream := zip.OpenFile(relsName);
try
ParsePartRelationshipsXml(relsStream, WorkbookPartName,
WorkbookTargetById, WorkbookTargetsByType);
finally
relsStream.Free;
end;
end;
// Typed singletons resolve by relationship type URI.
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
PartName := 'xl/sharedStrings.xml';
Sheet secara khusus harus melewati identifier map, bukan type map. Elemen <sheet> pada workbook part membawa atribut r:id, dan identifier itulah satu-satunya hal yang mengikat sebuah nama sheet ke sebuah part. HotXLS mengumpulkan identifier tersebut selama ParseWorkbookXml dan meresolusi masing-masing terhadap workbook relationship map, jatuh kembali ke nama bernomor konvensional hanya ketika identifier-nya tidak ada atau tidak bisa diresolusi
// Tier 2b: r:id -> worksheet part, per sheet, in workbook order.
PartName := '';
if (i < SheetRelIds.Count) and (SheetRelIds[i] <> '') then
PartName := WorkbookTargetById.Values[SheetRelIds[i]];
if PartName = '' then
PartName := 'xl/worksheets/sheet' + IntToStr(i + 1) + '.xml';
SheetPartNames.Add(String(PartName));
// Tier 3: each worksheet resolves its own satellites against its own name.
relsName := XlsxRelationshipPartName(PartName);
if zip.Exists(relsName) then
begin
relsStream := zip.OpenFile(relsName);
try
ParseWorksheetRelsXml(relsStream, PartName,
FParRels[i], ParTableTargets[i], ParPartTargets[i]);
finally
relsStream.Free;
end;
end;
Segala sesuatu di hilir menumpang pada mekanisme yang sama ini. Shared strings, style, theme, proyek VBA di bawah tipe ber-namespace Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, external link, person part yang berlingkup workbook, komentar lawas, komentar berulir (threaded comments), VML drawing yang membawa geometri balon komentar, drawing, gambar, chart, tabel, dan PivotTable semuanya mencapai byte-nya lewat target yang telah diresolusi. Bagian theme khususnya harus ditemukan dengan benar atau sebuah round-trip diam-diam menimpa palet brand pelanggan dengan theme Office bawaan, salah satu mode kegagalan yang dibahas dalam catatan tentang round-trip XLSX tanpa kehilangan untuk theme, extLst, dan calcChain. Pembacaan relationship juga menjadi alasan kenapa pemuatan dilakukan bertahap seperti ini: seluruh akses arsip terjadi pada satu thread sebelum XML worksheet diurai, karena state inflate dari sebuah arsip ZIP tidak thread-safe, sebuah batasan yang dijelaskan dalam tulisan tentang parallel XLSX parsing dan memory allocator
Kenapa sebuah rId duplikat merusak routing berbasis-tipe?
Karena sebuah entri malformed yang datang belakangan bisa menimpa yang valid sebelumnya dan membajak lookup-nya. Identifier relationship seharusnya unik di dalam sebuah relationship part, tetapi paket yang malformed memakainya ulang, dan sebuah penetapan naif Values[Id] := bersifat last-write-wins. Jika rId3 pertama menunjuk ke sebuah worksheet sungguhan dan rId3 kedua menunjuk ke sebuah target yang tidak didukung atau kosong, last-write-wins kehilangan worksheet-nya. ParsePartRelationshipsXml karena itu menerapkan sebuah aturan first-wins dengan dua kondisi: target yang diresolusi harus tidak kosong, dan identifier-nya belum boleh sudah ada. Kedua kondisi bersama-sama itulah yang membuatnya aman, karena tes tidak-kosong menghentikan sebuah relationship dengan Target yang hilang dari mengklaim slot sebelum yang bisa dipakai tiba
if (TargetById <> nil) and (Id <> '') and (resolvedTarget <> '') and
(TargetById.IndexOfName(String(Id)) < 0) then
TargetById.Values[String(Id)] := String(resolvedTarget);
if (TargetsByType <> nil) and (relType <> '') and (resolvedTarget <> '') then
TargetsByType.Add(String(relType + '=' + resolvedTarget));
Perhatikan asimetri yang disengaja dalam cuplikan itu. Identifier map adalah sebuah map sesungguhnya dengan sebuah guard first-wins, sementara koleksi type-nya adalah sebuah daftar append-only berisi pasangan type=target. Perbedaan itu bersifat menopang: sebuah workbook punya persis satu relationship shared-strings tapi banyak relationship worksheet dan external-link, sehingga type lookup lewat Values[] mengembalikan kecocokan pertama untuk singleton, dan tipe multi-nilai seperti externalLink dienumerasi dengan menelusuri daftarnya
Di mana penelusuran relationship berhenti
Batasan yang jujur lebih penting daripada cerita yang rapi. HotXLS jatuh kembali ke nama konvensional setiap kali sebuah relationship tidak ada, sehingga sebuah paket dengan relationship part yang rusak atau hilang tetap bisa dibuka bila kebetulan mengikuti layout Excel; fallback itu adalah sebuah fitur kompatibilitas, bukan sumber kebenaran kedua, dan itu bisa menutupi sebuah bug produsen selama pengujian. Ada tiga batasan lagi yang layak diketahui. Target yang ditandai TargetMode="External" disimpan apa adanya, bukan diresolusi, yang benar untuk hyperlink dan untuk relationship externalLinkPath yang membawa URL workbook jarak jauh, tapi itu berarti nilai yang Anda dapat kembali adalah apa pun yang ditulis produsen. Chart part yang ditemukan lewat sebuah drawing relationship part dipasangkan dengan drawing anchor secara posisional, bukan berdasarkan identifier, sehingga sebuah urutan anchor yang tidak biasa bisa membuat binding chart tidak sejajar. Dan streaming direct reader di lxDirectRead.pas mempertahankan penanganan path-nya sendiri yang lebih ringan dan dikunci ke xl/, sehingga resolver lengkap yang dijelaskan di sini mengatur entry point TXLSXWorkbook.Open dan GetSheetNames, bukan jalur scan low-allocation yang didokumentasikan dalam artikel tentang streaming direct reader untuk Delphi
Bila Anda membangun ini sendiri, ringkasan yang benar dan paling singkat adalah: jangan pernah mengonstruksi sebuah part name, selalu resolusikan satu. Baca _rels/.rels, ikuti officeDocument, resolusikan setiap Target terhadap part yang mendeklarasikannya, dan rutekan sheet lewat r:id. Bila Anda lebih memilih yang sudah teruji terhadap part yang diganti nama, penomoran sheet yang tidak berurutan, dan identifier relationship duplikat, resolver yang dijelaskan di sini hadir dalam Delphi spreadsheet component HotXLS, bersama mesin round-trip yang menjaga part yang tidak diurainya tetap utuh