Artículo técnico

Resolución de relaciones OPC de XLSX en parsers Delphi

Un xlsx válido no tiene por qué contener xl/worksheets/sheet1.xml. HotXLS, el componente nativo de hoja de cálculo Excel para Delphi y C++Builder, localiza cada parte a través del grafo de relaciones OPC en lugar de adivinar nombres, porque ISO/IEC 29500-2 solo garantiza que las partes son alcanzables desde _rels/.rels, nunca que se sitúan en rutas convencionales

¿Por qué falla mi parser con un xlsx válido?

Porque los nombres de parte que memorizaste son una convención de un productor, no un requisito del formato. Cada ruta que alguna vez codificaste a fuego, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, es lo que el escritor de Excel de escritorio resulta que emite. Un paquete conforme puede poner el libro en office/book.xml y la primera hoja en xl/custom/data-sheet.xml y seguir siendo SpreadsheetML legal, siempre que las relaciones apunten allí. Esta es la razón más común de todas por la que un lector casero reporta "no se encuentra sheet1.xml" en un fichero que Excel, LibreOffice y Numbers abren todos sin quejarse

Los productores que hacen esto no son exóticos. Los generadores de informes del lado del servidor reutilizan un paquete plantilla y conservan su layout original. Los pipelines de exportación que fusionan dos libros renumeran hojas y dejan huecos, así que un libro de cinco hojas tiene sheet1, sheet2, sheet4, sheet7, y sheet9. Las herramientas que eliminan una hoja no siempre renumeran a las supervivientes. En cada uno de esos casos la suposición basada en índice xl/worksheets/sheet + IntToStr(i + 1) + .xml silenciosamente lee la hoja equivocada o no lee nada, lo cual es peor que una excepción porque el libro carga y los números están mal. El paquete mínimo de abajo ejercita todo el problema, y es la forma contra la que HotXLS hace tests de regresión

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

¿Qué garantiza realmente ISO/IEC 29500-2?

Garantiza alcanzabilidad, no ubicación. ISO/IEC 29500-2 es la parte de Open Packaging Conventions del estándar, y su cláusula de relaciones define exactamente un punto de entrada fijo: la parte de relación de paquete en _rels/.rels. Desde ahí sigues la relación cuyo Type es http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument para llegar a la parte del libro, y cada otra parte se descubre leyendo la propia parte de relación de esa parte y siguiendo bordes tipados hacia fuera

Dos reglas más del mismo estándar hacen el trabajo real. La cláusula de nomenclatura de partes fija dónde vive una parte de relación: para una parte en <folder>/<name>, sus relaciones están en <folder>/_rels/<name>.rels, y para una parte en la raíz del paquete la carpeta es simplemente _rels/. La cláusula de marcado de relaciones establece que Target es una referencia URI resuelta contra la URI de la parte de origen, en el sentido ordinario de RFC 3986, a menos que TargetMode="External" lo marque como apuntando fuera del paquete. La resolución relativa al origen es el paso que todo el mundo se salta, y es por lo que el mismo literal ../notes/review.xml significa una cosa dentro de xl/custom/_rels/data-sheet.xml.rels y algo completamente distinto dentro de un fichero rels una carpeta más abajo. Una última arruga se sitúa entre el modelo lógico y los bytes en disco: los nombres de parte en el modelo lógico son absolutos y empiezan con una barra inclinada, pero la cláusula de mapeo físico ZIP elimina esa barra cuando convierte un nombre de parte en un nombre de elemento ZIP, así que un resolutor que lo olvida busca /xl/sharedStrings.xml en el archivo y no encuentra nada

Dentro de XlsxResolveRelationshipTarget

HotXLS concentra toda la regla de resolución en una función, XlsxResolveRelationshipTarget, declarada en lxHandleX.pas como function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Recibe el nombre de elemento ZIP de la parte de origen y el atributo Target en crudo, y devuelve un nombre de elemento ZIP sin barra inicial, listo para entregar directamente al archivo. Pasar un OwnerPartName vacío resuelve contra la raíz del paquete, que es exactamente lo que necesita la parte de relación de paquete. El orden de las operaciones importa más que los pasos individuales: las barras invertidas se normalizan a barras inclinadas primero, porque algunos productores escriben separadores de Windows en Target; cualquier fragmento introducido por # se corta antes del manejo de ruta, así que ../charts/chart1.xml#Sheet1 resuelve a un nombre de parte en lugar de a una entrada de archivo inexistente; solo entonces la función separa lo absoluto de lo 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;

El bucle de segmentos es un recorrido de pila sencillo: los segmentos vacíos y . se descartan, .. saca un nivel, y un .. que escaparía de la raíz del paquete se absorbe en lugar de producir un índice negativo o un nombre que empieza con ../. La asignación StrictDelimiter := True no es cosmética. Sin ella un TStringList de Delphi trata los espacios como delimitadores y respeta caracteres de comilla, lo que estropea cualquier nombre de parte que contenga un espacio, y los nombres de parte con espacios son legales

Seguir el grafo: libro, hoja, dibujo

