Artigo Técnico

Excel repara um XLSX válido? Regras OPC do pacote no Delphi

O Excel mostra o aviso de problema com o conteúdo num XLSX que o LibreOffice e qualquer leitor caseiro abrem sem reclamar porque o Excel exige duas coisas que esses leitores ignoram: atributos obrigatórios de esquema e as regras de unicidade das Open Packaging Conventions. O HotXLS, componente de planilha Excel nativo para Delphi e C++Builder, tropeçou exatamente nisso na v2.382.5 na primeira vez que sua saída passou por uma instância COM de verdade do Excel, e as três causas eram um <phoneticPr> sem fontId, um Override duplicado em [Content_Types].xml e dois relacionamentos de raiz compartilhando rId4

Por que o Excel rejeita um pacote que todo outro leitor aceita?

Porque o aviso de reparo é um validador de esquema e de pacote, não uma falha do parser. O corpus do HotXLS vinha fazendo round-trip de um modelo de empréstimo de 4805 fórmulas pela biblioteca, pelo LibreOffice e pelos validadores XML da suíte de testes durante semanas. O arquivo salvo era estruturalmente saudável no sentido OPC usado no artigo sobre resolução de relacionamentos OPC em XLSX: toda part alcançável, todo alvo resolvível. Então uma máquina Windows com Excel 16.0 build 20326 ficou disponível, o runner de corpus abriu o modelo salvo por Workbooks.Open numa instância COM isolada com DisplayAlerts desligado, e a chamada falhou de vez. Interativamente o mesmo arquivo produz o diálogo conhecido oferecendo reparo, e o log de reparo, quando o Excel se dá ao trabalho de escrevê-lo, nomeia a part mas não a regra. Três defeitos independentes se escondiam naquele único aviso, e o Excel não os reporta um de cada vez; ele rejeita a pasta de trabalho e deixa para você encontrá-los e analisá-los. O que vem a seguir é cada regra, a linha do HotXLS que a violava e a correção que saiu, porque todas elas são regras em que qualquer writer de XLSX em Delphi pode tropeçar

Regra 1: phoneticPr exige fontId, mesmo quando é zero

O elemento <phoneticPr> carrega um atributo fontId declarado use="required" na ECMA-376 Part 1 §18.4.3, e um valor 0 é um índice de fonte legal, não uma ausência. O antigo writer de worksheet do HotXLS tratava zero como não definido e só emitia o atributo quando Sheet.PhoneticFontId > 0. É um reflexo natural de Delphi, já que campos inteiros começam em zero, mas produz <phoneticPr type="noConversion"/> para qualquer pasta de trabalho cuja fonte fonética por acaso seja a primeira fonte de styles.xml, que é exatamente o que o modelo de empréstimo do corpus do HotXLS trazia. O Excel então rejeita, na volta, um valor que ele mesmo tinha escrito

Por que o Excel exigiu reparo na part de worksheet do HotXLS: o elemento phoneticPr declara fontId com use required na ECMA-376 Part 1, um índice de fonte 0 é valor legal, e o writer antigo que omitia o atributo quando PhoneticFontId era zero produzia phoneticPr type noConversion, enquanto o esquema dá defaults para type e alignment e nenhum para fontId
Omitir um atributo quando ele é igual ao default só é seguro quando o esquema declara esse default, e o modelo de empréstimo trazia sua fonte fonética como a primeiríssima entrada de styles.xml
// lxHandleX.pas, writer de worksheet — antes da v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — o atributo é obrigatório, zero incluído
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

O HotXLS ainda só emite o elemento quando TXLSXWorksheet.PhoneticType não está vazio, então pastas de trabalho que nunca carregaram configurações fonéticas não são afetadas. O teste de regressão PhoneticSettings_DefaultFontIsExplicit define PhoneticFontId como zero numa planilha nova, salva e afirma que <phoneticPr fontId="0" está presente em xl/worksheets/sheet1.xml. A lição mais ampla é que omitir quando é o default só é seguro quando o esquema declara um default; type e alignment têm defaults nesse elemento, fontId não tem

Regra 2: um Override por nome de part em [Content_Types].xml

