Techninis straipsnis

XLSX OPC ryšių sprendimas Delphi nagrinėtuvuose

Galiojantis xlsx neprivalo turėti xl/worksheets/sheet1.xml. HotXLS, natyvus Excel skaičiuoklių komponentas Delphi ir C++Builder, randa kiekvieną dalį per OPC ryšių grafą vietoj vardų spėjimo, nes ISO/IEC 29500-2 garantuoja tik tai, kad dalys pasiekiamos iš _rels/.rels, niekada kad jos sėdi įprastuose keliuose

Kodėl mano nagrinėtuvas nepavyksta su galiojančiu xlsx?

Todėl, kad dalių vardai, kuriuos įsiminėte, yra vieno gamintojo konvencija, ne formato reikalavimas. Kiekvienas kelias, kurį kada nors kietai įkodavote, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, yra tai, ką atsitiktinai išleidžia darbalaukio Excel rašytuvas. Atitinkantis paketas gali padėti darbaknygę office/book.xml ir pirmą darbalapį xl/custom/data-sheet.xml ir vis tiek būti teisėtas SpreadsheetML, kol ryšiai rodo ten. Tai vienintelė dažniausia priežastis, kodėl namuose sukurtas skaitytuvas praneša "nerandu sheet1.xml" faile, kurį Excel, LibreOffice ir Numbers visi atidaro be skundų

Gamintojai, tai darantys, nėra egzotiški. Serverio pusės ataskaitų generatoriai pakartotinai naudoja šablono paketą ir palieka jo originalų išdėstymą. Eksporto konvejeriai, sujungiantys dvi darbaknyges, pernumeruoja lapus ir palieka tarpus, todėl penkių lapų darbaknygė turi sheet1, sheet2, sheet4, sheet7 ir sheet9. Įrankiai, pašalinantys lapą, ne visada pernumeruoja išlikusius. Kiekvienu iš tų atvejų indeksu pagrįstas spėjimas xl/worksheets/sheet + IntToStr(i + 1) + .xml tyliai perskaito neteisingą lapą arba neperskaito nieko, kas yra blogiau nei klaida, nes darbaknygė įsikelia, o skaičiai neteisingi. Žemiau esantis minimalus paketas išbando visą problemą, ir tai yra forma, pagal kurią HotXLS vykdo regresinius testus

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

Ką iš tikrųjų garantuoja ISO/IEC 29500-2?

Ji garantuoja pasiekiamumą, ne vietą. ISO/IEC 29500-2 yra standarto Open Packaging Conventions dalis, ir jos ryšių sąlyga apibrėžia lygiai vieną fiksuotą įėjimo tašką: paketo ryšio dalį ties _rels/.rels. Iš ten sekate ryšį, kurio Type yra http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument, kad pasiektumėte darbaknygės dalį, o kiekviena kita dalis atrandama skaitant tos dalies pačios ryšio dalį ir sekant tipuotas briaunas išorėn

Dvi tolesnės to paties standarto taisyklės atlieka tikrąjį darbą. Dalies pavadinimo sąlyga fiksuoja, kur gyvena ryšio dalis: daliai ties <folder>/<name>, jos ryšiai yra ties <folder>/_rels/<name>.rels, o daliai paketo šaknyje aplankas tiesiog yra _rels/. Ryšio žymėjimo sąlyga teigia, kad Target yra URI nuoroda, išsprendžiama pagal šaltinio dalies URI, įprasta RFC 3986 prasme, jei tik TargetMode="External" jos nepažymi kaip rodančios už paketo ribų. Šaltiniui-santykinis sprendimas yra žingsnis, kurį visi praleidžia, ir būtent todėl tas pats literalus ../notes/review.xml reiškia vieną dalyką xl/custom/_rels/data-sheet.xml.rels viduje ir visiškai kitą dalyką rels faile vienu aplanku giliau. Viena paskutinė raukšlė sėdi tarp loginio modelio ir baitų diske: dalies vardai loginiame modelyje yra absoliutūs ir prasideda pasviru brūkšniu, tačiau ZIP fizinio atvaizdavimo sąlyga tą brūkšnį pašalina, kai paverčia dalies vardą ZIP elemento vardu, todėl sprendiklis, kuris tai pamiršta, ieško /xl/sharedStrings.xml archyve ir nieko neranda

