Tekninen artikkeli

XLSX OPC-suhteiden ratkaisu Delphi-jäsentimissä

Pätevän xlsx:n ei tarvitse sisältää xl/worksheets/sheet1.xml-tiedostoa. HotXLS, natiivi Excel-laskentataulukkokomponentti Delphille ja C++Builderille, paikantaa jokaisen osan OPC-suhdegraafin kautta arvaamisen sijaan, koska ISO/IEC 29500-2 takaa vain sen, että osat ovat tavoitettavissa _rels/.rels:stä, ei koskaan sitä, että ne istuvat tavanomaisissa poluissa

Miksi jäsentimeni epäonnistuu pätevällä xlsx:llä?

Koska osanimet, jotka opettelit ulkoa, ovat yhden tuottajan käytäntö, ei formaatin vaatimus. Jokainen polku, jonka olet koskaan kovakoodannut, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, on se, mitä työpöytä-Excelin kirjoittaja sattuu tuottamaan. Vaatimustenmukainen paketti voi sijoittaa työkirjan kohtaan office/book.xml ja ensimmäisen laskentataulukon kohtaan xl/custom/data-sheet.xml ja olla silti laillista SpreadsheetML:ää, kunhan suhteet osoittavat sinne. Tämä on yksittäinen yleisin syy, miksi kotikutoinen lukija raportoi "cannot find sheet1.xml" tiedostolla, jonka Excel, LibreOffice ja Numbers kaikki avaavat ilman valitusta

Tuottajat, jotka tekevät näin, eivät ole eksoottisia. Palvelinpuolen raporttigeneraattorit käyttävät uudelleen mallipakettia ja pitävät sen alkuperäisen asettelun. Vientiputket, jotka yhdistävät kaksi työkirjaa, numeroivat laskentataulukot uudelleen ja jättävät aukkoja, joten viisi laskentataulukkoa sisältävässä työkirjassa on sheet1, sheet2, sheet4, sheet7 ja sheet9. Työkalut, jotka poistavat laskentataulukon, eivät aina numeroi jäljelle jääneitä uudelleen. Jokaisessa näistä tapauksista indeksipohjainen arvaus xl/worksheets/sheet + IntToStr(i + 1) + .xml lukee hiljaa väärän laskentataulukon tai ei lue mitään, mikä on pahempaa kuin poikkeus, koska työkirja latautuu ja luvut ovat väärässä. Alla oleva minimipaketti koettelee koko ongelman, ja se on muoto, jota vasten HotXLS regressiotestaa

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

Mitä ISO/IEC 29500-2 oikeasti takaa?

Se takaa tavoitettavuuden, ei sijaintia. ISO/IEC 29500-2 on standardin Open Packaging Conventions -osa, ja sen suhdelauseke määrittelee täsmälleen yhden kiinteän aloituspisteen: pakettisuhdeosan kohdassa _rels/.rels. Sieltä seuraat suhdetta, jonka Type on http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument, tavoittaaksesi työkirjaosan, ja jokainen muu osa löydetään lukemalla tuon osan oma suhdeosa ja seuraamalla tyypitettyjä kaaria ulospäin

Kaksi lisäsääntöä samasta standardista tekevät todellisen työn. Osanimeämislauseke kiinnittää, missä suhdeosa elää: osalle kohdassa <folder>/<name>, sen suhteet ovat kohdassa <folder>/_rels/<name>.rels, ja pakettijuuressa olevalle osalle kansio on yksinkertaisesti _rels/. Suhdemerkintälauseke toteaa, että Target on URI-viittaus, joka ratkaistaan lähdeosan URI:a vasten, tavallisessa RFC 3986 -mielessä, ellei TargetMode="External" merkitse sitä paketin ulkopuolelle osoittavaksi. Lähdesuhteellinen ratkaisu on vaihe, jonka kaikki ohittavat, ja se on syy, miksi sama kirjaimellinen ../notes/review.xml tarkoittaa yhtä asiaa kohdassa xl/custom/_rels/data-sheet.xml.rels ja jotain aivan muuta yhtä kansiota syvemmässä rels-tiedostossa. Yksi viimeinen mutka istuu loogisen mallin ja levyllä olevien tavujen välissä: osanimet loogisessa mallissa ovat absoluuttisia ja alkavat kauttaviivalla, mutta ZIP-fyysinen kartoituslauseke poistaa tuon kauttaviivan, kun se muuttaa osanimen ZIP-alkionimeksi, joten ratkaisija, joka unohtaa tämän, hakee arkistosta /xl/sharedStrings.xml:ää eikä löydä mitään

