Teknisk artikel

XLSX OPC-relationsopløsning i Delphi-parsere

En gyldig xlsx behøver ikke indeholde xl/worksheets/sheet1.xml. HotXLS, den native Excel-regnearkskomponent til Delphi og C++Builder, lokaliserer hver del gennem OPC-relationsgrafen i stedet for at gætte navne, fordi ISO/IEC 29500-2 kun garanterer at dele kan nås fra _rels/.rels, aldrig at de sidder på konventionelle stier

Hvorfor fejler min parser på en gyldig xlsx?

Fordi de delnavne, man har udenad, er én producents konvention, ikke et krav i formatet. Hver sti man nogensinde har hårdkodet, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, er hvad desktop-Excel-skriveren tilfældigvis udsender. En konform pakke kan placere arbejdsbogen ved office/book.xml og det første regneark ved xl/custom/data-sheet.xml og stadig være lovlig SpreadsheetML, så længe relationerne peger derhen. Dette er den enkelt mest almindelige grund til, at en hjemmelavet læser rapporterer "kan ikke finde sheet1.xml" på en fil, som Excel, LibreOffice og Numbers alle åbner uden klage

Producenter, der gør dette, er ikke eksotiske. Server-side rapportgeneratorer genbruger en skabelonpakke og beholder dens oprindelige layout. Eksportpipelines, der fletter to arbejdsbøger, omnummererer ark og efterlader huller, så en fem-arks-arbejdsbog har sheet1, sheet2, sheet4, sheet7 og sheet9. Værktøjer, der fjerner et ark, omnummererer ikke altid de overlevende. I hvert af disse tilfælde læser det indeksbaserede gæt xl/worksheets/sheet + IntToStr(i + 1) + .xml stille det forkerte ark eller læser intet, hvilket er værre end en undtagelse, fordi arbejdsbogen indlæses, og tallene er forkerte. Den minimale pakke nedenfor udøver hele problemet, og det er formen, HotXLS regressionstester mod

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

Hvad garanterer ISO/IEC 29500-2 egentlig?

Den garanterer nåbarhed, ikke placering. ISO/IEC 29500-2 er Open Packaging Conventions-delen af standarden, og dens relationsklausul definerer præcis ét fast indgangspunkt: pakke-relationsdelen ved _rels/.rels. Derfra følger man den relation, hvis Type er http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument, for at nå arbejdsbogsdelen, og hver anden del opdages ved at læse den dels egen relationsdel og følge typede kanter udad

To yderligere regler fra samme standard udfører det egentlige arbejde. Del-navngivningsklausulen fastsætter, hvor en relationsdel bor: for en del ved <folder>/<name> er dens relationer ved <folder>/_rels/<name>.rels, og for en del ved pakkeroden er mappen simpelthen _rels/. Relations-markup-klausulen fastslår, at Target er en URI-reference opløst mod kilde-delens URI, i den almindelige RFC 3986-forstand, medmindre TargetMode="External" markerer den som pegende uden for pakken. Kilde-relativ opløsning er trinnet, alle springer over, og det er hvorfor den samme bogstavelige ../notes/review.xml betyder én ting inde i xl/custom/_rels/data-sheet.xml.rels og noget helt andet inde i en rels-fil én mappe dybere. En sidste finesse sidder mellem den logiske model og bytesne på disk: delnavne i den logiske model er absolutte og begynder med en fremadskråstreg, men ZIP-den-fysiske-mapnings-klausulen strips den skråstreg, når den gør et delnavn til et ZIP-item-navn, så en resolver, der glemmer det, slår /xl/sharedStrings.xml op i arkivet og finder intet

Inde i XlsxResolveRelationshipTarget

HotXLS koncentrerer hele opløsningsreglen i én funktion, XlsxResolveRelationshipTarget, deklareret i lxHandleX.pas som function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Den tager kilde-delens ZIP-item-navn og det rå Target-attribut, og returnerer et ZIP-item-navn uden indledende skråstreg, klar til at overgive direkte til arkivet. At overgive et tomt OwnerPartName opløser mod pakkeroden, hvilket er præcis hvad pakke-relationsdelen har brug for. Rækkefølgen af operationer betyder mere end de enkelte trin: baglæns-skråstreger normaliseres til fremadskråstreger først, fordi nogle producenter skriver Windows-separatorer ind i Target; ethvert fragment introduceret af # skæres væk før stihåndtering, så ../charts/chart1.xml#Sheet1 opløser til et delnavn frem for til en ikke-eksisterende arkivpost; først derefter splitter funktionen absolut fra relativ

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

Segment-loopet er en simpel stak-gennemgang: tomme segmenter og . droppes, .. popper ét niveau, og en .., der ville undslippe pakkeroden, absorberes i stedet for at producere et negativt indeks eller et navn, der begynder med ../. StrictDelimiter := True-tildelingen er ikke kosmetisk. Uden den behandler en Delphi TStringList mellemrum som delimitere og respekterer citationstegn, hvilket ødelægger ethvert delnavn med et mellemrum, og delnavne med mellemrum er lovlige

At følge grafen: arbejdsbog, regneark, tegning

