Excel pokaže "Našli smo težavo z nekatero vsebino" pri datoteki XLSX, ki jo LibreOffice in prav vsak bralnik, narejen doma, odpreta brez pritožbe, ker Excel uveljavlja dvoje, kar ti bralniki spregledajo: atribute, ki jih zahteva shema, in pravila edinstvenosti iz odprtih konvencij pakiranja (OPC). HotXLS, izvorna komponenta za preglednice Excel za Delphi in C++Builder, je v različici 2.382.5 naletel natanko na to, ko je njegov izhod prvič šel skozi pravo instanco COM programa Excel, trije vzroki pa so bili <phoneticPr> brez fontId, podvojen Override v [Content_Types].xml in dve korenski relaciji, ki sta si delili rId4
Zakaj Excel zavrne paket, ki ga sprejme vsak drug bralnik?
Ker poziv k popravilu ni napaka razčlenjevalnika, ampak preverjanje sheme in paketa. Korpus HotXLS je tedne vozil posojilno predlogo s 4805 formulami v obe smeri skozi knjižnico, skozi LibreOffice in skozi preverjevalnike XML v preskusnem nizu. Shranjena datoteka je bila strukturno zdrava v smislu OPC, ki ga uporablja članek o razreševanju relacij OPC pri XLSX: vsak del dosegljiv, vsak cilj razrešljiv. Nato je postala na voljo škatla Windows z Excelom 16.0 build 20326, izvajalnik korpusa je shranjeno predlogo odprl prek Workbooks.Open v izolirani instanci COM z izključenim DisplayAlerts in klic je takoj odpovedal. Interaktivno ista datoteka prikaže znano pogovorno okno s ponudbo za popravilo, dnevnik popravila pa, kadar se Excel sploh potrudi, da bi ga zapisal, navede del, ne pa pravila. Za tem enim pozivom so se skrivali trije neodvisni defekti in Excel jih ne prijavi enega za drugim; delovni zvezek zavrne, njihovo iskanje in analizo pa prepusti vam. Sledi vsako pravilo, vrstica HotXLS, ki ga je prekršila, in popravek, ki je izšel, ker je vsako od njih pravilo, ob katero se lahko spotakne vsak pisec XLSX v Delphiju
Pravilo 1: fontId v phoneticPr je obvezen, tudi ko je nič
Element <phoneticPr> nosi atribut fontId, ki je v ECMA-376 1. del §18.4.3 razglašen kot use="required", vrednost 0 pa je zakonit indeks pisave in ne odsotnost. Stari pisec delovnih listov v HotXLS je ničlo obravnaval kot "nenastavljeno" in atribut oddal samo, kadar je veljalo Sheet.PhoneticFontId > 0. To je naravni refleks v Delphiju, saj so celoštevilska polja privzeto nič, a za vsak delovni zvezek, katerega fonetična pisava je slučajno prva pisava v styles.xml, proizvede <phoneticPr type="noConversion"/> — natanko to pa je nosila posojilna predloga v korpusu HotXLS. Excel nato ob vračanju zavrne vrednost, ki jo je zapisal sam
// lxHandleX.pas, pisec delovnih listov — pred v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — atribut je obvezen, tudi ničla
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS element še vedno odda le, kadar je TXLSXWorksheet.PhoneticType neprazen, zato delovni zvezki, ki niso nikoli nosili fonetičnih nastavitev, ostanejo nedotaknjeni. Regresijski test PhoneticSettings_DefaultFontIsExplicit na svežem listu nastavi PhoneticFontId na nič, shrani in zatrdi, da je <phoneticPr fontId="0" prisoten v xl/worksheets/sheet1.xml. Širša lekcija je, da je "izpusti, ko je privzeto" varno samo takrat, ko shema privzeto vrednost razglasi; type in alignment jo v tem elementu imata, fontId pa ne
Pravilo 2: en Override na ime dela v [Content_Types].xml
Tok vrst vsebine sme vsako ime dela razglasiti največ enkrat in Excel drugi Override za isti PartName obravnava kot okvaro, tudi kadar oba vnosa nosita isti ContentType. V HotXLS v ta tok pišeta dva pisca. BuildContentTypesXml razglasi vsak del, ki ga ustvari objektni model: delovni zvezek, stile, skupne nize, temo, delovne liste in, kadar velja TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Ko je PreserveUnsupportedParts vklopljen, TXLSXOpaquePackage nato doda Override za vsak del, ki ga je dobesedno zajel iz izvornega paketa, da ti bajti na poti ven ostanejo razglašeni. Trk nastane pri delu, ki živi na obeh straneh. Lastnosti po meri se razčlenijo v model, izvorni paket pa je svoj docProps/custom.xml zajel tudi neprozorno, zato ga je združeni tok razglasil dvakrat; deli grafikona in predpomnilnika pivot pa lahko pristanejo na istem mestu, ko model znova zgradi del, ki ga je neprozorni sloj prav tako obdržal. Pred različico 2.382.5 ContentTypeOverridesXml ni imel vpogleda v to, kaj je model že zapisal, zato tega ni mogel vedeti
<!-- Kaj je 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"/>
Popravek posreduje generirani XML v ContentTypeOverridesXml in pusti neprozornemu piscu, da ga razčleni, preden kar koli odda. Pravilnost nosita dve podrobnosti. OpcLowerPartName pred primerjavo pretvori v male črke, obrne poševnice nazaj v poševnice naprej in odstrani vodilne poševnice, ker se imena delov OPC primerjajo brez razlikovanja velikih in malih črk, model pa jih zapiše z vodilno poševnico, medtem ko neprozorni sloj shranjuje imena elementov ZIP brez nje. In klicatelj v BuildContentTypesXml posreduje Result + '</Types>', s čimer zapre napol zgrajeni dokument, da TXMLReader vidi dobro oblikovan vnos in ne odrezanega toka. Pravilo, ki iz tega izhaja, je prvi zmaga, z modelom spredaj: kar razglasi objektni model, je merodajno, neprozorno predvajanje pa le zapolnjuje vrzeli
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Razčleni tok, ki ga je ustvaril model, in zberi vsak razglašeni 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; // že razglašen ali del rels
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Pravilo 3: identifikatorji relacij so znotraj dela relacij edinstveni
Vsak Relationship v delu .rels potrebuje Id, ki je znotraj tega dela edinstven, in Excel paket zavrne, ko si dva delita enega. HotXLS zapiše _rels/.rels na ravni paketa s stalnimi identifikatorji: rId1 za delovni zvezek, rId2 in rId3 za osnovne in razširjene lastnosti dokumenta ter rId4 za lastnosti po meri, kadar jih model ima. Neprozorni paket nato doda, kar je od korenskih relacij obdržal iz vira, in znova oštevilči vsak identifikator, ki je že na seznamu UsedIds. Seznam je poznal rId1 do rId3. Ni poznal rId4 in ni vedel, da bo model pravkar oddal svojo relacijo za lastnosti po meri, zato je izvorni paket, katerega relacija za lastnosti po meri je bila prav tako rId4 — kar Excel zapiše privzeto — izšel z dvema vnosoma rId4, ki kažeta na isti cilj. Klicatelj BuildRootRelsXml zdaj kot drugi argument posreduje Workbook.FCustomProps.Count > 0, tako da rezervacijo in preskok poganja isti pogoj, ki odloča, ali model sploh odda rId4. Ponovno oštevilčenje je v korenu paketa varno, ker se nič v delovnem zvezku ne sklicuje na identifikatorje korenskih relacij po imenu; isti prijem bi bil napačen raven nižje, kjer se atributi r:id v workbook.xml vežejo na identifikatorje v delu relacij delovnega zvezka, zato MergeWorkbookRelationshipsXml hrani ločen zemljevid identifikatorjev
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // rezerviral ga je pisec modela
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// Lastnosti po meri so zdaj v lasti modela; izvorne kopije ne predvajaj.
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žji prosti rIdN
UsedIds.Add(String(Id));
...
end;
Kaj je skupnega vsem trem odpovedim?
Vse tri so simptomi pisca z dvema viroma in brez enega samega lastnika invariant paketa. Objektni model ustvari dele, ki jih razume; neprozorni sloj predvaja dele, ki jih ne, tako da obhod v obe smeri ohrani grafikone, predpomnilnike pivot, XML po meri in vse drugo, opisano v zapiskih o brezizgubnem round-tripu teme, blokov extLst in calcChain. Vsaka stran je bila sama zase dosledna. Omejitvi, ki jih OPC nalaga celotnemu paketu — edinstvena imena delov Override in edinstveni identifikatorji relacij na del — obstajata samo na stiku, kjer se oba sestavita, in do različice 2.382.5 tega stika ni preverjal nihče. Napaka s fontId je enake oblike raven nižje: pisec je vedel, kaj hoče izpustiti, ni pa nikoli povprašal sheme, ki pravi, da tega ne sme. Popravek, na katerem se je HotXLS ustavil, je fiksna prednost in ne hevristika združevanja. Najprej piše model, neprozorni sloj vidi, kaj je bilo zapisano, in pri vsakem trku popusti, izvajalnik korpusa pa zdaj invariante uveljavlja od zunaj s verify_opc_uniqueness, ki prebere [Content_Types].xml in vsak element .rels v shranjenem paketu ter primer odpove ob vsakem podvojenem PartName, Extension ali Id. To preverjanje je poceni, ne potrebuje Excela in bi dva od treh defektov ujelo že ob prvem zagonu korpusa
Ista serija: obsegi tiskanja, ki so formule in ne obsegi
Prehod skozi Excel je opozoril tudi na _xlnm.Print_Area posojilne predloge, ki ga je Excel v izvirniku prijavil kot $A$1:$J$29 in ga je moral v shranjeni kopiji prijaviti enako. Za to eno trditvijo sta se skrivala dva ločena hrošča. Ob uvozu je XlsxStripSheetPrefix odrezal vse do prvega nezakritega !, zato se je dinamični obseg tiskanja, kot je OFFSET('Print Data'!$A$1,0,0,2,2), vrnil kot $A$1,0,0,2,2), kvalificirana unija, kot je 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2, pa je predpono izgubila le na svojem prvem segmentu. Ob izvozu je pisec ime lista pripel celotnemu shranjenemu PrintArea, zato je navadna unija $A$1:$B$2,$D$1:$E$2 iz knjižnice prišla s kvalificiranim prvim segmentom in golim drugim, česar Excel kot definicijo _xlnm.Print_Area po ECMA-376 1. del §18.2.5 ne sprejme
// Uvoz: predpono odstrani le, kadar je preostanek navaden 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 vrne nedotaknjen
// Izvoz: kvalificiraj vsak segment, ločen z vejico, ali pa nobenega
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: oddaj dobesedno
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;
Pravilo parjenja je na obeh straneh isto: obseg tiskanja je goli obseg samo, če se vsak segment razčleni kot obseg, sicer je formula in potuje dobesedno. PrintArea_FormulaDefinitionSurvivesRoundTrip pokriva poimenovano osnovo, osnovo s kvalificiranim listom in unijo skozi dva cikla shrani-in-odpri. Kako se obsegi tiskanja prepletajo z nastavitvami strani in ostalim modelom tiskanja, pokriva članek o zaščiti lista, nastavitvah strani in tiskanju
Kako najdete, na katero pravilo se Excel pritožuje?
Začnite s predpostavko, da se moti vaš preverjevalnik, saj je šel skozi. Preverjevalnik Open XML SDK bo kršitev sheme, kot je manjkajoči fontId, navedel z delom in potjo XPath, sloj pakiranja pod njim pa paketa s podvojenimi vnosi vrst vsebine sploh ne odpre, zato ga zaženite pred vsem drugim. Ko molči, Excel pa še vedno popravlja, paket razpolovite: razširite ZIP, izbrišite del in njegovo relacijo ter Override, znova stisnite in odprite ter vsakič prepolovite nabor kandidatov, dokler poziv ne izgine. Trije defekti od tod so izpadli v tem vrstnem redu in noben od njih ne bi bil viden v popravljeni datoteki, ki jo Excel ponudi shraniti, ker popravilo sporne vnose tiho spusti ali znova oštevilči. Meje popravka v različici 2.382.5 velja povedati prav tako naravnost. Odstranjevanje dvojnikov je prvi zmaga, z modelom spredaj, zato obvelja, če je izvorni paket za del, ki ga ustvari tudi model, razglasil drugo vrsto vsebine — modelova razglasitev zmaga, izvorna pa se zavrže, kar je pravilno za dele, ki jih HotXLS znova zgradi, in ni splošno združevanje. verify_opc_uniqueness preverja samo edinstvenost; shem ne preverja, zato bi se moral nekdanji obvezni atribut še vedno pojaviti v Excelu ali preverjevalniku sheme. Dodatni prehod TXMLReader čez generirani tok vrst vsebine pa se izvede ob vsakem shranjevanju z omogočenim PreserveUnsupportedParts, kar je majhen strošek proti toku, ki le redko preseže nekaj kilobajtov. S tem se obe različici posojilne predloge, za Win32 in Win64, v Excelu odpreta brez poziva, znova preračunata vseh 4805 preverjenih formul brez neujemanj in prijavita isti obseg tiskanja kot izvirnik
Če XLSX iz Delphija pišete sami, je kontrolni seznam kratek: oddajte vsak atribut, ki ga shema označi kot obvezen, ne glede na njegovo vrednost; razglasite vsako ime dela enkrat; in vodite en seznam uporabljenih identifikatorjev na del relacij skozi vse pisce, ki se ga dotaknejo. Če raje vidite, da ta seznam že obstaja in je preverjen proti Excelu, ne le proti vašemu bralniku, pisec paketov, opisan tukaj, izhaja v komponenti HotXLS Delphi za preglednice, skupaj z round-tripom neprozornih delov, zaradi katerega je bilo ta stik sploh vredno varovati