Technický článek

Rozřešení OPC vztahů XLSX v parserech pro Delphi

Platný xlsx nemusí obsahovat xl/worksheets/sheet1.xml. HotXLS, nativní komponenta pro tabulkové sešity Excel pro Delphi a C++Builder, hledá každou část přes graf vztahů OPC místo hádání názvů, protože ISO/IEC 29500-2 zaručuje pouze to, že části jsou dosažitelné z _rels/.rels, nikdy že sedí na konvenčních cestách

Proč můj parser selže na platném xlsx?

Protože názvy částí, které jste si zapamatovali, jsou konvence jednoho producenta, ne požadavek formátu. Každá cesta, kterou jste kdy natvrdo zapsali, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, je to, co náhodou generuje desktopový writer Excelu. Konformní balíček může mít sešit na office/book.xml a první list na xl/custom/data-sheet.xml a pořád být platným SpreadsheetML, pokud na to ukazují vztahy. Tohle je zdaleka nejčastější důvod, proč doma vyrobený čtecí kód hlásí "nelze najít sheet1.xml" u souboru, který Excel, LibreOffice i Numbers otevřou bez jediné stížnosti

Producenti, kteří tohle dělají, nejsou žádná exotika. Generátory reportů na straně serveru znovu použijí šablonový balíček a zachovají jeho původní rozvržení. Exportní pipeline, které slučují dva sešity, přečíslují listy a nechají mezery, takže pětilistý sešit má sheet1, sheet2, sheet4, sheet7 a sheet9. Nástroje, které odstraní list, nepřečíslují vždy přeživší. V každém z těchto případů odhad založený na indexu xl/worksheets/sheet + IntToStr(i + 1) + .xml potichu čte špatný list nebo nečte nic, což je horší než výjimka, protože sešit se načte a čísla jsou špatně. Minimální balíček níže procvičuje celý problém a je to tvar, proti kterému HotXLS regresně testuje

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

Co ISO/IEC 29500-2 skutečně zaručuje?

Zaručuje dosažitelnost, ne umístění. ISO/IEC 29500-2 je část normy Open Packaging Conventions a její klauzule o vztazích definuje přesně jeden pevný vstupní bod: část vztahů balíčku na _rels/.rels. Odtud sledujete vztah, jehož Type je http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument, abyste se dostali k části sešitu, a každá další část se objeví čtením vlastní části vztahů dané části a sledováním typovaných hran ven

Skutečnou práci odvádějí dvě další pravidla ze stejné normy. Klauzule o pojmenování částí určuje, kde žije část vztahů: pro část na <folder>/<name> jsou její vztahy na <folder>/_rels/<name>.rels, a pro část v kořeni balíčku je složkou jednoduše _rels/. Klauzule o značkování vztahů říká, že Target je URI reference rozřešená vůči URI zdrojové části, v obyčejném smyslu RFC 3986, pokud ji TargetMode="External" neoznačí jako ukazující mimo balíček. Rozřešení relativní ke zdroji je krok, který každý přeskočí, a proto stejný literál ../notes/review.xml znamená jednu věc uvnitř xl/custom/_rels/data-sheet.xml.rels a úplně něco jiného uvnitř souboru rels o složku hlouběji. Poslední záludnost sedí mezi logickým modelem a bajty na disku: názvy částí v logickém modelu jsou absolutní a začínají lomítkem, ale klauzule o fyzickém mapování ZIP toto lomítko odstraní, když mění název části na název položky ZIP, takže resolver, který na to zapomene, hledá v archivu /xl/sharedStrings.xml a nic nenajde

Uvnitř XlsxResolveRelationshipTarget

HotXLS soustředí celé pravidlo rozřešení do jedné funkce, XlsxResolveRelationshipTarget, deklarované v lxHandleX.pas jako function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Bere název položky ZIP zdrojové části a surový atribut Target a vrací název položky ZIP bez úvodního lomítka, připravený předat rovnou archivu. Předání prázdného OwnerPartName rozřeší vůči kořeni balíčku, což je přesně to, co potřebuje část vztahů balíčku. Na pořadí operací záleží víc než na jednotlivých krocích: zpětná lomítka se nejdřív normalizují na dopředná, protože někteří producenti zapisují do Target windowsovské oddělovače; jakýkoli fragment uvedený znakem # se ořízne před zpracováním cesty, takže ../charts/chart1.xml#Sheet1 se rozřeší na název části místo na neexistující položku archivu; teprve poté funkce rozliší absolutní od relativního

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

Smyčka přes segmenty je obyčejný průchod zásobníkem: prázdné segmenty a . se zahazují, .. vyskočí o jednu úroveň, a .., které by uniklo z kořene balíčku, se pohltí, místo aby vzniklo záporné číslo indexu nebo název začínající ../. Přiřazení StrictDelimiter := True není kosmetika. Bez něj TStringList v Delphi zachází s mezerami jako s oddělovači a respektuje uvozovky, což poškodí každý název části obsahující mezeru, a názvy částí s mezerami jsou legální

Sledování grafu: sešit, list, kresba

