Tehnički članak

Zašto Excel popravlja ispravan XLSX: OPC pravila u Delphiju

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

Zašto je Excel zahtijevao popravak HotXLS dijela radnog lista: element phoneticPr deklarira fontId s use required u ECMA-376 Part 1, indeks fonta 0 je legalna vrijednost, a stari pisac koji je izostavljao atribut kada je PhoneticFontId bio nula proizvodio je phoneticPr type noConversion, dok shema daje defaulte za type i alignment, a za fontId nijedan
Izostavljanje atributa kada je jednak defaultu sigurno je samo kada shema deklarira taj default, a predložak kredita nosio je svoj fonetski font kao prvi unos u styles.xml
// 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

Kako su se dva HotXLS pisca sudarila u [Content_Types].xml: BuildContentTypesXml deklarirao je docProps/custom.xml iz objektnog modela, dok je TXLSXOpaquePackage dodao Override za isti dio snimljen doslovno, a od v2.382.5 opaque sloj prvo parsira generirani stream, normalizira imena s OpcLowerPartName i pušta model da pobijedi u svakom sudaru
Svaki je pisac pojedinačno bio konzistentan, a ograničenje da se svako ime dijela smije pojaviti jednom postoji samo na šavu gdje se njihovi izlazi spajaju, zato popravak prosljeđuje stream modela
<!-- Š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

Sudar identifikatora relacija u korijenu HotXLS paketa: model piše rId1 do rId4 s rId4 rezerviranim za prilagođena svojstva, opaque sloj reproducirao je izvornu relaciju koja je također stigla kao rId4 jer je UsedIds znao samo rId1 do rId3, a popravak unaprijed rezervira rId4 kad god EmitCustomProps vrijedi i prenumerira ostale
Prenumeriranje je sigurno u korijenu paketa jer ništa unutar radne knjige ne referencira root identifikatore po imenu, a isti bi trik razinu niže pokvario svako r:id vezanje u workbook.xml
// 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