Excel toont "We found a problem with some content" bij een XLSX die LibreOffice en elke zelfgebouwde lezer zonder klagen openen, omdat Excel twee dingen afdwingt die die lezers negeren: door het schema verplichte attributen en de uniciteitsregels van de Open Packaging Conventions. HotXLS, de native Excel-spreadsheetcomponent voor Delphi en C++Builder, liep daar in v2.382.5 precies tegenaan de eerste keer dat zijn uitvoer door een echte Excel COM-instantie ging, en de drie oorzaken waren een <phoneticPr> zonder fontId, een dubbele Override in [Content_Types].xml, en twee rootrelaties die rId4 deelden
Waarom weigert Excel een pakket dat elke andere lezer accepteert?
Omdat de herstelmelding een schema- en pakketvalidator is, geen parserfout. Het HotXLS-corpus had wekenlang een leningtemplate met 4805 formules rondgezet door de library, door LibreOffice en door de XML-validators in de testsuite. Het opgeslagen bestand was structureel gezond in de OPC-zin die in het artikel over XLSX OPC-relatieresolutie wordt gebruikt: elk onderdeel bereikbaar, elk doel op te lossen. Toen kwam er een Windows-machine met Excel 16.0 build 20326 beschikbaar, opende de corpusrunner de opgeslagen template via Workbooks.Open in een geïsoleerde COM-instantie met DisplayAlerts uit, en faalde de aanroep meteen. Interactief geeft hetzelfde bestand de bekende dialoog die herstel aanbiedt, en het herstellogboek, als Excel er al een schrijft, noemt het onderdeel maar niet de regel. In die ene melding zaten drie onafhankelijke defecten verscholen, en Excel meldt ze niet één voor één; het weigert de workbook en laat jou ze vinden en analyseren. Hierna volgt elke regel, de regel HotXLS-code die hem overtrad, en de fix die is uitgebracht, want het zijn alle drie regels waar elke Delphi XLSX-writer over kan struikelen
Regel 1: phoneticPr fontId is verplicht, ook als hij nul is
Het element <phoneticPr> draagt een fontId-attribuut dat in ECMA-376 Part 1 §18.4.3 als use="required" is gedeclareerd, en een waarde van 0 is een geldige fontindex, geen afwezigheid. De oude werkbladschrijver van HotXLS behandelde nul als "niet ingesteld" en schreef het attribuut alleen als Sheet.PhoneticFontId > 0. Dat is een natuurlijke Delphi-reflex, want integervelden staan standaard op nul, maar het levert <phoneticPr type="noConversion"/> op voor elke workbook waarvan het fonetische font toevallig het eerste font in styles.xml is, en dat is precies wat de leningtemplate in het HotXLS-corpus bij zich droeg. Excel weigert dan bij het inlezen een waarde die het zelf had geschreven
// lxHandleX.pas, werkbladschrijver — vóór v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — het attribuut is verplicht, nul inbegrepen
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS schrijft het element nog steeds alleen als TXLSXWorksheet.PhoneticType niet leeg is, dus workbooks die nooit fonetische instellingen droegen blijven ongemoeid. De regressietest PhoneticSettings_DefaultFontIsExplicit zet PhoneticFontId op nul in een vers blad, slaat op, en controleert dat <phoneticPr fontId="0" aanwezig is in xl/worksheets/sheet1.xml. De bredere les is dat "weglaten bij de standaardwaarde" alleen veilig is als het schema die standaard declareert; type en alignment hebben standaardwaarden in dat element, fontId niet
Regel 2: één Override per onderdeelnaam in [Content_Types].xml
De contenttypes-stream mag elke onderdeelnaam hooguit één keer declareren, en Excel beschouwt een tweede Override voor dezelfde PartName als corruptie, zelfs als beide items hetzelfde ContentType dragen. HotXLS heeft twee schrijvers die op die stream voeden. BuildContentTypesXml declareert elk onderdeel dat het objectmodel genereert: workbook, styles, shared strings, theme, werkbladen, en, wanneer TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Als PreserveUnsupportedParts aan staat, voegt TXLSXOpaquePackage daarna een Override toe voor elk onderdeel dat het woordelijk uit het bronpakket heeft overgenomen, zodat die bytes bij het wegschrijven gedeclareerd blijven. De botsing zit bij een onderdeel dat aan beide kanten leeft. Aangepaste documenteigenschappen worden in het model geparseerd, maar het docProps/custom.xml van het bronpakket is ook opaque vastgelegd, dus de samengevoegde stream declareerde het twee keer, en grafiek- en pivotcache-onderdelen kunnen op dezelfde plek belanden wanneer het model een onderdeel opnieuw genereert dat de opaque laag ook had behouden. Vóór v2.382.5 had ContentTypeOverridesXml geen zicht op wat het model al had geschreven, dus het kon het niet weten
<!-- Wat Excel zag vóór 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"/>
De fix geeft de gegenereerde XML mee aan ContentTypeOverridesXml en laat de opaque schrijver die eerst parsen voordat hij iets uitschrijft. Twee details dragen de correctheid. OpcLowerPartName zet alles naar kleine letters, draait backslashes om naar forward slashes en stript leidende slashes voor de vergelijking, omdat OPC-onderdeelnamen hoofdletterongevoelig worden vergeleken en het model ze met een leidende slash schrijft terwijl de opaque laag ZIP-itemnamen zonder bewaart. En de aanroeper in BuildContentTypesXml geeft Result + '</Types>' mee, waarmee het deels opgebouwde document wordt gesloten zodat TXMLReader welgevormde invoer ziet in plaats van een afgebroken stream. De regel die daaruit volgt is first-wins met het model vooraan: wat het objectmodel declareert is bepalend, en de opaque terugspeel vult alleen gaten op
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Parse de door het model gegenereerde stream en verzamel elke gedeclareerde PartName.
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; // al gedeclareerd, of een rels-onderdeel
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Regel 3: relatie-Id's zijn uniek binnen een relatieonderdeel
Elke Relationship in een .rels-onderdeel heeft een Id nodig dat uniek is binnen dat onderdeel, en Excel weigert het pakket wanneer twee er één delen. HotXLS schrijft de pakketbrede _rels/.rels met vaste identifiers: rId1 voor de workbook, rId2 en rId3 voor de core- en extended-documenteigenschappen, en rId4 voor aangepaste eigenschappen wanneer het model die heeft. Het opaque pakket voegt daarna toe wat het aan rootrelaties uit de bron heeft behouden, waarbij elke identifier die al in een UsedIds-lijst staat opnieuw wordt genummerd. Die lijst kende rId1 tot en met rId3. Hij kende rId4 niet, en wist niet dat het model op het punt stond zijn eigen relatie voor aangepaste eigenschappen uit te schrijven, dus een bronpakket waarvan de relatie voor aangepaste eigenschappen ook rId4 was, wat Excel standaard schrijft, kwam eruit met twee rId4-items die naar hetzelfde doel wezen. De aanroeper, BuildRootRelsXml, geeft nu Workbook.FCustomProps.Count > 0 mee als tweede argument, zodat de reservering en het overslaan door dezelfde voorwaarde worden gestuurd die bepaalt of het model rId4 überhaupt uitschrijft. Hernummeren is veilig aan de pakketwortel omdat niets in de workbook root-relatie-identifiers bij naam aanroept; dezelfde truc zou één niveau dieper fout zijn, waar r:id-attributen in workbook.xml aan identifiers in het workbook-relatieonderdeel binden, en daarom houdt MergeWorkbookRelationshipsXml een aparte identifiermap bij
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // gereserveerd door de modelschrijver
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// Het model bezit de aangepaste eigenschappen nu; speel de bronkopie niet terug.
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); // laagste vrije rIdN
UsedIds.Add(String(Id));
...
end;
Wat hebben de drie fouten gemeen?
Alle drie zijn ze symptomen van een schrijver met twee bronnen en geen enkele eigenaar van de pakketinvarianten. Het objectmodel genereert onderdelen die het begrijpt; de opaque laag speelt onderdelen terug die het niet begrijpt, zodat een round trip grafieken, pivotcaches, custom XML en al het andere behoudt dat in de notities over verliesvrije round-trip van theme, extLst en calcChain wordt beschreven. Elke kant was op zichzelf consistent. De beperkingen die OPC aan het hele pakket oplegt, unieke Override-onderdeelnamen en unieke relatie-identifiers per onderdeel, bestaan alleen op de naad waar de twee worden samengevoegd, en tot v2.382.5 controleerde niemand die naad. De fontId-bug heeft dezelfde vorm één niveau dieper: de schrijver wist wat hij wilde weglaten, maar raadpleegde nooit het schema dat zegt dat het niet mag. De fix waar HotXLS op uitkwam is een vaste precedentie in plaats van een merge-heuristiek. Het model schrijft eerst, de opaque laag ziet wat er geschreven is en geeft toe bij elke botsing, en de corpusrunner handhaaft de invarianten nu van buitenaf met verify_opc_uniqueness, dat [Content_Types].xml en elk .rels-item in een opgeslagen pakket leest en de case laat falen op elke dubbele PartName, Extension of Id. Die controle is goedkoop, heeft geen Excel nodig, en zou twee van de drie defecten bij de eerste corpusrun hebben gevangen
Zelfde batch: afdrukgebieden die formules zijn, geen bereiken
De Excel-ronde markeerde ook de _xlnm.Print_Area van de leningtemplate, die Excel op het origineel als $A$1:$J$29 rapporteerde en op de opgeslagen kopie identiek moest rapporteren. Achter die ene assertie zaten twee afzonderlijke bugs. Bij het importeren knipte XlsxStripSheetPrefix alles tot de eerste niet-tussen-aanhalingstekens geplaatste ! weg, dus een dynamisch afdrukgebied als OFFSET('Print Data'!$A$1,0,0,2,2) kwam terug als $A$1,0,0,2,2), en een gekwalificeerde unie als 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 verloor de prefix alleen op zijn eerste segment. Bij het exporteren zette de schrijver de bladnaam één keer voor de hele opgeslagen PrintArea, dus een gewone unie $A$1:$B$2,$D$1:$E$2 verliet de library met een gekwalificeerd eerste segment en een kaal tweede, wat Excel niet accepteert als definitie van _xlnm.Print_Area onder ECMA-376 Part 1 §18.2.5
// Import: strip de prefix alleen als wat overblijft een gewone sqref is
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(...) komt er onaangeroerd uit
// Export: kwalificeer elk door komma gescheiden segment, of geen ervan
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; // een formule: woordelijk uitschrijven
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;
De paarvormingsregel is aan beide kanten dezelfde: een afdrukgebied is alleen een kaal bereik als elk segment als zodanig parseert, anders is het een formule en reist het woordelijk mee. PrintArea_FormulaDefinitionSurvivesRoundTrip dekt de benoemde basis, de bladgekwalificeerde basis en de unie, door twee opslaan-en-heropenen-cycli heen. Hoe afdrukgebieden samenspelen met pagina-instellingen en de rest van het afdrukmodel staat in het artikel over bladbeveiliging, pagina-instelling en afdrukken
Hoe vind je tegen welke regel Excel bezwaar maakt?
Begin bij de aanname dat je eigen validator ernaast zit, want die liet het door. De Open XML SDK-validator noemt bij een schending van het schema, zoals de ontbrekende fontId, het onderdeel en het XPath, en de packaginglaag eronder weigert een pakket met dubbele contenttype-items sowieso te openen, dus laat die als eerste lopen. Zwigt hij en herstelt Excel alsnog, halveer dan het pakket: uitpakken, een onderdeel plus zijn relatie en zijn Override verwijderen, opnieuw inpakken en heropenen, en zo elke keer de kandidatenlijst halveren tot de melding verdwijnt. De drie defecten hier vielen in die volgorde uit, en geen ervan zou zichtbaar zijn geweest in het herstelde bestand dat Excel aanbiedt op te slaan, want de reparatie laat de gewraakte items stilzwijgend weg of hernummert ze. De grenzen van de fix in v2.382.5 zijn net zo goed het benoemen waard. De deduplicatie is first-wins met het model vooraan, dus als het bronpakket een ander contenttype declareerde voor een onderdeel dat het model ook genereert, wint de declaratie van het model en wordt die van de bron weggegooid, wat correct is voor de onderdelen die HotXLS opnieuw genereert en geen algemene merge is. verify_opc_uniqueness controleert alleen uniciteit; het valideert geen schema's, dus een toekomstig verplicht attribuut zou nog steeds Excel of een schemavalidator nodig hebben om boven water te komen. En de extra TXMLReader-ronde over de gegenereerde contenttypes-stream draait bij elke opslag met PreserveUnsupportedParts aan, een kleine kostenpost tegen een stream die zelden meer dan een paar kilobyte is. Met dat alles op zijn plaats openen zowel de Win32- als de Win64-build van de leningtemplate nu in Excel zonder melding, herberekenen alle 4805 geverifieerde formules met nul afwijkingen, en rapporteren ze hetzelfde afdrukgebied als het origineel
Als je zelf XLSX schrijft vanuit Delphi, is de checklist kort: schrijf elk attribuut uit dat het schema als verplicht markeert, ongeacht zijn waarde, declareer elke onderdeelnaam één keer, en houd één lijst met gebruikte identifiers per relatieonderdeel bij over elke schrijver die eraan raakt. Als je liever hebt dat die lijst al bestaat en tegen Excel is getest in plaats van alleen tegen je eigen lezer, dan wordt de pakketschrijver die hier beschreven staat meegeleverd in de HotXLS Delphi spreadsheet component, samen met de opaque-onderdeel-round-trip die de naad überhaupt het bewaken waard maakte