Articolo tecnico

Risoluzione Relazioni OPC XLSX nei Parser Delphi

Un xlsx valido non deve necessariamente contenere xl/worksheets/sheet1.xml. HotXLS, il componente foglio di calcolo Excel nativo per Delphi e C++Builder, localizza ogni parte tramite il grafo delle relazioni OPC invece di indovinare i nomi, perché ISO/IEC 29500-2 garantisce solo che le parti siano raggiungibili da _rels/.rels, mai che si trovino a percorsi convenzionali

Perché il mio parser fallisce su un xlsx valido?

Perché i nomi di parte che hai memorizzato sono una convenzione di un produttore, non un requisito del formato. Ogni percorso che hai mai hardcodato, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, è ciò che il writer desktop di Excel capita di emettere. Un pacchetto conforme può mettere il workbook a office/book.xml e il primo foglio a xl/custom/data-sheet.xml ed essere comunque SpreadsheetML legale, finché le relazioni puntano lì. Questa è la ragione singola più comune per cui un lettore fatto in casa riporta "impossibile trovare sheet1.xml" su un file che Excel, LibreOffice, e Numbers aprono tutti senza lamentarsi

I produttori che fanno questo non sono esotici. I generatori di report lato server riutilizzano un pacchetto template e mantengono il suo layout originale. Le pipeline di export che uniscono due workbook rinumerano i fogli e lasciano buchi, così un workbook a cinque fogli ha sheet1, sheet2, sheet4, sheet7, e sheet9. Gli strumenti che rimuovono un foglio non sempre rinumerano i sopravvissuti. In ognuno di quei casi la stima basata su indice xl/worksheets/sheet + IntToStr(i + 1) + .xml legge silenziosamente il foglio sbagliato o non legge nulla, il che è peggio di un'eccezione perché il workbook si carica e i numeri sono sbagliati. Il pacchetto minimo qui sotto esercita l'intero problema, ed è la forma contro cui HotXLS esegue i test di regressione

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

Cosa garantisce realmente ISO/IEC 29500-2?

Garantisce raggiungibilità, non posizione. ISO/IEC 29500-2 è la parte Open Packaging Conventions dello standard, e la sua clausola sulle relazioni definisce esattamente un punto di ingresso fisso: la parte di relazione del pacchetto a _rels/.rels. Da lì segui la relazione il cui Type è http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument per raggiungere la parte workbook, e ogni altra parte viene scoperta leggendo la propria parte di relazione di quella parte e seguendo archi tipizzati verso l'esterno

Altre due regole dello stesso standard fanno il lavoro vero. La clausola di denominazione delle parti fissa dove vive una parte di relazione: per una parte a <folder>/<name>, le sue relazioni sono a <folder>/_rels/<name>.rels, e per una parte alla radice del pacchetto la cartella è semplicemente _rels/. La clausola di markup delle relazioni afferma che Target è un riferimento URI risolto rispetto all'URI della parte sorgente, nel senso ordinario di RFC 3986, a meno che TargetMode="External" lo marchi come puntante fuori dal pacchetto. La risoluzione relativa alla sorgente è il passo che tutti saltano, ed è per questo che lo stesso letterale ../notes/review.xml significa una cosa dentro xl/custom/_rels/data-sheet.xml.rels e qualcosa di completamente diverso dentro un file rels una cartella più in profondità. Un'ultima complicazione sta tra il modello logico e i byte su disco: i nomi di parte nel modello logico sono assoluti e iniziano con una barra in avanti, ma la clausola di mappatura fisica ZIP rimuove quella barra quando trasforma un nome di parte in un nome di elemento ZIP, così un resolver che se lo dimentica cerca /xl/sharedStrings.xml nell'archivio e non trova nulla

Dentro XlsxResolveRelationshipTarget

HotXLS concentra l'intera regola di risoluzione in un'unica funzione, XlsxResolveRelationshipTarget, dichiarata in lxHandleX.pas come function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Prende il nome dell'elemento ZIP della parte sorgente e l'attributo Target grezzo, e restituisce un nome di elemento ZIP senza barra iniziale, pronto per essere passato direttamente all'archivio. Passare un OwnerPartName vuoto risolve rispetto alla radice del pacchetto, che è esattamente ciò di cui ha bisogno la parte di relazione del pacchetto. L'ordine delle operazioni conta più dei singoli passi: le barre inverse vengono normalizzate a barre in avanti per prime, perché alcuni produttori scrivono separatori Windows in Target; qualunque frammento introdotto da # viene tagliato prima della gestione del percorso, così ../charts/chart1.xml#Sheet1 si risolve in un nome di parte anziché in una voce di archivio inesistente; solo allora la funzione separa assoluto da relativo

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

Il ciclo dei segmenti è una semplice percorrenza a stack: i segmenti vuoti e . vengono scartati, .. fa un pop di un livello, e un .. che sfuggirebbe dalla radice del pacchetto viene assorbito invece di produrre un indice negativo o un nome che inizia con ../. L'assegnazione StrictDelimiter := True non è cosmetica. Senza di essa un TStringList di Delphi tratta gli spazi come delimitatori e rispetta i caratteri di quoting, il che rovina qualunque nome di parte contenente uno spazio, e i nomi di parte con spazi sono legali

Percorrere il grafo: workbook, worksheet, drawing

