Technischer Artikel

XLSX-OPC-Relationship-Auflösung in Delphi-Parsern

Eine gültige xlsx muss xl/worksheets/sheet1.xml nicht enthalten. HotXLS, die native Excel-Tabellenkalkulationskomponente für Delphi und C++Builder, lokalisiert jeden Teil über den OPC-Relationship-Graphen, statt Namen zu erraten, denn ISO/IEC 29500-2 garantiert nur, dass Teile von _rels/.rels aus erreichbar sind, niemals, dass sie an konventionellen Pfaden sitzen

Warum scheitert mein Parser an einer gültigen xlsx?

Weil die Teilnamen, die man sich eingeprägt hat, eine Konvention eines einzelnen Erzeugers sind, keine Anforderung des Formats. Jeder je fest verdrahtete Pfad, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, ist das, was der Desktop-Excel-Writer zufällig emittiert. Ein konformes Paket darf das Workbook bei office/book.xml und das erste Arbeitsblatt bei xl/custom/data-sheet.xml platzieren und trotzdem gültiges SpreadsheetML sein, solange die Relationships dorthin zeigen. Das ist der mit Abstand häufigste Grund, warum ein selbstgebauter Reader "kann sheet1.xml nicht finden" bei einer Datei meldet, die Excel, LibreOffice und Numbers allesamt klaglos öffnen

Erzeuger, die das tun, sind nicht exotisch. Serverseitige Berichtsgeneratoren wiederverwenden ein Vorlagenpaket und behalten dessen ursprüngliches Layout. Export-Pipelines, die zwei Workbooks zusammenführen, nummerieren Sheets neu und lassen Lücken, sodass ein Workbook mit fünf Sheets sheet1, sheet2, sheet4, sheet7 und sheet9 hat. Werkzeuge, die ein Sheet entfernen, nummerieren die Überlebenden nicht immer neu. In jedem dieser Fälle liest die indexbasierte Vermutung xl/worksheets/sheet + IntToStr(i + 1) + .xml still das falsche Sheet oder gar nichts, was schlimmer ist als eine Exception, weil das Workbook lädt und die Zahlen falsch sind. Das minimale Paket unten übt das gesamte Problem aus, und es ist die Form, gegen die HotXLS regressionstestet

Diagramm eines gültigen XLSX-Pakets, in dem HotXLS for Delphi über OPC-Relationships zu office/book.xml, einem Arbeitsblatt mit eigenem Pfad und einem Kommentare-Teil gelangt, während jeder konventionelle hartkodierte Name fehlt
Nur _rels/.rels ist ein fester Name — Workbook-, Worksheet- und Comment-Parts sitzen alle, wohin ihre Beziehungen zeigen
<!-- _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>

Was garantiert ISO/IEC 29500-2 tatsächlich?

Sie garantiert Erreichbarkeit, nicht Ort. ISO/IEC 29500-2 ist der Open-Packaging-Conventions-Teil des Standards, und seine Relationship-Klausel definiert genau einen festen Einstiegspunkt: den Paket-Relationship-Teil bei _rels/.rels. Von dort folgt man der Relationship, deren Type http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument ist, um den Workbook-Teil zu erreichen, und jeder weitere Teil wird entdeckt, indem man den eigenen Relationship-Teil dieses Teils liest und typisierten Kanten nach außen folgt

