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. HotXLS, el componente nativo de hojas de cálculo para Delphi y C++Builder, dio exactamente con eso en v2.382.5 la primera vez que su salida pasó por una instancia real de Excel COM, y las tres causas fueron un <phoneticPr> sin fontId, un Override duplicado en [Content_Types].xml, y dos relaciones raíz compartiendo rId4
¿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. El corpus de HotXLS llevaba semanas haciendo round-trip de una plantilla de préstamos de 4805 fórmulas a través de la librería, de LibreOffice y de los validadores XML de la suite de tests. El archivo guardado era 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. Entonces se pudo usar una máquina Windows con Excel 16.0 build 20326, el runner del corpus abrió la plantilla guardada mediante Workbooks.Open en una instancia COM aislada con DisplayAlerts desactivado, y la llamada falló 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. Tres defectos independientes se escondían en ese único aviso, y Excel no los reporta de uno en uno; rechaza el libro y te deja encontrarlos y analizarlos. Lo que sigue es cada regla, la línea de HotXLS que la violaba y el arreglo que se publicó, 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. El antiguo escritor de hojas de HotXLS trataba el cero como "sin establecer" y solo emitía el atributo cuando Sheet.PhoneticFontId > 0. Es 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, que es exactamente lo que llevaba la plantilla de préstamos del corpus de HotXLS. Excel rechaza entonces, a la vuelta, un valor que él mismo había escrito
// lxHandleX.pas, escritor de hojas — antes de v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — 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 sigue emitiendo el elemento solo cuando TXLSXWorksheet.PhoneticType no está vacío, así que los libros que nunca llevaron ajustes fonéticos no se ven afectados. El test de regresión PhoneticSettings_DefaultFontIsExplicit pone PhoneticFontId a cero en una hoja nueva, guarda, y afirma que <phoneticPr fontId="0" está presente en xl/worksheets/sheet1.xml. 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 capturó de forma opaca, así que el stream combinado lo declaraba 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. Antes de v2.382.5, ContentTypeOverridesXml no tenía visión de lo que el modelo ya había escrito, así que no podía saberlo
<!-- Lo que vio Excel antes de 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"/>
El arreglo 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. La lista conocía rId1 a rId3. No conocía rId4, y no sabía que el modelo estaba a punto de emitir su propia relación de propiedades personalizadas, así que un paquete origen cuya relación de propiedades personalizadas era también rId4, que es lo que Excel escribe por defecto, salía con dos entradas rId4 apuntando al mismo destino. Quien llama, BuildRootRelsXml, pasa ahora 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
// 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 ahora 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 los tres fallos?
Los tres son síntomas de 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, de modo que un round trip conserva gráficos, cachés de tablas dinámicas, XML personalizado y todo lo demás descrito en las notas sobre el round-trip sin pérdida de theme, extLst y calcChain. Cada lado era consistente por separado. Las restricciones que OPC pone sobre el paquete entero, nombres de Override únicos e identificadores de relación únicos por parte, solo existen en la costura donde los dos se concatenan, y hasta v2.382.5 nadie revisaba la costura. El bug de fontId tiene la misma forma un nivel más abajo: el escritor sabía lo que quería omitir pero nunca consultó el esquema que dice que no puede. El arreglo en el que se asentó HotXLS es una precedencia fija en vez de una heurística de merge. El modelo escribe primero, la capa opaca ve lo escrito y cede en cualquier colisión, y el runner del corpus ahora impone los invariantes desde fuera con verify_opc_uniqueness, que lee el [Content_Types].xml y cada ítem .rels de un paquete guardado y hace fallar el caso ante cualquier PartName, Extension o Id duplicado. Esa comprobación es barata, no necesita Excel, y habría cazado dos de los tres defectos en la primera pasada del corpus
Mismo lote: áreas de impresión que son fórmulas, no rangos
La pasada de Excel también marcó el _xlnm.Print_Area de la plantilla de préstamos, que Excel reportaba como $A$1:$J$29 en el original y tenía que reportar idéntico en la copia guardada. Dos bugs separados se sentaban detrás de esa única aserción. En la importación, XlsxStripSheetPrefix cortaba todo hasta el primer ! sin comillas, así que un área de impresión dinámica como OFFSET('Print Data'!$A$1,0,0,2,2) volvía como $A$1,0,0,2,2), y una unión calificada como 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 perdía el prefijo solo en su primer segmento. En la exportación, el escritor anteponía el nombre de hoja una vez al PrintArea almacenado entero, así que una unión simple $A$1:$B$2,$D$1:$E$2 salía de la librería con el primer segmento calificado y el 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. PrintArea_FormulaDefinitionSurvivesRoundTrip cubre la base con nombre, la base calificada con hoja y la unión a lo largo de dos ciclos 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. Los tres defectos de aquí salieron en ese orden, y ninguno habría sido visible en el archivo reparado que Excel ofrece guardar, porque la reparación descarta o renumera en silencio las entradas ofensoras. Los límites del arreglo de v2.382.5 merecen enunciarse con la misma claridad. 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. verify_opc_uniqueness comprueba solo unicidad; no valida esquemas, así que un futuro atributo obligatorio seguiría necesitando Excel o un validador de esquema para asomar. 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 eso en su sitio, las builds Win32 y Win64 de la plantilla de préstamos abren ahora en Excel sin aviso, recalculan las 4805 fórmulas verificadas con cero discrepancias, y reportan 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