Valjan xlsx ne mora sadržavati xl/worksheets/sheet1.xml. HotXLS, nativna Excel komponenta za tablice za Delphi i C++Builder, locira svaki dio kroz graf OPC relationship-a umjesto pogađanja imena, jer ISO/IEC 29500-2 jamči samo da su dijelovi dostupni iz _rels/.rels, nikad da sjede na konvencionalnim putanjama
Zašto moj parser ne uspijeva na valjanom xlsx-u?
Zato što su imena dijelova koje ste zapamtili konvencija jednog proizvođača, a ne zahtjev formata. Svaka putanja koju ste ikad tvrdo kodirali, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, ono je što desktop Excel pisac slučajno emitira. Usklađen paket može staviti radnu bilježnicu na office/book.xml, a prvi list na xl/custom/data-sheet.xml i i dalje biti legalan SpreadsheetML, sve dok relationship-i pokazuju tamo. Ovo je jedan najčešći razlog zašto domaći čitač prijavi "cannot find sheet1.xml" na datoteci koju Excel, LibreOffice i Numbers otvaraju bez prigovora
Proizvođači koji ovo rade nisu egzotični. Poslužiteljski generatori izvještaja ponovno koriste predložak paketa i zadržavaju njegov izvorni raspored. Cjevovodi izvoza koji spajaju dvije radne bilježnice ponovno numeriraju listove i ostavljaju praznine, pa radna bilježnica od pet listova ima sheet1, sheet2, sheet4, sheet7 i sheet9. Alati koji uklone list ne ponovno numeriraju uvijek preživjele. U svakom od tih slučajeva pogodak temeljen na indeksu xl/worksheets/sheet + IntToStr(i + 1) + .xml tiho čita pogrešan list ili ne čita ništa, što je gore od iznimke jer se radna bilježnica učita, a brojevi su pogrešni. Minimalan paket ispod isprobava cijeli problem, i to je oblik protiv kojeg HotXLS regresijski testira
<!-- _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>
Što ISO/IEC 29500-2 zapravo jamči?
Jamči dostupnost, ne lokaciju. ISO/IEC 29500-2 je dio Open Packaging Conventions standarda, a njegova klauzula o relationship-ima definira točno jednu fiksnu ulaznu točku: dio relationship-a paketa na _rels/.rels. Odatle slijedite relationship čiji je Type http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument da dosegnete dio radne bilježnice, a svaki drugi dio otkriva se čitanjem vlastitog dijela relationship-a tog dijela i slijeđenjem tipiziranih rubova prema van
Dva daljnja pravila iz istog standarda obavljaju pravi posao. Klauzula imenovanja dijelova fiksira gdje živi dio relationship-a: za dio na <folder>/<name>, njegovi relationship-i su na <folder>/_rels/<name>.rels, a za dio u korijenu paketa folder je jednostavno _rels/. Klauzula označavanja relationship-a navodi da je Target URI referenca razriješena prema URI-ju izvornog dijela, u uobičajenom smislu RFC 3986, osim ako TargetMode="External" ne označi da pokazuje izvan paketa. Razrješavanje relativno prema izvoru korak je koji svi preskoče, a razlog je zašto isti literal ../notes/review.xml znači jedno unutar xl/custom/_rels/data-sheet.xml.rels, a nešto sasvim drugo unutar rels datoteke jedan folder dublje. Jedna zadnja kvrga sjedi između logičkog modela i bajtova na disku: imena dijelova u logičkom modelu su apsolutna i počinju kosom crtom, ali klauzula fizičkog mapiranja ZIP-a uklanja tu crtu kad ime dijela pretvori u ime ZIP stavke, pa razrješavač koji je zaboravi traži /xl/sharedStrings.xml u arhivi i ne pronađe ništa
Unutar XlsxResolveRelationshipTarget
HotXLS koncentrira cijelo pravilo razrješavanja u jednoj funkciji, XlsxResolveRelationshipTarget, deklariranoj u lxHandleX.pas kao function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Uzima ime ZIP stavke izvornog dijela i sirovi atribut Target, i vraća ime ZIP stavke bez vodeće kose crte, spremno za izravno predati arhivi. Prosljeđivanje praznog OwnerPartName razrješava se prema korijenu paketa, što je upravo ono što treba dio relationship-a paketa. Redoslijed operacija bitniji je od pojedinačnih koraka: obrnute kose crte prvo se normaliziraju u obične kose crte, jer neki proizvođači pišu Windows razdjelnike u Target; svaki fragment uveden s # odsijeca se prije obrade putanje, pa se ../charts/chart1.xml#Sheet1 razrješava u ime dijela, a ne u nepostojeći unos arhive; tek tada funkcija razdvaja apsolutno od relativnog
// 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;
Petlja segmenata je obično hodanje po stogu: prazni segmenti i . se odbacuju, .. skida jednu razinu, a .. koji bi pobjegao iz korijena paketa se apsorbira umjesto da proizvede negativan indeks ili ime koje počinje s ../. Postavljanje StrictDelimiter := True nije kozmetičko. Bez njega Delphi TStringList tretira razmake kao razdjelnike i poštuje znakove navoda, što unakazi svako ime dijela koje sadrži razmak, a imena dijelova s razmacima su legalna
Slijeđenje grafa: radna bilježnica, list, crtež
HotXLS prolazi tri razine dijelova relationship-a na putu TXLSXWorkbook.Open. Razinu paketa obrađuje XlsxFindOfficeDocumentPart, koja čita _rels/.rels i vraća cilj officeDocument. Razina radne bilježnice čita dio relationship-a radne bilježnice i gradi dvije mape odjednom: mapu identifikatora za pretrage r:id i mapu tipova za jedinstvene dijelove. Razine lista i crteža ponavljaju obrazac s ParseWorksheetRelsXml i ParseDrawingRelsXml, svaka prosljeđujući vlastito ime dijela kao osnovu razrješavanja, tako da crtež koji referencira ../media/image3.png sleti na pravi blob
// 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';
Listovi konkretno moraju ići kroz mapu identifikatora, ne kroz mapu tipova. Elementi <sheet> u dijelu radne bilježnice nose atribute r:id, i taj identifikator je jedino što veže ime lista uz dio. HotXLS prikuplja te identifikatore tijekom ParseWorkbookXml i razrješava svaki prema mapi relationship-a radne bilježnice, padajući natrag na konvencionalno numerirano ime samo kad identifikator nedostaje ili se ne može razriješiti
// 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;
Sve nizvodno vozi na istom mehanizmu. Zajednički nizovi, stilovi, tema, VBA projekt pod tipom s Microsoftovim imenskim prostorom http://schemas.microsoft.com/office/2006/relationships/vbaProject, vanjske veze, dio osobe na razini radne bilježnice, naslijeđeni komentari, komentari s nitima, VML crtež koji nosi geometriju balončića komentara, crteži, slike, grafikoni, tablice i pivot tablice svi dosežu svoje bajtove kroz razriješene ciljeve. Dio teme posebno se mora ispravno locirati ili round-trip tiho prepiše prilagođenu paletu marke klijenta stokovnom Office temom, jedan od načina neuspjeha pokrivenih u bilješkama o bezgubitnom XLSX round-tripu teme, extLst-a i calcChain-a. Čitanje relationship-a razlog je i zašto je učitavanje postavljeno na način na koji jest: sav pristup arhivi odvija se na jednoj dretvi prije nego se parsira XML lista, jer stanje inflate ZIP arhive nije sigurno za dretve, ograničenje objašnjeno u tekstu o paralelnom parsiranju XLSX-a i memorijskom alokatoru
Zašto duplicirani rId pokvari usmjeravanje po tipu?
Zato što kasniji loše oblikovan unos može prepisati raniji valjan i preoteti pretragu. Identifikatori relationship-a trebali bi biti jedinstveni unutar dijela relationship-a, ali loše oblikovani paketi ih ponovno koriste, a naivna dodjela Values[Id] := je posljednji-piše-pobjeđuje. Ako rId3 prvo pokazuje na pravi list, a drugi rId3 pokazuje na nepodržan ili prazan cilj, posljednji-piše-pobjeđuje gubi list. ParsePartRelationshipsXml zato primjenjuje pravilo prvi-pobjeđuje s dva uvjeta: razriješeni cilj mora biti neprazan, a identifikator ne smije već biti prisutan. Oba uvjeta zajedno čine ovo sigurnim, jer test neprazanosti sprječava relationship s nedostajućim Target da preotme mjesto prije nego stigne upotrebljiv
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));
Primijetite namjernu asimetriju u tom isječku. Mapa identifikatora je prava mapa sa zaštitom prvi-pobjeđuje, dok je zbirka tipova popis parova type=target u koji se samo dodaje. Ta razlika je nosiva: radna bilježnica ima točno jedan relationship zajedničkih nizova, ali mnogo relationship-a listova i vanjskih veza, pa pretraga tipa preko Values[] vraća prvo podudaranje za jedinstvene, a tipovi s više vrijednosti poput externalLink nabrajaju se prolaskom kroz popis
Gdje slijeđenje relationship-a staje
Iskrene granice važnije su od uredne priče. HotXLS pada natrag na konvencionalna imena kad god relationship nedostaje, pa se paket s oštećenim ili nedostajućim dijelom relationship-a i dalje otvori ako slučajno slijedi Excel raspored; taj rezervni put je značajka kompatibilnosti, ne drugi izvor istine, i može prikriti bug proizvođača tijekom testiranja. Vrijedi znati još tri granice. Ciljevi označeni s TargetMode="External" pohranjuju se doslovno umjesto da se razrješavaju, što je ispravno za hiperveze i za relationship externalLinkPath koji nosi URL udaljene radne bilježnice, ali znači da je vrijednost koju dobijete natrag ono što je proizvođač napisao. Dijelovi grafikona otkriveni kroz dio relationship-a crteža sparuju se s sidrima crteža pozicijski, a ne po identifikatoru, pa neuobičajen redoslijed sidra može poremetiti vezivanje grafikona. I strujni izravni čitač u lxDirectRead.pas zadržava vlastiti lakši put obrade ključan na xl/, pa puni razrješavač opisan ovdje upravlja ulaznim točkama TXLSXWorkbook.Open i GetSheetNames, ne putem skeniranja s malom alokacijom dokumentiranim u članku o strujnom izravnom čitaču za Delphi
Ako ovo gradite sami, najkraći ispravan sažetak je: nikad ne konstruirajte ime dijela, uvijek ga razriješite. Pročitajte _rels/.rels, slijedite officeDocument, razriješite svaki Target prema dijelu koji ga je deklarirao, i usmjerite listove po r:id. Ako biste radije imali to već testirano protiv preimenovanih dijelova, nekontinuiranog numeriranja listova i dupliciranih identifikatora relationship-a, razrješavač opisan ovdje isporučuje se u HotXLS Delphi komponenti za tablice, uz mehaniku round-tripa koja drži netaknutima dijelove koje ne parsira