Zwei weitere Regeln desselben Standards leisten die eigentliche Arbeit. Die Teilnamensklausel legt fest, wo ein Relationship-Teil lebt: Für einen Teil bei <folder>/<name> liegen seine Relationships bei <folder>/_rels/<name>.rels, und für einen Teil an der Paketwurzel ist der Ordner schlicht _rels/. Die Relationship-Markup-Klausel besagt, dass Target eine URI-Referenz ist, aufgelöst gegen die URI des Quellteils, im gewöhnlichen Sinn von RFC 3986, sofern nicht TargetMode="External" markiert, dass sie außerhalb des Pakets zeigt. Quellrelative Auflösung ist der Schritt, den jeder überspringt, und deshalb bedeutet dasselbe wörtliche ../notes/review.xml innerhalb von xl/custom/_rels/data-sheet.xml.rels etwas anderes als in einer Rels-Datei einen Ordner tiefer. Eine letzte Falte sitzt zwischen dem logischen Modell und den Bytes auf der Platte: Teilnamen im logischen Modell sind absolut und beginnen mit einem Schrägstrich, aber die ZIP-Physical-Mapping-Klausel entfernt diesen Schrägstrich, wenn sie einen Teilnamen in einen ZIP-Item-Namen umwandelt, sodass ein Resolver, der das vergisst, /xl/sharedStrings.xml im Archiv nachschlägt und nichts findet

Innerhalb von XlsxResolveRelationshipTarget

HotXLS bündelt die gesamte Auflösungsregel in einer einzigen Funktion, XlsxResolveRelationshipTarget, deklariert in lxHandleX.pas als function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Sie nimmt den ZIP-Item-Namen des Quellteils und das rohe Target-Attribut entgegen und gibt einen ZIP-Item-Namen ohne führenden Schrägstrich zurück, bereit, direkt an das Archiv übergeben zu werden. Ein leeres OwnerPartName zu übergeben löst gegen die Paketwurzel auf, genau was der Paket-Relationship-Teil braucht. Die Reihenfolge der Operationen ist wichtiger als die einzelnen Schritte: Backslashes werden zuerst zu Forward-Slashes normalisiert, weil manche Erzeuger Windows-Trenner in Target schreiben; jedes durch # eingeleitete Fragment wird vor der Pfadbehandlung abgeschnitten, sodass ../charts/chart1.xml#Sheet1 zu einem Teilnamen auflöst statt zu einem nicht existierenden Archiveintrag; erst dann trennt die Funktion Absolutes von Relativem

Flussdiagramm von XlsxResolveRelationshipTarget: HotXLS normalisiert Trenner, streift Fragmente, verbindet den Quellteil-Ordner und kollabiert Punktsegmente zu einem endgültigen ZIP-Item-Namen
Trennernormalisierung, Fragment-Abtragung, quellrelatives Zusammenfügen und ein Punkt-Segment-Stapeldurchlauf machen aus einem rohen Target einen archivfertigen Part-Namen
// Kern der Normalisierung, so wie in lxHandleX.pas implementiert
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)              // paket-absolut: nur den Slash abschneiden
else
begin
  p := LastDelimiter('/', String(OwnerPartName));
  if p > 0 then
    baseName := Copy(OwnerPartName, 1, p)
  else
    baseName := '';
  combined := baseName + combined;    // relativ zum Ordner des Quell-Parts
end;

source.StrictDelimiter := True;       // nur '/', keine Behandlung von Quotes oder Leerzeichen
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;                         // leere Segmente und Punkt-Segmente verschwinden
  if segment = '..' then
  begin
    if parts.Count > 0 then
      parts.Delete(parts.Count - 1);  // pop, und nie unter die Wurzel
  end
  else
    parts.Add(String(segment));
end;

Die Segment-Schleife ist ein schlichter Stapel-Durchlauf: leere Segmente und . werden verworfen, .. springt eine Ebene zurück, und ein .., das die Paketwurzel verlassen würde, wird absorbiert statt einen negativen Index oder einen mit ../ beginnenden Namen zu erzeugen. Die Zuweisung StrictDelimiter := True ist nicht kosmetisch. Ohne sie behandelt eine Delphi-TStringList Leerzeichen als Trenner und respektiert Anführungszeichen, was jeden Teilnamen mit Leerzeichen verstümmelt, und Teilnamen mit Leerzeichen sind legal

Dem Graphen folgen: Workbook, Worksheet, Drawing

