A valid xlsx does not have to contain xl/worksheets/sheet1.xml. HotXLS, the native Excel spreadsheet component for Delphi and C++Builder, locates every part through the OPC relationship graph instead of guessing names, because ISO/IEC 29500-2 guarantees only that parts are reachable from _rels/.rels, never that they sit at conventional paths
Why does my parser fail on a valid xlsx?
Because the part names you memorised are a convention of one producer, not a requirement of the format. Every path you have ever hardcoded, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, is what the desktop Excel writer happens to emit. A conforming package may put the workbook at office/book.xml and the first worksheet at xl/custom/data-sheet.xml and still be legal SpreadsheetML, as long as the relationships point there. This is the single most common reason a homegrown reader reports "cannot find sheet1.xml" on a file that Excel, LibreOffice, and Numbers all open without complaint
Producers that do this are not exotic. Server-side report generators reuse a template package and keep its original layout. Export pipelines that merge two workbooks renumber sheets and leave gaps, so a five-sheet workbook has sheet1, sheet2, sheet4, sheet7, and sheet9. Tools that strip a sheet do not always renumber the survivors. In every one of those cases the index-based guess xl/worksheets/sheet + IntToStr(i + 1) + .xml silently reads the wrong sheet or reads nothing, which is worse than an exception because the workbook loads and the numbers are wrong. The minimal package below exercises the whole problem, and it is the shape HotXLS regression-tests against
<!-- _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>
What does ISO/IEC 29500-2 actually guarantee?
It guarantees reachability, not location. ISO/IEC 29500-2 is the Open Packaging Conventions part of the standard, and its relationships clause defines exactly one fixed entry point: the package relationship part at _rels/.rels. From there you follow the relationship whose Type is http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument to reach the workbook part, and every other part is discovered by reading that part's own relationship part and following typed edges outward
Two further rules from the same standard do the real work. The part-naming clause fixes where a relationship part lives: for a part at <folder>/<name>, its relationships are at <folder>/_rels/<name>.rels, and for a part at the package root the folder is simply _rels/. The relationship markup clause states that Target is a URI reference resolved against the URI of the source part, in the ordinary RFC 3986 sense, unless TargetMode="External" marks it as pointing outside the package. Source-relative resolution is the step everyone skips, and it is why the same literal ../notes/review.xml means one thing inside xl/custom/_rels/data-sheet.xml.rels and something else entirely inside a rels file one folder deeper. One last wrinkle sits between the logical model and the bytes on disk: part names in the logical model are absolute and begin with a forward slash, but the ZIP physical mapping clause strips that slash when it turns a part name into a ZIP item name, so a resolver that forgets it looks up /xl/sharedStrings.xml in the archive and finds nothing
Inside XlsxResolveRelationshipTarget
HotXLS concentrates the entire resolution rule in one function, XlsxResolveRelationshipTarget, declared in lxHandleX.pas as function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. It takes the ZIP item name of the source part and the raw Target attribute, and returns a ZIP item name with no leading slash, ready to hand straight to the archive. Passing an empty OwnerPartName resolves against the package root, which is exactly what the package relationship part needs. The order of operations matters more than the individual steps: backslashes are normalised to forward slashes first, because some producers write Windows separators into Target; any fragment introduced by # is cut before path handling, so ../charts/chart1.xml#Sheet1 resolves to a part name rather than to a nonexistent archive entry; only then does the function split absolute from relative
// 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;
The segment loop is a plain stack walk: empty segments and . are dropped, .. pops one level, and a .. that would escape the package root is absorbed instead of producing a negative index or a name beginning with ../. The StrictDelimiter := True assignment is not cosmetic. Without it a Delphi TStringList treats spaces as delimiters and honours quote characters, which mangles any part name containing a space, and part names with spaces are legal
Following the graph: workbook, worksheet, drawing
HotXLS walks three tiers of relationship parts on the TXLSXWorkbook.Open path. The package tier is handled by XlsxFindOfficeDocumentPart, which reads _rels/.rels and returns the officeDocument target. The workbook tier reads the workbook relationship part and builds two maps at once: an identifier map for r:id lookups and a type map for singleton parts. The worksheet and drawing tiers repeat the pattern with ParseWorksheetRelsXml and ParseDrawingRelsXml, each passing its own part name as the resolution base so a drawing that references ../media/image3.png lands on the right 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';
Sheets specifically must go through the identifier map, not the type map. The <sheet> elements in the workbook part carry r:id attributes, and that identifier is the only thing binding a sheet name to a part. HotXLS collects those identifiers during ParseWorkbookXml and resolves each one against the workbook relationship map, falling back to the conventional numbered name only when the identifier is absent or unresolvable
// 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;
Everything downstream rides on that same mechanism. Shared strings, styles, theme, the VBA project under the Microsoft-namespaced type http://schemas.microsoft.com/office/2006/relationships/vbaProject, external links, the workbook-scoped person part, legacy comments, threaded comments, the VML drawing that carries comment balloon geometry, drawings, images, charts, tables, and PivotTables all reach their bytes through resolved targets. The theme part in particular has to be located correctly or a round-trip quietly overwrites a customer brand palette with the stock Office theme, one of the failure modes covered in the notes on lossless XLSX round-trip of theme, extLst, and calcChain. Relationship reading is also why the load is staged the way it is: all archive access happens on one thread before worksheet XML is parsed, because the inflate state of a ZIP archive is not thread-safe, a constraint explained in the write-up on parallel XLSX parsing and the memory allocator
Why does a duplicate rId break type-based routing?
Because a later malformed entry can overwrite an earlier valid one and hijack the lookup. Relationship identifiers are supposed to be unique within a relationship part, but malformed packages reuse them, and a naive Values[Id] := assignment is last-write-wins. If rId3 first points at a real worksheet and a second rId3 points at an unsupported or empty target, last-write-wins loses the worksheet. ParsePartRelationshipsXml therefore applies a first-wins rule with two conditions: the resolved target must be non-empty, and the identifier must not already be present. Both conditions together are what make it safe, because the non-empty test stops a relationship with a missing Target from claiming the slot before a usable one arrives
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));
Note the deliberate asymmetry in that snippet. The identifier map is a true map with a first-wins guard, while the type collection is an append-only list of type=target pairs. That distinction is load-bearing: a workbook has exactly one shared-strings relationship but many worksheet and external-link relationships, so type lookup via Values[] returns the first match for singletons, and multi-valued types such as externalLink are enumerated by walking the list
Where relationship following stops
Honest boundaries matter more than a clean story. HotXLS falls back to conventional names whenever a relationship is absent, so a package with a damaged or missing relationship part still opens if it happens to follow the Excel layout; that fallback is a compatibility feature, not a second source of truth, and it can mask a producer bug during testing. Three more limits are worth knowing. Targets marked TargetMode="External" are stored verbatim rather than resolved, which is correct for hyperlinks and for the externalLinkPath relationship carrying a remote workbook URL, but it means the value you get back is whatever the producer wrote. Chart parts discovered through a drawing relationship part are paired with drawing anchors positionally rather than by identifier, so an unusual anchor ordering can misalign chart bindings. And the streaming direct reader in lxDirectRead.pas keeps its own lighter path handling keyed to xl/, so the full resolver described here governs the TXLSXWorkbook.Open and GetSheetNames entry points, not the low-allocation scan path documented in the article on the streaming direct reader for Delphi
If you are building this yourself, the shortest correct summary is: never construct a part name, always resolve one. Read _rels/.rels, follow officeDocument, resolve every Target against the part that declared it, and route sheets by r:id. If you would rather have that already tested against renamed parts, non-contiguous sheet numbering, and duplicate relationship identifiers, the resolver described here ships in the HotXLS Delphi spreadsheet component, along with the round-trip machinery that keeps the parts it does not parse intact