Excel hlásí „Nalezen problém s částí obsahu" u XLSX, které LibreOffice i každou domácí čtečku otevřou bez jediného slova, protože Excel vynucuje dvě věci, které ty čtečky ignorují: atributy vyžadované schématem a pravidla jednoznačnosti Open Packaging Conventions. HotXLS, nativní tabulková komponenta Excelu pro Delphi a C++Builder, na to narazila ve v2.382.5 poprvé, když její výstup prošel skutečnou instancí Excel COM, a tři příčiny byly <phoneticPr> bez fontId, zdvojený Override v [Content_Types].xml a dvě kořenové relace sdílející rId4
Proč Excel odmítne balíček, který každá jiná čtečka přijme?
Protože výzva k opravě je validátor schématu a balíčku, ne selhání parseru. Korpus HotXLS přežíval šablonu půjčky s 4805 vzorci knihovnou, LibreOfficem i XML validátory v testovací sadě celé týdny. Uložený soubor byl strukturálně zdravý v tom OPC smyslu, který používá článek o rozřešení OPC relací v XLSX: každý part dosažitelný, každý cíl rozřešitelný. Pak se zpřístupnil Windows stroj s Excelem 16.0 build 20326, korpusový běhač otevřel uloženou šablonu přes Workbooks.Open v izolované instanci COM s vypnutými DisplayAlerts a volání selhalo naprosto. Interaktivně tentýž soubor vyhodí známý dialog s nabídkou opravy a opravný log, když se Excel k jeho zápisu dopracuje, jmenuje part, ne pravidlo. V té jediné výzvě se skrývaly tři nezávislé defekty a Excel je nehlásí jeden po druhém; odmítne sešit a jejich hledání a analýzu nechá na vás. Následuje každé pravidlo, řádek HotXLS, který ho porušil, a oprava, která vyšla, protože každé z nich je pravidlo, o které může zakopnout jakýkoli XLSX zapisovač v Delphi
Pravidlo 1: phoneticPr fontId je povinné, i když je nula
Element <phoneticPr> nese atribut fontId deklarovaný use="required" v ECMA-376 Part 1 §18.4.3 a hodnota 0 je legální index fontu, ne absence. Starý zapisovač listů v HotXLS bral nulu jako „nenastaveno" a atribut emitoval jen když Sheet.PhoneticFontId > 0. Je to přirozený delphí reflex, protože celočíselná pole mají default nulu, ale výsledkem je <phoneticPr type="noConversion"/> pro jakýkoli sešit, jehož fonetický font je zrovna první font v styles.xml, což přesně nesla šablona půjčky v korpusu HotXLS. Excel pak při cestě zpátky odmítá hodnotu, kterou si sám napsal
// lxHandleX.pas, zapisovač listů — před v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — atribut je povinný, nula nevyjímaje
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS i nadále emituje element jen když TXLSXWorksheet.PhoneticType není prázdné, takže sešity, které fonetická nastavení nikdy nenesly, se nedotkne. Regresní test PhoneticSettings_DefaultFontIsExplicit nastaví PhoneticFontId na nulu na čerstvém listu, uloží a assertuje, že <phoneticPr fontId="0" je přítomné v xl/worksheets/sheet1.xml. Širší poučení zní, že „vynechat při defaultu" je bezpečné jen tehdy, když schéma default deklaruje; type a alignment v tom elementu defaulty mají, fontId ne
Pravidlo 2: jeden Override na název partu v [Content_Types].xml
Content types stream smí deklarovat každý název partu nejvýše jednou a Excel bere druhý Override pro tentýž PartName jako korupci, i když obě položky nesou totéž ContentType. Do toho streamu se v HotXLS dva zapisovače. BuildContentTypesXml deklaruje každý part, který objektový model generuje: workbook, styles, shared strings, theme, listy a, když TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Když je zapnuté PreserveUnsupportedParts, přidá TXLSXOpaquePackage pak Override ke každému partu, který zachytil verbatim ze zdrojového balíčku, aby ty bajty zůstaly na výstupu deklarované. Kolize je part, který žije na obou stranách. Vlastní vlastnosti dokumentu se parsují do modelu, ale docProps/custom.xml zdrojového balíčku se zároveň zachytil neprůhledně, takže sloučený stream ho deklaroval dvakrát a party grafů a pivot cache se můžou ocitnout na stejném místě, když model regeneruje part, který neprůhledná vrstva taky uchovala. Před v2.382.5 nemělo ContentTypeOverridesXml žádný přehled o tom, co model už napsal, takže to nemohlo vědět
<!-- Co Excel viděl před 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 předává vygenerované XML do ContentTypeOverridesXml a nechá neprůhledného zapisovače ho nejdřív naparsovat, než emituje cokoli. Správnost nese dva detaily. OpcLowerPartName provede lowercase, překlopí zpětná lomítka na dopředná a před porovnáním ořízne úvodní lomítka, protože názvy partů v OPC se porovnávají bez ohledu na velikost písmen a model je píše s úvodním lomítkem, zatímco neprůhledná vrstva ukládá názvy položek ZIP bez něj. A volající v BuildContentTypesXml předává Result + '</Types>', čímž uzavře napůl postavený dokument, takže TXMLReader vidí well-formed vstup, ne uťatý stream. Pravidlo, které z toho vychází, je první vyhrává s modelem v čele: co objektový model deklaruje, je autoritativní a neprůhledné přehrání jen doplňuje mezery
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Naparsujte stream generovaný modelem a posbírejte 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é, nebo part .rels
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Pravidlo 3: Id relací jsou jednoznačná v rámci partu relací
Každý Relationship v partu .rels potřebuje Id jednoznačné v rámci toho partu a balíček, ve kterém se dvě sdílí, Excel odmítne. HotXLS píše balíčkovou úroveň _rels/.rels s pevnými identifikátory: rId1 pro workbook, rId2 a rId3 pro core a rozšířené vlastnosti dokumentu a rId4 pro vlastní vlastnosti, když je model má. Neprůhledný balíček pak připojí jakékoli kořenové relace uchované ze zdroje, s přečíslováním jakéhokoli identifikátoru, který už je v seznamu UsedIds. Seznam znal rId1 až rId3. O rId4 nevěděl a nevěděl ani to, že model chystá emitovat vlastní relaci custom-properties, takže zdrojový balíček, jehož relace custom-properties byla také rId4, což Excel píše defaultně, vyšel se dvěma položkami rId4 mířícími na stejný cíl. Volající, BuildRootRelsXml, teď předává Workbook.FCustomProps.Count > 0 jako druhý argument, takže rezervaci i přeskočení řídí táž podmínka, která rozhoduje, zda model vůbec emituje rId4. Přečíslování je na kořeni balíčku bezpečné, protože nic uvnitř sešitu neodkazuje na kořenové identifikátory relací jménem; tentýž trik by byl o úroveň níž chybný, kde se atributy r:id v workbook.xml vážou na identifikátory v partu relací sešitu, proto MergeWorkbookRelationshipsXml drží samostatnou mapu identifikátorů
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // rezervováno zapisovačem modelu
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// Custom properties teď vlastní model; nepřehrávejte zdrojovou kopii.
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); // nejnižší volné rIdN
UsedIds.Add(String(Id));
...
end;
Co mají ta tři selhání společného?
Všechny tři jsou symptomy zapisovače se dvěma zdroji a bez jediného vlastníka invariant balíčku. Objektový model generuje party, kterým rozumí; neprůhledná vrstva přehrává party, kterým nerozumí, aby round trip zachoval grafy, pivot cache, custom XML a všechno ostatní popsané v poznámkách o bezztrátovém round tripu theme, extLst a calcChain. Každá strana byla sama o sobě konzistentní. Omezení, která OPC klade na celý balíček, jednoznačné názvy partů Override a jednoznačné identifikátory relací na part, existují jen v švu, kde se ty dvě slepují, a až do v2.382.5 nikdo šev nekontroloval. Bug fontId je týž tvar o úroveň níž: zapisovač věděl, co chce vynechat, ale nikdy se nezeptal schématu, které říká, že nesmí. Oprava, kterou HotXLS zvolil, je pevná precedence, ne slučovací heuristika. Model píše první, neprůhledná vrstva vidí, co napsáno bylo, a u každé kolize ustoupí, a korpusový běhač teď vynucuje invarianty zvenčí přes verify_opc_uniqueness, který čte [Content_Types].xml a každou položku .rels v uloženém balíčku a shodí případ na jakémkoli duplicitním PartName, Extension nebo Id. Ta kontrola je laciná, nepotřebuje Excel a na prvním korpusovém běhu by chytla dva ze tří defektů
Stejná dávka: oblasti tisku, které jsou vzorce, ne rozsahy
Průchod Excelem též označil _xlnm.Print_Area šablony půjčky, kterou Excel hlásil jako $A$1:$J$29 na originále a musel hlásit stejně na uložené kopii. Za tím jedním assertionem seděly dva samostatné bugy. Při importu XlsxStripSheetPrefix odřízl všechno až po první neuvozovkované !, takže dynamická tisková oblast jako OFFSET('Print Data'!$A$1,0,0,2,2) se vrátila jako $A$1,0,0,2,2) a kvalifikovaný union jako 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 ztratil prefix jen na prvním segmentu. Při exportu zapisovač přidal název listu jednou k celému uloženému PrintArea, takže prostý union $A$1:$B$2,$D$1:$E$2 opustil knihovnu s kvalifikovaným prvním segmentem a holým druhým, což Excel nebere jako definici _xlnm.Print_Area pod ECMA-376 Part 1 §18.2.5
// Import: odřízni prefix jen když zbytkem je holý 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(...) se vrací nedotčené
// Export: kvalifikuj každý segment oddělený čárkou, nebo žádný
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; // vzorec: emituj verbatim
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árování je na obou stranách totéž: tisková oblast je holý rozsah jen tehdy, když se jako jeden parsuje každý segment, jinak je to vzorec a cestuje verbatim. PrintArea_FormulaDefinitionSurvivesRoundTrip pokrývá pojmenovanou základnu, základnu kvalifikovanou listem a union přes dva cykly ulož a otevř znovu. Jak tiskové oblasti interagují s nastavením stránky a zbytkem tiskového modelu, rozebírá článek o ochraně listů, nastavení stránky a tisku
Jak zjistíte, kterému pravidlu Excel vadí?
Začněte z předpokladu, že váš vlastní validátor se mýlí, protože prošel. Validátor Open XML SDK pojmenuje porušení schématu jako chybějící fontId s partem a XPath a vrstva balení pod ním odmítá vůbec otevřít balíček s duplicitními položkami content-type, takže ji pusťte jako první. Když mlčí a Excel pořád opravuje, bisektujte balíček: rozbalte, smažte part, jeho relaci a jeho Override, zabalte znovu a otevřete, s tím, že kandidátní množinu každým krokem na polovinu, dokud výzva nezmizí. Tři defekty zde vypadly v tom pořadí a žádný z nich by nebyl vidět v opraveném souboru, který Excel nabízí uložit, protože oprava offending položky potichu zahazuje nebo přečíslovává. Hranice opravy v2.382.5 stojí za to vyslovit stejně naplno. Deduplikace je první-vyhrává s modelem v čele, takže pokud zdrojový balíček deklaroval jiný content type pro part, který generuje i model, vyhrává deklarace modelu a zdrojová se zahodí, což je správně pro party, které HotXLS regeneruje, a není to obecný merge. verify_opc_uniqueness kontroluje jen jednoznačnost; schémata nevaliduje, takže budoucí povinný atribut by pořád potřeboval Excel nebo validátor schématu, aby vyšel najevo. A ten extra průchod TXMLReader přes generovaný stream content types běží při každém uložení se zapnutým PreserveUnsupportedParts, malá cena za stream, který málokdy přesáhne pár kilobytů. S tím na místě teď obě sestavení Win32 i Win64 šablony půjčky otevřou v Excelu bez výzvy, přepočítají všech 4805 ověřených vzorců bez jediné odchylky a hlásí stejnou tiskovou oblast jako originál
Když si XLSX z Delphi píšete sami, checklist je krátký: emitujte každý atribut, který schéma označí jako povinný, bez ohledu na jeho hodnotu, deklarujte každý název partu jen jednou a držte jediný seznam použitých identifikátorů na part relací napříč každým zapisovačem, který do něj sahá. Kdybyste radši, aby ten seznam už existoval a byl testovaný proti Excelu, ne jen proti vlastní čtečce, zapisovač balíčku popsaný tady dodává HotXLS jako tabulkovou komponentu pro Delphi, spolu s round tripem neprůhledných partů, který stál za to, aby se ten šev vůbec hlídal