Articol tehnic

Rezolvarea relațiilor OPC XLSX în parsere Delphi

Un xlsx valid nu trebuie neapărat să conțină xl/worksheets/sheet1.xml. HotXLS, componenta nativă pentru foi de calcul Excel pentru Delphi și C++Builder, localizează fiecare parte prin graful de relații OPC, în loc să ghicească nume, pentru că ISO/IEC 29500-2 garantează doar că părțile sunt accesibile din _rels/.rels, niciodată că se află la căi convenționale

De ce eșuează parserul meu pe un xlsx valid?

Pentru că numele de părți pe care le-ai memorat sunt o convenție a unui singur producător, nu o cerință a formatului. Fiecare cale pe care ai codat-o vreodată fix, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, este ceea ce se întâmplă să emită writer-ul desktop al Excel. Un pachet conform poate plasa registrul de lucru la office/book.xml și prima foaie de lucru la xl/custom/data-sheet.xml și tot să fie SpreadsheetML legal, atâta timp cât relațiile indică acolo. Acesta este cel mai frecvent motiv pentru care un reader construit intern raportează „nu găsesc sheet1.xml” pe un fișier pe care Excel, LibreOffice și Numbers îl deschid fără nicio problemă

Producătorii care fac asta nu sunt exotici. Generatoarele de rapoarte de pe server reutilizează un pachet-șablon și îi păstrează structura originală. Fluxurile de export care îmbină două registre de lucru renumerotează foile și lasă goluri, astfel încât un registru de lucru cu cinci foi ajunge să aibă sheet1, sheet2, sheet4, sheet7 și sheet9. Instrumentele care elimină o foaie nu renumerotează întotdeauna foile rămase. În fiecare dintre aceste cazuri, ghicirea bazată pe index xl/worksheets/sheet + IntToStr(i + 1) + .xml citește silențios foaia greșită sau nu citește nimic, ceea ce este mai rău decât o excepție, pentru că registrul de lucru se încarcă, iar numerele sunt greșite. Pachetul minimal de mai jos exersează întreaga problemă și este exact forma pe care HotXLS o testează la regresie

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

Ce garantează, de fapt, ISO/IEC 29500-2?

Garantează accesibilitatea, nu locația. ISO/IEC 29500-2 este partea din standard dedicată Open Packaging Conventions, iar clauza sa despre relații definește exact un singur punct de intrare fix: partea de relații a pachetului, la _rels/.rels. De acolo se urmărește relația al cărei Type este http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument pentru a ajunge la partea de registru de lucru, iar fiecare altă parte este descoperită citind propria parte de relații a acelei părți și urmărind mai departe muchiile tipizate

Alte două reguli din același standard fac treaba reală. Clauza de denumire a părților fixează unde locuiește o parte de relații: pentru o parte la <folder>/<name>, relațiile ei se află la <folder>/_rels/<name>.rels, iar pentru o parte de la rădăcina pachetului, folder-ul este pur și simplu _rels/. Clauza de marcaj a relațiilor precizează că Target este o referință URI rezolvată față de URI-ul părții sursă, în sensul obișnuit din RFC 3986, cu excepția cazului în care TargetMode="External" îl marchează ca indicând în afara pachetului. Rezolvarea relativă la sursă este pasul pe care toată lumea îl sare, și de aceea același literal ../notes/review.xml înseamnă un lucru în interiorul xl/custom/_rels/data-sheet.xml.rels și cu totul altceva într-un fișier rels aflat cu un folder mai jos. O ultimă nuanță se află între modelul logic și octeții de pe disc: numele de părți din modelul logic sunt absolute și încep cu o bară oblică, dar clauza de mapare fizică ZIP elimină acea bară oblică atunci când transformă un nume de parte într-un nume de element ZIP, astfel încât un rezolver care uită acest lucru caută /xl/sharedStrings.xml în arhivă și nu găsește nimic

În interiorul XlsxResolveRelationshipTarget

HotXLS concentrează întreaga regulă de rezolvare într-o singură funcție, XlsxResolveRelationshipTarget, declarată în lxHandleX.pas ca function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Aceasta primește numele elementului ZIP al părții sursă și atributul brut Target, și returnează un nume de element ZIP fără bară oblică la început, gata de predat direct arhivei. Trecerea unui OwnerPartName gol rezolvă față de rădăcina pachetului, exact ceea ce are nevoie partea de relații a pachetului. Ordinea operațiilor contează mai mult decât pașii individuali: barele oblice inverse sunt normalizate mai întâi la bare oblice directe, pentru că unii producători scriu separatori de Windows în Target; orice fragment introdus de # este eliminat înainte de procesarea căii, astfel încât ../charts/chart1.xml#Sheet1 se rezolvă la un nume de parte, nu la o intrare de arhivă inexistentă; abia apoi funcția separă absolutul de 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;

Bucla pe segmente este o simplă parcurgere de tip stivă: segmentele goale și . sunt eliminate, .. scoate un nivel, iar un .. care ar ieși din rădăcina pachetului este absorbit, în loc să producă un index negativ sau un nume care începe cu ../. Atribuirea StrictDelimiter := True nu este cosmetică. Fără ea, un TStringList din Delphi tratează spațiile ca delimitatori și respectă caracterele de citare, ceea ce distorsionează orice nume de parte care conține un spațiu, iar numele de părți cu spații sunt legale

Urmărind graful: registru de lucru, foaie de lucru, desen