O stream de content types pode declarar cada nome de part no máximo uma vez, e o Excel trata um segundo Override para o mesmo PartName como corrupção mesmo quando as duas entradas carregam o mesmo ContentType. O HotXLS tem dois writers alimentando esse stream. BuildContentTypesXml declara toda part que o modelo de objetos gera: workbook, styles, shared strings, theme, worksheets e, quando TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Com PreserveUnsupportedParts ligado, o TXLSXOpaquePackage então anexa um Override para cada part que ele capturou verbatim do pacote de origem, para que esses bytes continuem declarados na saída. A colisão é uma part que mora dos dois lados. As propriedades personalizadas do documento são analisadas para dentro do modelo, mas o docProps/custom.xml do pacote de origem também foi capturado de forma opaca, então o stream mesclado o declarava duas vezes, e parts de gráfico e de cache de pivô podem cair no mesmo ponto quando o modelo regenera uma part que a camada opaca também reteve. Antes da v2.382.5, ContentTypeOverridesXml não tinha visão do que o modelo já tinha escrito, então não tinha como saber

Como dois writers do HotXLS colidiram em [Content_Types].xml: BuildContentTypesXml declarava docProps/custom.xml a partir do modelo de objetos, enquanto TXLSXOpaquePackage anexava um Override para a mesma part capturada verbatim, e desde a v2.382.5 a camada opaca analisa primeiro o stream gerado, normaliza nomes com OpcLowerPartName e deixa o modelo vencer toda colisão
Cada writer era consistente por si só, e a restrição de que cada nome de part pode aparecer uma vez só existe na costura onde as saídas deles são concatenadas, e é por isso que a correção passa o stream do modelo como entrada
<!-- O que o Excel via antes da v2.382.5 -->
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>
...
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>

A correção passa o XML gerado para dentro de ContentTypeOverridesXml e deixa o writer opaco analisá-lo antes de emitir qualquer coisa. Dois detalhes sustentam a correção. OpcLowerPartName passa tudo para minúsculas, troca barras invertidas por barras normais e tira barras iniciais antes de comparar, porque nomes de part OPC são comparados sem diferenciar maiúsculas de minúsculas e o modelo os escreve com barra inicial enquanto a camada opaca guarda nomes de item do ZIP sem ela. E o chamador em BuildContentTypesXml passa Result + '</Types>', fechando o documento parcialmente construído para que o TXMLReader veja entrada bem formada em vez de um stream truncado. A regra que emerge é a do primeiro que vence, com o modelo na frente: o que o modelo de objetos declara é autoritativo, e o replay opaco só preenche lacunas

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Analisa o stream gerado pelo modelo e coleta todo PartName declarado.
    while Reader.Read do
      if (Reader.NodeType= xmlntElement)and (Reader.Name= 'Override') then
      begin
        Index:= Reader.AttributeIndex('PartName');
        if Index>= 0 then
          UsedNames.Add(String(OpcLowerPartName(Reader.Attribute[Index].Value)));
      end;
  for i:= 0 to FParts.Count- 1 do
  begin
    Part:= TXLSXOpaquePart(FParts[i]);
    if (Part.ContentType= '')or (LowerCase(ExtractFileExt(String(Part.PartName)))= '.rels')or
      (UsedNames.IndexOf(String(OpcLowerPartName(Part.PartName)))>= 0) then
      Continue;                       // já declarado, ou uma part rels
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Regra 3: ids de relacionamento são únicos dentro de uma part de relacionamentos

