Artigo Técnico

Resolução de Relacionamento OPC em XLSX em Parsers Delphi

Um xlsx válido não precisa conter xl/worksheets/sheet1.xml. O HotXLS, o componente de planilha Excel nativo para Delphi e C++Builder, localiza toda part através do grafo de relacionamento OPC em vez de adivinhar nomes, porque a ISO/IEC 29500-2 garante apenas que as parts são alcançáveis a partir de _rels/.rels, nunca que ficam em caminhos convencionais

Por que meu parser falha em um xlsx válido?

Porque os nomes de part que você memorizou são uma convenção de um produtor, não uma exigência do formato. Todo caminho que você já fixou no código, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, é o que o writer do Excel desktop por acaso emite. Um pacote conforme pode colocar o workbook em office/book.xml e a primeira worksheet em xl/custom/data-sheet.xml e ainda ser SpreadsheetML legal, contanto que os relacionamentos apontem para lá. Essa é a razão mais comum pela qual um leitor caseiro reporta "não consigo encontrar sheet1.xml" em um arquivo que o Excel, o LibreOffice, e o Numbers abrem todos sem reclamar

Produtores que fazem isso não são exóticos. Geradores de relatório no servidor reutilizam um pacote de template e mantêm seu layout original. Pipelines de exportação que fundem dois workbooks renumeram planilhas e deixam lacunas, então um workbook de cinco planilhas tem sheet1, sheet2, sheet4, sheet7, e sheet9. Ferramentas que removem uma planilha nem sempre renumeram as sobreviventes. Em cada um desses casos o palpite baseado em índice xl/worksheets/sheet + IntToStr(i + 1) + .xml silenciosamente lê a planilha errada ou não lê nada, o que é pior que uma exceção porque o workbook carrega e os números estão errados. O pacote mínimo abaixo exercita o problema inteiro, e é a forma contra a qual o HotXLS faz teste de regressão

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

O que a ISO/IEC 29500-2 realmente garante?

Ela garante alcançabilidade, não localização. A ISO/IEC 29500-2 é a parte de Open Packaging Conventions do padrão, e sua cláusula de relacionamentos define exatamente um ponto de entrada fixo: a part de relacionamento do pacote em _rels/.rels. A partir dali você segue o relacionamento cujo Type é http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument para alcançar a part do workbook, e toda outra part é descoberta lendo a própria part de relacionamento daquela part e seguindo arestas tipadas para fora

Duas regras adicionais do mesmo padrão fazem o trabalho de verdade. A cláusula de nomeação de part fixa onde vive uma part de relacionamento: para uma part em <folder>/<name>, seus relacionamentos ficam em <folder>/_rels/<name>.rels, e para uma part na raiz do pacote a pasta é simplesmente _rels/. A cláusula de markup de relacionamento afirma que Target é uma referência de URI resolvida contra a URI da part de origem, no sentido comum da RFC 3986, a menos que TargetMode="External" a marque como apontando para fora do pacote. Resolução relativa à origem é o passo que todo mundo pula, e é por isso que o mesmo literal ../notes/review.xml significa uma coisa dentro de xl/custom/_rels/data-sheet.xml.rels e algo inteiramente diferente dentro de um arquivo rels uma pasta mais fundo. Uma última ruga fica entre o modelo lógico e os bytes em disco: nomes de part no modelo lógico são absolutos e começam com uma barra à frente, mas a cláusula de mapeamento físico ZIP remove essa barra quando transforma um nome de part em um nome de item ZIP, então um resolvedor que esquece disso procura /xl/sharedStrings.xml no arquivo e não encontra nada

Dentro de XlsxResolveRelationshipTarget

O HotXLS concentra a regra de resolução inteira em uma função, XlsxResolveRelationshipTarget, declarada em lxHandleX.pas como function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Ela recebe o nome de item ZIP da part de origem e o atributo Target bruto, e retorna um nome de item ZIP sem barra à frente, pronto para entregar diretamente ao arquivo. Passar um OwnerPartName vazio resolve contra a raiz do pacote, que é exatamente o que a part de relacionamento do pacote precisa. A ordem das operações importa mais que os passos individuais: barras invertidas são normalizadas para barras primeiro, porque alguns produtores escrevem separadores do Windows em Target; qualquer fragmento introduzido por # é cortado antes do tratamento de caminho, então ../charts/chart1.xml#Sheet1 resolve para um nome de part em vez de uma entrada de arquivo inexistente; só então a função separa absoluto de 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;

O loop de segmento é uma travessia de pilha comum: segmentos vazios e . são descartados, .. retira um nível, e um .. que escaparia da raiz do pacote é absorvido em vez de produzir um índice negativo ou um nome começando com ../. A atribuição StrictDelimiter := True não é cosmética. Sem ela um TStringList do Delphi trata espaços como delimitadores e honra caracteres de aspas, o que corrompe qualquer nome de part contendo um espaço, e nomes de part com espaços são legais

Seguindo o grafo: workbook, worksheet, drawing

