Artículo técnico

Reparación de Excel y reglas OPC de paquete en HotXLS

Excel muestra "We found a problem with some content" en un XLSX que LibreOffice y cualquier lector casero abren sin queja, porque Excel exige dos cosas que esos lectores ignoran: los atributos que el esquema marca obligatorios y las reglas de unicidad de las Open Packaging Conventions. Tres de esas reglas son fáciles de romper en cualquier escritor que combine partes generadas con partes preservadas: un <phoneticPr> necesita su fontId, cada nombre de parte tiene exactamente un Override en [Content_Types].xml, y dos relaciones raíz jamás deben compartir un identificador como rId4. Este artículo recorre cada una y cómo escribe HotXLS, el componente nativo de hojas de cálculo para Delphi y C++Builder, el paquete para satisfacerla

¿Por qué Excel rechaza un paquete que todos los demás lectores aceptan?

Porque el aviso de reparación es un validador de esquema y de paquete, no un fallo de parseo. Un libro puede hacer round-trip a través de tu propia librería, de LibreOffice y de validadores XML, y ser estructuralmente sólido en el sentido OPC que usa el artículo sobre la resolución de relaciones OPC de XLSX: toda parte alcanzable, todo destino resoluble. Ábrelo con Workbooks.Open en una instancia COM aislada con DisplayAlerts desactivado y la llamada puede seguir fallando de plano. Interactivamente el mismo archivo produce el conocido diálogo que ofrece reparar, y el registro de reparación, cuando Excel se molesta en escribirlo, nombra la parte pero no la regla. Varias violaciones independientes pueden esconderse detrás de ese único aviso, y Excel no las reporta de una en una; rechaza el libro y te deja encontrarlas y analizarlas. Lo que sigue es cada regla, la forma natural de violarla y cómo escribe HotXLS el paquete en su lugar, porque cada una de ellas es una regla con la que cualquier escritor XLSX en Delphi puede tropezar

Regla 1: el fontId de phoneticPr es obligatorio, incluso cuando es cero

El elemento <phoneticPr> lleva un atributo fontId declarado use="required" en ECMA-376 Part 1 §18.4.3, y un valor de 0 es un índice de fuente legal, no una ausencia. Un escritor que trata el cero como "sin establecer" y solo emite el atributo cuando Sheet.PhoneticFontId > 0 sigue un reflejo natural en Delphi, ya que los campos enteros valen cero por defecto, pero produce <phoneticPr type="noConversion"/> para cualquier libro cuya fuente fonética resulte ser la primera fuente de styles.xml, algo fácil de encontrar porque el índice 0 es simplemente la primera fuente. Excel rechaza entonces, a la vuelta, un valor que él mismo escribió

Por qué Excel exige reparar una parte de hoja de cálculo: el elemento phoneticPr declara fontId con use required en ECMA-376 Part 1, un índice de fuente 0 es un valor legal, y un escritor que omite el atributo cuando PhoneticFontId es cero produce phoneticPr type noConversion, mientras que el esquema da valores por defecto a type y alignment y a fontId no
Omitir un atributo cuando vale el valor por defecto solo es seguro si el esquema declara ese defecto, y una plantilla que lleva su fuente fonética como la primerísima entrada de styles.xml da justo en ese caso
// El reflejo natural: cero tratado como sin establecer, fontId descartado
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// lxHandleX.pas, escritor de hojas — el atributo es obligatorio, cero incluido
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLS emite el elemento solo cuando TXLSXWorksheet.PhoneticType no está vacío, así que los libros que nunca llevaron ajustes fonéticos no se ven afectados. Pon PhoneticFontId a cero en una hoja nueva, guarda, y xl/worksheets/sheet1.xml lleva <phoneticPr fontId="0". La lección más amplia es que "omitir cuando es el defecto" solo es seguro si el esquema declara un defecto; type y alignment tienen defecto en ese elemento, fontId no

Regla 2: un Override por nombre de parte en [Content_Types].xml

El stream de content types puede declarar cada nombre de parte como mucho una vez, y Excel trata un segundo Override para el mismo PartName como corrupción aun cuando ambas entradas lleven el mismo ContentType. HotXLS tiene dos escritores alimentando ese stream. BuildContentTypesXml declara cada parte que genera el modelo de objetos: workbook, styles, shared strings, theme, hojas de cálculo y, cuando TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Cuando PreserveUnsupportedParts está activo, TXLSXOpaquePackage añade después un Override por cada parte que capturó verbatim del paquete origen, para que esos bytes sigan declarados a la salida. La colisión es una parte que vive en ambos lados. Las propiedades personalizadas del documento se parsean al modelo, pero el docProps/custom.xml del paquete origen también se captura de forma opaca, así que un merge ingenuo lo declara dos veces, y las partes de gráficos y de caché de tablas dinámicas pueden caer en el mismo sitio cuando el modelo regenera una parte que la capa opaca también retuvo. Un escritor opaco sin visión de lo que el modelo ya ha escrito no puede saberlo, y de ahí que ContentTypeOverridesXml reciba esa visión

