Artigo Técnico

Regras OPC que fazem o Excel reparar um XLSX em Delphi

O Excel mostra «Encontrámos um problema com algum conteúdo» num XLSX que o LibreOffice e qualquer leitor caseiro abrem sem queixas, porque o Excel impõe duas coisas que esses leitores ignoram: atributos exigidos pelo esquema e as regras de unicidade das Open Packaging Conventions. O HotXLS, o componente nativo de folhas de cálculo Excel para Delphi e C++Builder, deparou-se exatamente com isso na v2.382.5 na primeira vez que a sua saída passou por uma instância COM real do Excel, e as três causas eram um <phoneticPr> sem fontId, um Override duplicado no [Content_Types].xml e duas relações de raiz a partilhar o rId4

Porque é que o Excel rejeita um pacote que todos os outros leitores aceitam?

Porque o aviso de reparação é um validador de esquema e de pacote, não uma falha do parser. O corpus do HotXLS andava há semanas a fazer round-trip de um modelo de crédito com 4805 fórmulas através da biblioteca, do LibreOffice e dos validadores XML da suite de testes. O ficheiro gravado estava estruturalmente são no sentido OPC usado no artigo sobre resolução de relações OPC em XLSX: todas as partes alcançáveis, todos os destinos resolvíveis. Depois apareceu uma máquina Windows com o Excel 16.0 build 20326, o runner do corpus abriu o modelo gravado através de Workbooks.Open numa instância COM isolada com o DisplayAlerts desligado, e a chamada falhou de imediato. Interativamente, o mesmo ficheiro produz o diálogo habitual a propor reparação, e o registo de reparação, quando o Excel se dá ao trabalho de escrever um, indica a parte mas não a regra. Estavam três defeitos independentes escondidos naquele único aviso, e o Excel não os reporta um a um; rejeita o livro e deixa-o a si encontrá-los e analisá-los. O que se segue é cada regra, a linha do HotXLS que a violava e a correção que saiu, porque cada uma delas é uma regra em que qualquer writer XLSX em Delphi pode tropeçar

Regra 1: o fontId do phoneticPr é obrigatório, mesmo quando é zero

O elemento <phoneticPr> transporta um atributo fontId declarado use="required" na ECMA-376 Parte 1 §18.4.3, e um valor de 0 é um índice de tipo de letra legal, não uma ausência. O antigo writer de folhas de cálculo do HotXLS tratava o zero como «não definido» e só emitia o atributo quando Sheet.PhoneticFontId > 0. É um reflexo natural em Delphi, já que os campos inteiros assumem zero por predefinição, mas produz <phoneticPr type="noConversion"/> para qualquer livro cujo tipo de letra fonético seja o primeiro em styles.xml, que é exatamente o que o modelo de crédito do corpus do HotXLS trazia. O Excel rejeita então, na volta, um valor que ele próprio tinha escrito

Porque é que o Excel exigia uma reparação na parte de folha do HotXLS: o elemento phoneticPr declara fontId com use required na ECMA-376 Parte 1, um índice de tipo de letra 0 é um valor legal, e o writer antigo que omitia o atributo quando o PhoneticFontId era zero produzia phoneticPr com type noConversion, enquanto o esquema dá predefinições a type e alignment e nenhuma a fontId
Omitir um atributo quando este é igual à predefinição só é seguro quando o esquema declara essa predefinição, e o modelo de crédito trazia o seu tipo de letra fonético como a primeira entrada de styles.xml
// lxHandleX.pas, writer de folha de cálculo — 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 continua a emitir o elemento apenas quando o TXLSXWorksheet.PhoneticType não está vazio, pelo que os livros que nunca tiveram definições fonéticas não são afetados. O teste de regressão PhoneticSettings_DefaultFontIsExplicit põe o PhoneticFontId a zero numa folha nova, grava e verifica que <phoneticPr fontId="0" está presente em xl/worksheets/sheet1.xml. A lição mais ampla é que «omitir quando é a predefinição» só é seguro quando o esquema declara uma predefinição; type e alignment têm predefinições nesse elemento, o fontId não tem

Regra 2: um único Override por nome de parte no [Content_Types].xml