HotXLS recorre tres niveles de partes de relación en la ruta de TXLSXWorkbook.Open. El nivel de paquete lo maneja XlsxFindOfficeDocumentPart, que lee _rels/.rels y devuelve el destino officeDocument. El nivel de libro lee la parte de relación del libro y construye dos mapas a la vez: un mapa de identificadores para búsquedas de r:id y un mapa de tipos para partes singleton. Los niveles de hoja y dibujo repiten el patrón con ParseWorksheetRelsXml y ParseDrawingRelsXml, cada uno pasando su propio nombre de parte como base de resolución para que un dibujo que referencia ../media/image3.png aterrice en el blob correcto

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

Las hojas en concreto deben pasar por el mapa de identificadores, no por el mapa de tipos. Los elementos <sheet> en la parte del libro llevan atributos r:id, y ese identificador es lo único que vincula un nombre de hoja a una parte. HotXLS recolecta esos identificadores durante ParseWorkbookXml y resuelve cada uno contra el mapa de relaciones del libro, cayendo de vuelta al nombre numerado convencional solo cuando el identificador está ausente o no se puede resolver

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

Todo lo que viene después se apoya en el mismo mecanismo. Las cadenas compartidas, los estilos, el tema, el proyecto VBA bajo el tipo con namespace de Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, los enlaces externos, la parte de persona a nivel de libro, los comentarios heredados, los comentarios en hilo, el dibujo VML que lleva la geometría de los globos de comentario, dibujos, imágenes, gráficos, tablas, y tablas dinámicas todos llegan a sus bytes a través de destinos resueltos. La parte de tema en particular tiene que localizarse correctamente o un ciclo de ida y vuelta sobrescribe silenciosamente la paleta de marca de un cliente con el tema estándar de Office, uno de los modos de fallo cubiertos en las notas sobre el ciclo de ida y vuelta sin pérdidas de XLSX de tema, extLst y calcChain. La lectura de relaciones también es por lo que la carga se organiza por etapas de la forma en que lo hace: todo el acceso al archivo ocurre en un único hilo antes de que se analice el XML de hoja, porque el estado de inflado de un archivo ZIP no es seguro para hilos, una restricción explicada en el análisis sobre el análisis paralelo de XLSX y el asignador de memoria

¿Por qué un rId duplicado rompe el enrutamiento basado en tipo?

Porque una entrada malformada posterior puede sobrescribir a una anterior válida y secuestrar la búsqueda. Se supone que los identificadores de relación son únicos dentro de una parte de relación, pero los paquetes malformados los reutilizan, y una asignación ingenua Values[Id] := es last-write-wins. Si rId3 primero apunta a una hoja real y un segundo rId3 apunta a un destino no soportado o vacío, last-write-wins pierde la hoja. ParsePartRelationshipsXml por tanto aplica una regla first-wins con dos condiciones: el destino resuelto debe ser no vacío, y el identificador no debe estar ya presente. Ambas condiciones juntas son lo que lo hace seguro, porque la comprobación de no-vacío evita que una relación con un Target ausente reclame el slot antes de que llegue uno utilizable

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

Fíjate en la asimetría deliberada de ese fragmento. El mapa de identificadores es un mapa verdadero con una salvaguarda first-wins, mientras que la colección de tipos es una lista solo de anexión de pares type=target. Esa distinción es estructural: un libro tiene exactamente una relación de cadenas compartidas pero muchas relaciones de hoja y de enlace externo, así que la búsqueda de tipo vía Values[] devuelve la primera coincidencia para singletons, y los tipos multivaluados como externalLink se enumeran recorriendo la lista

Dónde se detiene el seguimiento de relaciones

Los límites honestos importan más que una historia impecable. HotXLS cae de vuelta a nombres convencionales siempre que una relación está ausente, así que un paquete con una parte de relación dañada o ausente todavía abre si resulta que sigue el layout de Excel; ese respaldo es una característica de compatibilidad, no una segunda fuente de verdad, y puede enmascarar un bug del productor durante las pruebas. Merece la pena conocer tres límites más. Los destinos marcados TargetMode="External" se almacenan literalmente en lugar de resolverse, lo cual es correcto para hipervínculos y para la relación externalLinkPath que lleva una URL de libro remoto, pero significa que el valor que recuperas es lo que sea que escribiera el productor. Las partes de gráfico descubiertas a través de una parte de relación de dibujo se emparejan con los anclajes de dibujo posicionalmente en lugar de por identificador, así que un ordenamiento de anclaje inusual puede desalinear las vinculaciones de gráfico. Y el lector directo en streaming en lxDirectRead.pas mantiene su propia ruta más ligera de manejo indexada a xl/, así que el resolutor completo descrito aquí gobierna los puntos de entrada TXLSXWorkbook.Open y GetSheetNames, no la ruta de escaneo de baja asignación documentada en el artículo sobre el lector directo en streaming para Delphi

Si estás construyendo esto tú mismo, el resumen correcto más corto es: nunca construyas un nombre de parte, siempre resuelve uno. Lee _rels/.rels, sigue officeDocument, resuelve cada Target contra la parte que lo declaró, y enruta las hojas por r:id. Si prefieres tener eso ya probado contra partes renombradas, numeración de hoja no contigua, e identificadores de relación duplicados, el resolutor descrito aquí se incluye en el componente de hoja de cálculo Delphi HotXLS, junto con la maquinaria de ciclo de ida y vuelta que mantiene intactas las partes que no analiza