HotXLS gennemgår tre lag af relationsdele på TXLSXWorkbook.Open-stien. Pakke-laget håndteres af XlsxFindOfficeDocumentPart, som læser _rels/.rels og returnerer officeDocument-målet. Arbejdsbogs-laget læser arbejdsbogens relationsdel og bygger to maps på én gang: et identifikator-map for r:id-opslag og et type-map for enkeltstående dele. Regneark- og tegning-lagene gentager mønstret med ParseWorksheetRelsXml og ParseDrawingRelsXml, hver med sit eget delnavn som opløsningsbase, så en tegning, der refererer ../media/image3.png, lander på den rigtige 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';

Ark skal specifikt gå gennem identifikator-mappet, ikke type-mappet. <sheet>-elementerne i arbejdsbogsdelen bærer r:id-attributter, og den identifikator er det eneste, der binder et arknavn til en del. HotXLS indsamler de identifikatorer under ParseWorkbookXml og opløser hver mod arbejdsbogens relationsmap, og falder kun tilbage til det konventionelle nummererede navn, når identifikatoren mangler eller ikke kan opløses

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

Alt nedstrøms rider på den samme mekanisme. Delte strenge, stilarter, tema, VBA-projektet under den Microsoft-namespacede type http://schemas.microsoft.com/office/2006/relationships/vbaProject, eksterne links, den arbejdsbog-omfattede person-del, legacy-kommentarer, threaded comments, VML-tegningen der bærer kommentar-ballon-geometri, tegninger, billeder, diagrammer, tabeller og PivotTables når alle deres bytes gennem opløste mål. Tema-delen skal især lokaliseres korrekt, ellers overskriver en rundtur stille en kundes brand-palet med Office-standardtemaet, en af de fejltilstande dækket i notaterne om tabsfri XLSX-rundtur af tema, extLst og calcChain. Relationslæsning er også hvorfor indlæsningen er iscenesat, som den er: al arkivadgang sker på én tråd, før regneark-XML parses, fordi inflate-tilstanden af et ZIP-arkiv ikke er trådsikker, en begrænsning forklaret i gennemgangen om parallel XLSX-parsing og hukommelsesallokatoren

Hvorfor ødelægger et duplikeret rId type-baseret routing?

Fordi en senere fejlbehæftet post kan overskrive en tidligere gyldig en og kapre opslaget. Relationsidentifikatorer forventes at være unikke inden for en relationsdel, men fejlbehæftede pakker genbruger dem, og en naiv Values[Id] :=-tildeling er sidste-skrivning-vinder. Hvis rId3 først peger på et rigtigt regneark, og en anden rId3 peger på et ikke-understøttet eller tomt mål, taber sidste-skrivning-vinder regnearket. ParsePartRelationshipsXml anvender derfor en først-vinder-regel med to betingelser: det opløste mål skal være ikke-tomt, og identifikatoren må ikke allerede være til stede. Begge betingelser sammen er hvad der gør det sikkert, fordi ikke-tom-testen stopper en relation med et manglende Target fra at kræve pladsen, før en brugbar en ankommer

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

Bemærk den bevidste asymmetri i det uddrag. Identifikator-mappet er et sandt map med en først-vinder-vagt, mens type-samlingen er en append-only-liste af type=target-par. Den skelnen er bærende: en arbejdsbog har præcis én shared-strings-relation, men mange regneark- og external-link-relationer, så type-opslag via Values[] returnerer det første match for enkeltstående, og multi-værdi-typer som externalLink opremses ved at gennemgå listen

Hvor relationsfølgning stopper

Ærlige grænser betyder mere end en ren historie. HotXLS falder tilbage til konventionelle navne, når en relation mangler, så en pakke med en beskadiget eller manglende relationsdel stadig åbner, hvis den tilfældigvis følger Excel-layoutet; det fallback er en kompatibilitetsfunktion, ikke en anden sandhedskilde, og det kan maskere en producentfejl under test. Tre grænser mere er værd at kende. Mål markeret TargetMode="External" gemmes bogstaveligt frem for opløst, hvilket er korrekt for hyperlinks og for externalLinkPath-relationen, der bærer en ekstern arbejdsbogs-URL, men det betyder at værdien man får tilbage er hvad producenten end skrev. Diagramdele opdaget gennem en tegnings-relationsdel parres med tegningsankre positionelt frem for efter identifikator, så en usædvanlig anker-rækkefølge kan fejljustere diagram-bindinger. Og den strømmende direkte læser i lxDirectRead.pas holder sin egen lettere sti-håndtering nøglet til xl/, så den fulde resolver beskrevet her styrer TXLSXWorkbook.Open- og GetSheetNames-indgangspunkterne, ikke den lav-allokerings-scan-sti dokumenteret i artiklen om den strømmende direkte læser til Delphi

Hvis man bygger dette selv, er det korteste korrekte resumé: konstruér aldrig et delnavn, opløs altid ét. Læs _rels/.rels, følg officeDocument, opløs hvert Target mod den del, der deklarerede det, og rut ark efter r:id. Hvis man hellere vil have det allerede testet mod omdøbte dele, ikke-sammenhængende arknummerering og duplikerede relationsidentifikatorer, leveres den resolver, beskrevet her, i HotXLS Delphi-regnearkskomponenten, sammen med den rundtur-maskine, der holder de dele, den ikke parser, intakte