Technisch artikel

XLSX OPC-relatieresolutie in Delphi-parsers

Een geldige xlsx hoeft geen xl/worksheets/sheet1.xml te bevatten. HotXLS, de native Excel-spreadsheetcomponent voor Delphi en C++Builder, vindt elk onderdeel via de OPC-relatiegraaf in plaats van namen te raden, omdat ISO/IEC 29500-2 alleen garandeert dat onderdelen bereikbaar zijn vanaf _rels/.rels, nooit dat ze op conventionele paden staan

Waarom faalt mijn parser bij een geldige xlsx

Omdat de onderdeelnamen die je uit je hoofd kent een conventie van één producent zijn, geen vereiste van het formaat. Elk pad dat je ooit hardcoded hebt, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, is wat de desktop-Excel-writer toevallig genereert. Een conform pakket mag het werkboek op office/book.xml plaatsen en het eerste werkblad op xl/custom/data-sheet.xml, en toch legale SpreadsheetML zijn, zolang de relaties daar naartoe wijzen. Dit is de meest voorkomende reden waarom een zelfgebouwde reader "cannot find sheet1.xml" meldt bij een bestand dat Excel, LibreOffice en Numbers stuk voor stuk zonder klagen openen

Producenten die dit doen zijn niet uitzonderlijk. Server-side rapportgeneratoren hergebruiken een sjabloonpakket en behouden de oorspronkelijke lay-out ervan. Exportpijplijnen die twee werkboeken samenvoegen hernummeren bladen en laten gaten vallen, zodat een werkboek met vijf bladen sheet1, sheet2, sheet4, sheet7 en sheet9 heeft. Tools die een blad verwijderen hernummeren de overblijvende bladen niet altijd. In elk van die gevallen leest de op index gebaseerde gok xl/worksheets/sheet + IntToStr(i + 1) + .xml stilzwijgend het verkeerde blad of leest het niets, wat erger is dan een uitzondering omdat het werkboek gewoon laadt en de cijfers fout zijn. Het minimale pakket hieronder demonstreert het hele probleem, en het is precies de vorm waartegen HotXLS regressietest

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

Wat garandeert ISO/IEC 29500-2 eigenlijk

Het garandeert bereikbaarheid, niet locatie. ISO/IEC 29500-2 is het Open Packaging Conventions-deel van de standaard, en de relatieclausule ervan definieert precies één vast toegangspunt: het package relationship-onderdeel op _rels/.rels. Vandaar volg je de relatie waarvan Type gelijk is aan http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument om bij het werkboekonderdeel te komen, en elk ander onderdeel wordt gevonden door het eigen relatie-onderdeel van dat onderdeel te lezen en getypeerde randen naar buiten te volgen

Twee verdere regels uit dezelfde standaard doen het echte werk. De onderdeelnaam-clausule legt vast waar een relatie-onderdeel zich bevindt: voor een onderdeel op <folder>/<name> staan de relaties op <folder>/_rels/<name>.rels, en voor een onderdeel op de packageroot is de map gewoon _rels/. De relatie-opmaakclausule stelt dat Target een URI-referentie is die wordt opgelost tegen de URI van het bronomderdeel, in de gewone RFC 3986-zin, tenzij TargetMode="External" aangeeft dat deze buiten het pakket wijst. Bron-relatieve resolutie is de stap die iedereen overslaat, en het is de reden waarom dezelfde letterlijke ../notes/review.xml iets anders betekent binnen xl/custom/_rels/data-sheet.xml.rels dan binnen een rels-bestand één map dieper. Nog een laatste addertje zit tussen het logische model en de bytes op schijf: onderdeelnamen in het logische model zijn absoluut en beginnen met een forward slash, maar de ZIP-fysieke-mapping-clausule verwijdert die slash wanneer een onderdeelnaam wordt omgezet naar een ZIP-itemnaam, dus een resolver die dit vergeet zoekt /xl/sharedStrings.xml op in het archief en vindt niets

Binnenin XlsxResolveRelationshipTarget

HotXLS concentreert de hele resolutieregel in één functie, XlsxResolveRelationshipTarget, gedeclareerd in lxHandleX.pas als function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Ze neemt de ZIP-itemnaam van het bronomderdeel en het ruwe Target-attribuut, en geeft een ZIP-itemnaam terug zonder leidende slash, klaar om rechtstreeks aan het archief door te geven. Een lege OwnerPartName meegeven lost op tegen de packageroot, precies wat het package relationship-onderdeel nodig heeft. De volgorde van bewerkingen doet er meer toe dan de afzonderlijke stappen: backslashes worden eerst genormaliseerd naar forward slashes, omdat sommige producenten Windows-scheidingstekens in Target schrijven; elk fragment dat door # wordt geïntroduceerd, wordt afgesneden voordat de padverwerking begint, zodat ../charts/chart1.xml#Sheet1 oplost naar een onderdeelnaam in plaats van naar een niet-bestaand archiefitem; pas daarna splitst de functie absoluut van relatief

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

De segmentlus is een gewone stack-wandeling: lege segmenten en . worden weggegooid, .. haalt één niveau weg, en een .. die buiten de packageroot zou treden wordt geabsorbeerd in plaats van een negatieve index of een naam die begint met ../ op te leveren. De toewijzing StrictDelimiter := True is niet cosmetisch. Zonder die instelling behandelt een Delphi TStringList spaties als scheidingstekens en respecteert het aanhalingstekens, wat elke onderdeelnaam met een spatie verminkt, en onderdeelnamen met spaties zijn legaal

De graaf volgen: werkboek, werkblad, tekening

