Excel afișează „Am găsit o problemă cu o parte din conținut” la un XLSX pe care LibreOffice și orice cititor scris în casă îl deschid fără nicio obiecție, pentru că Excel impune două lucruri pe care acei cititori le ignoră: atributele cerute de schemă și regulile de unicitate ale Open Packaging Conventions. HotXLS, componenta nativă de foaie de calcul Excel pentru Delphi și C++Builder, a lovit exact asta în v2.382.5 prima dată când rezultatul ei a trecut printr-o instanță COM reală de Excel, iar cele trei cauze erau un <phoneticPr> fără fontId, un Override duplicat în [Content_Types].xml și două relații de rădăcină care împărțeau rId4
De ce respinge Excel un pachet pe care orice alt cititor îl acceptă?
Pentru că promptul de reparare este un validator de schemă și de pachet, nu o eroare de parser. Corpusul HotXLS făcuse round-trip pe un șablon de credit cu 4805 formule prin librărie, prin LibreOffice și prin validatoarele XML din suita de teste timp de săptămâni întregi. Fișierul salvat era structural sănătos în sensul OPC folosit în articolul despre rezolvarea relațiilor OPC în XLSX: fiecare parte accesibilă, fiecare țintă rezolvabilă. Apoi a devenit disponibilă o mașină Windows cu Excel 16.0 build 20326, runner-ul de corpus a deschis șablonul salvat prin Workbooks.Open într-o instanță COM izolată cu DisplayAlerts oprit, iar apelul a eșuat direct. Interactiv, același fișier produce dialogul cunoscut care oferă repararea, iar jurnalul de reparare, când Excel se obosește să scrie unul, numește partea dar nu regula. Trei defecte independente se ascundeau în acel singur prompt, iar Excel nu le raportează unul câte unul; respinge registrul și vă lasă să le găsiți și să le analizați. Urmează fiecare regulă, linia din HotXLS care a încălcat-o și reparația livrată, pentru că fiecare dintre ele este o regulă în care poate împiedica orice scriitor XLSX din Delphi
Regula 1: fontId din phoneticPr este obligatoriu, chiar și când este zero
Elementul <phoneticPr> poartă un atribut fontId declarat use="required" în ECMA-376 Part 1 §18.4.3, iar valoarea 0 este un index de font legal, nu o absență. Vechiul scriitor de foi de calcul din HotXLS trata zero ca „nesetat” și emitea atributul doar când Sheet.PhoneticFontId > 0. Este un reflex Delphi natural, dat fiind că câmpurile întregi au zero ca valoare implicită, dar produce <phoneticPr type="noConversion"/> pentru orice registru al cărui font fonetic se întâmplă să fie primul font din styles.xml, exact ce purta șablonul de credit din corpusul HotXLS. Excel respinge apoi, la întoarcere, o valoare pe care o scrisese el însuși
// lxHandleX.pas, scriitorul de foi de calcul — înainte de v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — atributul este obligatoriu, inclusiv zero
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS emite în continuare elementul doar când TXLSXWorksheet.PhoneticType este nevid, așa că registrele care nu au purtat niciodată setări fonetice nu sunt afectate. Testul de regresie PhoneticSettings_DefaultFontIsExplicit setează PhoneticFontId la zero pe o foaie nouă, salvează și verifică prezența lui <phoneticPr fontId="0" în xl/worksheets/sheet1.xml. Lecția mai largă este că „omite când este implicit” este sigur doar când schema declară o valoare implicită; type și alignment au valori implicite în acel element, fontId nu are
Regula 2: un singur Override per nume de parte în [Content_Types].xml
Fluxul de tipuri de conținut poate declara fiecare nume de parte cel mult o dată, iar Excel tratează un al doilea Override pentru același PartName ca pe o corupere, chiar dacă ambele intrări poartă același ContentType. HotXLS are doi scriitori care alimentează acel flux. BuildContentTypesXml declară fiecare parte pe care o generează modelul de obiecte: registru, stiluri, șiruri partajate, temă, foi de calcul și, când TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Când PreserveUnsupportedParts este activ, TXLSXOpaquePackage adaugă apoi un Override pentru fiecare parte pe care a capturat-o identic din pachetul sursă, ca acei octeți să rămână declarați la ieșire. Coliziunea apare la o parte care trăiește de ambele părți. Proprietățile personalizate de document sunt paraste în model, dar docProps/custom.xml al pachetului sursă a fost capturat și el opac, așa că fluxul îmbinat îl declara de două ori, iar părțile de diagramă și de cache pivot pot ajunge în același loc atunci când modelul regenerează o parte pe care stratul opac a păstrat-o și el. Înainte de v2.382.5, ContentTypeOverridesXml nu avea nicio vedere asupra a ce scrisese deja modelul, așa că nu putea ști
<!-- Ce vedea Excel înainte de 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"/>
Reparația transmite XML-ul generat în ContentTypeOverridesXml și lasă scriitorul opac să îl parseze înainte de a emite ceva. Două detalii susțin corectitudinea. OpcLowerPartName trece în litere mici, transformă backslash-urile în slash-uri normale și elimină slash-urile de la început înainte de comparație, pentru că numele de părți OPC se compară fără a ține cont de majuscule, iar modelul le scrie cu slash la început, în timp ce stratul opac stochează numele de elemente ZIP fără el. Iar apelantul din BuildContentTypesXml transmite Result + '</Types>', închizând documentul construit parțial, ca TXMLReader să vadă intrare bine formată și nu un flux trunchiat. Regula care rezultă este primul câștigă, cu modelul în față: orice declară modelul de obiecte este autoritar, iar redarea opacă umple doar golurile
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Parsează fluxul generat de model și colectează fiecare PartName declarat.
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; // deja declarat, sau o parte rels
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Regula 3: Id-urile de relație sunt unice într-o parte de relații
Fiecare Relationship dintr-o parte .rels are nevoie de un Id unic în acea parte, iar Excel refuză pachetul când două împart același id. HotXLS scrie _rels/.rels la nivel de pachet cu identificatori ficși: rId1 pentru registru, rId2 și rId3 pentru proprietățile de document de bază și extinse, iar rId4 pentru proprietățile personalizate când modelul are vreuna. Pachetul opac adaugă apoi orice relații de rădăcină a păstrat din sursă, renumerotând orice identificator care se află deja într-o listă UsedIds. Lista știa de rId1 până la rId3. Nu știa de rId4 și nu știa că modelul era pe punctul de a emite propria relație de proprietăți personalizate, așa că un pachet sursă a cărui relație de proprietăți personalizate era și ea rId4, exact ce scrie Excel implicit, ieșea cu două intrări rId4 care indicau aceeași țintă. Apelantul, BuildRootRelsXml, transmite acum Workbook.FCustomProps.Count > 0 ca al doilea argument, așa că rezervarea și sărirea sunt conduse de aceeași condiție care decide dacă modelul emite deloc rId4. Renumerotarea este sigură la rădăcina pachetului pentru că nimic din interiorul registrului nu referențiază identificatorii de relație de rădăcină după nume; același truc ar fi greșit un nivel mai jos, unde atributele r:id din workbook.xml se leagă de identificatori din partea de relații a registrului, motiv pentru care MergeWorkbookRelationshipsXml ține o hartă separată de identificatori
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // rezervat de scriitorul modelului
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// Modelul deține acum proprietățile personalizate; nu reda copia din sursă.
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); // cel mai mic rIdN liber
UsedIds.Add(String(Id));
...
end;
Ce au în comun cele trei eșecuri?
Toate trei sunt simptomele unui scriitor cu două surse și fără un singur proprietar al invariantelor de pachet. Modelul de obiecte generează părțile pe care le înțelege; stratul opac redă părțile pe care nu le înțelege, ca un round-trip să păstreze diagramele, cache-urile pivot, XML-ul personalizat și tot ce este descris în notele despre round-trip fără pierderi pentru theme, extLst și calcChain. Fiecare parte era consistentă în sine. Constrângerile pe care OPC le impune întregului pachet, nume Override de parte unice și identificatori de relație unici pe parte, există doar la cusătura unde cele două sunt concatenate, iar până la v2.382.5 nimeni nu verificase cusătura. Bug-ul de fontId are aceeași formă un nivel mai jos: scriitorul știa ce vrea să omită, dar nu a consultat niciodată schema care spune că nu are voie. Reparația pe care s-a oprit HotXLS este o precedență fixă, nu o euristică de îmbinare. Modelul scrie primul, stratul opac vede ce s-a scris și cedează la orice coliziune, iar runner-ul de corpus impune acum invariantele din exterior cu verify_opc_uniqueness, care citește [Content_Types].xml și fiecare element .rels dintr-un pachet salvat și pune cazul pe eșec la orice PartName, Extension sau Id duplicat. Verificarea este ieftină, nu are nevoie de Excel și ar fi prins două dintre cele trei defecte la prima rulare pe corpus
În același lot: zone de tipărire care sunt formule, nu intervale
Trecerile prin Excel au semnalat și _xlnm.Print_Area al șablonului de credit, pe care Excel îl raporta ca $A$1:$J$29 la original și trebuia să îl raporteze identic la copia salvată. În spatele acelei singure aserțiuni stăteau două bug-uri separate. La import, XlsxStripSheetPrefix tăia tot până la primul ! neghilimat, așa că o zonă de tipărire dinamică precum OFFSET('Print Data'!$A$1,0,0,2,2) revenea ca $A$1,0,0,2,2), iar o uniune calificată precum 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 pierdea prefixul doar la primul segment. La export, scriitorul adăuga numele foii o singură dată în fața întregului PrintArea stocat, așa că o uniune simplă $A$1:$B$2,$D$1:$E$2 lăsa librăria cu un prim segment calificat și un al doilea gol, ceea ce Excel nu acceptă ca definiție _xlnm.Print_Area conform ECMA-376 Part 1 §18.2.5
// Import: elimină prefixul doar când ce rămâne este un sqref simplu
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(...) este returnat neatins
// Export: califică fiecare segment separat prin virgulă, sau niciunul
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; // o formulă: emite identic
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;
Regula de împerechere este aceeași pe ambele părți: o zonă de tipărire este un interval simplu doar dacă fiecare segment se parsează ca atare, altfel este o formulă și circulă identic. PrintArea_FormulaDefinitionSurvivesRoundTrip acoperă baza numită, baza calificată cu foaia și uniunea, prin două cicluri de salvare și redeschidere. Cum interacționează zonele de tipărire cu configurarea paginii și cu restul modelului de tipărire este acoperit în articolul despre protejarea foilor, configurarea paginii și tipărirea
Cum aflați la ce regulă se opune Excel?
Porniți de la premisa că validatorul vostru greșește, pentru că a trecut. Validatorul Open XML SDK va numi o încălcare de schemă precum fontId lipsă, împreună cu partea și XPath-ul, iar stratul de ambalare de sub el refuză cu totul să deschidă un pachet cu intrări de tip de conținut duplicate, așa că rulați-l înainte de orice altceva. Când tace și Excel tot repară, împărțiți pachetul în două: dezarhivați, ștergeți o parte împreună cu relația și Override-ul ei, re-arhivați și redeschideți, înjumătățind setul de candidați de fiecare dată până când promptul dispare. Cele trei defecte de aici au căzut în acea ordine și niciunul nu ar fi fost vizibil în fișierul reparat pe care Excel oferă să îl salveze, pentru că repararea elimină sau renumerotează în tăcere intrările problematice. Limitele reparației din v2.382.5 merită spuse la fel de direct. Deduplicarea este primul câștigă, cu modelul în față, așa că dacă pachetul sursă declara un tip de conținut diferit pentru o parte pe care modelul o generează și el, declarația modelului câștigă și cea a sursei este aruncată, ceea ce este corect pentru părțile pe care HotXLS le regenerează și nu este o îmbinare generală. verify_opc_uniqueness verifică doar unicitatea; nu validează scheme, așa că un atribut obligatoriu apărut în viitor ar avea tot nevoie de Excel sau de un validator de schemă ca să iasă la suprafață. Iar trecerea suplimentară a TXMLReader peste fluxul generat de tipuri de conținut rulează la fiecare salvare cu PreserveUnsupportedParts activat, un cost mic pentru un flux care rareori depășește câțiva kiloocteți. Cu toate acestea la locul lor, atât build-ul Win32 cât și cel Win64 al șablonului de credit se deschid acum în Excel fără niciun prompt, recalculează toate cele 4805 formule verificate cu zero nepotriviri și raportează aceeași zonă de tipărire ca originalul
Dacă scrieți XLSX din Delphi pe cont propriu, lista de verificare este scurtă: emiteți fiecare atribut pe care schema îl marchează drept obligatoriu indiferent de valoarea lui, declarați fiecare nume de parte o singură dată și țineți o singură listă de identificatori folosiți pe parte de relații, în toți scriitorii care o ating. Dacă ați prefera ca acea listă să existe deja și să fie testată contra Excel, nu doar contra propriului cititor, scriitorul de pachete descris aici este livrat în HotXLS, componenta de foaie de calcul Delphi, împreună cu round-trip-ul de părți opece care a făcut ca acea cusătură să merite păzită de la început