Todo Relationship numa part .rels precisa de um Id que seja único dentro dessa part, e o Excel recusa o pacote quando dois compartilham um. O HotXLS escreve o _rels/.rels no nível do pacote com identificadores fixos: rId1 para o workbook, rId2 e rId3 para as propriedades de documento principais e estendidas, e rId4 para propriedades personalizadas quando o modelo tem alguma. O pacote opaco então anexa os relacionamentos de raiz que reteve da origem, renumerando qualquer identificador já presente numa lista UsedIds. A lista conhecia rId1 até rId3. Não conhecia rId4, e não sabia que o modelo estava prestes a emitir o próprio relacionamento de propriedades personalizadas, então um pacote de origem cujo relacionamento de propriedades personalizadas também era rId4, que é o que o Excel escreve por padrão, saía com duas entradas rId4 apontando para o mesmo alvo. O chamador, BuildRootRelsXml, agora passa Workbook.FCustomProps.Count > 0 como segundo argumento, então a reserva e o descarte são guiados pela mesma condição que decide se o modelo emite rId4. Renumerar é seguro na raiz do pacote porque nada dentro do workbook referencia identificadores de relacionamento de raiz por nome; o mesmo truque estaria errado um nível abaixo, onde atributos r:id em workbook.xml se ligam a identificadores da part de relacionamentos do workbook, e é por isso que MergeWorkbookRelationshipsXml mantém um mapa de identificadores separado

A colisão de identificadores de relacionamento na raiz de um pacote HotXLS: o modelo escreve rId1 até rId4 com rId4 reservado para propriedades personalizadas, a camada opaca reproduziu um relacionamento de origem que também chegou como rId4 porque UsedIds só conhecia rId1 até rId3, e a correção reserva rId4 de antemão sempre que EmitCustomProps vale e renumera o resto
Renumerar é seguro na raiz do pacote porque nada dentro do workbook referencia identificadores de raiz por nome, e o mesmo truque um nível abaixo quebraria toda ligação r:id em workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // reservado pelo writer do modelo
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // O modelo é dono das propriedades personalizadas agora; não reproduza a cópia da origem.
  if EmitCustomProps and OpcEndsWith(LowerCase(Rel.RelType), '/custom-properties') then
    Continue;
  Id:= Rel.Id;
  if (Id= '')or (UsedIds.IndexOf(String(Id))>= 0) then
    Id:= AllocateRelationshipId(UsedIds);      // menor rIdN livre
  UsedIds.Add(String(Id));
  ...
end;

O que as três falhas têm em comum?

As três são sintomas de um writer com duas fontes e nenhum dono único das invariantes do pacote. O modelo de objetos gera as parts que entende; a camada opaca reproduz as que não entende, para que um round trip preserve gráficos, caches de pivô, XML personalizado e tudo o mais descrito nas notas sobre round-trip sem perdas de theme, extLst e calcChain. Cada lado era consistente por conta própria. As restrições que o OPC impõe ao pacote inteiro — nomes de Override únicos e identificadores de relacionamento únicos por part — só existem na costura onde os dois são concatenados, e até a v2.382.5 ninguém verificava a costura. O bug do fontId tem a mesma forma um nível abaixo: o writer sabia o que queria omitir, mas nunca consultou o esquema que diz que não pode. A correção que o HotXLS adotou é precedência fixa, e não heurística de merge. O modelo escreve primeiro, a camada opaca vê o que foi escrito e cede em qualquer colisão, e o runner de corpus agora impõe as invariantes de fora com verify_opc_uniqueness, que lê [Content_Types].xml e todo item .rels de um pacote salvo e reprova o caso em qualquer PartName, Extension ou Id duplicado. Essa checagem é barata, não precisa do Excel e teria pegado dois dos três defeitos já na primeira rodada do corpus

No mesmo lote: áreas de impressão que são fórmulas, não intervalos

A passagem pelo Excel também sinalizou o _xlnm.Print_Area do modelo de empréstimo, que o Excel reportava como $A$1:$J$29 no original e tinha que reportar igual na cópia salva. Dois bugs separados estavam atrás dessa única asserção. Na importação, XlsxStripSheetPrefix cortava tudo até o primeiro ! fora de aspas, então uma área de impressão dinâmica como OFFSET('Print Data'!$A$1,0,0,2,2) voltava como $A$1,0,0,2,2), e uma união qualificada como 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 perdia o prefixo só no primeiro segmento. Na exportação, o writer prefixava o nome da planilha uma vez ao PrintArea guardado inteiro, então uma união simples $A$1:$B$2,$D$1:$E$2 saía da biblioteca com o primeiro segmento qualificado e o segundo cru, o que o Excel não aceita como definição de _xlnm.Print_Area segundo a ECMA-376 Part 1 §18.2.5