O stream de tipos de conteúdo pode declarar cada nome de parte no máximo uma vez, e o Excel trata um segundo Override para o mesmo PartName como corrupção, mesmo quando as duas entradas trazem o mesmo ContentType. O HotXLS tem dois writers a alimentar esse stream. O BuildContentTypesXml declara todas as partes que o modelo de objetos gera: workbook, styles, shared strings, theme, folhas de cálculo e, quando TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Quando o PreserveUnsupportedParts está ligado, o TXLSXOpaquePackage acrescenta depois um Override para cada parte que capturou textualmente do pacote de origem, para que esses bytes continuem declarados à saída. A colisão é uma parte que vive dos dois lados. As propriedades de documento personalizadas são analisadas para o modelo, mas o docProps/custom.xml do pacote de origem também foi capturado de forma opaca, pelo que o stream resultante da junção o declarava duas vezes, e as partes de gráfico e de cache de tabela dinâmica podem cair no mesmo ponto quando o modelo regenera uma parte que a camada opaca também reteve. Antes da v2.382.5, o ContentTypeOverridesXml não tinha visão do que o modelo já tinha escrito, pelo que não o podia saber

Como dois writers do HotXLS colidiam no [Content_Types].xml: o BuildContentTypesXml declarava docProps/custom.xml a partir do modelo de objetos enquanto o TXLSXOpaquePackage acrescentava um Override para a mesma parte capturada textualmente, e desde a v2.382.5 a camada opaca analisa primeiro o stream gerado, normaliza os nomes com OpcLowerPartName e deixa o modelo ganhar todas as colisões
Cada writer era individualmente coerente, e a restrição de que cada nome de parte só pode aparecer uma vez existe apenas na costura onde as suas saídas se concatenam, e é por isso que a correção passa o stream do modelo
<!-- 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 do ContentTypeOverridesXml e deixa o writer opaco analisá-lo antes de emitir seja o que for. Dois pormenores sustentam a correção. O OpcLowerPartName passa a minúsculas, troca as barras invertidas por barras normais e retira as barras iniciais antes da comparação, porque os nomes de parte OPC são comparados sem distinguir maiúsculas e o modelo escreve-os com uma barra inicial enquanto a camada opaca guarda os nomes de item ZIP sem ela. E o chamador no 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 daí resulta é a de que ganha o primeiro, com o modelo à frente: o que o modelo de objetos declara é que manda, e a repetição opaca só preenche lacunas

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Analisar o stream gerado pelo modelo e recolher todos os PartName declarados.
    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 parte rels
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Regra 3: os Id de relação são únicos dentro de uma parte de relações

Cada Relationship numa parte .rels precisa de um Id único dentro dessa parte, e o Excel recusa o pacote quando duas partilham o mesmo. O HotXLS escreve o _rels/.rels ao 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 as propriedades personalizadas quando o modelo tem algumas. O pacote opaco acrescenta depois as relações de raiz que reteve da origem, renumerando qualquer identificador que já esteja numa lista UsedIds. A lista conhecia os rId1 a rId3. Não conhecia o rId4, nem sabia que o modelo estava prestes a emitir a sua própria relação de propriedades personalizadas, pelo que um pacote de origem cuja relação de propriedades personalizadas fosse também rId4, que é o que o Excel escreve por predefinição, saía com duas entradas rId4 a apontar para o mesmo destino. O chamador, BuildRootRelsXml, passa agora Workbook.FCustomProps.Count > 0 como segundo argumento, de modo que a reserva e o salto são guiados pela mesma condição que decide se o modelo emite sequer o rId4. Renumerar é seguro na raiz do pacote porque nada dentro do livro referencia os identificadores de relação da raiz pelo nome; o mesmo truque estaria errado um nível abaixo, onde os atributos r:id no workbook.xml se ligam a identificadores na parte de relações do livro, e é por isso que o MergeWorkbookRelationshipsXml mantém um mapa de identificadores separado

A colisão de identificadores de relação na raiz de um pacote HotXLS: o modelo escreve rId1 a rId4 com rId4 reservado para as propriedades personalizadas, a camada opaca repetia uma relação de origem que também chegava como rId4 porque a UsedIds só conhecia rId1 a rId3, e a correção reserva o rId4 desde logo sempre que EmitCustomProps se verifica e renumera o resto
Renumerar é seguro na raiz do pacote porque nada dentro do livro referencia os identificadores da raiz pelo nome, e o mesmo truque um nível abaixo quebraria todas as ligações r:id no 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 é agora dono das propriedades personalizadas; não repetir a cópia de 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);      // rIdN livre mais baixo
  UsedIds.Add(String(Id));
  ...
end;

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