Cómo pueden colisionar dos escritores en [Content_Types].xml: BuildContentTypesXml declara docProps/custom.xml desde el modelo de objetos mientras TXLSXOpaquePackage añadiría un Override para la misma parte capturada verbatim, así que la capa opaca de HotXLS parsea primero el stream generado, normaliza nombres con OpcLowerPartName y deja que el modelo gane cada colisión
Cada escritor es consistente por separado, y la restricción de que cada nombre de parte aparezca una sola vez solo existe en la costura donde sus salidas se concatenan, y de ahí que HotXLS pase el stream del modelo por dentro
<!-- Lo que produce un merge ingenuo -->
<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"/>

HotXLS pasa el XML generado a ContentTypeOverridesXml y deja que el escritor opaco lo parsee antes de emitir nada. Dos detalles cargan con la corrección. OpcLowerPartName pasa a minúsculas, voltea barras invertidas a barras normales y elimina las barras iniciales antes de comparar, porque los nombres de parte OPC se comparan sin distinción de mayúsculas y el modelo los escribe con barra inicial mientras que la capa opaca almacena los nombres de ítem ZIP sin ella. Y quien llama en BuildContentTypesXml pasa Result + '</Types>', cerrando el documento a medio construir para que TXMLReader vea entrada bien formada en lugar de un stream truncado. La regla que emerge es gana-el-primero con el modelo delante: lo que declare el modelo de objetos es autoridad, y el replay opaco solo rellena huecos

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Parsea el stream generado por el modelo y colecta cada 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;                       // ya declarada, o una parte rels
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Regla 3: los Id de relación son únicos dentro de una parte de relaciones

Cada Relationship en una parte .rels necesita un Id único dentro de esa parte, y Excel rechaza el paquete cuando dos comparten uno. HotXLS escribe el _rels/.rels a nivel de paquete con identificadores fijos: rId1 para el workbook, rId2 y rId3 para las propiedades de documento core y extendidas, y rId4 para las propiedades personalizadas cuando el modelo tiene alguna. El paquete opaco añade entonces las relaciones raíz que retuvo del origen, renumerando cualquier identificador que ya esté en una lista UsedIds. Si esa lista solo conoce rId1 a rId3, y no sabe que el modelo está a punto de emitir su propia relación de propiedades personalizadas, un paquete origen cuya relación de propiedades personalizadas es también rId4, que es lo que Excel escribe por defecto, sale con dos entradas rId4 apuntando al mismo destino. RootRelationshipsXml reserva por tanto rId4 y se salta la copia del origen siempre que el modelo emite propiedades personalizadas. Quien llama, BuildRootRelsXml, pasa Workbook.FCustomProps.Count > 0 como segundo argumento, de modo que la reserva y el salto quedan gobernados por la misma condición que decide si el modelo emite rId4 en absoluto. Renumerar es seguro en la raíz del paquete porque nada dentro del workbook referencia identificadores de relación raíz por nombre; el mismo truco estaría mal un nivel más abajo, donde los atributos r:id de workbook.xml se atan a identificadores de la parte de relaciones del workbook, y de ahí que MergeWorkbookRelationshipsXml lleve un mapa de identificadores separado

La colisión de identificadores de relación en la raíz de un paquete de HotXLS: el modelo escribe rId1 a rId4 con rId4 reservado para propiedades personalizadas, la capa opaca reprodujo una relación del origen que también llegó como rId4 porque UsedIds solo conocía rId1 a rId3, y el arreglo reserva rId4 por adelantado siempre que EmitCustomProps se cumpla y renumera el resto
Renumerar es seguro en la raíz del paquete porque nada dentro del workbook referencia identificadores raíz por nombre, y el mismo truco un nivel más abajo rompería cada binding r:id de workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // reservado por el escritor del 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]);
  // El modelo es dueño de las propiedades personalizadas; no reproduzcas la copia del origen.
  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);      // el rIdN libre más bajo
  UsedIds.Add(String(Id));
  ...
end;

¿Qué tienen en común las tres reglas?