O HotXLS percorre três níveis de parts de relacionamento no caminho de TXLSXWorkbook.Open. O nível de pacote é tratado por XlsxFindOfficeDocumentPart, que lê _rels/.rels e retorna o alvo officeDocument. O nível de workbook lê a part de relacionamento do workbook e constrói dois mapas de uma vez: um mapa de identificador para buscas de r:id e um mapa de tipo para parts singleton. Os níveis de worksheet e drawing repetem o padrão com ParseWorksheetRelsXml e ParseDrawingRelsXml, cada um passando seu próprio nome de part como a base de resolução para que um drawing que referencia ../media/image3.png caia no blob certo

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

Planilhas especificamente precisam passar pelo mapa de identificador, não pelo mapa de tipo. Os elementos <sheet> na part do workbook carregam atributos r:id, e esse identificador é a única coisa vinculando um nome de planilha a uma part. O HotXLS coleta esses identificadores durante ParseWorkbookXml e resolve cada um contra o mapa de relacionamento do workbook, caindo de volta ao nome numerado convencional só quando o identificador está ausente ou não resolvível

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

Tudo a jusante depende desse mesmo mecanismo. Shared strings, styles, theme, o projeto VBA sob o tipo com namespace da Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, links externos, a part de pessoa no escopo do workbook, comentários legados, comentários encadeados, o drawing VML que carrega a geometria do balão de comentário, drawings, imagens, gráficos, tabelas, e PivotTables todos alcançam seus bytes através de alvos resolvidos. A part de theme em particular precisa ser localizada corretamente ou um round-trip silenciosamente sobrescreve a paleta de marca de um cliente com o theme padrão do Office, um dos modos de falha cobertos nas notas sobre round-trip sem perdas de XLSX de theme, extLst, e calcChain. A leitura de relacionamento também é por que o carregamento é organizado em estágios da forma que é: todo acesso ao arquivo acontece em uma única thread antes que o XML da worksheet seja analisado, porque o estado de inflate de um arquivo ZIP não é thread-safe, uma restrição explicada no artigo sobre parsing paralelo de XLSX e o alocador de memória

Por que um rId duplicado quebra o roteamento baseado em tipo?

Porque uma entrada malformada posterior pode sobrescrever uma anterior válida e sequestrar a busca. Identificadores de relacionamento supostamente são únicos dentro de uma part de relacionamento, mas pacotes malformados os reutilizam, e uma atribuição ingênua Values[Id] := segue a regra de a última escrita vence. Se rId3 primeiro aponta para uma worksheet real e um segundo rId3 aponta para um alvo não suportado ou vazio, a última escrita vence perde a worksheet. ParsePartRelationshipsXml, portanto, aplica uma regra de primeira-vence com duas condições: o alvo resolvido precisa ser não vazio, e o identificador não pode já estar presente. As duas condições juntas são o que torna isso seguro, porque o teste de não vazio impede que um relacionamento com um Target ausente reivindique o slot antes que um utilizável chegue

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

Note a assimetria deliberada nesse trecho. O mapa de identificador é um mapa verdadeiro com uma proteção de primeira-vence, enquanto a coleção de tipo é uma lista apenas-para-anexar de pares type=target. Essa distinção é estrutural: um workbook tem exatamente um relacionamento de shared-strings mas muitos relacionamentos de worksheet e link externo, então a busca de tipo via Values[] retorna a primeira correspondência para singletons, e tipos multivalorados como externalLink são enumerados percorrendo a lista

Onde a busca de relacionamento para

Limites honestos importam mais que uma história limpa. O HotXLS cai de volta para nomes convencionais sempre que um relacionamento está ausente, então um pacote com uma part de relacionamento danificada ou ausente ainda abre se por acaso seguir o layout do Excel; esse fallback é um recurso de compatibilidade, não uma segunda fonte de verdade, e pode mascarar um bug de produtor durante o teste. Mais três limites valem a pena saber. Alvos marcados TargetMode="External" são armazenados literalmente em vez de resolvidos, o que é correto para hyperlinks e para o relacionamento externalLinkPath carregando uma URL de workbook remoto, mas significa que o valor que você recebe de volta é o que quer que o produtor escreveu. Parts de gráfico descobertas através de uma part de relacionamento de drawing são pareadas com âncoras de drawing posicionalmente em vez de por identificador, então uma ordenação de âncora incomum pode desalinhar vínculos de gráfico. E o leitor direto em streaming em lxDirectRead.pas mantém seu próprio caminho mais leve de tratamento indexado a xl/, então o resolvedor completo descrito aqui governa os pontos de entrada TXLSXWorkbook.Open e GetSheetNames, não o caminho de varredura de baixa alocação documentado no artigo sobre o leitor direto em streaming para Delphi

Se você está construindo isso você mesmo, o resumo correto mais curto é: nunca construa um nome de part, sempre resolva um. Leia _rels/.rels, siga officeDocument, resolva todo Target contra a part que o declarou, e roteie planilhas por r:id. Se você preferir ter isso já testado contra parts renomeadas, numeração de planilha não contígua, e identificadores de relacionamento duplicados, o resolvedor descrito aqui vem no componente de planilha Delphi HotXLS, junto com a maquinaria de round-trip que mantém intactas as parts que não analisa