Excel visar "We found a problem with some content" för en XLSX som LibreOffice och varje egenbyggd läsare öppnar utan klagomål, eftersom Excel upprätthåller två saker som de läsarna struntar i: schemakrävda attribut och unikhetsreglerna i Open Packaging Conventions. HotXLS, den nativa kalkylbladskomponenten för Excel i Delphi och C++Builder, råkade precis ut för det i v2.382.5 första gången dess utdata gick genom en riktig Excel COM-instans, och de tre orsakerna var en <phoneticPr> utan fontId, en dubblerad Override i [Content_Types].xml och två rotrelationer som delade rId4
Varför avvisar Excel ett paket som alla andra läsare accepterar?
För att reparationsprompten är en schema- och paketvalidator, inte ett parserfel. HotXLS-korpusen hade rundrest en lånetabell med 4805 formler genom biblioteket, genom LibreOffice och genom XML-validatorerna i testsviten i veckor. Den sparade filen var strukturellt frisk i den OPC-bemärkelse som används i artikeln om XLSX OPC-relationsuppslagning: varje del nåbar, varje mål upplösbart. Sedan blev en Windows-maskin med Excel 16.0 build 20326 tillgänglig, korpusköraren öppnade den sparade mallen via Workbooks.Open i en isolerad COM-instans med DisplayAlerts avstängt, och anropet misslyckades direkt. Interaktivt ger samma fil den välbekanta dialogen som erbjuder reparation, och reparationsloggen, när Excel bryr sig om att skriva någon, namnger delen men inte regeln. Tre oberoende fel gömde sig i den enda prompten, och Excel rapporterar dem inte ett i taget; det avvisar arbetsboken och lämnar dig att hitta och analysera dem. Det som följer är varje regel, raden i HotXLS som bröt mot den, och fixen som släpptes, för varenda en av dem är en regel som vilken Delphi-XLSX-skrivare som helst kan snubbla på
Regel 1: phoneticPr fontId är obligatoriskt, även när det är noll
Elementet <phoneticPr> bär ett fontId-attribut som är deklarerat use="required" i ECMA-376 Part 1 §18.4.3, och värdet 0 är ett giltigt typsnittsindex, inte en frånvaro. Den gamla kalkylbladsskrivaren i HotXLS behandlade noll som "inte satt" och gav bara attributet när Sheet.PhoneticFontId > 0. Det är en naturlig Delphi-reflex, eftersom heltalsfält är noll som standard, men den producerar <phoneticPr type="noConversion"/> för varje arbetsbok vars fonetiska typsnitt råkar vara det första typsnittet i styles.xml, vilket är exakt vad lånetabellen i HotXLS-korpusen hade. Excel avvisar sedan på vägen in ett värde som det självt hade skrivit
// lxHandleX.pas, kalkylbladsskrivaren — före v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — attributet är obligatoriskt, noll inräknat
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS ger fortfarande bara elementet när TXLSXWorksheet.PhoneticType inte är tom, så arbetsböcker som aldrig haft fonetiska inställningar påverkas inte. Regressionstestet PhoneticSettings_DefaultFontIsExplicit sätter PhoneticFontId till noll på ett nytt ark, sparar och kontrollerar att <phoneticPr fontId="0" finns i xl/worksheets/sheet1.xml. Den vidare lärdomen är att "utelämna när det är standard" bara är säkert när schemat deklarerar ett standardvärde; type och alignment har standardvärden i det elementet, fontId har det inte
Regel 2: en Override per delnamn i [Content_Types].xml
Content types-strömmen får deklarera varje delnamn högst en gång, och Excel behandlar en andra Override för samma PartName som korruption även när båda posterna bär samma ContentType. HotXLS har två skrivare som matar den strömmen. BuildContentTypesXml deklarerar varje del som objektmodellen genererar: arbetsbok, stilar, delade strängar, tema, kalkylblad och, när TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. När PreserveUnsupportedParts är på lägger TXLSXOpaquePackage sedan till en Override för varje del som den fångade ordagrant från källpaketet, så att de byten förblir deklarerade på vägen ut. Kollisionen är en del som lever på båda sidor. Anpassade dokumentegenskaper parsas in i modellen, men källpaketets docProps/custom.xml fångades också som ogenomskinlig, så den sammanslagna strömmen deklarerade den två gånger, och diagram- och pivotcache-delar kan hamna på samma fläck när modellen regenererar en del som det ogenomskinliga lagret också behöll. Före v2.382.5 hade ContentTypeOverridesXml ingen insyn i vad modellen redan hade skrivit, så den kunde inte veta
<!-- Vad Excel såg före 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"/>
Fixen skickar in den genererade XML:en i ContentTypeOverridesXml och låter den ogenomskinliga skrivaren parsa den innan den ger ifrån sig något. Två detaljer bär korrektheten. OpcLowerPartName gör om till gemener, vänder omvänt snedstreck till rakt snedstreck och skalar av inledande snedstreck före jämförelsen, eftersom OPC-delnamn jämförs skiftlägesokänsligt och modellen skriver dem med inledande snedstreck medan det ogenomskinliga lagret lagrar ZIP-postnamn utan. Och anroparen i BuildContentTypesXml skickar Result + '</Types>', vilket stänger det halvfärdiga dokumentet så att TXMLReader ser välformat indata i stället för en avklippt ström. Regeln som växer fram är först-till-kvarn med modellen främst: vad objektmodellen än deklarerar är auktoritativt, och den ogenomskinliga uppspelningen fyller bara luckor
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Parsa den modellgenererade strömmen och samla varje deklarerat 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; // redan deklarerad, eller en rels-del
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Regel 3: relations-Id är unika inom en relationsdel
Varje Relationship i en .rels-del behöver ett Id som är unikt inom den delen, och Excel vägrar paketet när två delar samma. HotXLS skriver paketnivåns _rels/.rels med fasta identifierare: rId1 för arbetsboken, rId2 och rId3 för kärn- och utökade dokumentegenskaper, och rId4 för anpassade egenskaper när modellen har några. Det ogenomskinliga paketet lägger sedan till vilka rotrelationer det än behöll från källan, och numrerar om varje identifierare som redan finns i en UsedIds-lista. Listan kände till rId1 till rId3. Den kände inte till rId4, och den visste inte att modellen var på väg att ge ifrån sig sin egen relation för anpassade egenskaper, så ett källpaket vars relation för anpassade egenskaper också var rId4, vilket är vad Excel skriver som standard, kom ut med två rId4-poster som pekade på samma mål. Anroparen BuildRootRelsXml skickar nu Workbook.FCustomProps.Count > 0 som andra argument, så reserveringen och överhoppningen drivs av samma villkor som avgör om modellen alls ger rId4. Omnumrering är säkert i paketroten eftersom inget inuti arbetsboken refererar rotrelationsidentifierare vid namn; samma knep skulle vara fel en nivå ned, där r:id-attribut i workbook.xml binder till identifierare i arbetsbokens relationsdel, vilket är varför MergeWorkbookRelationshipsXml håller en separat identifierarkarta
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // reserverad av modellskrivaren
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 äger de anpassade egenskaperna nu; spela inte upp källkopian.
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); // lägsta lediga rIdN
UsedIds.Add(String(Id));
...
end;
Vad har de tre felen gemensamt?
Alla tre är symptom på en skrivare med två källor och ingen enskild ägare av paketinvarianterna. Objektmodellen genererar delar den förstår; det ogenomskinliga lagret spelar upp delar den inte förstår, så att en rundresa behåller diagram, pivotcachar, anpassad XML och allt annat som beskrivs i noterna om förlustfri rundresa av theme, extLst och calcChain. Varje sida var för sig konsistent. De krav OPC ställer på hela paketet, unika Override-delnamn och unika relationsidentifierare per del, existerar bara i sömmen där de två sammanfogas, och fram till v2.382.5 kontrollerade ingen sömmen. fontId-buggen har samma form en nivå ned: skrivaren visste vad den ville utelämna men konsulterade aldrig schemat som säger att den inte får. Fixen HotXLS landade i är en fast prioritetsordning i stället för en sammanslagningsheuristik. Modellen skriver först, det ogenomskinliga lagret ser vad som skrevs och viker sig vid varje kollision, och korpusköraren upprätthåller nu invarianterna utifrån med verify_opc_uniqueness, som läser [Content_Types].xml och varje .rels-post i ett sparat paket och fäller fallet vid varje dubblerat PartName, Extension eller Id. Den kontrollen är billig, behöver ingen Excel, och skulle ha fångat två av de tre felen vid första korpuskörningen
Samma batch: utskriftsområden som är formler, inte områden
Excel-genomgången flaggade också lånetabellens _xlnm.Print_Area, som Excel rapporterade som $A$1:$J$29 på originalet och måste rapportera identiskt på den sparade kopian. Två separata buggar låg bakom det enda påståendet. Vid import skar XlsxStripSheetPrefix bort allt fram till det första ! som inte står inom citattecken, så ett dynamiskt utskriftsområde som OFFSET('Print Data'!$A$1,0,0,2,2) kom tillbaka som $A$1,0,0,2,2), och en kvalificerad union som 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 tappade prefixet bara på sitt första segment. Vid export satte skrivaren arknamnet en gång framför hela det lagrade PrintArea, så en vanlig union $A$1:$B$2,$D$1:$E$2 lämnade biblioteket med ett kvalificerat första segment och ett bart andra, vilket Excel inte accepterar som en _xlnm.Print_Area-definition enligt ECMA-376 Part 1 §18.2.5
// Import: skala bara av prefixet när det som återstår är en ren 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(...) returneras orörd
// Export: kvalificera varje kommaavskilt segment, eller inget av 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: skriv ut ordagrant
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;
Parningsregeln är densamma på båda sidor: ett utskriftsområde är ett bart område bara om varje segment parsas som ett, annars är det en formel och färdas ordagrant. PrintArea_FormulaDefinitionSurvivesRoundTrip täcker den namngivna basen, den arkvalificerade basen och unionen genom två spara-och-öppna-cykler. Hur utskriftsområden samspelar med sidinställningar och resten av utskriftsmodellen tas upp i artikeln om arksekretess, sidinställning och utskrift
Hur hittar du vilken regel Excel invänder mot?
Utgå från att din egen validator har fel, för den godkände filen. Validatorn i Open XML SDK namnger en schemabrytelse som det saknade fontId med delen och XPath-uttrycket, och paketeringslagret under den vägrar alls öppna ett paket med dubblerade content-type-poster, så kör den före allt annat. När den är tyst och Excel ändå reparerar, bisectera paketet: packa upp, radera en del plus dess relation och dess Override, packa om och öppna igen, och halvera kandidatmängden varje gång tills prompten försvinner. De tre felen här föll ut i den ordningen, och inget av dem skulle ha synts i den reparerade filen som Excel erbjuder att spara, eftersom reparationen tyst kastar eller numrerar om de felande posterna. Gränserna för v2.382.5-fixen är värda att säga lika rakt. Dedupliceringen är först-till-kvarn med modellen främst, så om källpaketet deklarerade en annan innehållstyp för en del som modellen också genererar vinner modellens deklaration och källans kastas, vilket är korrekt för de delar HotXLS regenererar och inte är en allmän sammanslagning. verify_opc_uniqueness kontrollerar bara unikhet; den validerar inte scheman, så ett framtida obligatoriskt attribut skulle fortfarande behöva Excel eller en schemavalidator för att komma upp till ytan. Och den extra TXMLReader-genomgången av den genererade content types-strömmen kör vid varje sparning med PreserveUnsupportedParts aktiverat, en liten kostnad mot en ström som sällan är mer än några kilobyte. Med dem på plats öppnas nu både Win32- och Win64-bygget av lånetabellen i Excel utan prompt, räknar om alla 4805 verifierade formler med noll avvikelser och rapporterar samma utskriftsområde som originalet
Om du skriver XLSX från Delphi själv är checklistan kort: ge ifrån dig varje attribut som schemat markerar som obligatoriskt oavsett dess värde, deklarera varje delnamn en gång, och håll en enda lista över använda identifierare per relationsdel hos varje skrivare som rör den. Om du hellre ser att listan redan finns och är testad mot Excel i stället för bara mot din egen läsare, levereras paketskrivaren som beskrivs här i HotXLS Delphi spreadsheet component, tillsammans med rundresan för ogenomskinliga delar som gjorde sömmen värd att vakta från första början