Tehnični članak

Razreševanje razmerij OPC XLSX v razčlenjevalnikih Delphi

Veljaven xlsx ne rabi vsebovati xl/worksheets/sheet1.xml. HotXLS, izvorna komponenta preglednice Excel za Delphi in C++Builder, vsak del locira prek grafa razmerij OPC namesto ugibanja imen, ker ISO/IEC 29500-2 zagotavlja le, da so deli dosegljivi iz _rels/.rels, nikoli pa, da sedijo na konvencionalnih poteh

Zakaj moj razčlenjevalnik odpove na veljavnem xlsx?

Ker so imena delov, ki ste si jih zapomnili, konvencija enega proizvajalca, ne pa zahteva formata. Vsaka pot, ki ste jo kdaj trdo kodirali, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, je tisto, kar namizni pisec Excel slučajno izda. Skladen paket lahko postavi delovni zvezek na office/book.xml, prvi delovni list pa na xl/custom/data-sheet.xml in je še vedno zakonit SpreadsheetML, dokler razmerja kažejo tja. To je najpogostejši razlog, zakaj domač bralnik poroča "cannot find sheet1.xml" na datoteki, ki jo Excel, LibreOffice in Numbers vsi odprejo brez pritožb

Proizvajalci, ki to počnejo, niso eksotični. Strežniški generatorji poročil ponovno uporabijo predlogo paketa in obdržijo njeno izvirno postavitev. Izvozni cevovodi, ki združijo dva delovna zvezka, preštevilčijo liste in pustijo vrzeli, tako da ima delovni zvezek s petimi listi sheet1, sheet2, sheet4, sheet7 in sheet9. Orodja, ki odstranijo list, preživelih ne preštevilčijo vedno. V vsakem od teh primerov na indeksu temelječe ugibanje xl/worksheets/sheet + IntToStr(i + 1) + .xml tiho prebere napačen list ali ne prebere ničesar, kar je slabše od izjeme, ker se delovni zvezek naloži, števila pa so napačna. Minimalen paket spodaj preizkusi celotno težavo in je oblika, proti kateri HotXLS regresijsko testira

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

Kaj ISO/IEC 29500-2 dejansko zagotavlja?

Zagotavlja dosegljivost, ne pa lokacijo. ISO/IEC 29500-2 je del standarda Open Packaging Conventions, njegova klavzula o razmerjih pa definira natanko eno fiksno vstopno točko: del paketnega razmerja pri _rels/.rels. Od tam sledite razmerju, katerega Type je http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument, da dosežete del delovnega zvezka, vsak drug del pa je odkrit z branjem lastnega dela razmerja tega dela in sledenjem tipiziranim povezavam navzven

Dve nadaljnji pravili istega standarda opravita resnično delo. Klavzula o poimenovanju delov določi, kje živi del razmerja: za del pri <folder>/<name> so njegova razmerja pri <folder>/_rels/<name>.rels, za del pri korenu paketa pa je mapa preprosto _rels/. Klavzula o označevanju razmerij navaja, da je Target referenca URI, razrešena proti URI izvornega dela, v navadnem smislu RFC 3986, razen če TargetMode="External" ga označi kot kažočega zunaj paketa. Razreševanje glede na izvor je korak, ki ga vsi preskočijo, in razlog, da isti dobesedni ../notes/review.xml pomeni eno stvar znotraj xl/custom/_rels/data-sheet.xml.rels in nekaj povsem drugega znotraj rels datoteke eno mapo globlje. Ena zadnja guba sedi med logičnim modelom in bajti na disku: imena delov v logičnem modelu so absolutna in se začnejo s poševnico naprej, klavzula fizičnega preslikovanja ZIP pa to poševnico odstrani, ko ime dela spremeni v ime elementa ZIP, tako da razreševalnik, ki to pozabi, v arhivu poišče /xl/sharedStrings.xml in ne najde ničesar

Znotraj XlsxResolveRelationshipTarget

HotXLS koncentrira celotno pravilo razreševanja v eno funkcijo, XlsxResolveRelationshipTarget, deklarirano v lxHandleX.pas kot function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Sprejme ime elementa ZIP izvornega dela in surov atribut Target ter vrne ime elementa ZIP brez vodilne poševnice, pripravljeno za neposredno predajo arhivu. Podaja praznega OwnerPartName razreši proti korenu paketa, kar je natanko tisto, kar del paketnega razmerja potrebuje. Vrstni red operacij je pomembnejši od posameznih korakov: povratne poševnice so najprej normalizirane v poševnice naprej, ker nekateri proizvajalci v Target zapišejo ločila Windows; vsak fragment, uveden z #, je odrezan pred obravnavo poti, tako da se ../charts/chart1.xml#Sheet1 razreši v ime dela namesto v neobstoječ vnos arhiva; šele nato funkcija loči absolutno od relativnega

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

Zanka segmentov je navaden hoja po skladu: prazni segmenti in . so izpuščeni, .. pobere en nivo, .., ki bi pobegnil iz korena paketa, pa je absorbiran namesto da bi proizvedel negativen indeks ali ime, ki se začne z ../. Dodelitev StrictDelimiter := True ni kozmetika. Brez nje Delphi TStringList presledke obravnava kot ločila in upošteva narekovaje, kar skrha vsako ime dela, ki vsebuje presledek, imena delov s presledki pa so zakonita

Sledenje grafu: delovni zvezek, delovni list, risba

