Tehnični članak

Zakaj Excel popravi veljaven XLSX: pravila paketa OPC

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

Zakaj je Excel za del delovnega lista HotXLS zahteval popravilo: element phoneticPr razglasi fontId z use required v ECMA-376 1. delu, indeks pisave 0 je zakonita vrednost, stari pisec, ki je atribut izpustil, ko je bil PhoneticFontId nič, pa je proizvedel phoneticPr type noConversion, medtem ko shema daje privzeti vrednosti za type in alignment, za fontId pa nobene
Izpuščanje atributa, ko je enak privzeti vrednosti, je varno samo takrat, ko shema to privzeto vrednost razglasi, posojilna predloga pa je nosila svojo fonetično pisavo kot prvi vnos v styles.xml
// 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

Kako sta se dva pisca HotXLS zaletela v [Content_Types].xml: BuildContentTypesXml je razglasil docProps/custom.xml iz objektnega modela, medtem ko je TXLSXOpaquePackage dodal Override za isti dobesedno zajeti del; od različice 2.382.5 neprozorni sloj najprej razčleni generirani tok, normalizira imena z OpcLowerPartName in pusti modelu, da zmaga pri vsakem trku
Vsak pisec je bil sam zase dosleden, omejitev, da se vsako ime dela pojavi največ enkrat, pa obstaja samo na stiku, kjer se njuna izhoda sestavita, zato popravek posreduje tok, ki ga je ustvaril model
<!-- 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

Trk identifikatorjev relacij v korenu paketa HotXLS: model zapiše rId1 do rId4, pri čemer je rId4 rezerviran za lastnosti po meri, neprozorni sloj pa je predvajal izvorno relacijo, ki je prav tako prišla kot rId4, ker je UsedIds poznal le rId1 do rId3; popravek zato vnaprej rezervira rId4, kadar velja EmitCustomProps, in znova oštevilči ostale
Ponovno oštevilčenje je v korenu paketa varno, ker se nič v delovnem zvezku ne sklicuje na korenske identifikatorje po imenu, isti prijem raven nižje pa bi polomil vsako vezavo r:id v workbook.xml
// 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