Teknisk artikel

XLSX OPC-relationsupplösning i Delphi-parsrar

En giltig xlsx behöver inte innehålla xl/worksheets/sheet1.xml. HotXLS, den inbyggda Excel-kalkylbladskomponenten för Delphi och C++Builder, hittar varje del genom OPC-relationsgrafen istället för att gissa namn, eftersom ISO/IEC 29500-2 bara garanterar att delar är nåbara från _rels/.rels, aldrig att de sitter på konventionella sökvägar

Varför misslyckas min parser på en giltig xlsx?

Därför att delnamnen du memorerat är en konvention hos en producent, inte ett krav i formatet. Varje sökväg du någonsin hårdkodat, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, är vad skrivbords-Excel råkar skriva ut. Ett konformt paket kan lägga arbetsboken på office/book.xml och det första kalkylbladet på xl/custom/data-sheet.xml och ändå vara giltig SpreadsheetML, så länge relationerna pekar dit. Det här är den enskilt vanligaste anledningen till att en hemmagjord läsare rapporterar "hittar inte sheet1.xml" på en fil som Excel, LibreOffice och Numbers alla öppnar utan klagomål

Producenter som gör detta är inte exotiska. Serversidiga rapportgeneratorer återanvänder ett mallpaket och behåller dess ursprungliga layout. Exportpipelines som slår ihop två arbetsböcker omnumrerar blad och lämnar luckor, så en femblads-arbetsbok har sheet1, sheet2, sheet4, sheet7 och sheet9. Verktyg som tar bort ett blad omnumrerar inte alltid de överlevande. I vart och ett av dessa fall läser den indexbaserade gissningen xl/worksheets/sheet + IntToStr(i + 1) + .xml tyst fel blad eller läser ingenting, vilket är värre än ett undantag eftersom arbetsboken laddas och talen är fel. Minimipaketet nedan övar hela problemet, och det är formen HotXLS regressionstestar mot

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

Vad garanterar ISO/IEC 29500-2 egentligen?

Den garanterar nåbarhet, inte plats. ISO/IEC 29500-2 är Open Packaging Conventions-delen av standarden, och dess relationsklausul definierar exakt en fast ingångspunkt: paketrelationsdelen vid _rels/.rels. Därifrån följer du relationen vars Type är http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument för att nå arbetsboksdelen, och varje annan del upptäcks genom att läsa den delens egen relationsdel och följa typade kanter utåt

Två ytterligare regler från samma standard gör det verkliga arbetet. Del-namngivningsklausulen fixerar var en relationsdel bor: för en del vid <folder>/<name> ligger dess relationer vid <folder>/_rels/<name>.rels, och för en del vid paketroten är mappen helt enkelt _rels/. Relationsmarkupklausulen anger att Target är en URI-referens löst mot källdelens URI, i den vanliga RFC 3986-meningen, om inte TargetMode="External" markerar den som pekande utanför paketet. Källrelativ upplösning är steget alla hoppar över, och det är varför samma bokstavliga ../notes/review.xml betyder en sak inuti xl/custom/_rels/data-sheet.xml.rels och något helt annat inuti en rels-fil en mapp djupare. En sista krök sitter mellan den logiska modellen och byten på disk: delnamn i den logiska modellen är absoluta och börjar med ett snedstreck framåt, men ZIP:s fysiska mappningsklausul tar bort det snedstrecket när den gör om ett delnamn till ett ZIP-postnamn, så en upplösare som glömmer det slår upp /xl/sharedStrings.xml i arkivet och hittar ingenting

Inuti XlsxResolveRelationshipTarget

HotXLS koncentrerar hela upplösningsregeln i en funktion, XlsxResolveRelationshipTarget, deklarerad i lxHandleX.pas som function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Den tar ZIP-postnamnet på källdelen och det råa Target-attributet, och returnerar ett ZIP-postnamn utan inledande snedstreck, klart att skicka rakt till arkivet. Att skicka ett tomt OwnerPartName löser mot paketroten, vilket är precis vad paketrelationsdelen behöver. Ordningen på operationerna spelar mer roll än de enskilda stegen: bakåtstreck normaliseras till framåtstreck först, eftersom vissa producenter skriver Windows-avgränsare i Target; ett fragment som introduceras av # klipps bort innan sökvägshanteringen, så ../charts/chart1.xml#Sheet1 löser till ett delnamn snarare än till en icke-existerande arkivpost; först då delar funktionen absolut från relativt

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

Segmentloopen är en enkel stackgenomgång: tomma segment och . tas bort, .. poppar en nivå, och en .. som skulle fly paketroten absorberas istället för att producera ett negativt index eller ett namn som börjar med ../. Tilldelningen StrictDelimiter := True är inte kosmetisk. Utan den behandlar en Delphi-TStringList mellanslag som avgränsare och respekterar citattecken, vilket förvränger vilket delnamn som helst med ett mellanslag, och delnamn med mellanslag är lagliga

Att följa grafen: arbetsbok, kalkylblad, ritning