As três são sintomas de um writer com duas fontes e sem um único dono dos invariantes do pacote. O modelo de objetos gera as partes que compreende; a camada opaca repete as que não compreende, para que um round-trip mantenha gráficos, caches de tabela dinâmica, XML personalizado e tudo o resto descrito nas notas sobre round-trip sem perdas de theme, extLst e calcChain. Cada lado era individualmente coerente. As restrições que a OPC impõe ao pacote inteiro, nomes de parte de Override únicos e identificadores de relação únicos por parte, só existem na costura onde os dois se concatenam, e até à 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 com que o HotXLS ficou é uma precedência fixa e não uma heurística de junção. O modelo escreve primeiro, a camada opaca vê o que foi escrito e cede perante qualquer colisão, e o runner do corpus passa agora a impor os invariantes do exterior com verify_opc_uniqueness, que lê o [Content_Types].xml e todos os itens .rels de um pacote gravado e falha o caso perante qualquer PartName, Extension ou Id duplicado. Essa verificação é barata, não precisa do Excel e teria apanhado dois dos três defeitos logo na primeira corrida do corpus

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

A passagem pelo Excel também assinalou o _xlnm.Print_Area do modelo de crédito, que o Excel reportava como $A$1:$J$29 no original e tinha de reportar de forma idêntica na cópia gravada. Atrás dessa única asserção estavam dois bugs separados. Na importação, o XlsxStripSheetPrefix cortava tudo até ao primeiro ! não entre aspas, pelo que 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 apenas no primeiro segmento. Na exportação, o writer acrescentava o nome da folha uma vez ao PrintArea guardado por inteiro, pelo que uma união simples $A$1:$B$2,$D$1:$E$2 deixava a biblioteca com o primeiro segmento qualificado e o segundo nu, o que o Excel não aceita como definição de _xlnm.Print_Area segundo a ECMA-376 Parte 1 §18.2.5

// Importação: só retirar 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;                                  // O OFFSET(...) é devolvido intacto

// Exportação: qualificar todos os segmentos separados por vírgulas, 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: emitir textualmente
  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 emparelhamento é a mesma dos dois lados: uma área de impressão só é um intervalo nu se todos os segmentos o forem, caso contrário é uma fórmula e viaja textualmente. O PrintArea_FormulaDefinitionSurvivesRoundTrip cobre a base com nome, a base qualificada pela folha e a união ao longo de dois ciclos de gravar e reabrir. A forma como as áreas de impressão interagem com a configuração de página e o resto do modelo de impressão é tratada no artigo sobre proteção de folhas, configuração de página e impressão

Como é que se descobre a que regra o Excel está a objetar?

Comece por assumir que o seu próprio validador está errado, porque passou. O validador do Open XML SDK indica uma violação de esquema como o fontId em falta, com a parte e o XPath, e a camada de empacotamento por baixo recusa-se a abrir um pacote com entradas de tipo de conteúdo duplicadas, pelo que vale a pena corrê-lo antes de qualquer outra coisa. Quando ele fica calado e o Excel continua a reparar, bisseccione o pacote: descomprima, apague uma parte e a sua relação e o seu Override, volte a comprimir e reabra, reduzindo o conjunto de candidatos a metade de cada vez até o aviso desaparecer. Os três defeitos aqui caíram por essa ordem, e nenhum deles seria visível no ficheiro reparado que o Excel se oferece para gravar, já que a reparação descarta ou renumera silenciosamente as entradas problemáticas. Vale a pena enunciar os limites da correção da v2.382.5 com a mesma clareza. A desduplicação é a de ganhar o primeiro, com o modelo à frente, pelo que se o pacote de origem declarava um tipo de conteúdo diferente para uma parte que o modelo também gera, a declaração do modelo ganha e a da origem é descartada, o que é correto para as partes que o HotXLS regenera e não é uma junção geral. O verify_opc_uniqueness verifica apenas a unicidade; não valida esquemas, pelo que um futuro atributo obrigatório continuaria a precisar do Excel ou de um validador de esquema para vir ao de cima. E a passagem extra do TXMLReader sobre o stream de tipos de conteúdo gerado corre em todas as gravações com o PreserveUnsupportedParts ligado, um custo pequeno face a um stream que raramente passa de uns quantos kilobytes. Com isto no lugar, as compilações Win32 e Win64 do modelo de crédito abrem agora no Excel sem aviso, recalculam as 4805 fórmulas verificadas com zero divergências e reportam a mesma área de impressão que o original

Se escreve XLSX a partir de Delphi por sua conta, a lista de verificação é curta: emita todos os atributos que o esquema marca como obrigatórios, independentemente do valor, declare cada nome de parte uma só vez e mantenha uma lista única de identificadores usados por parte de relações em todos os writers que lhe toquem. Se preferir que essa lista já exista e seja testada contra o Excel e não apenas contra o seu próprio leitor, o writer de pacotes aqui descrito vem no componente de folhas de cálculo Delphi do HotXLS, juntamente com o round-trip de partes opacas que é o que torna a costura digna de ser guardada