HotXLS percorre tre livelli di parti di relazione sul percorso TXLSXWorkbook.Open. Il livello pacchetto è gestito da XlsxFindOfficeDocumentPart, che legge _rels/.rels e restituisce il target officeDocument. Il livello workbook legge la parte di relazione del workbook e costruisce due mappe contemporaneamente: una mappa di identificatori per le ricerche r:id e una mappa di tipi per le parti singleton. I livelli worksheet e drawing ripetono il pattern con ParseWorksheetRelsXml e ParseDrawingRelsXml, ciascuno passando il proprio nome di parte come base di risoluzione così un drawing che referenzia ../media/image3.png atterra sul blob giusto

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

I fogli in particolare devono passare attraverso la mappa degli identificatori, non la mappa dei tipi. Gli elementi <sheet> nella parte workbook portano attributi r:id, e quell'identificatore è l'unica cosa che lega un nome di foglio a una parte. HotXLS raccoglie quegli identificatori durante ParseWorkbookXml e risolve ciascuno rispetto alla mappa di relazione del workbook, ricadendo sul nome numerato convenzionale solo quando l'identificatore è assente o non risolvibile

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

Tutto ciò che sta a valle si affida a quello stesso meccanismo. Shared strings, stili, tema, il progetto VBA sotto il tipo con namespace Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, link esterni, la parte person con scope workbook, commenti legacy, commenti threaded, il drawing VML che porta la geometria dei balloon dei commenti, drawing, immagini, grafici, tabelle, e PivotTable raggiungono tutti i propri byte tramite target risolti. La parte tema in particolare deve essere localizzata correttamente o un round-trip sovrascrive silenziosamente una palette di brand del cliente con il tema Office di serie, una delle modalità di fallimento trattate nelle note su round-trip XLSX senza perdite di tema, extLst e calcChain. La lettura delle relazioni è anche il motivo per cui il caricamento è organizzato a fasi in questo modo: tutto l'accesso all'archivio avviene su un thread prima che l'XML del worksheet venga analizzato, perché lo stato di inflate di un archivio ZIP non è thread-safe, un vincolo spiegato nel pezzo su parsing XLSX parallelo e l'allocatore di memoria

Perché un rId duplicato rompe il routing basato su tipo?

Perché una voce malformata successiva può sovrascrivere una precedente valida e dirottare la ricerca. Gli identificatori di relazione dovrebbero essere unici all'interno di una parte di relazione, ma i pacchetti malformati li riutilizzano, e un'assegnazione ingenua Values[Id] := è last-write-wins. Se rId3 punta prima a un worksheet reale e un secondo rId3 punta a un target non supportato o vuoto, last-write-wins perde il worksheet. ParsePartRelationshipsXml applica quindi una regola first-wins con due condizioni: il target risolto deve essere non vuoto, e l'identificatore non deve essere già presente. Entrambe le condizioni insieme sono ciò che la rende sicura, perché il test di non-vuoto impedisce a una relazione con un Target mancante di reclamare lo slot prima che ne arrivi uno utilizzabile

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

Nota l'asimmetria deliberata in quello snippet. La mappa degli identificatori è una vera mappa con una protezione first-wins, mentre la raccolta dei tipi è una lista solo-append di coppie type=target. Quella distinzione è strutturale: un workbook ha esattamente una relazione shared-strings ma molte relazioni worksheet ed external-link, quindi la ricerca per tipo tramite Values[] restituisce la prima corrispondenza per i singleton, e i tipi multi-valore come externalLink vengono enumerati percorrendo la lista

Dove si ferma il seguire le relazioni

I confini onesti contano più di una storia pulita. HotXLS ricade su nomi convenzionali ogni volta che una relazione è assente, così un pacchetto con una parte di relazione danneggiata o mancante si apre comunque se per caso segue il layout di Excel; quel fallback è una funzionalità di compatibilità, non una seconda fonte di verità, e può mascherare un bug del produttore durante i test. Vale la pena conoscere altri tre limiti. I target marcati TargetMode="External" vengono memorizzati verbatim anziché risolti, il che è corretto per i collegamenti ipertestuali e per la relazione externalLinkPath che porta l'URL di un workbook remoto, ma significa che il valore che ottieni è qualunque cosa il produttore abbia scritto. Le parti chart scoperte tramite una parte di relazione drawing vengono abbinate agli anchor del drawing posizionalmente anziché per identificatore, così un ordinamento insolito degli anchor può disallineare i binding dei grafici. E il lettore diretto in streaming in lxDirectRead.pas mantiene una propria gestione del percorso più leggera basata su xl/, quindi il resolver completo descritto qui governa i punti di ingresso TXLSXWorkbook.Open e GetSheetNames, non il percorso di scansione a bassa allocazione documentato nell'articolo sul lettore diretto in streaming per Delphi

Se stai costruendo questo da solo, il riassunto corretto più breve è: non costruire mai un nome di parte, risolvine sempre uno. Leggi _rels/.rels, segui officeDocument, risolvi ogni Target rispetto alla parte che lo ha dichiarato, e instrada i fogli tramite r:id. Se preferisci avere questo già testato contro parti rinominate, numerazione dei fogli non contigua, e identificatori di relazione duplicati, il resolver descritto qui viene fornito nel componente foglio di calcolo Delphi HotXLS, insieme al meccanismo di round-trip che mantiene intatte le parti che non analizza