Technický článek

Proč Excel opravuje validní XLSX: pravidla OPC balíčku

Excel hlásí „Nalezen problém s částí obsahu" u XLSX, které LibreOffice i každou domácí čtečku otevřou bez jediného slova, protože Excel vynucuje dvě věci, které ty čtečky ignorují: atributy vyžadované schématem a pravidla jednoznačnosti Open Packaging Conventions. HotXLS, nativní tabulková komponenta Excelu pro Delphi a C++Builder, na to narazila ve v2.382.5 poprvé, když její výstup prošel skutečnou instancí Excel COM, a tři příčiny byly <phoneticPr> bez fontId, zdvojený Override v [Content_Types].xml a dvě kořenové relace sdílející rId4

Proč Excel odmítne balíček, který každá jiná čtečka přijme?

Protože výzva k opravě je validátor schématu a balíčku, ne selhání parseru. Korpus HotXLS přežíval šablonu půjčky s 4805 vzorci knihovnou, LibreOfficem i XML validátory v testovací sadě celé týdny. Uložený soubor byl strukturálně zdravý v tom OPC smyslu, který používá článek o rozřešení OPC relací v XLSX: každý part dosažitelný, každý cíl rozřešitelný. Pak se zpřístupnil Windows stroj s Excelem 16.0 build 20326, korpusový běhač otevřel uloženou šablonu přes Workbooks.Open v izolované instanci COM s vypnutými DisplayAlerts a volání selhalo naprosto. Interaktivně tentýž soubor vyhodí známý dialog s nabídkou opravy a opravný log, když se Excel k jeho zápisu dopracuje, jmenuje part, ne pravidlo. V té jediné výzvě se skrývaly tři nezávislé defekty a Excel je nehlásí jeden po druhém; odmítne sešit a jejich hledání a analýzu nechá na vás. Následuje každé pravidlo, řádek HotXLS, který ho porušil, a oprava, která vyšla, protože každé z nich je pravidlo, o které může zakopnout jakýkoli XLSX zapisovač v Delphi

Pravidlo 1: phoneticPr fontId je povinné, i když je nula

Element <phoneticPr> nese atribut fontId deklarovaný use="required" v ECMA-376 Part 1 §18.4.3 a hodnota 0 je legální index fontu, ne absence. Starý zapisovač listů v HotXLS bral nulu jako „nenastaveno" a atribut emitoval jen když Sheet.PhoneticFontId > 0. Je to přirozený delphí reflex, protože celočíselná pole mají default nulu, ale výsledkem je <phoneticPr type="noConversion"/> pro jakýkoli sešit, jehož fonetický font je zrovna první font v styles.xml, což přesně nesla šablona půjčky v korpusu HotXLS. Excel pak při cestě zpátky odmítá hodnotu, kterou si sám napsal

Proč Excel vyžadoval opravu partu listů HotXLS: element phoneticPr deklaruje fontId s use required v ECMA-376 Part 1, font index 0 je legální hodnota a starý zapisovač, který atribut vynechal, když byl PhoneticFontId nula, produkoval phoneticPr type noConversion, zatímco schéma dává type a alignment defaulty a fontId žádný
Vynechat atribut, když se rovná defaultu, je bezpečné jen tehdy, když schéma ten default deklaruje, a šablona půjčky nesla svůj fonetický font jako úplně první položku v styles.xml
// lxHandleX.pas, zapisovač listů — před v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — atribut je povinný, nula nevyjímaje
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLS i nadále emituje element jen když TXLSXWorksheet.PhoneticType není prázdné, takže sešity, které fonetická nastavení nikdy nenesly, se nedotkne. Regresní test PhoneticSettings_DefaultFontIsExplicit nastaví PhoneticFontId na nulu na čerstvém listu, uloží a assertuje, že <phoneticPr fontId="0" je přítomné v xl/worksheets/sheet1.xml. Širší poučení zní, že „vynechat při defaultu" je bezpečné jen tehdy, když schéma default deklaruje; type a alignment v tom elementu defaulty mají, fontId ne

Pravidlo 2: jeden Override na název partu v [Content_Types].xml

Content types stream smí deklarovat každý název partu nejvýše jednou a Excel bere druhý Override pro tentýž PartName jako korupci, i když obě položky nesou totéž ContentType. Do toho streamu se v HotXLS dva zapisovače. BuildContentTypesXml deklaruje každý part, který objektový model generuje: workbook, styles, shared strings, theme, listy a, když TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Když je zapnuté PreserveUnsupportedParts, přidá TXLSXOpaquePackage pak Override ke každému partu, který zachytil verbatim ze zdrojového balíčku, aby ty bajty zůstaly na výstupu deklarované. Kolize je part, který žije na obou stranách. Vlastní vlastnosti dokumentu se parsují do modelu, ale docProps/custom.xml zdrojového balíčku se zároveň zachytil neprůhledně, takže sloučený stream ho deklaroval dvakrát a party grafů a pivot cache se můžou ocitnout na stejném místě, když model regeneruje part, který neprůhledná vrstva taky uchovala. Před v2.382.5 nemělo ContentTypeOverridesXml žádný přehled o tom, co model už napsal, takže to nemohlo vědět

