Excel zobrazí „We found a problem with some content“ pri XLSX, ktorý LibreOffice aj každý vlastnoručný reader otvorí bez sťažnosti, pretože Excel vyžaduje dve veci, ktoré tieto readery ignorujú: atribúty povinné podľa schémy a pravidlá jedinečnosti z Open Packaging Conventions. HotXLS, natívny Excel tabuľkový komponent pre Delphi a C++Builder, na to narazil presne vo v2.382.5, keď jeho výstup prvýkrát prešiel skutočnou Excel COM inštanciou, a príčiny boli tri: <phoneticPr> bez fontId, duplicitné Override v [Content_Types].xml a dva root relationships zdieľajúce rId4
Prečo Excel odmieta package, ktorý každý iný reader prijme?
Pretože výzva na opravu je schema a package validátor, nie zlyhanie parsera. Corpus HotXLS už týždne round-tripoval úverovú šablónu s 4805 formulami cez knižnicu, cez LibreOffice aj cez XML validátory v test suite. Uložený súbor bol štrukturálne v poriadku v OPC zmysle, aký používa článok o XLSX OPC relationship resolution: každá časť dosiahnuteľná, každý cieľ rozlíšiteľný. Potom pribudol Windows stroj s Excelom 16.0 build 20326, corpus runner otvoril uloženú šablónu cez Workbooks.Open v izolovanej COM inštancii s vypnutým DisplayAlerts a volanie rovno zlyhalo. Interaktívne ten istý súbor vyvolá známy dialóg s ponukou opravy, a repair log, keď sa Excel obťažuje nejaký napísať, pomenuje časť, ale nie pravidlo. V tej jednej výzve sa skrývali tri samostatné defekty a Excel ich nehlási po jednom; odmietne workbook a hľadanie aj analýzu nechá na vás. Nasleduje každé pravidlo, riadok HotXLS, ktorý ho porušil, a oprava, ktorá vyšla, pretože každé z nich je pravidlo, o ktoré môže zakopnúť hocijaký Delphi XLSX writer
Pravidlo 1: phoneticPr fontId je povinný, aj keď je nula
Element <phoneticPr> nesie atribút fontId deklarovaný ako use="required" v ECMA-376 Part 1 §18.4.3, a hodnota 0 je legálny index fontu, nie jeho absencia. Starý worksheet writer v HotXLS bral nulu ako „nenastavené“ a atribút emitoval len vtedy, keď Sheet.PhoneticFontId > 0. To je prirodzený Delphi reflex, keďže celočíselné polia defaultujú na nulu, ale produkuje <phoneticPr type="noConversion"/> pre každý workbook, ktorého fonetický font je náhodou prvým fontom v styles.xml, čo je presne to, čo niesla úverová šablóna v korpuse HotXLS. Excel potom pri ceste späť odmietne hodnotu, ktorú sám zapísal
// lxHandleX.pas, worksheet writer — pred v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — atribút je povinný, vrátane nuly
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS element stále emituje len vtedy, keď je TXLSXWorksheet.PhoneticType neprázdny, takže workbooky, ktoré nikdy nemali fonetické nastavenia, to nepocítia. Regresný test PhoneticSettings_DefaultFontIsExplicit nastaví PhoneticFontId na nulu na čerstvom hárku, uloží a assertuje, že <phoneticPr fontId="0" je prítomné v xl/worksheets/sheet1.xml. Širšia lekcia je, že „vynechaj, keď je default“ je bezpečné len vtedy, keď schéma default deklaruje; type a alignment default v tom elemente majú, fontId nie
Pravidlo 2: jeden Override na meno partu v [Content_Types].xml
Content types stream môže každé meno partu deklarovať najviac raz a Excel berie druhé Override pre to isté PartName ako poškodenie, aj keď oba záznamy nesú rovnaký ContentType. Do toho streamu píšu v HotXLS dvaja writery. BuildContentTypesXml deklaruje každú časť, ktorú generuje object model: workbook, styles, shared strings, theme, worksheets a, keď TXLSXWorkbook.CustomProperties.Count > 0, aj /docProps/custom.xml. Keď je zapnuté PreserveUnsupportedParts, TXLSXOpaquePackage potom pridá Override pre každú časť, ktorú doslovne zachytil zo zdrojového package, aby tie bajty zostali na ceste von deklarované. Kolízia je časť, ktorá žije na oboch stranách. Vlastné dokumentové properties sa parsujú do modelu, ale docProps/custom.xml zdrojového package sa zachytil aj opaque cestou, takže zlúčený stream ho deklaroval dvakrát, a chart a pivot cache party môžu skončiť na tom istom mieste, keď model regeneruje časť, ktorú opaque vrstva tiež podržala. Pred v2.382.5 nemal ContentTypeOverridesXml prehľad o tom, čo model už zapísal, takže to nemohol vedieť
<!-- Čo Excel videl pred 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"/>
Oprava posiela vygenerované XML do ContentTypeOverridesXml a nechá opaque writer naparsovať ho skôr, než čokoľvek emituje. Správnosť nesú dva detaily. OpcLowerPartName pred porovnaním prepíše na malé písmená, zmení spätné lomky na dopredné a strhne vedúce lomky, pretože OPC mená partov sa porovnávajú case-insensitive a model ich zapisuje s vedúcim lomkom, kým opaque vrstva ukladá názvy ZIP položiek bez neho. A volajúci v BuildContentTypesXml posiela Result + '</Types>', čím uzavrie rozostavaný dokument, aby TXMLReader videl well-formed vstup a nie odseknutý stream. Pravidlo, ktoré z toho vychádza, je first-wins s modelom vpredu: čo deklaruje object model, je autoritatívne, a opaque replay len dopĺňa medzery
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Naparsuj modelom generovaný stream a pozbieraj každé deklarované 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; // už deklarované, alebo rels časť
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Pravidlo 3: relationship Id sú jedinečné v rámci relationships partu
Každý Relationship v .rels parte potrebuje Id, ktoré je v rámci toho partu jedinečné, a Excel package odmietne, keď dve zdieľajú jedno. HotXLS zapisuje package-level _rels/.rels s pevnými identifikátormi: rId1 pre workbook, rId2 a rId3 pre core a extended dokumentové properties a rId4 pre vlastné properties, keď ich model nejaké má. Opaque package potom pridá root relationships, ktoré podržal zo zdroja, a prečísluje každý identifikátor, ktorý už je v zozname UsedIds. Ten zoznam vedel o rId1 až rId3. Nevedel o rId4 a nevedel, že model sa chystá emitovať vlastný custom-properties relationship, takže zdrojový package, ktorého custom-properties relationship bolo tiež rId4, čo Excel zapisuje defaultne, vyšiel s dvoma rId4 záznamami ukazujúcimi na ten istý cieľ. Volajúci BuildRootRelsXml teraz posiela Workbook.FCustomProps.Count > 0 ako druhý argument, takže rezerváciu aj skip riadi tá istá podmienka, ktorá rozhoduje o tom, či model vôbec emituje rId4. Prečíslovanie je na koreni package bezpečné, pretože nič vnútri workbooku neodkazuje na root relationship identifikátory podľa mena; ten istý trik by bol nesprávny o úroveň nižšie, kde sa atribúty r:id vo workbook.xml viažu na identifikátory v relationships parte workbooku, a preto MergeWorkbookRelationshipsXml drží samostatnú mapu identifikátorov
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // rezervované modelovým writerom
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// Vlastné properties teraz vlastní model; zdrojovú kópiu neprehrávaj.
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); // najnižšie voľné rIdN
UsedIds.Add(String(Id));
...
end;
Čo majú tie tri zlyhania spoločné?
Všetky tri sú príznakom writera s dvoma zdrojmi a bez jediného vlastníka package invariant. Object model generuje časti, ktorým rozumie; opaque vrstva prehráva tie, ktorým nerozumie, aby round trip udržal grafy, pivot cache, vlastné XML a všetko ostatné opísané v poznámkach o lossless round-tripe témy, extLst a calcChain. Každá strana bola sama o sebe konzistentná. Obmedzenia, ktoré OPC kladie na celý package, teda jedinečné mená Override partov a jedinečné relationship identifikátory na part, existujú len na švíku, kde sa obe spájajú, a do v2.382.5 ten švík nikto nekontroloval. Bug s fontId má ten istý tvar o úroveň nižšie: writer vedel, čo chce vynechať, ale nikdy sa neopýtal schémy, ktorá hovorí, že nesmie. Oprava, na ktorej sa HotXLS ustálil, je pevná precedencia a nie merge heuristika. Model píše prvý, opaque vrstva vidí, čo sa zapísalo, a pri každej kolízii ustúpi, a corpus runner teraz vynucuje invarianty zvonka cez verify_opc_uniqueness, ktorá prečíta [Content_Types].xml a každú .rels položku v uloženom package a zhodí prípad na každom duplicitnom PartName, Extension alebo Id. Tá kontrola je lacná, nepotrebuje Excel a chytila by dva z troch defektov hneď v prvom corpus behu
Ten istý batch: print areas, ktoré sú formulami, nie rozsahmi
Excel prechod označil aj _xlnm.Print_Area úverovej šablóny, ktorú Excel hlásil ako $A$1:$J$29 na origináli a musel ju rovnako hlásiť aj na uloženej kópii. Za tou jednou assertion sedeli dva samostatné bugy. Pri importe XlsxStripSheetPrefix odsekol všetko po prvý neúvodzovkovaný !, takže dynamická print area ako OFFSET('Print Data'!$A$1,0,0,2,2) sa vrátila ako $A$1,0,0,2,2) a kvalifikovaná únia ako 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 stratila prefix len na svojom prvom segmente. Pri exporte writer pridal meno hárku raz pred celú uloženú PrintArea, takže obyčajná únia $A$1:$B$2,$D$1:$E$2 nechala knižnicu s kvalifikovaným prvým segmentom a holým druhým, čo Excel neprijíma ako definíciu _xlnm.Print_Area podľa ECMA-376 Part 1 §18.2.5
// Import: prefix strhni len vtedy, keď to, čo zostane, je obyčajný 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(...) sa vracia nedotknuté
// Export: kvalifikuj každý segment oddelený čiarkou, alebo žiadny
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; // formula: emituj doslovne
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;
Pravidlo párovania je na oboch stranách rovnaké: print area je holý rozsah len vtedy, keď sa každý segment parsuje ako rozsah, inak je to formula a cestuje doslovne. PrintArea_FormulaDefinitionSurvivesRoundTrip pokrýva pomenovanú bázu, bázu kvalifikovanú hárkom aj úniu cez dva cykly uloženia a znovuotvorenia. Ako print areas interagujú s page setup a zvyškom tlačového modelu, pokrýva článok o ochrane hárku, page setup a tlači
Ako zistíte, proti ktorému pravidlu Excel namieta?
Vychádzajte z predpokladu, že váš vlastný validátor sa mýli, pretože prešiel. Validátor z Open XML SDK pomenuje porušenie schémy, ako chýbajúce fontId, spolu s partom a XPath, a packaging vrstva pod ním odmietne otvoriť package s duplicitnými content-type záznamami úplne, takže ho púšťajte pred všetkým ostatným. Keď mlčí a Excel aj tak opravuje, bisekujte package: rozzipujte, zmažte časť aj jej relationship aj jej Override, zzipujte a znova otvorte, a kandidátsku množinu každým krokom polte, kým výzva nezmizne. Tri defekty odtiaľto vypadli presne v tomto poradí a ani jeden z nich by nebol viditeľný v opravenom súbore, ktorý Excel ponúkne uložiť, pretože oprava problematické záznamy ticho zahodí alebo prečísluje. Hranice opravy vo v2.382.5 stoja za to povedať rovnako priamo. Deduplikácia je first-wins s modelom vpredu, takže ak zdrojový package deklaroval iný content type pre časť, ktorú model tiež generuje, vyhrá deklarácia modelu a zdrojová sa zahodí, čo je správne pre časti, ktoré HotXLS regeneruje, a nie je to všeobecný merge. verify_opc_uniqueness kontroluje len jedinečnosť; nevaliduje schémy, takže budúci povinný atribút by stále potreboval Excel alebo schema validátor, aby sa vynoril. A extra prechod TXMLReader cez vygenerovaný content types stream beží pri každom uložení so zapnutým PreserveUnsupportedParts, čo je malá cena proti streamu, ktorý málokedy presiahne pár kilobajtov. S tým všetkým sa obe buildy úverovej šablóny, Win32 aj Win64, teraz otvárajú v Exceli bez výzvy, prepočítajú všetkých 4805 overených formúl s nulovými nezhodami a hlásia rovnakú print area ako originál
Ak píšete XLSX z Delphi sami, checklist je krátky: emitujte každý atribút, ktorý schéma označuje za povinný, bez ohľadu na jeho hodnotu, deklarujte každé meno partu raz a držte jeden zoznam použitých identifikátorov na relationships part naprieč všetkými writermi, ktoré sa ho dotknú. Ak by ste radšej mali ten zoznam už hotový a testovaný proti Excelu a nie len proti vlastnému readeru, package writer opísaný v tomto článku vychádza v HotXLS Delphi spreadsheet component, spolu s opaque-part round-tripom, ktorý spravil ten švík hodným stráženia