Platný xlsx nemusí obsahovať xl/worksheets/sheet1.xml. HotXLS, natívna Excel spreadsheet komponenta pre Delphi a C++Builder, lokalizuje každú časť cez graf OPC relationship namiesto hádania mien, pretože ISO/IEC 29500-2 garantuje len to, že časti sú dosiahnuteľné z _rels/.rels, nikdy že sedia na konvenčných cestách
Prečo môj parser zlyhá na platnom xlsx?
Pretože mená častí, ktoré ste si zapamätali, sú konvencia jedného producenta, nie požiadavka formátu. Každá cesta, ktorú ste kedy hardcodovali, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, je to, čo desktopový Excel writer náhodou vydáva. Konformný balík môže umiestniť workbook na office/book.xml a prvý hárok na xl/custom/data-sheet.xml a stále byť legálny SpreadsheetML, pokiaľ relationships tam ukazujú. Toto je jediný najbežnejší dôvod, prečo domáci reader hlási „cannot find sheet1.xml" na súbore, ktorý Excel, LibreOffice a Numbers otvoria bez sťažnosti
Producenti, ktorí toto robia, nie sú exotickí. Serverové generátory reportov znovu použijú šablónový balík a ponechajú jeho pôvodné rozloženie. Export pipelines, ktoré zlúčia dva workbooky, prečíslujú hárky a nechajú medzery, takže päťhárkový workbook má sheet1, sheet2, sheet4, sheet7 a sheet9. Nástroje, ktoré odstránia hárok, nie vždy prečíslujú preživšie. V každom z týchto prípadov hádanie na základe indexu xl/worksheets/sheet + IntToStr(i + 1) + .xml potichu prečíta nesprávny hárok alebo neprečíta nič, čo je horšie ako výnimka, pretože workbook sa načíta a čísla sú zlé. Minimálny balík nižšie precvičuje celý problém a je to tvar, voči ktorému HotXLS regresne testuje
<!-- _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>
Čo ISO/IEC 29500-2 skutočne garantuje?
Garantuje dosiahnuteľnosť, nie umiestnenie. ISO/IEC 29500-2 je časť Open Packaging Conventions štandardu, a jej klauzula o relationships definuje presne jeden pevný vstupný bod: package relationship časť na _rels/.rels. Odtiaľ nasledujete relationship, ktorého Type je http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument, aby ste dosiahli workbook časť, a každá ďalšia časť sa objaví čítaním relationship časti tejto časti a nasledovaním typovaných hrán smerom von
Dve ďalšie pravidlá z toho istého štandardu robia skutočnú prácu. Klauzula o pomenovaní častí fixuje, kde relationship časť žije: pre časť na <folder>/<name> sú jej relationships na <folder>/_rels/<name>.rels, a pre časť v koreni balíka je folder jednoducho _rels/. Klauzula o markupe relationship uvádza, že Target je URI referencia riešená voči URI zdrojovej časti, v obyčajnom zmysle RFC 3986, pokiaľ ju TargetMode="External" neoznačí ako ukazujúcu mimo balíka. Riešenie relatívne k zdroju je krok, ktorý každý preskočí, a je to dôvod, prečo ten istý literál ../notes/review.xml znamená jednu vec vnútri xl/custom/_rels/data-sheet.xml.rels a niečo úplne iné vnútri rels súboru o priečinok hlbšie. Ešte jeden zádrhel sedí medzi logickým modelom a bajtmi na disku: mená častí v logickom modeli sú absolútne a začínajú lomkou vpred, ale klauzula o fyzickom mapovaní ZIP odstráni túto lomku, keď premení meno časti na meno ZIP položky, takže resolver, ktorý na to zabudne, vyhľadá /xl/sharedStrings.xml v archíve a nič nenájde
Vnútri XlsxResolveRelationshipTarget
HotXLS koncentruje celé pravidlo riešenia do jednej funkcie, XlsxResolveRelationshipTarget, deklarovanej v lxHandleX.pas ako function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Berie meno ZIP položky zdrojovej časti a surový atribút Target, a vráti meno ZIP položky bez vedúcej lomky, pripravené na priame odovzdanie archívu. Odovzdanie prázdneho OwnerPartName vyrieši voči koreňu balíka, čo je presne to, čo potrebuje package relationship časť. Poradie operácií záleží viac než jednotlivé kroky: spätné lomky sa najprv normalizujú na lomky vpred, pretože niektorí producenti zapisujú do Target Windows oddeľovače; akýkoľvek fragment zavedený # sa odreže pred spracovaním cesty, takže ../charts/chart1.xml#Sheet1 sa vyrieši na meno časti namiesto na neexistujúci archívny záznam; až potom funkcia rozdelí absolútne od relatívneho
// 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;
Slučka segmentov je obyčajný prechod zásobníkom: prázdne segmenty a . sa vyhodia, .. vysunie jednu úroveň, a .., ktoré by uniklo z koreňa balíka, sa absorbuje namiesto vyprodukovania záporného indexu alebo mena začínajúceho ../. Priradenie StrictDelimiter := True nie je kozmetické. Bez neho TStringList v Delphi traktuje medzery ako oddeľovače a rešpektuje znaky úvodzoviek, čo pokazí akékoľvek meno časti obsahujúce medzeru, a mená častí s medzerami sú legálne
Nasledovanie grafu: workbook, worksheet, drawing
HotXLS prechádza tri vrstvy relationship častí na ceste TXLSXWorkbook.Open. Vrstvu balíka spracúva XlsxFindOfficeDocumentPart, ktorá prečíta _rels/.rels a vráti cieľ officeDocument. Vrstva workbooku číta relationship časť workbooku a zostaví naraz dve mapy: identifikátorovú mapu pre vyhľadávania r:id a typovú mapu pre singleton časti. Vrstvy worksheet a drawing opakujú vzor s ParseWorksheetRelsXml a ParseDrawingRelsXml, každá odovzdávajúca vlastné meno časti ako základ riešenia, takže drawing, ktorý odkazuje na ../media/image3.png, pristane na správnom blobe
// 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';
Hárky musia konkrétne prejsť cez identifikátorovú mapu, nie cez typovú mapu. Elementy <sheet> vo workbook časti nesú atribúty r:id, a tento identifikátor je jediná vec, ktorá viaže meno hárka na časť. HotXLS zbiera tieto identifikátory počas ParseWorkbookXml a rieši každý z nich voči mape relationship workbooku, s fallbackom na konvenčné číslované meno len keď identifikátor chýba alebo sa nedá vyriešiť
// 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;
Všetko downstream jazdí na tom istom mechanizme. Zdieľané reťazce, štýly, téma, projekt VBA pod typom v Microsoft namespace http://schemas.microsoft.com/office/2006/relationships/vbaProject, externé odkazy, časť osoby v rozsahu workbooku, legacy komentáre, threaded komentáre, VML drawing nesúci geometriu komentárovej bubliny, drawings, obrázky, grafy, tabuľky a PivotTables — všetky dosiahnu svoje bajty cez vyriešené ciele. Časť témy musí byť lokalizovaná najmä správne, inak round-trip potichu prepíše brand paletu zákazníka stock Office témou, jeden z chybových režimov pokrytých v poznámkach o bezstratovom XLSX round-trip témy, extLst a calcChain. Čítanie relationship je tiež dôvod, prečo je load rozvrhnutý tak, ako je: celý prístup do archívu sa deje na jednom vlákne skôr, než sa parsuje worksheet XML, pretože inflate stav ZIP archívu nie je thread-safe, obmedzenie vysvetlené v prehľade o paralelnom XLSX parsovaní a alokátore pamäte
Prečo duplicitné rId pokazí typovo založené smerovanie?
Pretože neskorší poškodený záznam môže prepísať skorší platný a uniesť vyhľadávanie. Identifikátory relationship majú byť jedinečné vnútri relationship časti, ale poškodené balíky ich znovu použijú, a naivné priradenie Values[Id] := je last-write-wins. Ak rId3 najprv ukazuje na skutočný hárok a druhé rId3 ukazuje na nepodporovaný alebo prázdny cieľ, last-write-wins stratí hárok. ParsePartRelationshipsXml preto aplikuje pravidlo first-wins s dvomi podmienkami: vyriešený cieľ musí byť neprázdny, a identifikátor ešte nesmie byť prítomný. Obe podmienky spolu robia toto bezpečným, pretože test neprázdnosti zabráni relationship s chýbajúcim Target, aby si zabralo slot skôr, než príde použiteľný
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));
Všimnite si zámernú asymetriu v tomto úryvku. Identifikátorová mapa je skutočná mapa s guardom first-wins, kým typová zbierka je append-only zoznam párov type=target. Toto rozlíšenie je nosné: workbook má presne jednu relationship zdieľaných reťazcov, ale mnoho relationships worksheet a externých odkazov, takže typové vyhľadávanie cez Values[] vráti prvú zhodu pre singletony, a viachodnotové typy ako externalLink sa vymenujú prechodom zoznamu
Kde nasledovanie relationship prestáva
Na poctivých hraniciach záleží viac než na čistom príbehu. HotXLS padne späť na konvenčné mená vždy, keď relationship chýba, takže balík s poškodenou alebo chýbajúcou relationship časťou sa stále otvorí, ak náhodou nasleduje rozloženie Excelu; tento fallback je kompatibilná funkcia, nie druhý zdroj pravdy, a môže maskovať bug producenta počas testovania. Za zmienku stoja tri ďalšie limity. Ciele označené TargetMode="External" sa ukladajú doslovne namiesto riešenia, čo je správne pre hyperlinky a pre relationship externalLinkPath nesúcu vzdialenú URL workbooku, ale znamená to, že hodnota, ktorú dostanete späť, je čokoľvek, čo producent napísal. Chart časti objavené cez relationship časť drawingu sú spárované s kotvami drawingu poziciónálne, nie podľa identifikátora, takže nezvyčajné poradie kotiev môže zle zarovnať väzby grafov. A streamovací priamy reader v lxDirectRead.pas si drží vlastnú ľahšiu cestu spracovania kľúčovanú na xl/, takže plný resolver tu popísaný riadi vstupné body TXLSXWorkbook.Open a GetSheetNames, nie cestu skenu s nízkou alokáciou zdokumentovanú v článku o streamovacom priamom readeri pre Delphi
Ak si toto staviate sami, najkratšie správne zhrnutie je: nikdy nekonštruujte meno časti, vždy jedno vyriešte. Prečítajte _rels/.rels, nasledujte officeDocument, vyriešte každý Target voči časti, ktorá ho deklarovala, a smerujte hárky podľa r:id. Ak by ste radšej mali toto už otestované voči premenovaným častiam, nesúvislému číslovaniu hárkov a duplicitným identifikátorom relationship, resolver tu popísaný sa dodáva v HotXLS Delphi spreadsheet komponente, spolu s round-trip mašinériou, ktorá udržuje časti, ktoré neparsuje, nedotknuté