Jak se srazily dva zapisovače HotXLS v [Content_Types].xml: BuildContentTypesXml deklaroval docProps/custom.xml z objektového modelu, zatímco TXLSXOpaquePackage připojil Override pro tentýž part zachycený verbatim, a od v2.382.5 neprůhledná vrstva nejdřív parsuje vygenerovaný stream, normalizuje názvy přes OpcLowerPartName a nechá model vyhrát každou kolizi
Každý zapisovač byl sám o sobě konzistentní a omezení, že se název partu smí objevit jen jednou, existuje jen v švu, kde se jejich výstupy slepují, proto oprava předává stream modelu
<!-- Co Excel viděl před 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"/>

Oprava předává vygenerované XML do ContentTypeOverridesXml a nechá neprůhledného zapisovače ho nejdřív naparsovat, než emituje cokoli. Správnost nese dva detaily. OpcLowerPartName provede lowercase, překlopí zpětná lomítka na dopředná a před porovnáním ořízne úvodní lomítka, protože názvy partů v OPC se porovnávají bez ohledu na velikost písmen a model je píše s úvodním lomítkem, zatímco neprůhledná vrstva ukládá názvy položek ZIP bez něj. A volající v BuildContentTypesXml předává Result + '</Types>', čímž uzavře napůl postavený dokument, takže TXMLReader vidí well-formed vstup, ne uťatý stream. Pravidlo, které z toho vychází, je první vyhrává s modelem v čele: co objektový model deklaruje, je autoritativní a neprůhledné přehrání jen doplňuje mezery

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Naparsujte stream generovaný modelem a posbírejte každý deklarovaný 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;                       // už deklarované, nebo part .rels
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Pravidlo 3: Id relací jsou jednoznačná v rámci partu relací

Každý Relationship v partu .rels potřebuje Id jednoznačné v rámci toho partu a balíček, ve kterém se dvě sdílí, Excel odmítne. HotXLS píše balíčkovou úroveň _rels/.rels s pevnými identifikátory: rId1 pro workbook, rId2 a rId3 pro core a rozšířené vlastnosti dokumentu a rId4 pro vlastní vlastnosti, když je model má. Neprůhledný balíček pak připojí jakékoli kořenové relace uchované ze zdroje, s přečíslováním jakéhokoli identifikátoru, který už je v seznamu UsedIds. Seznam znal rId1 až rId3. O rId4 nevěděl a nevěděl ani to, že model chystá emitovat vlastní relaci custom-properties, takže zdrojový balíček, jehož relace custom-properties byla také rId4, což Excel píše defaultně, vyšel se dvěma položkami rId4 mířícími na stejný cíl. Volající, BuildRootRelsXml, teď předává Workbook.FCustomProps.Count > 0 jako druhý argument, takže rezervaci i přeskočení řídí táž podmínka, která rozhoduje, zda model vůbec emituje rId4. Přečíslování je na kořeni balíčku bezpečné, protože nic uvnitř sešitu neodkazuje na kořenové identifikátory relací jménem; tentýž trik by byl o úroveň níž chybný, kde se atributy r:id v workbook.xml vážou na identifikátory v partu relací sešitu, proto MergeWorkbookRelationshipsXml drží samostatnou mapu identifikátorů

Kolize identifikátorů relací v kořeni balíčku HotXLS: model píše rId1 až rId4 s rId4 rezervovaným pro custom properties, neprůhledná vrstva přehrála zdrojovou relaci, která také přišla jako rId4, protože UsedIds znal jen rId1 až rId3, a oprava rezervuje rId4 předem, kdykoli platí EmitCustomProps, a přečíslová zbytek
Přečíslování je na kořeni balíčku bezpečné, protože nic uvnitř sešitu neodkazuje na kořenové identifikátory jménem, a tentýž trik o úroveň níž by rozbil každou vazbu r:id v workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // rezervováno zapisovačem modelu
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // Custom properties teď vlastní model; nepřehrávejte zdrojovou kopii.
  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);      // nejnižší volné rIdN
  UsedIds.Add(String(Id));
  ...
end;

Co mají ta tři selhání společného?