HotXLS na poti TXLSXWorkbook.Open prehodi tri nivoje delov razmerij. Nivo paketa obravnava XlsxFindOfficeDocumentPart, ki prebere _rels/.rels in vrne cilj officeDocument. Nivo delovnega zvezka prebere del razmerja delovnega zvezka in hkrati zgradi dve mapi: mapo identifikatorjev za iskanja r:id in mapo tipov za samotarske dele. Nivoja delovnega lista in risbe ponovita vzorec z ParseWorksheetRelsXml in ParseDrawingRelsXml, vsak pa kot osnovo razreševanja poda svoje lastno ime dela, tako da risba, ki se sklicuje na ../media/image3.png, pristane na pravem blobu

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

Listi morajo posebej iti skozi mapo identifikatorjev, ne pa mapo tipov. Elementi <sheet> v delu delovnega zvezka nosijo atribute r:id, ta identifikator pa je edina stvar, ki veže ime lista na del. HotXLS te identifikatorje zbere med ParseWorkbookXml in vsakega razreši proti mapi razmerij delovnega zvezka, pri čemer pade nazaj na konvencionalno oštevilčeno ime le, kadar identifikator manjka ali ga ni mogoče razrešiti

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

Vse naprej po verigi jaha na istem mehanizmu. Skupni nizi, slogi, tema, projekt VBA pod tipom z imenskim prostorom Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, zunanje povezave, del osebe na ravni delovnega zvezka, podedovani komentarji, niteni komentarji, risba VML, ki nosi geometrijo oblačka komentarja, risbe, slike, grafi, tabele in vrtilne tabele vsi dosežejo svoje bajte prek razrešenih ciljev. Del teme je treba posebej pravilno locirati, sicer krožno potovanje tiho prepiše blagovno paleto stranke s standardno temo Office, kar je eden od načinov odpovedi, obravnavan v opombah o brezizgubnem krožnem potovanju XLSX teme, extLst in calcChain. Branje razmerij je tudi razlog, da je nalaganje stopnjevano, kot je: ves dostop do arhiva se zgodi na eni niti, preden je razčlenjen XML delovnega lista, ker stanje napihovanja arhiva ZIP ni varno za niti, omejitev, pojasnjena v zapisu o vzporednem razčlenjevanju XLSX in dodelilniku pomnilnika

Zakaj podvojen rId pokvari usmerjanje na podlagi tipa?

Ker lahko poznejši napačno oblikovan vnos prepiše prejšnjega veljavnega in ugrabi iskanje. Identifikatorji razmerij naj bi bili edinstveni znotraj dela razmerja, vendar jih napačno oblikovani paketi ponovno uporabijo, naivna dodelitev Values[Id] := pa je zadnji-zapis-zmaga. Če rId3 najprej kaže na resničen delovni list, drugi rId3 pa kaže na nepodprt ali prazen cilj, zadnji-zapis-zmaga izgubi delovni list. ParsePartRelationshipsXml zato uveljavi pravilo prvi-zmaga z dvema pogojema: razrešen cilj mora biti nepraz, identifikator pa še ne sme biti prisoten. Oba pogoja skupaj naredita to varno, ker test nepraznosti prepreči, da bi razmerje z manjkajočim Target zahtevalo mesto, preden pride uporaben

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

Opazite namerno asimetrijo v tem odlomku. Mapa identifikatorjev je prava mapa z varovalom prvi-zmaga, zbirka tipov pa je le-dodajaj seznam parov type=target. Ta razlika je nosilna: delovni zvezek ima natanko eno razmerje skupnih nizov, a veliko razmerij delovnih listov in zunanjih povezav, tako da iskanje tipa prek Values[] za samotarske vrne prvo ujemanje, tipi z več vrednostmi, kot je externalLink, pa so našteti s prehodom po seznamu

Kje se sledenje razmerjem ustavi

Poštene meje so pomembnejše od čiste zgodbe. HotXLS pade nazaj na konvencionalna imena, kadarkoli razmerje manjka, tako da se paket s poškodovanim ali manjkajočim delom razmerja še vedno odpre, če slučajno sledi postavitvi Excel; ta nadomestna pot je funkcija združljivosti, ne pa drug vir resnice, med testiranjem pa lahko zamaskira hrošč proizvajalca. Vredno je poznati še tri meje. Cilji, označeni z TargetMode="External", so shranjeni dobesedno namesto razrešeni, kar je pravilno za hiperpovezave in za razmerje externalLinkPath, ki nosi URL oddaljenega delovnega zvezka, vendar pomeni, da je vrednost, ki jo dobite nazaj, kar koli je proizvajalec zapisal. Deli grafa, odkriti prek dela razmerja risbe, so parjeni s sidri risbe pozicijsko namesto po identifikatorju, tako da lahko nenavaden vrstni red sider poravna vezave grafa narobe. In pretočni neposredni bralnik v lxDirectRead.pas obdrži svojo lastno lažjo obravnavo poti, ključeno na xl/, tako da tukaj opisani polni razreševalnik upravlja vstopni točki TXLSXWorkbook.Open in GetSheetNames, ne pa poti skeniranja z nizko dodelitvijo, dokumentirane v članku o pretočnem neposrednem bralniku za Delphi

Če to gradite sami, je najkrajši pravilen povzetek: nikoli ne konstruirajte imena dela, vedno ga razrešite. Preberite _rels/.rels, sledite officeDocument, razrešite vsak Target proti delu, ki ga je deklariral, in liste usmerite po r:id. Če bi raje imeli to že preizkušeno proti preimenovanim delom, nezvezno oštevilčenim listom in podvojenim identifikatorjem razmerij, je tukaj opisani razreševalnik izdan v HotXLS Delphi spreadsheet component, skupaj z mehaniko krožnega potovanja, ki dele, ki jih ne razčlenjuje, ohrani nedotaknjene