Excel muestra "We found a problem with some content" sobre un XLSX que LibreOffice y cualquier lector casero abren sin quejarse, 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 de Excel para Delphi y C++Builder, dio exactamente con eso en v2.382.5 la primera vez que su salida pasó por una instancia COM real de Excel, 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 había estado 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 durante semanas. El archivo guardado era estructuralmente sano en el sentido OPC que usa el artículo sobre resolución de relaciones OPC de XLSX: cada parte alcanzable, cada target resoluble. Entonces se hizo disponible una máquina Windows con Excel 16.0 build 20326, el runner del corpus abrió la plantilla guardada vía Workbooks.Open en una instancia COM aislada con DisplayAlerts apagado, y la llamada falló de plano. Interactivamente el mismo archivo produce el conocido diálogo de ofrecer reparar, y el log 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 a uno; rechaza el libro y lo deja a uno buscándolos y analizándolos. Lo que sigue es cada regla, la línea de HotXLS que la violaba, y el fix que se publicó, porque cada una es una regla con la que cualquier writer de 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 writer de hojas viejo de HotXLS trataba el cero como "sin definir" y solo emitía el atributo cuando Sheet.PhoneticFontId > 0. Es un reflejo natural de Delphi, dado que los campos enteros arrancan en cero, 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 traía la plantilla de préstamos del corpus de HotXLS. Excel entonces rechaza, a la vuelta, un valor que él mismo había escrito
// lxHandleX.pas, writer de hoja — 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 en cero sobre 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 default" solo es seguro cuando el esquema declara un default; type y alignment tienen defaults 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 máximo una vez, y Excel trata un segundo Override para el mismo PartName como corrupción aunque ambas entradas lleven el mismo ContentType. HotXLS tiene dos writers alimentando ese stream. BuildContentTypesXml declara cada parte que el modelo de objetos genera: workbook, styles, shared strings, theme, worksheets, y, cuando TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Cuando PreserveUnsupportedParts está activado, TXLSXOpaquePackage después agrega un Override por cada parte que capturó textualmente del paquete fuente, 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 fuente también fue capturado de forma opaca, así que el stream combinado lo declaraba dos veces, y las partes de gráficos y de pivot cache pueden caer en el mismo punto cuando el modelo regenera una parte que la capa opaca también retuvo. Antes de v2.382.5, ContentTypeOverridesXml no tenía visibilidad de lo que el modelo ya había escrito, así que no podía saberlo
<!-- Lo que Excel veía 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 fix le pasa el XML generado a ContentTypeOverridesXml y deja que el writer opaco lo parsee antes de emitir nada. Dos detalles cargan con la corrección. OpcLowerPartName pasa a minúsculas, voltea backslashes a slashes y quita los slashes iniciales antes de comparar, porque los nombres de parte OPC se comparan sin distinguir mayúsculas y el modelo los escribe con slash inicial mientras la capa opaca guarda los nombres de ítems ZIP sin él. Y el llamador en BuildContentTypesXml pasa Result + '</Types>', cerrando el documento parcialmente construido para que TXMLReader vea entrada bien formada en vez de un stream truncado. La regla que emerge es gana-el-primero con el modelo adelante: lo que el modelo de objetos declara es autoritativo, y el replay opaco solo llena 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 declarado, 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 propiedades personalizadas cuando el modelo tiene. El paquete opaco después agrega las relaciones raíz que retuvo de la fuente, renumerando cualquier identificador que ya esté en una lista UsedIds. La lista sabía de rId1 a rId3. No sabía del rId4, y no sabía que el modelo estaba por emitir su propia relación de propiedades personalizadas, así que un paquete fuente cuya relación de propiedades personalizadas también era rId4, que es lo que Excel escribe por defecto, salía con dos entradas rId4 apuntando al mismo target. El llamador, BuildRootRelsXml, ahora pasa Workbook.FCustomProps.Count > 0 como segundo argumento, así que la reserva y el salto se manejan con la misma condición que decide si el modelo emite rId4 o no. 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 un nivel abajo estaría mal, donde los atributos r:id de workbook.xml se atan a identificadores de la parte de relaciones del workbook, razón por la cual MergeWorkbookRelationshipsXml mantiene un mapa de identificadores separado
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // reservado por el writer 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 ahora es dueño de las propiedades personalizadas; no repliques la copia de la fuente.
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 writer 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 replica las que no, de modo que un round-trip conserva gráficos, pivot caches, 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 completo, nombres de parte Override únicos e identificadores de relación únicos por parte, existen solo 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 abajo: el writer sabía qué quería omitir pero nunca consultó el esquema que dice que no puede. El fix en el que se asentó HotXLS es una precedencia fija en lugar de una heurística de merge. El modelo escribe primero, la capa opaca ve lo escrito y cede ante cualquier colisión, y el runner del corpus ahora hace cumplir los invariantes desde afuera con verify_opc_uniqueness, que lee el [Content_Types].xml y cada ítem .rels de un paquete guardado y falla el caso ante cualquier PartName, Extension o Id duplicado. Ese chequeo es barato, no necesita Excel, y habría atrapado dos de los tres defectos en la primera corrida del corpus
Mismo lote: áreas de impresión que son fórmulas, no rangos
El pase por 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. Al importar, 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. Al exportar, el writer anteponía el nombre de la hoja una vez al PrintArea almacenado completo, 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, cosa que Excel no acepta como definición de _xlnm.Print_Area bajo ECMA-376 Part 1 §18.2.5
// Import: quita el prefijo solo cuando 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
// Export: 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: se emite 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, de lo contrario es una fórmula y viaja textual. PrintArea_FormulaDefinitionSurvivesRoundTrip cubre la base con nombre, la base calificada con hoja, y la unión a través de dos ciclos de guardar y reabrir. Cómo interactúan las áreas de impresión con el page setup y el resto del modelo de impresión está cubierto en el artículo de protección de hojas, page setup e impresión
¿Cómo encuentra uno la regla a la que Excel se opone?
Parta de la suposición de que su propio validador está mal, porque pasó. El validador del Open XML SDK nombra una violación de esquema como el fontId faltante con la parte y el XPath, y la capa de empaquetado de debajo se niega a abrir directamente un paquete con entradas de content type duplicadas, así que corralo antes que nada. Cuando calla y Excel igualmente repara, haga bisección del paquete: descomprima, borre una parte con su relación y su Override, recomprima y reabra, reduciendo a la mitad el conjunto candidato cada vez hasta que el aviso desaparezca. Los tres defectos de acá 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 fix de v2.382.5 valen enunciarse con la misma claridad. La deduplicación es gana-el-primero con el modelo adelante, así que si el paquete fuente declaraba un content type distinto para una parte que el modelo también genera, gana la declaración del modelo y la de la fuente se descarta, lo que es correcto para las partes que HotXLS regenera y no es un merge general. verify_opc_uniqueness solo verifica unicidad; no valida esquemas, así que un atributo obligatorio futuro seguiría necesitando Excel o un validador de esquema para salir a la luz. Y el pase extra de TXMLReader sobre el stream de content types generado corre en cada guardado con PreserveUnsupportedParts activado, un costo pequeño contra un stream que rara vez pasa de unos pocos kilobytes. Con eso en su sitio, las compilaciones Win32 y Win64 de la plantilla de préstamos ahora abren 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 usted mismo escribe XLSX desde Delphi, el checklist es corto: emita cada atributo que el esquema marca obligatorio sin importar su valor, declare cada nombre de parte una sola vez, y mantenga una lista de identificadores usados por parte de relaciones a través de todos los writers que la tocan. Si prefiere que esa lista ya exista y esté probada contra Excel y no solo contra su propio lector, el writer de paquetes descrito acá 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 valiera la pena custodiar en primer lugar