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

<!-- _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

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

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

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

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

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