HotXLS parcurge trei niveluri de părți de relații pe traseul TXLSXWorkbook.Open. Nivelul de pachet este gestionat de XlsxFindOfficeDocumentPart, care citește _rels/.rels și returnează ținta officeDocument. Nivelul de registru de lucru citește partea de relații a registrului de lucru și construiește deodată două hărți: o hartă de identificatori pentru căutările r:id și o hartă de tipuri pentru părțile singleton. Nivelurile de foaie de lucru și de desen repetă tiparul cu ParseWorksheetRelsXml și ParseDrawingRelsXml, fiecare trecând propriul nume de parte ca bază de rezolvare, astfel încât un desen care referențiază ../media/image3.png ajunge la blob-ul corect

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

Foile, în mod specific, trebuie să treacă prin harta de identificatori, nu prin harta de tipuri. Elementele <sheet> din partea de registru de lucru poartă atribute r:id, iar acel identificator este singurul lucru care leagă un nume de foaie de o parte. HotXLS colectează acele identificatoare în timpul ParseWorkbookXml și rezolvă fiecare dintre ele față de harta de relații a registrului de lucru, revenind la numele numerotat convențional doar atunci când identificatorul lipsește sau nu poate fi rezolvat

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

Tot ce urmează în aval se bazează pe același mecanism. Șirurile partajate, stilurile, tema, proiectul VBA aflat sub tipul cu spațiu de nume Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, legăturile externe, partea de persoană la nivel de registru de lucru, comentariile clasice, comentariile în fir, desenul VML care poartă geometria baloanelor de comentarii, desenele, imaginile, graficele, tabelele și tabelele pivot ajung toate la octeții lor prin ținte rezolvate. Partea de temă, în special, trebuie localizată corect, altfel un round-trip suprascrie discret o paletă de brand a clientului cu tema standard Office, unul dintre modurile de eșec acoperite în notele despre round-trip-ul XLSX fără pierderi al temei, extLst și calcChain. Citirea relațiilor este și motivul pentru care încărcarea este eșalonată așa cum este: tot accesul la arhivă are loc pe un singur fir de execuție înainte ca XML-ul foii de lucru să fie analizat, pentru că starea de decomprimare a unei arhive ZIP nu este sigură pentru fire multiple, o constrângere explicată în articolul despre analiza paralelă a XLSX și alocatorul de memorie

De ce un rId duplicat strică rutarea bazată pe tip?

Pentru că o intrare malformată ulterioară poate suprascrie una validă anterioară și poate deturna căutarea. Identificatorii de relații ar trebui să fie unici în interiorul unei părți de relații, dar pachetele malformate îi reutilizează, iar o atribuire naivă de tipul Values[Id] := înseamnă că ultima scriere câștigă. Dacă un prim rId3 indică spre o foaie de lucru reală, iar un al doilea rId3 indică spre o țintă nesuportată sau goală, regula ultimei scrieri pierde foaia de lucru. ParsePartRelationshipsXml aplică deci o regulă în care câștigă prima apariție, cu două condiții: ținta rezolvată trebuie să fie nevidă, iar identificatorul nu trebuie să fie deja prezent. Ambele condiții împreună sunt cele care fac acest lucru sigur, pentru că testul de nevidare oprește o relație cu un Target lipsă să ocupe slotul înainte să sosească una utilizabilă

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

Observă asimetria deliberată din acel fragment. Harta de identificatori este o hartă adevărată cu o gardă de tip „câștigă primul”, în timp ce colecția de tipuri este o listă doar-adăugare de perechi type=target. Distincția aceasta este esențială: un registru de lucru are exact o singură relație de șiruri partajate, dar multe relații de foaie de lucru și de legături externe, așa că o căutare de tip prin Values[] returnează prima potrivire pentru singletoane, iar tipurile cu valori multiple, cum ar fi externalLink, sunt enumerate parcurgând lista

Unde se oprește urmărirea relațiilor

Limitele oneste contează mai mult decât o poveste curată. HotXLS revine la nume convenționale ori de câte ori o relație lipsește, așa că un pachet cu o parte de relații deteriorată sau lipsă tot se deschide dacă întâmplă să urmeze structura Excel; acea alternativă este o funcție de compatibilitate, nu o a doua sursă de adevăr, și poate masca o eroare de producător în timpul testării. Mai merită cunoscute încă trei limite. Țintele marcate TargetMode="External" sunt stocate ca atare, fără a fi rezolvate, ceea ce este corect pentru hyperlink-uri și pentru relația externalLinkPath care poartă un URL de registru de lucru la distanță, dar înseamnă că valoarea primită înapoi este exact ce a scris producătorul. Părțile de grafic descoperite printr-o parte de relații de desen sunt asociate cu ancorele desenului pozițional, nu după identificator, așa că o ordine neobișnuită a ancorelor poate dezalinia legăturile graficului. Iar reader-ul direct de streaming din lxDirectRead.pas își păstrează propria gestionare, mai ușoară, a căii, ancorată la xl/, așa că rezolverul complet descris aici guvernează punctele de intrare TXLSXWorkbook.Open și GetSheetNames, nu calea de scanare cu alocare redusă documentată în articolul despre reader-ul direct de streaming pentru Delphi

Dacă îți construiești singur acest mecanism, cel mai scurt rezumat corect este: nu construi niciodată un nume de parte, rezolvă-l întotdeauna. Citește _rels/.rels, urmărește officeDocument, rezolvă fiecare Target față de partea care l-a declarat și rutează foile după r:id. Dacă preferi să ai deja testat acest lucru pentru părți redenumite, numerotare neconsecutivă a foilor și identificatori de relații duplicați, rezolverul descris aici este inclus în componenta pentru foi de calcul Delphi a HotXLS, împreună cu mecanismul de round-trip care păstrează intacte părțile pe care nu le analizează