XlsxResolveRelationshipTarget-funktion sisällä

HotXLS keskittää koko ratkaisusäännön yhteen funktioon, XlsxResolveRelationshipTarget, julistettuna lxHandleX.pas:ssa muodossa function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Se ottaa lähdeosan ZIP-alkionimen ja raa'an Target-attribuutin, ja palauttaa ZIP-alkionimen ilman johtavaa kauttaviivaa, valmiina annettavaksi suoraan arkistolle. Tyhjän OwnerPartName:n antaminen ratkaisee pakettijuurta vasten, mikä on täsmälleen se, mitä pakettisuhdeosa tarvitsee. Toimintojen järjestys on tärkeämpi kuin yksittäiset vaiheet: kenoviivat normalisoidaan kautta­viivoiksi ensin, koska jotkut tuottajat kirjoittavat Windows-erottimia kohtaan Target; mikä tahansa #:n tuoma fragmentti leikataan pois ennen polkukäsittelyä, joten ../charts/chart1.xml#Sheet1 ratkeaa osanimeksi eikä olemattomaksi arkistomerkinnäksi; vasta sitten funktio erottaa absoluuttisen suhteellisesta

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

Segmenttisilmukka on tavallinen pinon läpikäynti: tyhjät segmentit ja . pudotetaan, .. ponnahduttaa yhden tason pois, ja .., joka pakenisi pakettijuuren ulkopuolelle, imeytyy sen sijaan, että se tuottaisi negatiivisen indeksin tai nimen, joka alkaa ../:llä. StrictDelimiter := True -sijoitus ei ole kosmeettinen. Ilman sitä Delphin TStringList käsittelee välilyöntejä erottimina ja kunnioittaa lainausmerkkejä, mikä sekoittaa minkä tahansa välilyönnin sisältävän osanimen, ja osanimet välilyönneillä ovat laillisia

Graafin seuraaminen: työkirja, laskentataulukko, piirros

HotXLS kulkee läpi kolme suhdeosien tasoa TXLSXWorkbook.Open-polulla. Pakettitason käsittelee XlsxFindOfficeDocumentPart, joka lukee _rels/.rels:n ja palauttaa officeDocument-kohteen. Työkirjataso lukee työkirjan suhdeosan ja rakentaa kaksi karttaa kerralla: tunnistekartan r:id-hakuja varten ja tyyppikartan yksittäisosille. Laskentataulukko- ja piirrostasot toistavat kuvion funktioilla ParseWorksheetRelsXml ja ParseDrawingRelsXml, kumpikin antaen oman osanimensä ratkaisun perustaksi, jotta piirros, joka viittaa kohteeseen ../media/image3.png, laskeutuu oikeaan blobiin

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

Laskentataulukoiden täytyy erityisesti kulkea tunnistekartan kautta, ei tyyppikartan. Työkirjaosan <sheet>-elementit kantavat r:id-attribuutteja, ja tuo tunniste on ainoa asia, joka sitoo laskentataulukon nimen osaan. HotXLS kerää nuo tunnisteet ParseWorkbookXml:n aikana ja ratkaisee jokaisen työkirjan suhdekarttaa vasten, palaten tavanomaiseen numeroituun nimeen vain, kun tunniste puuttuu tai on ratkaisematon

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

Kaikki alavirrassa oleva ratsastaa saman koneiston varassa. Jaetut merkkijonot, tyylit, teema, VBA-projekti Microsoft-nimiavaruudessa olevan tyypin http://schemas.microsoft.com/office/2006/relationships/vbaProject alla, ulkoiset linkit, työkirjan laajuinen henkilöosa, vanhat kommentit, säikeistetyt kommentit, VML-piirros, joka kantaa kommenttikuplan geometriaa, piirrokset, kuvat, kaaviot, taulukot ja PivotTablet tavoittavat kaikki tavunsa ratkaistujen kohteiden kautta. Erityisesti teemaosa täytyy paikantaa oikein, tai edestakainen kierto hiljaa ylikirjoittaa asiakkaan brändipaletin vakio-Office-teemalla, yksi epäonnistumistiloista, jotka käsitellään muistiossa häviöttömästä XLSX-edestakaisesta kierrosta teeman, extLst:n ja calcChain:n osalta. Suhteiden lukeminen on myös syy, miksi lataus on vaiheistettu tavalla, jolla se on: kaikki arkistokäyttö tapahtuu yhdessä säikeessä ennen kuin laskentataulukon XML jäsennetään, koska ZIP-arkiston purkutila ei ole säieturvallinen, rajoite selitetty artikkelissa rinnakkaisesta XLSX-jäsennyksestä ja muistinvaraajasta

