Validan xlsx ne mora sadržati xl/worksheets/sheet1.xml. HotXLS, nativna Excel spreadsheet komponenta za Delphi i C++Builder, lociraju svaki deo kroz graf OPC odnosa umesto da pogađa imena, jer ISO/IEC 29500-2 garantuje samo da su delovi dostupni od _rels/.rels, nikad da sede na konvencionalnim putanjama
Zašto moj parser otkazuje na validnom xlsx-u?
Zato što su putanje delova koje ste zapamtili konvencija jednog proizvođača, ne zahtev formata. Svaka putanja koju ste ikada hardkodovali, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, je ono što desktop Excel writer slučajno emituje. Usaglašen paket može staviti workbook na office/book.xml, a prvi worksheet na xl/custom/data-sheet.xml i i dalje biti legalan SpreadsheetML, sve dok odnosi pokazuju tamo. Ovo je najčešći pojedinačan razlog zašto domaći čitač prijavljuje „ne mogu pronaći sheet1.xml" na fajlu koji Excel, LibreOffice, i Numbers svi otvaraju bez primedbe
Proizvođači koji ovo rade nisu egzotični. Server-side generatori izveštaja ponovo koriste paket šablona i zadržavaju njegov originalni raspored. Export pipelinei koji spajaju dva workbooka prenumerišu listove i ostavljaju praznine, pa workbook od pet listova ima sheet1, sheet2, sheet4, sheet7, i sheet9. Alati koji uklanjaju list ne prenumerišu uvek preživele. U svakom od tih slučajeva pogađanje bazirano na indeksu xl/worksheets/sheet + IntToStr(i + 1) + .xml tiho čita pogrešan list ili ne čita ništa, što je gore od izuzetka jer se workbook učita, a brojevi su pogrešni. Minimalan paket ispod vežba čitav problem, i to je oblik protiv kog se HotXLS regresiono 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>
Šta ISO/IEC 29500-2 zapravo garantuje?
Garantuje dostupnost, ne lokaciju. ISO/IEC 29500-2 je deo standarda o Open Packaging Conventions, a njegova klauzula o odnosima definiše tačno jednu fiksnu ulaznu tačku: deo odnosa paketa na _rels/.rels. Odatle pratite odnos čiji je Type http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument da biste stigli do dela workbooka, a svaki drugi deo se otkriva čitanjem sopstvenog dela odnosa tog dela i praćenjem tipizovanih grana napolje
Još dva pravila iz istog standarda obavljaju stvaran posao. Klauzula imenovanja delova fiksira gde deo odnosa živi: za deo na <folder>/<name>, njegovi odnosi su na <folder>/_rels/<name>.rels, a za deo na korenu paketa folder je jednostavno _rels/. Klauzula markupa odnosa navodi da je Target URI referenca razrešena naspram URI-ja izvornog dela, u običnom RFC 3986 smislu, osim ako TargetMode="External" ne označi da pokazuje van paketa. Razrešavanje relativno u odnosu na izvor je korak koji svi preskaču, i zato isti literal ../notes/review.xml znači jedno unutar xl/custom/_rels/data-sheet.xml.rels, a nešto sasvim drugo unutar rels fajla jedan folder dublje. Jedna poslednja bora sedi između logičkog modela i bajtova na disku: imena delova u logičkom modelu su apsolutna i počinju kosom crtom, ali klauzula fizičkog ZIP mapiranja tu crtu uklanja kada pretvara ime dela u ime ZIP stavke, pa resolver koji to zaboravi traži /xl/sharedStrings.xml u arhivi i ne pronalazi ništa
Unutar XlsxResolveRelationshipTarget
HotXLS koncentriše celo pravilo razrešavanja u jednoj funkciji, XlsxResolveRelationshipTarget, deklarisanoj u lxHandleX.pas kao function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Uzima ime ZIP stavke izvornog dela i sirov atribut Target, i vraća ime ZIP stavke bez vodeće kose crte, spremno da se preda direktno arhivi. Prosleđivanje praznog OwnerPartName razrešava naspram korena paketa, što je tačno ono što delu odnosa paketa treba. Redosled operacija je bitniji od pojedinačnih koraka: obrnute kose crte se prvo normalizuju u obične kose crte, jer neki proizvođači pišu Windows separatore u Target; svaki fragment uveden sa # se odseca pre obrade putanje, pa se ../charts/chart1.xml#Sheet1 razrešava u ime dela umesto u nepostojeću stavku 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 obilaženje steka: prazni segmenti i . se odbacuju, .. skida jedan nivo, a .. koji bi pobegao iz korena paketa se apsorbuje umesto da proizvede negativan indeks ili ime koje počinje sa ../. Dodela StrictDelimiter := True nije kozmetička. Bez nje Delphi TStringList tretira razmake kao delimitere i poštuje karaktere navoda, što unakazi svako ime dela koje sadrži razmak, a imena delova sa razmacima su legalna
Praćenje grafa: workbook, worksheet, drawing
HotXLS obilazi tri nivoa delova odnosa na putanji TXLSXWorkbook.Open. Nivo paketa obrađuje XlsxFindOfficeDocumentPart, koja čita _rels/.rels i vraća metu officeDocument. Nivo workbooka čita deo odnosa workbooka i gradi dve mape odjednom: mapu identifikatora za pretrage r:id i mapu tipova za singl-delove. Nivoi worksheeta i drawinga ponavljaju šablon sa ParseWorksheetRelsXml i ParseDrawingRelsXml, svaki prosleđujući sopstveno ime dela kao osnovu razrešavanja, tako da drawing koji referiše ../media/image3.png pogodi 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 mapu tipova. Elementi <sheet> u delu workbooka nose atribute r:id, i taj identifikator je jedino što vezuje ime lista za deo. HotXLS prikuplja te identifikatore tokom ParseWorkbookXml i razrešava svaki naspram mape odnosa workbooka, padajući nazad na konvencionalno numerisano ime samo kada je identifikator odsutan ili nerazrešiv
// 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 se vozi na tom istom mehanizmu. Deljeni stringovi, stilovi, tema, VBA projekat pod tipom u Microsoft imenskom prostoru http://schemas.microsoft.com/office/2006/relationships/vbaProject, eksterni linkovi, deo osobe u opsegu workbooka, zastareli komentari, komentari u niti, VML drawing koji nosi geometriju balona komentara, drawingovi, slike, grafikoni, tabele, i PivotTable-ovi svi stižu do svojih bajtova kroz razrešene mete. Deo teme posebno mora biti ispravno lociran ili round-trip tiho prepiše korisnikovu brend paletu ugrađenom Office temom, jedan od modova otkazivanja pokrivenih u belešci o bezgubitnom XLSX round-tripu teme, extLst, i calcChain. Čitanje odnosa je takođe razlog zašto je učitavanje faziranu na način na koji jeste: sav pristup arhivi se dešava na jednom thread-u pre nego što se parsira XML worksheeta, jer stanje inflate ZIP arhive nije thread-safe, ograničenje objašnjeno u tekstu o paralelnom XLSX parsiranju i memorijskom alokatoru
Zašto duplirani rId ruši rutiranje bazirano na tipu?
Zato što kasniji loše formiran unos može prepisati raniji validan i oteti pretragu. Identifikatori odnosa bi trebalo da budu jedinstveni unutar dela odnosa, ali loše formirani paketi ih ponovo koriste, a naivna dodela Values[Id] := je poslednji-piše-pobeđuje. Ako rId3 prvo pokazuje na pravi worksheet, a drugi rId3 pokazuje na nepodržanu ili praznu metu, poslednji-piše-pobeđuje gubi worksheet. ParsePartRelationshipsXml zato primenjuje pravilo prvi-pobeđuje sa dva uslova: razrešena meta mora biti neprazna, a identifikator ne sme već biti prisutan. Oba uslova zajedno čine to bezbednim, jer test neprazno sprečava odnos sa nedostajućim Target da zauzme mesto pre nego što upotrebljiv stigne
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));
Obratite pažnju na namernu asimetriju u tom isečku. Mapa identifikatora je prava mapa sa čuvarom prvi-pobeđuje, dok je kolekcija tipova lista samo-za-dodavanje parova type=target. Ta razlika nosi teret: workbook ima tačno jedan odnos deljenih stringova, ali mnogo odnosa worksheeta i eksternih linkova, pa pretraga tipa preko Values[] vraća prvo poklapanje za singl-delove, a tipovi sa više vrednosti poput externalLink se nabrajaju obilaskom liste
Gde se praćenje odnosa zaustavlja
Iskrene granice znače više od uredne priče. HotXLS pada nazad na konvencionalna imena kad god odnos nije prisutan, pa se paket sa oštećenim ili nedostajućim delom odnosa i dalje otvara ako slučajno prati Excel raspored; taj fallback je funkcija kompatibilnosti, ne drugi izvor istine, i može sakriti bag proizvođača tokom testiranja. Još tri granice vredi znati. Mete obeležene sa TargetMode="External" se čuvaju bukvalno umesto da se razrešavaju, što je ispravno za hiperlinkove i za odnos externalLinkPath koji nosi URL udaljenog workbooka, ali znači da je vrednost koju dobijete nazad tačno ono što je proizvođač napisao. Delovi grafikona otkriveni kroz deo odnosa drawinga su upareni sa sidrima drawinga pozicijski, a ne po identifikatoru, pa neuobičajen redosled sidra može poremetiti vezivanje grafikona. A streaming direktan čitač u lxDirectRead.pas drži sopstvenu lakšu obradu putanja ključanu na xl/, pa kompletan resolver opisan ovde upravlja ulaznim tačkama TXLSXWorkbook.Open i GetSheetNames, ne putanjom skeniranja sa niskom alokacijom dokumentovanom u članku o streaming direktnom čitaču za Delphi
Ako ovo gradite sami, najkraći ispravan rezime je: nikad ne konstruišite ime dela, uvek razrešite jedno. Pročitajte _rels/.rels, pratite officeDocument, razrešite svaki Target naspram dela koji ga je deklarisao, i rutirajte listove po r:id. Ako biste radije imali to već testirano protiv preimenovanih delova, nekontinuirane numeracije listova, i dupliranih identifikatora odnosa, resolver opisan ovde isporučuje se u HotXLS Delphi spreadsheet komponenti, zajedno sa mašinerijom round-tripa koja održava delove koje ne parsira netaknutim