// Importação: só tira o prefixo quando o que resta é um sqref simples
function XlsxPrintAreaFromDefinition(const Formula: WideString): WideString;
begin
  Result:= Formula;
  if not XlsxReadFormulaSheetPrefixAt(Formula, 1, Prefix, SheetPart, Start) then
    Exit;
  Area:= Copy(Formula, Start, Length(Formula));
  if XlsxParseSqrefPart(Area, R1, C1, R2, C2) then
    Result:= Area;                    // 'Sheet'!$A$1:$J$29 -> $A$1:$J$29
end;                                  // OFFSET(...) volta sem alteração

// Exportação: qualifica todo segmento separado por vírgula, ou nenhum
function XlsxPrintAreaDefinition(const SheetName, Area: WideString): WideString;
begin
  Result:= Area;
  ... split Area on ',' with StrictDelimiter ...
  for I:= 0 to Parts.Count- 1 do
    if not XlsxParseSqrefPart(WideString(Trim(Parts[I])), R1, C1, R2, C2) then
      Exit;                           // uma fórmula: emite verbatim
  Result:= '';
  for I:= 0 to Parts.Count- 1 do
  begin
    if I> 0 then Result:= Result+ ',';
    Result:= Result+ XlsxQuoteSheetName(SheetName)+ '!'+ WideString(Trim(Parts[I]));
  end;
end;

A regra de pareamento é a mesma dos dois lados: uma área de impressão é um intervalo cru só se todo segmento for analisado como tal, senão é uma fórmula e viaja verbatim. PrintArea_FormulaDefinitionSurvivesRoundTrip cobre a base nomeada, a base qualificada por planilha e a união em dois ciclos de salvar e reabrir. Como áreas de impressão interagem com a configuração de página e o resto do modelo de impressão está no artigo sobre proteção de planilha, configuração de página e impressão

Como descobrir a qual regra o Excel está se opondo?

Comece partindo do princípio de que o seu próprio validador está errado, porque ele passou. O validador do Open XML SDK nomeia uma violação de esquema como o fontId ausente junto com a part e o XPath, e a camada de empacotamento por baixo dele se recusa a abrir um pacote com entradas de content type duplicadas, então rode-o antes de qualquer outra coisa. Quando ele fica em silêncio e o Excel ainda repara, faça bissecção no pacote: descompacte, apague uma part e o relacionamento dela e o Override dela, recompacte e reabra, dividindo o conjunto de candidatos pela metade a cada vez até o aviso desaparecer. Os três defeitos daqui apareceram nessa ordem, e nenhum deles seria visível no arquivo reparado que o Excel oferece para salvar, já que o reparo descarta ou renumera as entradas problemáticas em silêncio. Os limites da correção da v2.382.5 merecem ser ditos com a mesma clareza. A desduplicação é do primeiro que vence, com o modelo na frente, então se o pacote de origem declarava um content type diferente para uma part que o modelo também gera, a declaração do modelo vence e a da origem é descartada, o que é correto para as parts que o HotXLS regenera e não é um merge geral. O verify_opc_uniqueness confere unicidade apenas; ele não valida esquemas, então um atributo obrigatório futuro ainda precisaria do Excel ou de um validador de esquema para aparecer. E a passagem extra do TXMLReader sobre o stream de content types gerado roda em todo save com PreserveUnsupportedParts habilitado, um custo pequeno diante de um stream que raramente passa de alguns kilobytes. Com tudo isso no lugar, as compilações Win32 e Win64 do modelo de empréstimo agora abrem no Excel sem aviso, recalculam todas as 4805 fórmulas verificadas sem nenhuma divergência e reportam a mesma área de impressão do original

Se você escreve XLSX por conta própria em Delphi, a lista de verificação é curta: emita todo atributo que o esquema marca como obrigatório, independentemente do valor, declare cada nome de part uma vez e mantenha uma única lista de identificadores usados por part de relacionamentos em todos os writers que a tocam. Se você preferir que essa lista já exista e seja testada contra o Excel, e não só contra o seu próprio leitor, o writer de pacote descrito aqui vem no componente de planilha Delphi HotXLS, junto com o round-trip de parts opacas que fez a costura valer a pena proteger desde o começo