HotXLS doorloopt drie lagen relatie-onderdelen op het pad TXLSXWorkbook.Open. De packagelaag wordt afgehandeld door XlsxFindOfficeDocumentPart, die _rels/.rels leest en het officeDocument-doel teruggeeft. De werkboeklaag leest het relatie-onderdeel van het werkboek en bouwt meteen twee mappings: een identifier-mapping voor r:id-opzoekingen en een typemapping voor singleton-onderdelen. De werkblad- en tekeninglagen herhalen het patroon met ParseWorksheetRelsXml en ParseDrawingRelsXml, elk met zijn eigen onderdeelnaam als resolutiebasis, zodat een tekening die verwijst naar ../media/image3.png op de juiste blob terechtkomt

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

Bladen specifiek moeten via de identifier-mapping gaan, niet via de typemapping. De <sheet>-elementen in het werkboekonderdeel dragen r:id-attributen, en die identifier is het enige dat een bladnaam aan een onderdeel bindt. HotXLS verzamelt die identifiers tijdens ParseWorkbookXml en lost elk ervan op tegen de relatiemapping van het werkboek, met terugval naar de conventionele genummerde naam alleen wanneer de identifier ontbreekt of niet oplosbaar is

// 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 stroomafwaarts leunt op datzelfde mechanisme. Gedeelde tekenreeksen, stijlen, thema, het VBA-project onder het Microsoft-namespace-type http://schemas.microsoft.com/office/2006/relationships/vbaProject, externe koppelingen, het werkboekgebonden persoononderdeel, legacy-opmerkingen, threaded comments, de VML-tekening die de balgeometrie van opmerkingen draagt, tekeningen, afbeeldingen, grafieken, tabellen en draaitabellen bereiken allemaal hun bytes via opgeloste doelen. Het themaonderdeel in het bijzonder moet correct worden gevonden, anders overschrijft een round-trip stilzwijgend een aangepast merkpalet van een klant met het standaard Office-thema, een van de faalmodi die worden behandeld in de aantekeningen over verliesvrije XLSX-round-trip van thema, extLst en calcChain. Het lezen van relaties is ook de reden waarom het laden op deze manier is gefaseerd: alle archieftoegang gebeurt op één thread voordat werkbladen-XML wordt geparset, omdat de inflate-status van een ZIP-archief niet thread-safe is, een beperking die wordt uitgelegd in het artikel over parallelle XLSX-parsing en de geheugenallocator

Waarom breekt een dubbele rId type-gebaseerde routering

Omdat een latere misvormde entry een eerdere geldige kan overschrijven en de opzoeking kan kapen. Relatie-identifiers horen uniek te zijn binnen een relatie-onderdeel, maar misvormde pakketten hergebruiken ze, en een naïeve toewijzing Values[Id] := is last-write-wins. Als rId3 eerst naar een echt werkblad wijst en een tweede rId3 naar een niet-ondersteund of leeg doel wijst, verliest last-write-wins het werkblad. ParsePartRelationshipsXml past daarom een first-wins-regel toe met twee voorwaarden: het opgeloste doel moet niet-leeg zijn, en de identifier mag nog niet aanwezig zijn. Beide voorwaarden samen maken het veilig, want de niet-leeg-test voorkomt dat een relatie met een ontbrekende Target de plek claimt voordat een bruikbare aankomt

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

Let op de bewuste asymmetrie in dat fragment. De identifier-mapping is een echte map met een first-wins-bescherming, terwijl de typeverzameling een append-only lijst van type=target-paren is. Dat onderscheid is essentieel: een werkboek heeft precies één relatie voor gedeelde tekenreeksen maar veel relaties voor werkbladen en externe koppelingen, dus type-opzoeking via Values[] geeft de eerste match voor singletons terug, en meerwaardige types zoals externalLink worden opgesomd door de lijst te doorlopen

Waar het volgen van relaties stopt

Eerlijke grenzen zijn belangrijker dan een net verhaal. HotXLS valt terug op conventionele namen wanneer een relatie ontbreekt, zodat een pakket met een beschadigd of ontbrekend relatie-onderdeel toch opent als het toevallig de Excel-lay-out volgt; die terugval is een compatibiliteitsfunctie, geen tweede bron van waarheid, en kan een producentbug tijdens testen maskeren. Drie verdere grenzen zijn het weten waard. Doelen gemarkeerd met TargetMode="External" worden letterlijk opgeslagen in plaats van opgelost, wat correct is voor hyperlinks en voor de relatie externalLinkPath die een externe werkboek-URL draagt, maar het betekent dat de waarde die je terugkrijgt is wat de producent heeft geschreven. Grafiekonderdelen die via een tekening-relatie-onderdeel worden gevonden, worden positioneel gekoppeld aan tekeningankers in plaats van via identifier, dus een ongebruikelijke ankervolgorde kan grafiekbindingen laten verschuiven. En de streaming direct reader in lxDirectRead.pas houdt zijn eigen lichtere padverwerking aan, gekoppeld aan xl/, dus de volledige resolver die hier is beschreven regeert de toegangspunten TXLSXWorkbook.Open en GetSheetNames, niet het low-allocation scanpad dat wordt gedocumenteerd in het artikel over de streaming direct reader voor Delphi

Als je dit zelf bouwt, is de kortste correcte samenvatting: construeer nooit een onderdeelnaam, los er altijd één op. Lees _rels/.rels, volg officeDocument, los elke Target op tegen het onderdeel dat hem declareerde, en routeer bladen via r:id. Als je liever iets neemt dat al getest is tegen hernoemde onderdelen, niet-aaneengesloten bladnummering en dubbele relatie-identifiers, dan zit de hier beschreven resolver in de HotXLS Delphi-spreadsheetcomponent, samen met de round-trip-mechaniek die de onderdelen die hij niet parseert intact houdt