Excel zeigt „Wir haben ein Problem mit einigen Inhalten festgestellt“ bei einer XLSX, die LibreOffice und jeder selbst gestrickte Reader anstandslos öffnen, weil Excel zwei Dinge erzwingt, die diese Reader ignorieren: schema-erforderliche Attribute und die Eindeutigkeitsregeln der Open Packaging Conventions. HotXLS, die native Excel-Tabellenkomponente für Delphi und C++Builder, ist in v2.382.5 genau daran hängen geblieben, als ihre Ausgabe zum ersten Mal durch eine echte Excel-COM-Instanz lief, und die drei Ursachen waren ein <phoneticPr> ohne fontId, ein doppelter Override in [Content_Types].xml und zwei Root-Beziehungen, die sich rId4 teilten
Warum weist Excel ein Paket zurück, das jeder andere Reader akzeptiert?
Weil der Reparatur-Prompt ein Schema- und Paket-Validator ist, kein Parser-Fehler. Der HotXLS-Korpus hatte eine Darlehensvorlage mit 4805 Formeln wochenlang durch die Bibliothek, durch LibreOffice und durch die XML-Validatoren der Testsuite geschickt. Die gespeicherte Datei war strukturell gesund im OPC-Sinne, wie ihn der Artikel zur XLSX-OPC-Beziehungsauflösung benutzt: jedes Part erreichbar, jedes Ziel auflösbar. Dann stand ein Windows-Rechner mit Excel 16.0 Build 20326 zur Verfügung, der Korpus-Läufer öffnete die gespeicherte Vorlage über Workbooks.Open in einer isolierten COM-Instanz mit ausgeschaltetem DisplayAlerts, und der Aufruf scheiterte komplett. Interaktiv bringt dieselbe Datei den vertrauten Dialog, der Reparatur anbietet, und das Reparaturprotokoll benennt, wenn Excel sich die Mühe macht, eines zu schreiben, das Part, aber nicht die Regel. Drei unabhängige Defekte versteckten sich in diesem einen Prompt, und Excel meldet sie nicht einzeln; es verweigert die Mappe und überlässt Ihnen das Suchen und Analysieren. Was folgt, ist jede Regel, die Zeile in HotXLS, die sie verletzte, und der Fix, der ausgeliefert wurde, denn jede davon ist eine Regel, über die jeder Delphi-XLSX-Writer stolpern kann
Regel 1: phoneticPr-fontId ist erforderlich, auch wenn sie null ist
Das Element <phoneticPr> trägt ein Attribut fontId, das in ECMA-376 Part 1 §18.4.3 mit use="required" deklariert ist, und der Wert 0 ist ein legaler Font-Index, kein Fehlen. Der alte Worksheet-Writer von HotXLS behandelte null als „nicht gesetzt“ und gab das Attribut nur aus, wenn Sheet.PhoneticFontId > 0. Das ist ein natürlicher Delphi-Reflex, weil Integer-Felder standardmäßig null sind, aber er produziert <phoneticPr type="noConversion"/> für jede Mappe, deren phonetischer Font zufällig der erste Font in styles.xml ist – genau das trug die Darlehensvorlage im HotXLS-Korpus mit sich. Excel weist auf dem Rückweg also einen Wert zurück, den es selbst geschrieben hatte
// lxHandleX.pas, Worksheet-Writer — vor v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — das Attribut ist erforderlich, null eingeschlossen
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS gibt das Element weiterhin nur aus, wenn TXLSXWorksheet.PhoneticType nicht leer ist, daher bleiben Mappen ohne phonetische Einstellungen unberührt. Das Regressionstest-Muster PhoneticSettings_DefaultFontIsExplicit setzt PhoneticFontId auf einem frischen Sheet auf null, speichert und behauptet, dass <phoneticPr fontId="0" in xl/worksheets/sheet1.xml auftaucht. Die allgemeinere Lehre: „Weglassen, wenn Default“ ist nur sicher, wenn das Schema einen Default deklariert; type und alignment haben in diesem Element Defaults, fontId nicht
Regel 2: ein Override pro Part-Namen in [Content_Types].xml
Der Content-Types-Stream darf jeden Part-Namen höchstens einmal deklarieren, und Excel wertet ein zweites Override für denselben PartName als Korruption, selbst wenn beide Einträge dasselbe ContentType tragen. In HotXLS speisen zwei Writer diesen Stream. BuildContentTypesXml deklariert jedes Part, das das Objektmodell erzeugt: Workbook, Styles, Shared Strings, Theme, Worksheets und, wenn TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Ist PreserveUnsupportedParts an, hängt TXLSXOpaquePackage anschließend für jedes Part, das es wortgetreu aus dem Quellpaket übernommen hat, ein Override an, damit diese Bytes auf dem Ausweg weiter deklariert bleiben. Die Kollision ist ein Part, das auf beiden Seiten existiert. Custom-Dokumenteigenschaften werden ins Modell geparst, aber das docProps/custom.xml des Quellpakets wurde zusätzlich opak übernommen, also deklarierte der zusammengeführte Stream es zweimal, und Chart- und Pivot-Cache-Parts können an dieselbe Stelle geraten, wenn das Modell ein Part neu erzeugt, das die opake Schicht ebenfalls behalten hat. Vor v2.382.5 hatte ContentTypeOverridesXml keinen Blick darauf, was das Modell bereits geschrieben hatte, und konnte es deshalb nicht wissen
<!-- Was Excel vor v2.382.5 sah -->
<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"/>
Der Fix reicht das erzeugte XML in ContentTypeOverridesXml hinein und lässt den opaken Writer es parsen, bevor er irgendetwas ausgibt. Zwei Details tragen die Korrektheit. OpcLowerPartName wandelt in Kleinbuchstaben, dreht Backslashes in Forward Slashes und schneidet führende Slashes ab, bevor verglichen wird, denn OPC-Part-Namen werden case-insensitive verglichen, und das Modell schreibt sie mit führendem Slash, während die opake Schicht ZIP-Eintragsnamen ohne solchen ablegt. Und der Aufrufer in BuildContentTypesXml übergibt Result + '</Types>' und schließt damit das halb fertige Dokument, sodass TXMLReader wohlgeformte Eingabe sieht statt eines abgeschnittenen Streams. Die Regel, die dabei herauskommt, lautet First-Wins mit dem Modell vorne: Was das Objektmodell deklariert, ist maßgeblich, und die opake Wiedergabe füllt nur Lücken
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Den vom Modell erzeugten Stream parsen und jeden deklarierten PartName einsammeln.
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; // bereits deklariert oder ein rels-Part
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Regel 3: Beziehungs-Ids sind innerhalb eines Relationships-Parts eindeutig
Jedes Relationship in einem .rels-Part braucht eine Id, die innerhalb dieses Parts eindeutig ist, und Excel verweigert das Paket, wenn sich zwei eine teilen. HotXLS schreibt das Paket-level _rels/.rels mit festen Bezeichnern: rId1 für das Workbook, rId2 und rId3 für die Kern- und erweiterten Dokumenteigenschaften und rId4 für Custom-Properties, wenn das Modell welche hat. Das opake Paket hängt dann alle Root-Beziehungen an, die es aus der Quelle behalten hat, und nummeriert jeden Bezeichner um, der bereits in einer UsedIds-Liste steht. Die Liste kannte rId1 bis rId3. Sie kannte rId4 nicht, und sie wusste nicht, dass das Modell gleich seine eigene Custom-Properties-Beziehung ausgeben würde, also kam ein Quellpaket, dessen Custom-Properties-Beziehung ebenfalls rId4 war – was Excel standardmäßig schreibt – mit zwei rId4-Einträgen heraus, die auf dasselbe Ziel zeigten. Der Aufrufer, BuildRootRelsXml, übergibt jetzt Workbook.FCustomProps.Count > 0 als zweites Argument, sodass die Reservierung und das Überspringen von derselben Bedingung gesteuert werden, die entscheidet, ob das Modell überhaupt rId4 ausgibt. Umnummerieren ist am Paket-Root sicher, weil nichts innerhalb der Mappe Root-Beziehungs-Bezeichner beim Namen referenziert; derselbe Trick wäre eine Ebene tiefer falsch, wo sich die r:id-Attribute in workbook.xml an Bezeichner im Workbook-Relationships-Part binden – deshalb führt MergeWorkbookRelationshipsXml eine eigene Bezeichner-Map
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // vom Modell-Writer reserviert
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// Das Modell besitzt die Custom-Properties jetzt; die Quell-Kopie nicht wiedergeben.
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); // niedrigste freie rIdN
UsedIds.Add(String(Id));
...
end;
Was haben die drei Ausfälle gemeinsam?
Alle drei sind Symptome eines Writers mit zwei Quellen und ohne einen einzigen Besitzer der Paket-Invarianten. Das Objektmodell erzeugt Parts, die es versteht; die opake Schicht spielt Parts zurück, die es nicht tut, sodass ein Round Trip Charts, Pivot-Caches, Custom-XML und alles andere bewahrt, was die Notizen zum verlustfreien Round Trip von Theme, extLst und calcChain beschreiben. Jede Seite war für sich konsistent. Die Auflagen, die OPC dem ganzen Paket stellt – eindeutige Override-Part-Namen und eindeutige Beziehungs-Bezeichner pro Part –, existieren nur an der Naht, an der beide zusammengeklebt werden, und bis v2.382.5 hat niemand die Naht geprüft. Der fontId-Bug hat dieselbe Gestalt, eine Ebene tiefer: Der Writer wusste, was er weglassen wollte, hat aber nie das Schema konsultiert, das ihm das verbietet. Der Fix, auf den sich HotXLS geeinigt hat, ist eine feste Präzedenz statt einer Merge-Heuristik. Das Modell schreibt zuerst, die opake Schicht sieht, was geschrieben wurde, und weicht bei jeder Kollision, und der Korpus-Läufer erzwingt die Invarianten jetzt von außen mit verify_opc_uniqueness, das [Content_Types].xml und jeden .rels-Eintrag in einem gespeicherten Paket liest und den Fall scheitern lässt, sobald ein doppelter PartName, Extension oder Id auftaucht. Diese Prüfung ist billig, braucht kein Excel und hätte zwei der drei Defekte schon im ersten Korpus-Lauf gefangen
Dieselbe Charge: Druckbereiche, die Formeln sind, keine Bereiche
Der Excel-Durchlauf markierte auch den _xlnm.Print_Area der Darlehensvorlage, den Excel beim Original als $A$1:$J$29 meldete und auf der gespeicherten Kopie identisch melden musste. Zwei getrennte Bugs steckten hinter dieser einen Behauptung. Beim Import schnitt XlsxStripSheetPrefix alles bis zum ersten nicht in Anführungszeichen stehenden ! ab, sodass ein dynamischer Druckbereich wie OFFSET('Print Data'!$A$1,0,0,2,2) als $A$1,0,0,2,2) zurückkam, und eine qualifizierte Vereinigung wie 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 verlor den Präfix nur auf ihrem ersten Segment. Beim Export stellte der Writer den Sheet-Namen einmal vor die ganze gespeicherte PrintArea, sodass eine schlichte Vereinigung $A$1:$B$2,$D$1:$E$2 die Bibliothek mit einem qualifizierten ersten und einem nackten zweiten Segment verließ, was Excel nicht als _xlnm.Print_Area-Definition nach ECMA-376 Part 1 §18.2.5 akzeptiert
// Import: den Präfix nur abtrennen, wenn übrig ein schlichter sqref bleibt
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(...) wird unangetastet zurückgegeben
// Export: jedes kommagetrennte Segment qualifizieren oder keins
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; // eine Formel: wortgetreu ausgeben
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;
Die Paarungsregel ist auf beiden Seiten dieselbe: Ein Druckbereich ist nur dann ein nackter Bereich, wenn jedes Segment als einer parst, sonst ist er eine Formel und reist wortgetreu. PrintArea_FormulaDefinitionSurvivesRoundTrip deckt die benannte Basis, die blattqualifizierte Basis und die Vereinigung über zwei Speicher-und-Wiederöffnen-Zyklen ab. Wie Druckbereiche mit dem Seiten-Setup und dem Rest des Druckmodells zusammenspielen, behandelt der Artikel zu Blattschutz, Seiten-Setup und Drucken
Wie finden Sie heraus, gegen welche Regel Excel etwas hat?
Beginnen Sie mit der Annahme, dass Ihr eigener Validator falsch liegt, denn er hat durchgelassen. Der Open XML SDK Validator benennt eine Schema-Verletzung wie das fehlende fontId mit Part und XPath, und die Packaging-Schicht darunter verweigert ohnehin das Öffnen eines Pakets mit doppelten Content-Type-Einträgen, also fahren Sie ihn vor allem anderen. Wenn er schweigt und Excel trotzdem repariert, halbieren Sie das Paket per Bisektion: entpacken, ein Part samt seiner Beziehung und seinem Override löschen, neu packen und neu öffnen, und die Kandidatenmenge jedes Mal halbieren, bis der Prompt verschwindet. Die drei Defekte hier fielen in genau dieser Reihenfolge heraus, und keiner davon wäre in der reparierten Datei sichtbar gewesen, die Excel anzubieten pflegt, denn die Reparatur wirft die beanstandeten Einträge still weg oder nummeriert sie um. Die Grenzen des v2.382.5-Fixes sind ebenso klar zu benennen. Die Deduplikation ist First-Wins mit dem Modell vorne: Hat das Quellpaket für ein Part, das das Modell ebenfalls erzeugt, einen anderen Content-Typ deklariert, gewinnt die Deklaration des Modells und die der Quelle fliegt raus – korrekt für die Parts, die HotXLS neu erzeugt, und kein allgemeiner Merge. verify_opc_uniqueness prüft nur Eindeutigkeit; Schemas validiert es nicht, daher bräuchte ein künftiges erforderliches Attribut weiterhin Excel oder einen Schema-Validator, um aufzutauchen. Und der zusätzliche TXMLReader-Lauf über den erzeugten Content-Types-Stream läuft bei jedem Save mit aktiviertem PreserveUnsupportedParts – ein kleiner Preis für einen Stream, der selten mehr als ein paar Kilobyte hat. Mit all dem öffnen jetzt sowohl der Win32- als auch der Win64-Build der Darlehensvorlage ohne Prompt in Excel, berechnen alle 4805 verifizierten Formeln mit null Abweichungen neu und melden denselben Druckbereich wie das Original
Wenn Sie XLSX selbst aus Delphi heraus schreiben, ist die Checkliste kurz: Geben Sie jedes Attribut aus, das das Schema als erforderlich markiert, egal mit welchem Wert, deklarieren Sie jeden Part-Namen genau einmal, und führen Sie eine einzige Liste benutzter Bezeichner pro Relationships-Part über alle Writer hinweg, die es anfassen. Wenn Sie diese Liste lieber bereits vorhanden und gegen Excel getestet hätten statt nur gegen Ihren eigenen Reader: Der hier beschriebene Paket-Writer steckt in der HotXLS Delphi-Tabellenkomponente, zusammen mit dem Opaque-Part-Round-Trip, der die Naht überhaupt erst bewachenswert gemacht hat