Las tres custodian frente a un escritor con dos fuentes y ningún dueño único de los invariantes del paquete. El modelo de objetos genera las partes que entiende; la capa opaca reproduce las que no, para que un round-trip conserve gráficos, cachés de tablas dinámicas, XML personalizado y todo lo demás que describen las notas sobre el round-trip sin pérdidas de theme, extLst y calcChain. Cada lado puede ser consistente por separado. Las restricciones que OPC impone al paquete entero, nombres de parte Override únicos e identificadores de relación únicos por parte, solo existen en la costura donde los dos se concatenan, así que algo tiene que ser dueño de la costura. La regla del fontId es la misma forma un nivel más abajo: un escritor puede saber lo que quiere omitir y nunca consultar el esquema que dice que no puede. HotXLS lo resuelve con una precedencia fija en lugar de una heurística de merge. El modelo escribe primero, y la capa opaca ve lo escrito y cede en cualquier colisión. Los invariantes además se comprueban baratos desde fuera: lee [Content_Types].xml y cada ítem .rels de un paquete guardado y falla ante cualquier PartName, Extension o Id duplicado. Esa comprobación no necesita Excel y cubre las dos reglas de unicidad; la regla del fontId sigue necesitando un validador de esquema

Áreas de impresión que son fórmulas, no rangos

El área de impresión es la otra cosa que Excel compara en una copia guardada: una plantilla cuyo _xlnm.Print_Area Excel reporta como $A$1:$J$29 tiene que reportarlo idéntico tras un round-trip. Dos trampas se sientan detrás de eso. En la importación, un recorte de prefijo que corta todo hasta el primer ! sin comillas convierte un área de impresión dinámica como OFFSET('Print Data'!$A$1,0,0,2,2) en $A$1,0,0,2,2), y hace que una unión calificada como 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 pierda el prefijo solo en su primer segmento. En la exportación, anteponer el nombre de hoja una vez al PrintArea almacenado entero le da a una unión simple $A$1:$B$2,$D$1:$E$2 un primer segmento calificado y un segundo desnudo, que Excel no acepta como definición de _xlnm.Print_Area bajo ECMA-376 Part 1 §18.2.5

// Importación: quita el prefijo solo si lo que queda es un sqref simple
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(...) se devuelve intacto

// Exportación: califica cada segmento separado por comas, o ninguno
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;                           // una fórmula: emitir tal cual
  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;

La regla de emparejamiento es la misma en ambos lados: un área de impresión es un rango desnudo solo si cada segmento parsea como tal; si no, es una fórmula y viaja verbatim. Con esa regla, la base con nombre, la base calificada con hoja y la unión sobreviven ciclos repetidos de guardar y reabrir. Cómo interactúan las áreas de impresión con la configuración de página y el resto del modelo de impresión está cubierto en el artículo sobre protección de hojas, configuración de página e impresión

¿Cómo averiguas a qué regla le objeta Excel?

Parte de la suposición de que tu propio validador está equivocado, porque aprobó. El validador del Open XML SDK nombrará una violación de esquema como el fontId ausente con la parte y el XPath, y la capa de empaquetado de debajo se niega a abrir siquiera un paquete con entradas de content-type duplicadas, así que ejecútalo antes que nada. Cuando calla y Excel sigue reparando, haz una bisección del paquete: descomprime, borra una parte con su relación y su Override, recomprime y reabre, reduciendo a la mitad el conjunto de candidatos cada vez hasta que el aviso desaparezca. Las tres violaciones de aquí salen en ese orden, y ninguna sería visible en el archivo reparado que Excel ofrece guardar, porque la reparación descarta o renumera en silencio las entradas ofensoras. Dos decisiones de diseño del escritor de HotXLS conviene conocer. La deduplicación es gana-el-primero con el modelo delante, así que si el paquete origen declaraba un content type distinto para una parte que el modelo también genera, gana la declaración del modelo y la del origen se descarta, lo que es correcto para las partes que HotXLS regenera y no es un merge general. Y la pasada extra de TXMLReader sobre el stream de content types generado corre en cada guardado con PreserveUnsupportedParts activo, un coste pequeño contra un stream que rara vez pasa de unos pocos kilobytes. Con estas reglas en su sitio, una plantilla de préstamos de 4805 fórmulas guardada por las builds Win32 y Win64 abre en Excel sin aviso, recalcula con cero discrepancias, y reporta la misma área de impresión que el original

Si escribes XLSX desde Delphi por tu cuenta, la lista de comprobación es corta: emite cada atributo que el esquema marque obligatorio sin importar su valor, declara cada nombre de parte una vez, y lleva una única lista de identificadores usados por parte de relaciones entre todos los escritores que la toquen. Si prefieres que esa lista ya exista y esté probada contra Excel y no solo contra tu propio lector, el escritor de paquetes descrito aquí viaja en el componente de hojas de cálculo Delphi de HotXLS, junto con el round-trip de partes opacas que hizo que la costura mereciera guardarse en primer lugar