Viduje XlsxResolveRelationshipTarget

HotXLS sukoncentruoja visą sprendimo taisyklę vienoje funkcijoje, XlsxResolveRelationshipTarget, deklaruotoje lxHandleX.pas kaip function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Ji ima šaltinio dalies ZIP elemento vardą ir žalią Target atributą, ir grąžina ZIP elemento vardą be pirmaujančio brūkšnio, paruoštą perduoti tiesiai archyvui. Tuščio OwnerPartName perdavimas sprendžia pagal paketo šaknį, kas yra būtent tai, ko reikia paketo ryšio daliai. Operacijų tvarka svarbesnė nei atskiri žingsniai: atgaliniai brūkšniai pirmiausia normalizuojami į pasvirus, nes kai kurie gamintojai rašo Windows skirtukus į Target; bet koks fragmentas, įvestas #, nukertamas prieš kelio tvarkymą, todėl ../charts/chart1.xml#Sheet1 išsprendžiamas į dalies vardą, o ne į neegzistuojantį archyvo įrašą; tik tada funkcija atskiria absoliutų nuo santykinio

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

Segmento ciklas yra paprastas dėklo vaikščiojimas: tušti segmentai ir . atmetami, .. nuima vieną lygį, o .., kuris pabėgtų iš paketo šaknies, absorbuojamas, o ne sukuria neigiamą indeksą ar vardą, prasidedantį ../. StrictDelimiter := True priskyrimas nėra kosmetika. Be jo Delphi TStringList traktuoja tarpus kaip skirtukus ir gerbia kabučių simbolius, kas sugadina bet kokį dalies vardą, turintį tarpą, o dalių vardai su tarpais yra teisėti

Grafo sekimas: darbaknygė, darbalapis, piešinys

HotXLS vaikščioja per tris ryšio dalių pakopas TXLSXWorkbook.Open kelyje. Paketo pakopą tvarko XlsxFindOfficeDocumentPart, kuri skaito _rels/.rels ir grąžina officeDocument taikinį. Darbaknygės pakopa skaito darbaknygės ryšio dalį ir kuria du žemėlapius vienu metu: identifikatoriaus žemėlapį r:id paieškoms ir tipo žemėlapį atskiroms dalims. Darbalapio ir piešinio pakopos pakartoja šabloną su ParseWorksheetRelsXml ir ParseDrawingRelsXml, kiekviena perduodama savo dalies vardą kaip sprendimo bazę, todėl piešinys, nurodantis ../media/image3.png, atsiduria ant teisingo blob'o

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

Lapai konkrečiai turi eiti per identifikatoriaus žemėlapį, ne tipo žemėlapį. <sheet> elementai darbaknygės dalyje neša r:id atributus, ir tas identifikatorius yra vienintelis dalykas, susiejantis lapo vardą su dalimi. HotXLS surenka tuos identifikatorius ParseWorkbookXml metu ir išsprendžia kiekvieną pagal darbaknygės ryšio žemėlapį, grįždama prie konvencinio numeruoto vardo tik tada, kai identifikatorius nebuvimas arba neišsprendžiamas

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

Viskas žemiau to remiasi tuo pačiu mechanizmu. Bendrinamos eilutės, stiliai, tema, VBA projektas pagal Microsoft-vardų srities tipą http://schemas.microsoft.com/office/2006/relationships/vbaProject, išorinės nuorodos, darbaknygės apimties asmens dalis, senos pastabos, gijų komentarai, VML piešinys, nešantis komentaro burbulo geometriją, piešiniai, vaizdai, diagramos, lentelės ir suvestinės lentelės (PivotTable) visos pasiekia savo baitus per išspręstus taikinius. Ypač temos dalis turi būti teisingai rasta, kitaip pilnas apvažiavimas tyliai perrašo kliento prekės ženklo paletę su standartine Office tema, vienas iš atvejų, aprašytas pastabose apie nenuostolingą XLSX pilną apvažiavimą su tema, extLst ir calcChain. Ryšių skaitymas yra taip pat kodėl įkėlimas etapais toks, koks yra: visa archyvo prieiga vyksta viename gijoje prieš nagrinėjant darbalapio XML, nes ZIP archyvo išpūtimo būsena nėra gijų saugi, apribojimas, paaiškintas straipsnyje apie lygiagretų XLSX nagrinėjimą ir atminties paskirstytoją