HotXLS durchläuft drei Ebenen von Relationship-Teilen auf dem TXLSXWorkbook.Open-Pfad. Die Paketebene wird von XlsxFindOfficeDocumentPart behandelt, das _rels/.rels liest und das officeDocument-Ziel zurückgibt. Die Workbook-Ebene liest den Workbook-Relationship-Teil und baut zwei Maps gleichzeitig: eine Bezeichner-Map für r:id-Lookups und eine Typ-Map für Singleton-Teile. Die Worksheet- und Drawing-Ebenen wiederholen das Muster mit ParseWorksheetRelsXml und ParseDrawingRelsXml, wobei jede ihren eigenen Teilnamen als Auflösungsbasis übergibt, sodass ein Drawing, das ../media/image3.png referenziert, beim richtigen Blob landet

Diagramm der drei OPC-Ebenen, die HotXLS beim Öffnen von XLSX-Dateien in Delphi durchläuft: jedes Arbeitsblatt wird über r:id geroutet, während eine Zuerst-gewinnt-Sperre vor doppelten Relationship-Identifikatoren schützt
Jede Ebene wiederholt ein Muster — den rels-Part orten und jedes Target auflösen — während Blätter über die Bezeichnermap binden, nie über positionelles Raten
// Stufe 1: der einzige feste Name im ganzen Format
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
  WorkbookPartName := 'xl/workbook.xml';        // legacy fallback
if not zip.Exists(WorkbookPartName) then
  Exit;

// Stufe 2: <folder>/_rels/<name>.rels für den Workbook-Part selbst
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParsePartRelationshipsXml(relsStream, WorkbookPartName,
      WorkbookTargetById, WorkbookTargetsByType);
  finally
    relsStream.Free;
  end;
end;

// Typisierte Singletons werden über die Relationship-Type-URI aufgelöst
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
  PartName := 'xl/sharedStrings.xml';

Sheets müssen ausdrücklich über die Bezeichner-Map gehen, nicht über die Typ-Map. Die <sheet>-Elemente im Workbook-Teil tragen r:id-Attribute, und dieser Bezeichner ist das Einzige, was einen Sheet-Namen an einen Teil bindet. HotXLS sammelt diese Bezeichner während ParseWorkbookXml und löst jeden gegen die Workbook-Relationship-Map auf, wobei nur dann auf den konventionellen nummerierten Namen zurückgefallen wird, wenn der Bezeichner fehlt oder nicht auflösbar ist

// 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));

// Stufe 3: jedes Worksheet löst seine eigenen Satelliten gegen den eigenen Namen auf
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;

Alles Nachgelagerte reitet auf demselben Mechanismus. Shared Strings, Styles, Theme, das VBA-Projekt unter dem Microsoft-namensgebundenen Typ http://schemas.microsoft.com/office/2006/relationships/vbaProject, externe Links, der workbook-gebundene Person-Teil, Legacy-Kommentare, Threaded Comments, das VML-Drawing, das die Geometrie der Kommentarblasen trägt, Drawings, Bilder, Diagramme, Tabellen und PivotTables erreichen ihre Bytes allesamt über aufgelöste Ziele. Insbesondere der Theme-Teil muss korrekt lokalisiert werden, sonst überschreibt ein Round-Trip still eine Kunden-Markenpalette mit dem Standard-Office-Theme, einer der Fehlermodi, die in den Notizen zum verlustfreien XLSX-Round-Trip von Theme, extLst und calcChain behandelt werden. Relationship-Lesen ist auch, warum der Ladevorgang so gestaffelt ist: Aller Archivzugriff geschieht auf einem Thread, bevor Worksheet-XML geparst wird, denn der Inflate-Zustand eines ZIP-Archivs ist nicht thread-sicher, eine Einschränkung erklärt im Beitrag zu paralleler XLSX-Parsing und dem Speicher-Allocator