HotXLS na cestě TXLSXWorkbook.Open prochází tři úrovně částí vztahů. Úroveň balíčku obstarává XlsxFindOfficeDocumentPart, která čte _rels/.rels a vrací cíl officeDocument. Úroveň sešitu čte část vztahů sešitu a najednou staví dvě mapy: mapu identifikátorů pro vyhledávání r:id a mapu typů pro jedinečné části. Úrovně listu a kresby opakují stejný vzor pomocí ParseWorksheetRelsXml a ParseDrawingRelsXml, každá předává svůj vlastní název části jako základ pro rozřešení, takže kresba, která odkazuje na ../media/image3.png, skončí na správném 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';

Konkrétně listy musí projít mapou identifikátorů, ne mapou typů. Elementy <sheet> v části sešitu nesou atributy r:id, a tento identifikátor je jediná věc, která váže název listu k části. HotXLS tyto identifikátory sbírá během ParseWorkbookXml a každý z nich rozřeší proti mapě vztahů sešitu, přičemž se vrátí ke konvenčnímu číslovanému názvu jen tehdy, když identifikátor chybí nebo ho nelze rozřešit

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

Všechno, co následuje, jede na stejném mechanismu. Sdílené řetězce, styly, motiv, projekt VBA pod typem s microsoftím jmenným prostorem http://schemas.microsoft.com/office/2006/relationships/vbaProject, externí odkazy, část osoby na úrovni sešitu, starší komentáře, vláknové komentáře, kresba VML nesoucí geometrii bublin komentářů, kresby, obrázky, grafy, tabulky a kontingenční tabulky, to všechno se ke svým bajtům dostává přes rozřešené cíle. Zejména část motivu musí být umístěna správně, jinak round-trip potichu přepíše zákaznickou značkovou paletu stock motivem Office, což je jeden z režimů selhání popsaný v poznámkách o bezeztrátový round-trip XLSX pro motiv, extLst a calcChain. Čtení vztahů je také důvod, proč je načítání rozfázované právě takhle: veškerý přístup k archivu probíhá na jednom vlákně předtím, než se parsuje XML listu, protože stav dekomprese ZIP archivu není bezpečný pro vlákna, což je omezení vysvětlené v článku o paralelní parsování XLSX a paměťový alokátor

Proč duplicitní rId rozbije směrování založené na typu?

Protože pozdější poškozený záznam může přepsat dřívější platný a unést vyhledávání. Identifikátory vztahů mají být uvnitř jedné části vztahů jedinečné, ale poškozené balíčky je opakují, a naivní přiřazení Values[Id] := platí pravidlo poslední-vyhrává. Pokud rId3 nejdřív ukazuje na skutečný list a druhé rId3 ukazuje na nepodporovaný nebo prázdný cíl, poslední-vyhrává list ztratí. ParsePartRelationshipsXml proto uplatňuje pravidlo první-vyhrává se dvěma podmínkami: rozřešený cíl musí být neprázdný a identifikátor tam ještě nesmí být. Bezpečné to dělají obě podmínky dohromady, protože test na neprázdnost brání vztahu s chybějícím Target obsadit slot dřív, než dorazí použitelný

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

Všimněte si záměrné asymetrie v tomto úryvku. Mapa identifikátorů je skutečná mapa s pojistkou první-vyhrává, zatímco kolekce typů je jen přidávající seznam dvojic type=target. Tento rozdíl je nosný: sešit má přesně jeden vztah sdílených řetězců, ale mnoho vztahů listů a externích odkazů, takže vyhledání typu přes Values[] vrátí první shodu u jedinečných typů, a vícehodnotové typy jako externalLink se vyčíslují procházením seznamu

Kde sledování vztahů končí

Na poctivých hranicích záleží víc než na úhledném příběhu. HotXLS se vrací ke konvenčním názvům kdykoli vztah chybí, takže balíček s poškozenou nebo chybějící částí vztahů se pořád otevře, pokud náhodou dodržuje rozvržení Excelu; tento fallback je kompatibilní vlastnost, ne druhý zdroj pravdy, a během testování může zamaskovat chybu producenta. Stojí za to znát ještě tři omezení. Cíle označené TargetMode="External" se ukládají doslovně místo rozřešení, což je správné pro hypertextové odkazy a pro vztah externalLinkPath nesoucí URL vzdáleného sešitu, ale znamená to, že hodnota, kterou dostanete zpátky, je přesně to, co napsal producent. Části grafů objevené přes část vztahů kresby se párují s kotvami kresby pozičně, ne podle identifikátoru, takže neobvyklé pořadí kotev může vazby grafu rozhodit. A streamovací přímý čtecí kód v lxDirectRead.pas si drží vlastní lehčí zpracování cest klíčované na xl/, takže úplný resolver popsaný zde řídí vstupní body TXLSXWorkbook.Open a GetSheetNames, ne cestu skenování s nízkou alokací zdokumentovanou v článku o streamovací přímý čtecí kód pro Delphi

Pokud si tohle stavíte sami, nejkratší správné shrnutí zní: nikdy nekonstruujte název části, vždy jeden rozřešte. Přečtěte _rels/.rels, sledujte officeDocument, rozřešte každý Target proti části, která ho deklarovala, a listy směrujte podle r:id. Pokud byste raději měli něco už otestovaného proti přejmenovaným částem, nesouvislému číslování listů a duplicitním identifikátorům vztahů, resolver popsaný zde je součástí HotXLS Delphi spreadsheet component, spolu s mechanikou round-tripu, která nechává části, jež neparsuje, nedotčené