Excel viser "We found a problem with some content" på en XLSX, som LibreOffice og enhver hjemmelavet læser åbner uden brok, fordi Excel håndhæver to ting, de læsere ignorerer: skema-påkrævede attributter og entydighedsreglerne i Open Packaging Conventions. HotXLS, den native Excel-regnearkskomponent til Delphi og C++Builder, ramte præcis det i v2.382.5, første gang dets output gik gennem en ægte Excel COM-instans, og de tre årsager var et <phoneticPr> uden fontId, en duplikeret Override i [Content_Types].xml og to root-relationships, der delte rId4
Hvorfor afviser Excel en pakke, som alle andre læsere accepterer?
Fordi reparationsdialogen er en skema- og pakkvalidator, ikke en parserfejl. HotXLS-korpuset havde round-trippet en låneskabelon med 4805 formler gennem biblioteket, gennem LibreOffice og gennem XML-validatorerne i test-suitten i ugevis. Den gemte fil var strukturelt sund i den OPC-forstand, der bruges i artiklen om XLSX OPC relationship resolution: hver part nåbar, hver target opløselig. Så blev en Windows-maskine med Excel 16.0 build 20326 tilgængelig, corpus-runneren åbnede den gemte skabelon gennem Workbooks.Open i en isoleret COM-instans med DisplayAlerts slået fra, og kaldet fejlede fuldstændigt. Interaktivt producerer samme fil den velkendte dialog, der tilbyder at reparere, og reparationsloggen, når Excel gider skrive en, nævner parten, men ikke reglen. Tre uafhængige defekter gemte sig i den ene dialog, og Excel rapporterer dem ikke én ad gangen; den afviser arbejdsbogen og efterlader dig med at finde og analysere dem. Det følgende er hver regel, den linje i HotXLS, der brød den, og det fix, der shippede, fordi hver enkelt af dem er en regel, enhver Delphi XLSX-writer kan snuble over
Regel 1: phoneticPr fontId er påkrævet, selv når den er nul
Elementet <phoneticPr> bærer en fontId-attribut, der er deklareret use="required" i ECMA-376 Part 1 §18.4.3, og værdien 0 er et lovligt fontindeks, ikke et fravær. Den gamle HotXLS-regnearkswriter behandlede nul som "ikke sat" og emitterede kun attributten, når Sheet.PhoneticFontId > 0. Det er en naturlig Delphi-refleks, eftersom integer-felter som standard er nul, men det producerer <phoneticPr type="noConversion"/> for enhver arbejdsbog, hvis fonetiske font tilfældigvis er den første font i styles.xml, hvilket er præcis, hvad låneskabelonen i HotXLS-korpuset bar. Excel afviser altså på vejen tilbage en værdi, den selv havde skrevet
// lxHandleX.pas, regnearkswriter — før v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — attributten er påkrævet, nul inkluderet
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS emitter stadig kun elementet, når TXLSXWorksheet.PhoneticType er ikke-tom, så arbejdsbøger, der aldrig bar fonetiske indstillinger, er upåvirkede. Regressionstesten PhoneticSettings_DefaultFontIsExplicit sætter PhoneticFontId til nul på et friskt ark, gemmer og asserter, at <phoneticPr fontId="0" er til stede i xl/worksheets/sheet1.xml. Den bredere lektie er, at "udelad ved default" kun er sikkert, når skemaet deklarerer en default; type og alignment har defaults i det element, fontId har ikke
Regel 2: én Override pr. partnavn i [Content_Types].xml
Content types-streamen må deklarere hvert partnavn højst én gang, og Excel behandler en anden Override for det samme PartName som korruption, selv når begge poster bærer samme ContentType. HotXLS har to writere, der føder den stream. BuildContentTypesXml deklarerer hver part, objektmodellen genererer: workbook, styles, shared strings, theme, worksheets og, når TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Når PreserveUnsupportedParts er slået til, appenderer TXLSXOpaquePackage derefter en Override for hver part, den har fanget ordret fra kildepakken, så de bytes forbliver deklareret på vejen ud. Kollisionen er en part, der lever på begge sider. Brugerdefinerede dokumentegenskaber parses ind i modellen, men kildepakkens docProps/custom.xml blev også fanget opaqt, så den flettede stream deklarede den to gange, og chart- og pivot cache-parts kan lande samme sted, når modellen regenererer en part, det opakte lag også beholdt. Før v2.382.5 havde ContentTypeOverridesXml intet syn for, hvad modellen allerede havde skrevet, så den kunne ikke vide det
<!-- Hvad Excel så fø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"/>
Fixet sender den genererede XML ind i ContentTypeOverridesXml og lader den opakte writer parse den, før den emitterer noget. To detaljer bærer korrektheden. OpcLowerPartName konverterer til små bogstaver, vender backslashes til skråstreger og stripper indledende skråstreger før sammenligningen, fordi OPC-partnavne sammenlignes case-insensitivt, og modellen skriver dem med indledende skråstreg, mens det opakte lag gemmer ZIP-elementnavne uden. Og calleren i BuildContentTypesXml sender Result + '</Types>' og lukker dermed det delvist byggede dokument, så TXMLReader ser velformet input frem for en trunkeret stream. Reglen, der opstår, er first-wins med modellen forrest: Uanset hvad objektmodellen deklarerer, er autoritativt, og den opakte replay udfylder kun huller
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Parse den modelgenererede stream og saml hver deklareret 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; // allerede deklareret, eller en rels-part
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Regel 3: relationship-Id'er er unikke inden for en relationships-part
Hver Relationship i en .rels-part behøver en Id, der er unik inden for den part, og Excel nægter pakken, når to deler én. HotXLS skriver pakke-niveau _rels/.rels med faste identifikatorer: rId1 for arbejdsbogen, rId2 og rId3 for core og extended document properties og rId4 for brugerdefinerede egenskaber, når modellen har nogen. Den opakte pakke appenderer derefter de root-relationships, den beholdt fra kilden, og omnummererer enhver identifikator, der allerede er i en UsedIds-liste. Listen kendte rId1 til og med rId3. Den kendte ikke rId4, og den vidste ikke, at modellen var ved at emitte sin egen custom-properties-relationship, så en kildepakke, hvis custom-properties-relationship også var rId4, hvilket er det, Excel skriver som default, kom ud med to rId4-poster, der pegede på samme target. Calleren, BuildRootRelsXml, sender nu Workbook.FCustomProps.Count > 0 som andet argument, så reservationen og springet styres af samme betingelse, der afgør, om modellen overhovedet emitterer rId4. Omnummerering er sikker ved pakkeroden, fordi intet inde i arbejdsbogen refererer root-relationship-identifikatorer ved navn; samme trick ville være forkert ét niveau nede, hvor r:id-attributter i workbook.xml binder til identifikatorer i arbejdsbogens relationships-part, hvilket er grunden til, at MergeWorkbookRelationshipsXml fører et separat identifikator-kort
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // reserveret af model-writeren
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// Modellen ejer de brugerdefinerede egenskaber nu; replay ikke kildekopien.
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); // laveste ledige rIdN
UsedIds.Add(String(Id));
...
end;
Hvad har de tre fejl tilfælles?
Alle tre er symptomer på en writer med to kilder og ingen enig ejer af pakkens invarianter. Objektmodellen genererer de parts, den forstår; det opakte lag replay'er de parts, den ikke gør, så en round trip bevarer charts, pivot caches, custom XML og alt det andet, der er beskrevet i noterne om lossless round-trip af theme, extLst og calcChain. Hver side var hver for sig konsistent. De begrænsninger, OPC lægger på hele pakken, unikke Override-partnavne og unikke relationship-identifikatorer pr. part, findes kun ved sømmen, hvor de to konkateneres, og indtil v2.382.5 tjekkede ingen sømmen. fontId-bugen er samme form ét niveau nede: Writeren vidste, hvad den ville udelade, men konsulterede aldrig det skema, der siger, at den ikke må. Fixet, HotXLS landede på, er en fast præcedens frem for en merge-heuristik. Modellen skriver først, det opakte lag ser, hvad der er skrevet, og viger ved enhver kollision, og corpus-runneren håndhæver nu invarianterne udefra med verify_opc_uniqueness, som læser [Content_Types].xml og hvert .rels-element i en gemt pakke og fejler casen ved enhver duplikeret PartName, Extension eller Id. Det tjek er billigt, behøver ingen Excel og ville have fanget to af de tre defekter ved den første corpus-kørsel
Samme batch: print-områder, der er formler, ikke ranges
Excel-passet flaggede også låneskabelonens _xlnm.Print_Area, som Excel rapporterede som $A$1:$J$29 på originalen og skulle rapportere identisk på den gemte kopi. To separate bugs sad bag den ene assertion. Ved import skar XlsxStripSheetPrefix alt op til det første !, der ikke var i anførselstegn, så et dynamisk print-område som OFFSET('Print Data'!$A$1,0,0,2,2) kom tilbage som $A$1,0,0,2,2), og en kvalificeret union som 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 mistede prefixet på sit første segment alene. Ved eksport satte writeren arkenavnet foran én gang for hele den gemte PrintArea, så en almindelig union $A$1:$B$2,$D$1:$E$2 forlod biblioteket med et kvalificeret første segment og et nøgent andet, hvilket Excel ikke accepterer som en _xlnm.Print_Area-definition under ECMA-376 Part 1 §18.2.5
// Import: Strip kun prefixet, når det, der resterer, er en almindelig sqref
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(...) returneres urørt
// Eksport: Kvalificér hvert kommasepareret segment, eller ingen af dem
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; // en formel: emit ordret
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;
Paringsreglen er den samme på begge sider: Et print-område er et nøgent range kun, hvis hvert segment parser som ét, ellers er det en formel og rejser ordret. PrintArea_FormulaDefinitionSurvivesRoundTrip dækker den navngivne base, den arkekvalificerede base og unionen gennem to gem-og-genåbn-cyklusser. Hvordan print-områder interagerer med sideopsætning og resten af print-modellen er dækket i artiklen om arkbeskyttelse, sideopsætning og udskrivning
Hvordan finder du ud af, hvilken regel Excel indvender mod?
Start fra antagelsen om, at din egen validator tager fejl, fordi den bestod. Open XML SDK-validatoren vil nævne en skemavovertrædelse som den manglende fontId med part og XPath, og pakningslaget under den nægter overhovedet at åbne en pakke med duplikerede content-type-poster, så kør den før noget som helst andet. Når den er tavs, og Excel stadig reparerer, så bisect pakken: unzip, slet en part og dens relationship og dens Override, rezip og genåbn, og halvér kandidatsættet hver gang, indtil dialogen forsvinder. De tre defekter her faldt ud i den rækkefølge, og ingen af dem ville have været synlig i den reparerede fil, Excel tilbyder at gemme, da reparationen lydløst dropper eller omnummererer de problematiske poster. Grænserne for v2.382.5-fixet er det værd at sige lige så klart. Deduplikeringen er first-wins med modellen forrest, så hvis kildepakken deklarede en anden content type for en part, som modellen også genererer, vinder modellens deklaration, og kildens kasseres, hvilket er korrekt for de parts, HotXLS regenererer, og er ikke en generel merge. verify_opc_uniqueness tjekker kun entydighed; den validerer ikke skemaer, så en fremtidig påkrævet attribut ville stadig kræve Excel eller en skemavalidator for at vise sig. Og det ekstra TXMLReader-pass over den genererede content types-stream kører ved hvert gem med PreserveUnsupportedParts slået til, en lille pris for en stream, der sjældent er mere end nogle få kilobytes. Med det på plads åbner både Win32- og Win64-buildene af låneskabelonen nu i Excel uden dialog, genberegner alle 4805 verificerede formler med nul mismatches og rapporterer samme print-område som originalen
Hvis du selv skriver XLSX fra Delphi, er tjeklisten kort: Emitér enhver attribut, skemaet markerer som påkrævet, uanset dens værdi, deklarér hvert partnavn én gang, og hold én liste over brugte identifikatorer pr. relationships-part på tværs af enhver writer, der rører den. Hvis du hellere vil have, at den liste allerede findes og er testet mod Excel frem for kun mod din egen læser, så ships pakkewriteren beskrevet her i HotXLS Delphi-regnearkskomponent, sammen med den opakte part-round-trip, der gjorde sømmen værd at vogte i første omgang