Excel prikazuje "We found a problem with some content" na XLSX-u koji LibreOffice i svaki domaći čitač otvaraju bez prigovora zato što Excel provodi dvije stvari koje ti čitači ignoriraju: atribute koje shema zahtijeva i pravila jedinstvenosti Open Packaging Conventions. HotXLS, izvorna Excel spreadsheet komponenta za Delphi i C++Builder, udario je točno u to u v2.382.5 prvi put kada je njegov izlaz prošao kroz pravu Excel COM instancu, a tri uzroka bila su <phoneticPr> bez fontId, duplicirani Override u [Content_Types].xml i dvije root relacije koje dijele rId4
Zašto Excel odbija paket koji svaki drugi čitač prihvaća?
Zato što je upit za popravak validator sheme i paketa, a ne pad parsera. HotXLS corpus već je tjednima provodio round trip predloška kredita s 4805 formula kroz biblioteku, kroz LibreOffice i kroz XML validatore u test suiteu. Spremljena datoteka bila je strukturno ispravna u OPC smislu koji se koristi u članku o XLSX OPC razrješavanju relacija: svaki dio dostupan, svako odredište razrješivo. Zatim je postao dostupan Windows stroj s Excelom 16.0 build 20326, corpus runner otvorio je spremljeni predložak kroz Workbooks.Open u izoliranoj COM instanci s isključenim DisplayAlerts, i poziv je pao u cijelosti. Interaktivno ista datoteka proizvodi poznati dijalog s ponudom za popravak, a zapis popravka, kada ga Excel uopće napiše, imenuje dio ali ne i pravilo. Tri neovisna defekta krila su se u tom jednom upitu, a Excel ih ne prijavljuje jedan po jedan; odbija radnu knjigu i ostavlja vas da ih nađete i analizirate. Slijedi svako pravilo, redak HotXLS-a koji ga je prekršio i popravak koji je isporučen, jer je svako od njih pravilo o koje svaki Delphi XLSX pisac može zapeti
Pravilo 1: phoneticPr fontId je obavezan, čak i kada je nula
Element <phoneticPr> nosi atribut fontId deklariran kao use="required" u ECMA-376 Part 1 §18.4.3, a vrijednost 0 je legalan indeks fonta, a ne odsutnost. Stari HotXLS pisac radnih listova tretirao je nulu kao "nepostavljeno" i emitirao atribut samo kada je Sheet.PhoneticFontId > 0. To je prirodni Delphi refleks, jer integer polja po defaultu imaju nulu, ali proizvodi <phoneticPr type="noConversion"/> za svaku radnu knjigu čiji je fonetski font slučajno prvi font u styles.xml, što je upravo ono što je nosio predložak kredita u HotXLS corpusu. Excel zatim odbija na povratku vrijednost koju je sam napisao
// lxHandleX.pas, pisac radnih listova — prije v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — atribut je obavezan, uključujući i nulu
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS i dalje emitira element samo kada je TXLSXWorksheet.PhoneticType neprazan, pa radne knjige koje nikad nisu nosile fonetske postavke nisu pogođene. Regresijski test PhoneticSettings_DefaultFontIsExplicit postavlja PhoneticFontId na nulu na svježem listu, sprema i tvrdi da je <phoneticPr fontId="0" prisutan u xl/worksheets/sheet1.xml. Šira lekcija je da je "izostavi kada je default" sigurno samo kada shema deklarira taj default; type i alignment imaju defaulte u tom elementu, fontId ne
Pravilo 2: jedan Override po imenu dijela u [Content_Types].xml
Stream tipova sadržaja smije deklarirati svako ime dijela najviše jednom, a Excel drugi Override za isto PartName tretira kao korupciju čak i kada oba unosa nose isti ContentType. HotXLS ima dva pisca koja hrane taj stream. BuildContentTypesXml deklarira svaki dio koji generira objektni model: radnu knjigu, stilove, dijeljene stringove, temu, radne listove i, kada je TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Kada je PreserveUnsupportedParts uključen, TXLSXOpaquePackage zatim dodaje Override za svaki dio koji je doslovno snimio iz izvornog paketa, da ti bajtovi ostanu deklarirani na izlazu. Sudar je dio koji živi na objema stranama. Prilagođena svojstva dokumenta parsiraju se u model, ali je izvorni docProps/custom.xml iz paketa također snimljen opaque putem, pa je spojeni stream deklarirao taj dio dvaput, a chart i pivot cache dijelovi mogu sletjeti na isto mjesto kada model regenerira dio koji je opaque sloj također zadržao. Prije v2.382.5 ContentTypeOverridesXml nije imao uvid u ono što je model već napisao, pa nije mogao znati
<!-- Što je Excel vidio prije 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"/>
Popravak prosljeđuje generirani XML u ContentTypeOverridesXml i pušta opaque pisac da ga parsira prije nego išta emituje. Dva detalja nose ispravnost. OpcLowerPartName spušta na mala slova, pretvara backslasheve u forward slasheve i skida vodeće slasheve prije usporedbe, jer se OPC imena dijelova uspoređuju neosjetljivo na veličinu slova, a model ih piše s vodećim slashom dok opaque sloj sprema ZIP imena stavki bez njega. A pozivatelj u BuildContentTypesXml prosljeđuje Result + '</Types>', zatvarajući djelomično izgrađen dokument da TXMLReader vidi dobro oblikovan ulaz, a ne odsječen stream. Pravilo koje iz toga izlazi je prvi pobjeđuje, s modelom sprijeda: što god deklarira objektni model je mjerodavno, a opaque reprodukcija samo popunjava praznine
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Parsiraj stream koji je generirao model i prikupi svaki deklarirani 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; // već deklarirano, ili rels dio
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Pravilo 3: Id relacija jedinstveni su unutar dijela s relacijama
Svaka Relationship u .rels dijelu treba Id koji je jedinstven unutar tog dijela, a Excel odbija paket kada dvije dijele jedan. HotXLS piše _rels/.rels na razini paketa s fiksnim identifikatorima: rId1 za radnu knjigu, rId2 i rId3 za osnovna i proširena svojstva dokumenta, i rId4 za prilagođena svojstva kada ih model ima. Opaque paket zatim dodaje sve root relacije koje je zadržao iz izvora, prenumerirajući svaki identifikator koji je već u listi UsedIds. Lista je znala za rId1 do rId3. Nije znala za rId4, niti da će model upravo emitirati vlastitu relaciju prilagođenih svojstava, pa je izvorni paket čija je relacija prilagođenih svojstava također bila rId4, što Excel piše po defaultu, izašao s dva rId4 unosa koji pokazuju na isto odredište. Pozivatelj, BuildRootRelsXml, sada prosljeđuje Workbook.FCustomProps.Count > 0 kao drugi argument, pa su rezervacija i preskakanje vođeni istim uvjetom koji odlučuje hoće li model uopće emitirati rId4. Prenumeriranje je sigurno u korijenu paketa jer ništa unutar radne knjige ne referencira identifikatore root relacija po imenu; isti bi trik bio pogrešan razinu niže, gdje se r:id atributi u workbook.xml vežu na identifikatore u dijelu relacija radne knjige, zato MergeWorkbookRelationshipsXml drži zasebnu mapu identifikatora
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // rezervirao pisac 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]);
// Model sada posjeduje prilagođena svojstva; ne reproduciraj izvornu kopiju.
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ži slobodni rIdN
UsedIds.Add(String(Id));
...
end;
Što je zajedničko trima padovima?
Sva tri su simptomi pisca s dva izvora i bez jednog vlasnika invarijanti paketa. Objektni model generira dijelove koje razumije; opaque sloj reproducira dijelove koje ne razumije, da bi round trip sačuvao grafikone, pivot cachee, prilagođeni XML i sve ostalo opisano u bilješkama o lossless round tripu teme, extLst-a i calcChain-a. Svaka je strana bila pojedinačno konzistentna. Ograničenja koja OPC postavlja na cijeli paket, jedinstvena imena Override dijelova i jedinstveni identifikatori relacija po dijelu, postoje samo na šavu gdje se njihovi izlazi spajaju, i do v2.382.5 nitko nije provjeravao šav. Bug s fontId istog je oblika razinu niže: pisac je znao što želi izostaviti, ali nikad nije konzultirao shemu koja kaže da ne smije. Popravak na kojem se HotXLS zaustavio je fiksna precedencija, a ne heuristika spajanja. Model piše prvi, opaque sloj vidi što je napisano i popušta kod svakog sudara, a corpus runner sada provodi invarijante izvana s verify_opc_uniqueness, koji čita [Content_Types].xml i svaku .rels stavku u spremljenom paketu i ruši slučaj na svakom dupliciranom PartName, Extension ili Id. Ta provjera je jeftina, ne treba Excel i uhvatila bi dva od tri defekta već u prvom corpus pokretanju
Isti batch: print područja koja su formule, a ne rasponi
Excel prolaz također je označio _xlnm.Print_Area predloška kredita, koji je Excel prijavio kao $A$1:$J$29 na originalu i morao prijaviti identično na spremljenoj kopiji. Dva različita buga stajala su iza te jedne tvrdnje. Pri uvozu, XlsxStripSheetPrefix odrezao je sve do prvog necitiranog !, pa se dinamično print područje poput OFFSET('Print Data'!$A$1,0,0,2,2) vratilo kao $A$1,0,0,2,2), a kvalificirana unija poput 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 izgubila je prefiks samo na svom prvom segmentu. Pri izvozu, pisac je dodao ime lista jednom na cijeli spremljeni PrintArea, pa je obična unija $A$1:$B$2,$D$1:$E$2 ostavila biblioteku s kvalificiranim prvim segmentom i golim drugim, što Excel ne prihvaća kao definiciju _xlnm.Print_Area po ECMA-376 Part 1 §18.2.5
// Uvoz: skini prefiks samo kada je ono što ostane običan 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 vraća nedirnut
// Izvoz: kvalificiraj svaki segment odvojen zarezom, ili nijedan
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 doslovno
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 uparivanja isto je na objema stranama: print područje je goli raspon samo ako se svaki segment parsira kao raspon, inače je formula i putuje doslovno. PrintArea_FormulaDefinitionSurvivesRoundTrip pokriva imenovanu bazu, bazu kvalificiranu listom i uniju kroz dva ciklusa spremanja i ponovnog otvaranja. Kako print područja interagiraju s page setupom i ostatkom modela ispisa obrađeno je u članku o zaštiti lista, page setupu i ispisu
Kako pronaći na koje se pravilo Excel žali?
Krenite od pretpostavke da je vaš vlastiti validator pogrešan, jer je prošao. Open XML SDK validator imenovat će prekršaj sheme poput nedostajućeg fontId uz dio i XPath, a sloj pakiranja pod njim uopće odbija otvoriti paket s dupliciranim unosima tipova sadržaja, pa ga pokrenite prije svega ostalog. Kada šuti, a Excel i dalje popravlja, bisecirajte paket: raspakirajte, obrišite dio i njegovu relaciju i njegov Override, ponovno zapakirajte i otvorite, prepolovljujući skup kandidata svaki put dok upit ne nestane. Tri defekta ovdje ispala su tim redom, i nijedan ne bi bio vidljiv u popravljenoj datoteci koju Excel nudi da spremite, jer popravak tiho odbacuje ili prenumerira sporne unose. Granice popravka v2.382.5 valja reći jednako otvoreno. Deduplikacija je prvi pobjeđuje s modelom sprijeda, pa ako je izvorni paket deklarirao drugi tip sadržaja za dio koji model također generira, deklaracija modela pobjeđuje i izvorna se odbacuje, što je ispravno za dijelove koje HotXLS regenerira i nije općenito spajanje. verify_opc_uniqueness provjerava samo jedinstvenost; ne validira sheme, pa bi budući obavezni atribut i dalje trebao Excel ili validator sheme da ispliva. A dodatni TXMLReader prolaz preko generiranog streama tipova sadržaja izvršava se pri svakom spremanju s uključenim PreserveUnsupportedParts, mali trošak prema streamu koji rijetko prelazi nekoliko kilobajta. Uz sve to na mjestu, i Win32 i Win64 buildovi predloška kredita sada se otvaraju u Excelu bez upita, preračunavaju svih 4805 provjerenih formula bez ijednog neslaganja i prijavljuju isto print područje kao original
Ako sami pišete XLSX iz Delphija, kontrolna lista je kratka: emitirajte svaki atribut koji shema označava obaveznim bez obzira na njegovu vrijednost, deklarirajte svako ime dijela jednom i držite jednu listu iskorištenih identifikatora po dijelu s relacijama kroz svakog pisca koji ga dotiče. Ako biste radije da ta lista već postoji i da je testirana protiv Excela, a ne samo protiv vašeg vlastitog čitača, pisac paketa opisan ovdje isporučuje se u HotXLS Delphi spreadsheet komponenti, zajedno s opaque-part round tripom koji je taj šav i učinio vrijednim čuvanja