Miksi kaksoiskappale rId rikkoo tyyppipohjaisen reitityksen?

Koska myöhempi väärin muodostettu merkintä voi ylikirjoittaa aiemman pätevän ja kaapata haun. Suhdetunnisteiden oletetaan olevan uniikkeja suhdeosan sisällä, mutta väärin muodostetut paketit käyttävät niitä uudelleen, ja naiivi Values[Id] :=-sijoitus on viimeinen-kirjoitus-voittaa. Jos rId3 osoittaa ensin todelliseen laskentataulukkoon ja toinen rId3 osoittaa tukemattomaan tai tyhjään kohteeseen, viimeinen-kirjoitus-voittaa menettää laskentataulukon. ParsePartRelationshipsXml soveltaa siis ensimmäinen-voittaa-sääntöä kahdella ehdolla: ratkaistun kohteen täytyy olla tyhjentymätön, ja tunnisteen ei saa jo olla läsnä. Molemmat ehdot yhdessä ovat se, mikä tekee siitä turvallisen, koska tyhjentymättömyystesti estää suhdetta, jolla on puuttuva Target, viemästä paikkaa ennen kuin käyttökelpoinen saapuu

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

Huomaa tarkoituksellinen epäsymmetria tuossa katkelmassa. Tunnistekartta on todellinen kartta ensimmäinen-voittaa-vartijalla, kun taas tyyppikokoelma on vain-lisäävä lista type=target-pareista. Tuo ero on kantava: työkirjalla on täsmälleen yksi jaettu-merkkijono-suhde, mutta monta laskentataulukko- ja ulkoinen-linkki-suhdetta, joten tyyppihaku Values[]:n kautta palauttaa ensimmäisen täsmäyksen yksittäisosille, ja moniarvoiset tyypit kuten externalLink luetteloidaan kulkemalla listan läpi

Mihin suhteiden seuraaminen pysähtyy

Rehelliset rajat ovat tärkeämpiä kuin siisti tarina. HotXLS palaa tavanomaisiin nimiin aina, kun suhde puuttuu, joten paketti, jossa on vahingoittunut tai puuttuva suhdeosa, avautuu silti, jos se sattuu noudattamaan Excel-asettelua; tuo varajärjestely on yhteensopivuusominaisuus, ei toinen totuuden lähde, ja se voi peittää tuottajan bugin testauksen aikana. Kolme muuta rajaa kannattaa tietää. Kohteet, jotka on merkitty TargetMode="External", tallennetaan sanasta sanaan sen sijaan, että ne ratkaistaisiin, mikä on oikein hyperlinkeille ja externalLinkPath-suhteelle, joka kantaa etätyökirjan URL:ia, mutta se tarkoittaa, että saamasi arvo on juuri se, mitä tuottaja kirjoitti. Piirrossuhdeosan kautta löydetyt kaavio-osat parittuvat piirrosankkureihin sijainnin perusteella, ei tunnisteen, joten epätavallinen ankkurointijärjestys voi vääristää kaaviosidoksia. Ja lxDirectRead.pas:n suoratoistava suoralukija pitää oman kevyemmän polkukäsittelynsä avainnettuna xl/:hen, joten tässä kuvattu täysi ratkaisija hallitsee TXLSXWorkbook.Open- ja GetSheetNames-liittymäpisteitä, ei matalan varauksen skannauspolkua, joka on dokumentoitu artikkelissa suoratoistavasta suoralukijasta Delphille

Jos rakennat tätä itse, lyhin oikea yhteenveto on: älä koskaan rakenna osanimeä, ratkaise aina yksi. Lue _rels/.rels, seuraa officeDocument:ia, ratkaise jokainen Target osaa vasten, joka sen ilmoitti, ja reititä laskentataulukot r:id:n mukaan. Jos haluat mieluummin jotain, joka on jo testattu uudelleennimettyjä osia, epäjatkuvaa laskentataulukkonumerointia ja kaksoiskappalesuhdetunnisteita vasten, tässä kuvattu ratkaisija toimitetaan HotXLS Delphi-laskentataulukkokomponentissa, yhdessä edestakaisen kierron koneiston kanssa, joka pitää osat, joita se ei jäsennä, koskemattomina