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ó
// 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
<!-- 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
// 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