Warum bricht ein doppeltes rId typbasiertes Routing?

Weil ein späterer fehlerhafter Eintrag einen früheren gültigen überschreiben und den Lookup kapern kann. Relationship-Bezeichner sollten innerhalb eines Relationship-Teils eindeutig sein, aber fehlerhafte Pakete verwenden sie wieder, und eine naive Values[Id] :=-Zuweisung ist Last-Write-Wins. Zeigt rId3 zuerst auf ein echtes Worksheet und ein zweites rId3 auf ein nicht unterstütztes oder leeres Ziel, verliert Last-Write-Wins das Worksheet. ParsePartRelationshipsXml wendet daher eine First-Wins-Regel mit zwei Bedingungen an: Das aufgelöste Ziel muss nicht leer sein, und der Bezeichner darf noch nicht vorhanden sein. Beide Bedingungen zusammen machen es sicher, denn der Nicht-leer-Test hindert eine Relationship mit fehlendem Target daran, den Platz zu beanspruchen, bevor eine brauchbare eintrifft

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));

Man beachte die bewusste Asymmetrie in diesem Ausschnitt. Die Bezeichner-Map ist eine echte Map mit First-Wins-Schutz, während die Typ-Sammlung eine reine Anhänge-Liste von type=target-Paaren ist. Diese Unterscheidung ist tragend: Ein Workbook hat genau eine Shared-Strings-Relationship, aber viele Worksheet- und External-Link-Relationships, sodass der Typ-Lookup über Values[] für Singletons den ersten Treffer zurückgibt, und mehrwertige Typen wie externalLink werden durch Durchlaufen der Liste enumeriert

Wo das Relationship-Folgen endet

Ehrliche Grenzen zählen mehr als eine saubere Geschichte. HotXLS fällt auf konventionelle Namen zurück, wann immer eine Relationship fehlt, sodass ein Paket mit beschädigtem oder fehlendem Relationship-Teil dennoch öffnet, falls es zufällig dem Excel-Layout folgt; dieser Fallback ist ein Kompatibilitätsfeature, keine zweite Quelle der Wahrheit, und er kann einen Erzeugerfehler beim Testen verdecken. Drei weitere Grenzen sind es wert, gekannt zu werden. Ziele, die mit TargetMode="External" markiert sind, werden wörtlich gespeichert statt aufgelöst, was für Hyperlinks und für die externalLinkPath-Relationship, die eine entfernte Workbook-URL trägt, korrekt ist, bedeutet aber, dass der zurückerhaltene Wert genau das ist, was der Erzeuger geschrieben hat. Chart-Teile, entdeckt über einen Drawing-Relationship-Teil, werden mit Drawing-Ankern positionell statt über Bezeichner gepaart, sodass eine ungewöhnliche Anker-Reihenfolge Chart-Bindungen fehlausrichten kann. Und der Streaming-Direct-Reader in lxDirectRead.pas behält seinen eigenen leichteren Pfad, geschlüsselt auf xl/, sodass der hier beschriebene vollständige Resolver die Einstiegspunkte TXLSXWorkbook.Open und GetSheetNames regiert, nicht den im Artikel zum Streaming-Direct-Reader für Delphi dokumentierten Low-Allocation-Scan-Pfad

Wer das selbst baut, dem lautet die kürzeste korrekte Zusammenfassung: Nie einen Teilnamen konstruieren, immer einen auflösen. _rels/.rels lesen, officeDocument folgen, jedes Target gegen den Teil auflösen, der es deklariert hat, und Sheets über r:id routen. Wer das lieber schon gegen umbenannte Teile, nicht-durchgehende Sheet-Nummerierung und doppelte Relationship-Bezeichner getestet hätte, findet den hier beschriebenen Resolver in der HotXLS-Delphi Spreadsheet Component, zusammen mit der Round-Trip-Maschinerie, die die nicht geparsten Teile intakt hält