Všechny tři jsou symptomy zapisovače se dvěma zdroji a bez jediného vlastníka invariant balíčku. Objektový model generuje party, kterým rozumí; neprůhledná vrstva přehrává party, kterým nerozumí, aby round trip zachoval grafy, pivot cache, custom XML a všechno ostatní popsané v poznámkách o bezztrátovém round tripu theme, extLst a calcChain. Každá strana byla sama o sobě konzistentní. Omezení, která OPC klade na celý balíček, jednoznačné názvy partů Override a jednoznačné identifikátory relací na part, existují jen v švu, kde se ty dvě slepují, a až do v2.382.5 nikdo šev nekontroloval. Bug fontId je týž tvar o úroveň níž: zapisovač věděl, co chce vynechat, ale nikdy se nezeptal schématu, které říká, že nesmí. Oprava, kterou HotXLS zvolil, je pevná precedence, ne slučovací heuristika. Model píše první, neprůhledná vrstva vidí, co napsáno bylo, a u každé kolize ustoupí, a korpusový běhač teď vynucuje invarianty zvenčí přes verify_opc_uniqueness, který čte [Content_Types].xml a každou položku .rels v uloženém balíčku a shodí případ na jakémkoli duplicitním PartName, Extension nebo Id. Ta kontrola je laciná, nepotřebuje Excel a na prvním korpusovém běhu by chytla dva ze tří defektů

Stejná dávka: oblasti tisku, které jsou vzorce, ne rozsahy

Průchod Excelem též označil _xlnm.Print_Area šablony půjčky, kterou Excel hlásil jako $A$1:$J$29 na originále a musel hlásit stejně na uložené kopii. Za tím jedním assertionem seděly dva samostatné bugy. Při importu XlsxStripSheetPrefix odřízl všechno až po první neuvozovkované !, takže dynamická tisková oblast jako OFFSET('Print Data'!$A$1,0,0,2,2) se vrátila jako $A$1,0,0,2,2) a kvalifikovaný union jako 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 ztratil prefix jen na prvním segmentu. Při exportu zapisovač přidal název listu jednou k celému uloženému PrintArea, takže prostý union $A$1:$B$2,$D$1:$E$2 opustil knihovnu s kvalifikovaným prvním segmentem a holým druhým, což Excel nebere jako definici _xlnm.Print_Area pod ECMA-376 Part 1 §18.2.5

// Import: odřízni prefix jen když zbytkem je holý 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 vrací nedotčené

// Export: kvalifikuj každý segment oddělený čárkou, nebo žádný
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;                           // vzorec: emituj verbatim
  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;

Pravidlo párování je na obou stranách totéž: tisková oblast je holý rozsah jen tehdy, když se jako jeden parsuje každý segment, jinak je to vzorec a cestuje verbatim. PrintArea_FormulaDefinitionSurvivesRoundTrip pokrývá pojmenovanou základnu, základnu kvalifikovanou listem a union přes dva cykly ulož a otevř znovu. Jak tiskové oblasti interagují s nastavením stránky a zbytkem tiskového modelu, rozebírá článek o ochraně listů, nastavení stránky a tisku

Jak zjistíte, kterému pravidlu Excel vadí?

Začněte z předpokladu, že váš vlastní validátor se mýlí, protože prošel. Validátor Open XML SDK pojmenuje porušení schématu jako chybějící fontId s partem a XPath a vrstva balení pod ním odmítá vůbec otevřít balíček s duplicitními položkami content-type, takže ji pusťte jako první. Když mlčí a Excel pořád opravuje, bisektujte balíček: rozbalte, smažte part, jeho relaci a jeho Override, zabalte znovu a otevřete, s tím, že kandidátní množinu každým krokem na polovinu, dokud výzva nezmizí. Tři defekty zde vypadly v tom pořadí a žádný z nich by nebyl vidět v opraveném souboru, který Excel nabízí uložit, protože oprava offending položky potichu zahazuje nebo přečíslovává. Hranice opravy v2.382.5 stojí za to vyslovit stejně naplno. Deduplikace je první-vyhrává s modelem v čele, takže pokud zdrojový balíček deklaroval jiný content type pro part, který generuje i model, vyhrává deklarace modelu a zdrojová se zahodí, což je správně pro party, které HotXLS regeneruje, a není to obecný merge. verify_opc_uniqueness kontroluje jen jednoznačnost; schémata nevaliduje, takže budoucí povinný atribut by pořád potřeboval Excel nebo validátor schématu, aby vyšel najevo. A ten extra průchod TXMLReader přes generovaný stream content types běží při každém uložení se zapnutým PreserveUnsupportedParts, malá cena za stream, který málokdy přesáhne pár kilobytů. S tím na místě teď obě sestavení Win32 i Win64 šablony půjčky otevřou v Excelu bez výzvy, přepočítají všech 4805 ověřených vzorců bez jediné odchylky a hlásí stejnou tiskovou oblast jako originál

Když si XLSX z Delphi píšete sami, checklist je krátký: emitujte každý atribut, který schéma označí jako povinný, bez ohledu na jeho hodnotu, deklarujte každý název partu jen jednou a držte jediný seznam použitých identifikátorů na part relací napříč každým zapisovačem, který do něj sahá. Kdybyste radši, aby ten seznam už existoval a byl testovaný proti Excelu, ne jen proti vlastní čtečce, zapisovač balíčku popsaný tady dodává HotXLS jako tabulkovou komponentu pro Delphi, spolu s round tripem neprůhledných partů, který stál za to, aby se ten šev vůbec hlídal