Um xlsx válido não tem de conter xl/worksheets/sheet1.xml. O HotXLS, o componente nativo de folha de cálculo Excel para Delphi e C++Builder, localiza cada part através do grafo de relações OPC em vez de adivinhar nomes, porque o ISO/IEC 29500-2 só garante que as parts são alcançáveis a partir de _rels/.rels, nunca que ficam em caminhos convencionais
Porque é que o meu parser falha num xlsx válido?
Porque os nomes de part que memorizou são uma convenção de um produtor, não uma exigência do formato. Cada caminho que alguma vez fixou no código, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, é o que o escritor de Excel de ambiente de trabalho por acaso emite. Um pacote conforme pode colocar o workbook em office/book.xml e a primeira folha em xl/custom/data-sheet.xml e continuar a ser SpreadsheetML legal, desde que as relações apontem para lá. Esta é a razão mais comum de todas para um leitor caseiro reportar "não encontra sheet1.xml" num ficheiro que o Excel, o LibreOffice e o Numbers abrem todos sem reclamar
Os produtores que fazem isto não são exóticos. Geradores de relatórios do lado do servidor reutilizam um pacote modelo e mantêm o seu layout original. Pipelines de exportação que fundem duas folhas de cálculo renumeram folhas e deixam lacunas, pelo que uma folha de cálculo com cinco folhas tem sheet1, sheet2, sheet4, sheet7 e sheet9. Ferramentas que retiram uma folha nem sempre renumeram as sobreviventes. Em cada um desses casos o palpite baseado em índice xl/worksheets/sheet + IntToStr(i + 1) + .xml lê silenciosamente a folha errada ou não lê nada, o que é pior do que uma exceção porque a folha de cálculo carrega e os números estão errados. O pacote mínimo abaixo exercita todo o problema, e é a forma contra a qual o HotXLS corre testes 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 é que o ISO/IEC 29500-2 realmente garante?
Garante alcançabilidade, não localização. O ISO/IEC 29500-2 é a parte Open Packaging Conventions da norma, e a sua cláusula de relações define exatamente um ponto de entrada fixo: a part de relação do pacote em _rels/.rels. A partir daí segue-se a relação cujo Type é http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument para chegar à part do workbook, e cada outra part é descoberta lendo a própria part de relação dessa part e seguindo arestas tipadas para fora
Duas regras adicionais da mesma norma fazem o trabalho real. A cláusula de nomeação de parts fixa onde vive uma part de relação: para uma part em <folder>/<name>, as suas relações ficam em <folder>/_rels/<name>.rels, e para uma part na raiz do pacote a pasta é simplesmente _rels/. A cláusula de markup de relações declara que Target é uma referência URI resolvida contra o URI da part de origem, no sentido comum do RFC 3986, a menos que TargetMode="External" a marque como apontando para fora do pacote. A resolução relativa à origem é o passo que toda a gente salta, e é por isso que o mesmo literal ../notes/review.xml significa uma coisa dentro de xl/custom/_rels/data-sheet.xml.rels e outra coisa inteiramente diferente dentro de um ficheiro rels uma pasta mais fundo. Uma última ruga fica entre o modelo lógico e os bytes em disco: os nomes de part no modelo lógico são absolutos e começam com uma barra, mas a cláusula de mapeamento físico ZIP retira essa barra quando transforma um nome de part num nome de item ZIP, pelo que um resolvedor que se esqueça disso procura /xl/sharedStrings.xml no arquivo e não encontra nada
Dentro de XlsxResolveRelationshipTarget
O HotXLS concentra toda a regra de resolução numa única função, XlsxResolveRelationshipTarget, declarada em lxHandleX.pas como function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Recebe o nome de item ZIP da part de origem e o atributo Target em bruto, e devolve um nome de item ZIP sem barra inicial, pronto a entregar diretamente ao arquivo. Passar um OwnerPartName vazio resolve contra a raiz do pacote, o que é exatamente o que a part de relação do pacote precisa. A ordem das operações importa mais do que os passos individuais: as barras invertidas são normalizadas primeiro para barras normais, porque alguns produtores escrevem separadores de Windows em Target; qualquer fragmento introduzido por # é cortado antes de tratar o caminho, pelo que ../charts/chart1.xml#Sheet1 resolve para um nome de part em vez de para 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 ciclo de segmentos é um percurso de pilha simples: 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 a começar por ../. A atribuição StrictDelimiter := True não é cosmética. Sem ela uma TStringList em Delphi trata espaços como delimitadores e respeita carateres de aspas, o que estropia qualquer nome de part que contenha um espaço, e nomes de part com espaços são legais
A percorrer o grafo: workbook, worksheet, drawing
O HotXLS percorre três camadas de parts de relação no caminho de TXLSXWorkbook.Open. A camada de pacote é tratada por XlsxFindOfficeDocumentPart, que lê _rels/.rels e devolve o alvo officeDocument. A camada de workbook lê a part de relação do workbook e constrói dois mapas de uma só vez: um mapa de identificadores para pesquisas r:id e um mapa de tipos para parts singleton. As camadas de worksheet e de drawing repetem o padrão com ParseWorksheetRelsXml e ParseDrawingRelsXml, cada uma passando o seu próprio nome de part como base de resolução para que um drawing que referencie ../media/image3.png aterre 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';
As folhas em particular têm de passar pelo mapa de identificadores, não pelo mapa de tipos. Os elementos <sheet> na part do workbook transportam atributos r:id, e esse identificador é a única coisa que liga um nome de folha a uma part. O HotXLS recolhe esses identificadores durante ParseWorkbookXml e resolve cada um contra o mapa de relações do workbook, recuando para o nome numerado convencional só quando o identificador está ausente ou não é resolú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 assenta no mesmo mecanismo. Strings partilhadas, estilos, tema, o projeto VBA sob o tipo com namespace da Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, links externos, a part de pessoa ao nível do workbook, comentários legados, comentários encadeados, o drawing VML que transporta a geometria dos balões de comentário, drawings, imagens, gráficos, tabelas e PivotTables chegam todos aos seus bytes através de alvos resolvidos. A part de tema em particular tem de ser localizada corretamente ou um ciclo de ida-e-volta sobrepõe silenciosamente uma paleta de marca do cliente com o tema Office de série, um dos modos de falha coberto nas notas sobre o ciclo de ida-e-volta sem perdas de XLSX para tema, extLst e calcChain. A leitura de relações é também a razão pela qual o carregamento é faseado da forma como é: todo o acesso ao arquivo acontece numa única thread antes de o XML da worksheet ser analisado, porque o estado de inflate de um arquivo ZIP não é thread-safe, uma restrição explicada no artigo sobre análise paralela de XLSX e o alocador de memória
Porque é que um rId duplicado quebra o encaminhamento baseado em tipo?
Porque uma entrada mal formada mais tardia pode sobrepor-se a uma anterior válida e sequestrar a pesquisa. Os identificadores de relação supõem-se únicos dentro de uma part de relação, mas pacotes mal formados reutilizam-nos, e uma atribuição ingénua Values[Id] := é a última-escrita-vence. Se um primeiro rId3 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, por isso, aplica uma regra de primeira-vence com duas condições: o alvo resolvido tem de ser não vazio, e o identificador não pode já estar presente. As duas condições em conjunto são o que torna isto seguro, porque o teste de não-vazio impede que uma relação com um Target em falta reclame o lugar antes de uma utilizável chegar
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));
Repare na assimetria deliberada nesse trecho. O mapa de identificadores é um verdadeiro mapa com uma proteção de primeira-vence, enquanto a coleção de tipos é uma lista apenas-de-acrescento de pares type=target. Essa distinção é estrutural: um workbook tem exatamente uma relação de strings partilhadas mas muitas relações de worksheet e de link externo, pelo que a pesquisa por tipo via Values[] devolve a primeira correspondência para singletons, e tipos multivalor como externalLink são enumerados percorrendo a lista
Onde é que o seguimento de relações para
Fronteiras honestas importam mais do que uma história limpa. O HotXLS recua para nomes convencionais sempre que uma relação está ausente, pelo que um pacote com uma part de relação danificada ou em falta ainda abre se por acaso seguir o layout do Excel; esse fallback é uma funcionalidade de compatibilidade, não uma segunda fonte de verdade, e pode mascarar um bug do produtor durante os testes. Vale a pena conhecer mais três limites. Alvos marcados com TargetMode="External" são guardados textualmente em vez de resolvidos, o que está correto para hiperligações e para a relação externalLinkPath que transporta um URL de workbook remoto, mas significa que o valor que se recebe de volta é o que o produtor escreveu. As parts de gráfico descobertas através de uma part de relação de drawing são emparelhadas com âncoras de drawing posicionalmente em vez de por identificador, pelo que uma ordenação de âncora invulgar pode desalinhar as ligações de gráficos. E o leitor direto em streaming em lxDirectRead.pas mantém o seu próprio tratamento de caminho mais leve indexado a xl/, pelo que o resolvedor completo aqui descrito 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 estiver a construir isto você próprio, o resumo correto mais curto é: nunca construa um nome de part, resolva sempre um. Leia _rels/.rels, siga officeDocument, resolva cada Target contra a part que o declarou, e encaminhe folhas por r:id. Se preferir já ter isso testado contra parts renomeadas, numeração de folhas não contígua, e identificadores de relação duplicados, o resolvedor aqui descrito vem incluído no componente de folha de cálculo Delphi HotXLS, juntamente com a maquinaria de ciclo de ida-e-volta que mantém intactas as parts que não analisa