HotXLS går igenom tre nivåer av relationsdelar på vägen TXLSXWorkbook.Open. Paketnivån hanteras av XlsxFindOfficeDocumentPart, som läser _rels/.rels och returnerar officeDocument-målet. Arbetsboksnivån läser arbetsbokens relationsdel och bygger två kartor samtidigt: en identifierarkarta för r:id-uppslagningar och en typkarta för singletondelar. Kalkylblads- och ritningsnivåerna upprepar mönstret med ParseWorksheetRelsXml och ParseDrawingRelsXml, var och en med sitt eget delnamn som upplösningsbas så en ritning som refererar till ../media/image3.png landar på rätt 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';

Blad specifikt måste gå genom identifierarkartan, inte typkartan. <sheet>-elementen i arbetsboksdelen bär r:id-attribut, och den identifieraren är det enda som binder ett bladnamn till en del. HotXLS samlar dessa identifierare under ParseWorkbookXml och löser var och en mot arbetsbokens relationskarta, och faller tillbaka till det konventionella numrerade namnet endast när identifieraren saknas eller inte kan lösas

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

Allt nedströms rider på samma mekanism. Delade strängar, stilar, tema, VBA-projektet under den Microsoft-namnrymdade typen http://schemas.microsoft.com/office/2006/relationships/vbaProject, externa länkar, den arbetsboksomfångade person-delen, äldre kommentarer, trådade kommentarer, VML-ritningen som bär kommentarballongens geometri, ritningar, bilder, diagram, tabeller och pivottabeller når alla sina byte genom lösta mål. Temadelen speciellt måste hittas korrekt annars kan en round-trip tyst skriva över en kunds varumärkespalett med Office-standardtemat, ett av felfallen som täcks i anteckningarna om förlustfri XLSX-round-trip av tema, extLst och calcChain. Relationsläsning är också varför laddningen är uppdelad som den är: all arkivåtkomst sker på en tråd innan kalkylblads-XML parsas, eftersom inflate-tillståndet i ett ZIP-arkiv inte är trådsäkert, en begränsning förklarad i genomgången om parallell XLSX-parsning och minnesallokeraren

Varför bryter ett duplicerat rId typbaserad routing?

Därför att en senare felformad post kan skriva över en tidigare giltig och kapa uppslagningen. Relationsidentifierare förutsätts vara unika inom en relationsdel, men felformade paket återanvänder dem, och en naiv Values[Id] :=-tilldelning är sist-vinner. Om rId3 först pekar på ett riktigt kalkylblad och en andra rId3 pekar på ett osupporterat eller tomt mål, förlorar sist-vinner kalkylbladet. ParsePartRelationshipsXml tillämpar därför en först-vinner-regel med två villkor: det lösta målet måste vara icke-tomt, och identifieraren får inte redan finnas. Båda villkoren tillsammans är det som gör det säkert, eftersom det icke-tomma testet hindrar en relation med ett saknat Target från att ta platsen innan en användbar en anländer

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

Notera den avsiktliga asymmetrin i det utdraget. Identifierarkartan är en riktig karta med en först-vinner-vakt, medan typsamlingen är en append-bara lista av type=target-par. Den distinktionen bär last: en arbetsbok har exakt en delad-strängar-relation men många kalkylblads- och externlänksrelationer, så typuppslagning via Values[] returnerar den första matchningen för singletoner, och flervärdestyper som externalLink uppräknas genom att gå igenom listan

Var relationsföljningen stannar

Ärliga gränser spelar mer roll än en snygg historia. HotXLS faller tillbaka på konventionella namn närhelst en relation saknas, så ett paket med en skadad eller saknad relationsdel öppnas ändå om det råkar följa Excel-layouten; den fallbacken är en kompatibilitetsfunktion, inte en andra sanningskälla, och den kan dölja en producentbugg under testning. Tre fler begränsningar är värda att veta. Mål markerade TargetMode="External" lagras ordagrant snarare än lösta, vilket är korrekt för hyperlänkar och för externalLinkPath-relationen som bär en fjärrarbetsboks-URL, men det betyder att värdet du får tillbaka är precis vad producenten skrev. Diagramdelar upptäckta genom en ritningsrelationsdel paras med ritningsankare positionellt snarare än via identifierare, så en ovanlig ankarordning kan felinrikta diagrambindningar. Och den strömmande direktläsaren i lxDirectRead.pas håller sin egen lättare sökvägshantering nycklad till xl/, så den fullständiga upplösaren som beskrivs här styr ingångspunkterna TXLSXWorkbook.Open och GetSheetNames, inte den lågallokerings-genomsökningsvägen dokumenterad i artikeln om den strömmande direktläsaren för Delphi

Om du bygger detta själv är den kortaste korrekta sammanfattningen: konstruera aldrig ett delnamn, lös alltid ett. Läs _rels/.rels, följ officeDocument, lös varje Target mot den del som deklarerade det, och dirigera blad via r:id. Om du hellre vill ha det redan testat mot omdöpta delar, icke-sammanhängande bladnumrering och duplicerade relationsidentifierare, levereras upplösaren som beskrivs här i HotXLS Delphi-kalkylbladskomponent, tillsammans med round-trip-maskineriet som håller de delar den inte parsar intakta