Kodėl dubliuotas rId sugadina tipu pagrįstą maršrutizavimą?

Todėl, kad vėlesnis netinkamai suformuotas įrašas gali perrašyti ankstesnį galiojantį ir pagrobti paiešką. Ryšio identifikatoriai turėtų būti unikalūs ryšio dalyje, tačiau netinkamai suformuoti paketai juos pakartotinai naudoja, o naivus Values[Id] := priskyrimas yra paskutinis-laimi. Jei rId3 pirmiausia rodo į tikrą darbalapį, o antras rId3 rodo į nepalaikomą ar tuščią taikinį, paskutinis-laimi praranda darbalapį. ParsePartRelationshipsXml todėl taiko pirmas-laimi taisyklę su dviem sąlygomis: išspręstas taikinys turi būti netuščias, o identifikatorius neturi jau egzistuoti. Abi sąlygos kartu yra tai, kas tai daro saugu, nes netuščio patikra sustabdo ryšį su trūkstamu Target, kad jis nepasiglemžtų vietos prieš atvykstant naudojamam

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

Atkreipkite dėmesį į tyčinę asimetriją tame fragmente. Identifikatoriaus žemėlapis yra tikras žemėlapis su pirmas-laimi apsauga, o tipo rinkinys yra tik-pridėti sąrašas type=target porų. Tas skirtumas svarbus: darbaknygė turi lygiai vieną bendrų eilučių ryšį, bet daug darbalapio ir išorinės nuorodos ryšių, todėl tipo paieška per Values[] grąžina pirmą atitikimą vieninteliams, o daugiareikšmiai tipai, tokie kaip externalLink, išvardijami vaikščiojant sąrašu

Kur ryšių sekimas sustoja

Sąžiningos ribos svarbesnės nei švari istorija. HotXLS grįžta prie konvencinių vardų, kada tik ryšys nebūna, todėl paketas su pažeista ar trūkstama ryšio dalimi vis tiek atsidaro, jei jis atsitiktinai seka Excel išdėstymą; tas atsarginis kelias yra suderinamumo funkcija, ne antras tiesos šaltinis, ir jis gali užmaskuoti gamintojo klaidą testavimo metu. Dar trys ribos vertos žinoti. Taikiniai, pažymėti TargetMode="External", saugomi pažodžiui, o ne sprendžiami, kas teisinga hipernuorodoms ir externalLinkPath ryšiui, nešančiam nuotolinės darbaknygės URL, tačiau tai reiškia, kad gauta reikšmė yra tai, ką gamintojas parašė. Diagramos dalys, rastos per piešinio ryšio dalį, susiejamos su piešinio inkarais pozicijos, ne identifikatoriaus pagrindu, todėl neįprasta inkaro tvarka gali sutrikdyti diagramų susiejimą. O srautinis tiesioginis skaitytuvas lxDirectRead.pas palaiko savo lengvesnį kelią, susietą su xl/, todėl čia aprašytas pilnas sprendiklis valdo TXLSXWorkbook.Open ir GetSheetNames įėjimo taškus, ne mažo paskirstymo skenavimo kelią, dokumentuotą straipsnyje apie srautinį tiesioginį skaitytuvą Delphi

Jei tai kuriate patys, trumpiausia teisinga santrauka yra: niekada nekonstruokite dalies vardo, visada spręskite vieną. Skaitykite _rels/.rels, sekite officeDocument, spręskite kiekvieną Target pagal dalį, kuri jį deklaravo, ir maršrutizuokite lapus per r:id. Jei norėtumėte, kad tai jau būtų ištestuota su pervadintomis dalimis, nesujungta lapų numeracija ir dubliuotais ryšio identifikatoriais, čia aprašytas sprendiklis pristatomas HotXLS Delphi skaičiuoklių komponente, kartu su apvažiavimo mechanika, kuri palaiko nepaliestas dalis, kurių jis nenagrinėja