Odborný článok

Prečo Excel opravuje platný XLSX: pravidlá OPC package

Excel zobrazí „We found a problem with some content“ pri XLSX, ktorý LibreOffice aj každý vlastnoručný reader otvorí bez sťažnosti, pretože Excel vyžaduje dve veci, ktoré tieto readery ignorujú: atribúty povinné podľa schémy a pravidlá jedinečnosti z Open Packaging Conventions. HotXLS, natívny Excel tabuľkový komponent pre Delphi a C++Builder, na to narazil presne vo v2.382.5, keď jeho výstup prvýkrát prešiel skutočnou Excel COM inštanciou, a príčiny boli tri: <phoneticPr> bez fontId, duplicitné Override v [Content_Types].xml a dva root relationships zdieľajúce rId4

Prečo Excel odmieta package, ktorý každý iný reader prijme?

Pretože výzva na opravu je schema a package validátor, nie zlyhanie parsera. Corpus HotXLS už týždne round-tripoval úverovú šablónu s 4805 formulami cez knižnicu, cez LibreOffice aj cez XML validátory v test suite. Uložený súbor bol štrukturálne v poriadku v OPC zmysle, aký používa článok o XLSX OPC relationship resolution: každá časť dosiahnuteľná, každý cieľ rozlíšiteľný. Potom pribudol Windows stroj s Excelom 16.0 build 20326, corpus runner otvoril uloženú šablónu cez Workbooks.Open v izolovanej COM inštancii s vypnutým DisplayAlerts a volanie rovno zlyhalo. Interaktívne ten istý súbor vyvolá známy dialóg s ponukou opravy, a repair log, keď sa Excel obťažuje nejaký napísať, pomenuje časť, ale nie pravidlo. V tej jednej výzve sa skrývali tri samostatné defekty a Excel ich nehlási po jednom; odmietne workbook a hľadanie aj analýzu nechá na vás. Nasleduje každé pravidlo, riadok HotXLS, ktorý ho porušil, a oprava, ktorá vyšla, pretože každé z nich je pravidlo, o ktoré môže zakopnúť hocijaký Delphi XLSX writer

Pravidlo 1: phoneticPr fontId je povinný, aj keď je nula

Element <phoneticPr> nesie atribút fontId deklarovaný ako use="required" v ECMA-376 Part 1 §18.4.3, a hodnota 0 je legálny index fontu, nie jeho absencia. Starý worksheet writer v HotXLS bral nulu ako „nenastavené“ a atribút emitoval len vtedy, keď Sheet.PhoneticFontId > 0. To je prirodzený Delphi reflex, keďže celočíselné polia defaultujú na nulu, ale produkuje <phoneticPr type="noConversion"/> pre každý workbook, ktorého fonetický font je náhodou prvým fontom v styles.xml, čo je presne to, čo niesla úverová šablóna v korpuse HotXLS. Excel potom pri ceste späť odmietne hodnotu, ktorú sám zapísal

Prečo Excel žiadal opravu worksheet partu HotXLS: element phoneticPr deklaruje fontId s use required v ECMA-376 Part 1, index fontu 0 je legálna hodnota, a starý writer, ktorý atribút vynechal, keď bolo PhoneticFontId nula, produkoval phoneticPr type noConversion, kým schéma dáva default pre type a alignment a pre fontId žiadny
Vynechať atribút, keď sa rovná defaultu, je bezpečné len vtedy, keď schéma ten default deklaruje, a úverová šablóna niesla svoj fonetický font ako úplne prvý záznam v styles.xml
// lxHandleX.pas, worksheet writer — pred v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — atribút je povinný, vrátane nuly
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLS element stále emituje len vtedy, keď je TXLSXWorksheet.PhoneticType neprázdny, takže workbooky, ktoré nikdy nemali fonetické nastavenia, to nepocítia. Regresný test PhoneticSettings_DefaultFontIsExplicit nastaví PhoneticFontId na nulu na čerstvom hárku, uloží a assertuje, že <phoneticPr fontId="0" je prítomné v xl/worksheets/sheet1.xml. Širšia lekcia je, že „vynechaj, keď je default“ je bezpečné len vtedy, keď schéma default deklaruje; type a alignment default v tom elemente majú, fontId nie

Pravidlo 2: jeden Override na meno partu v [Content_Types].xml

Content types stream môže každé meno partu deklarovať najviac raz a Excel berie druhé Override pre to isté PartName ako poškodenie, aj keď oba záznamy nesú rovnaký ContentType. Do toho streamu píšu v HotXLS dvaja writery. BuildContentTypesXml deklaruje každú časť, ktorú generuje object model: workbook, styles, shared strings, theme, worksheets a, keď TXLSXWorkbook.CustomProperties.Count > 0, aj /docProps/custom.xml. Keď je zapnuté PreserveUnsupportedParts, TXLSXOpaquePackage potom pridá Override pre každú časť, ktorú doslovne zachytil zo zdrojového package, aby tie bajty zostali na ceste von deklarované. Kolízia je časť, ktorá žije na oboch stranách. Vlastné dokumentové properties sa parsujú do modelu, ale docProps/custom.xml zdrojového package sa zachytil aj opaque cestou, takže zlúčený stream ho deklaroval dvakrát, a chart a pivot cache party môžu skončiť na tom istom mieste, keď model regeneruje časť, ktorú opaque vrstva tiež podržala. Pred v2.382.5 nemal ContentTypeOverridesXml prehľad o tom, čo model už zapísal, takže to nemohol vedieť

Ako sa v [Content_Types].xml zrazili dvaja writery HotXLS: BuildContentTypesXml deklaroval docProps/custom.xml z object modelu, kým TXLSXOpaquePackage pridal Override pre tú istú časť zachytenú doslovne, a od v2.382.5 opaque vrstva najprv parsuje vygenerovaný stream, normalizuje mená cez OpcLowerPartName a nechá model vyhrať každú kolíziu
Každý writer bol sám o sebe konzistentný a obmedzenie, že každé meno partu môže byť uvedené raz, existuje len na švíku, kde sa ich výstupy spájajú, a preto oprava posiela modelový stream dnu
<!-- Čo 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"/>

Oprava posiela vygenerované XML do ContentTypeOverridesXml a nechá opaque writer naparsovať ho skôr, než čokoľvek emituje. Správnosť nesú dva detaily. OpcLowerPartName pred porovnaním prepíše na malé písmená, zmení spätné lomky na dopredné a strhne vedúce lomky, pretože OPC mená partov sa porovnávajú case-insensitive a model ich zapisuje s vedúcim lomkom, kým opaque vrstva ukladá názvy ZIP položiek bez neho. A volajúci v BuildContentTypesXml posiela Result + '</Types>', čím uzavrie rozostavaný dokument, aby TXMLReader videl well-formed vstup a nie odseknutý stream. Pravidlo, ktoré z toho vychádza, je first-wins s modelom vpredu: čo deklaruje object model, je autoritatívne, a opaque replay len dopĺňa medzery

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Naparsuj modelom generovaný stream a pozbieraj 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é, alebo rels časť
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Pravidlo 3: relationship Id sú jedinečné v rámci relationships partu

Každý Relationship v .rels parte potrebuje Id, ktoré je v rámci toho partu jedinečné, a Excel package odmietne, keď dve zdieľajú jedno. HotXLS zapisuje package-level _rels/.rels s pevnými identifikátormi: rId1 pre workbook, rId2 a rId3 pre core a extended dokumentové properties a rId4 pre vlastné properties, keď ich model nejaké má. Opaque package potom pridá root relationships, ktoré podržal zo zdroja, a prečísluje každý identifikátor, ktorý už je v zozname UsedIds. Ten zoznam vedel o rId1 až rId3. Nevedel o rId4 a nevedel, že model sa chystá emitovať vlastný custom-properties relationship, takže zdrojový package, ktorého custom-properties relationship bolo tiež rId4, čo Excel zapisuje defaultne, vyšiel s dvoma rId4 záznamami ukazujúcimi na ten istý cieľ. Volajúci BuildRootRelsXml teraz posiela Workbook.FCustomProps.Count > 0 ako druhý argument, takže rezerváciu aj skip riadi tá istá podmienka, ktorá rozhoduje o tom, či model vôbec emituje rId4. Prečíslovanie je na koreni package bezpečné, pretože nič vnútri workbooku neodkazuje na root relationship identifikátory podľa mena; ten istý trik by bol nesprávny o úroveň nižšie, kde sa atribúty r:id vo workbook.xml viažu na identifikátory v relationships parte workbooku, a preto MergeWorkbookRelationshipsXml drží samostatnú mapu identifikátorov

Kolízia relationship identifikátorov v koreni package HotXLS: model zapisuje rId1 až rId4, pričom rId4 je rezervované pre vlastné properties, opaque vrstva prehrala zdrojový relationship, ktorý tiež prišiel ako rId4, pretože UsedIds poznalo len rId1 až rId3, a oprava rezervuje rId4 dopredu vždy, keď platí EmitCustomProps, a zvyšok prečísluje
Prečíslovanie je v koreni package bezpečné, pretože nič vnútri workbooku neodkazuje na root identifikátory podľa mena, a ten istý trik o úroveň nižšie by rozbil každú r:id väzbu vo workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // rezervované modelovým writerom
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // Vlastné properties teraz vlastní model; zdrojovú kópiu neprehrávaj.
  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žšie voľné rIdN
  UsedIds.Add(String(Id));
  ...
end;

Čo majú tie tri zlyhania spoločné?

Všetky tri sú príznakom writera s dvoma zdrojmi a bez jediného vlastníka package invariant. Object model generuje časti, ktorým rozumie; opaque vrstva prehráva tie, ktorým nerozumie, aby round trip udržal grafy, pivot cache, vlastné XML a všetko ostatné opísané v poznámkach o lossless round-tripe témy, extLst a calcChain. Každá strana bola sama o sebe konzistentná. Obmedzenia, ktoré OPC kladie na celý package, teda jedinečné mená Override partov a jedinečné relationship identifikátory na part, existujú len na švíku, kde sa obe spájajú, a do v2.382.5 ten švík nikto nekontroloval. Bug s fontId má ten istý tvar o úroveň nižšie: writer vedel, čo chce vynechať, ale nikdy sa neopýtal schémy, ktorá hovorí, že nesmie. Oprava, na ktorej sa HotXLS ustálil, je pevná precedencia a nie merge heuristika. Model píše prvý, opaque vrstva vidí, čo sa zapísalo, a pri každej kolízii ustúpi, a corpus runner teraz vynucuje invarianty zvonka cez verify_opc_uniqueness, ktorá prečíta [Content_Types].xml a každú .rels položku v uloženom package a zhodí prípad na každom duplicitnom PartName, Extension alebo Id. Tá kontrola je lacná, nepotrebuje Excel a chytila by dva z troch defektov hneď v prvom corpus behu

Ten istý batch: print areas, ktoré sú formulami, nie rozsahmi

Excel prechod označil aj _xlnm.Print_Area úverovej šablóny, ktorú Excel hlásil ako $A$1:$J$29 na origináli a musel ju rovnako hlásiť aj na uloženej kópii. Za tou jednou assertion sedeli dva samostatné bugy. Pri importe XlsxStripSheetPrefix odsekol všetko po prvý neúvodzovkovaný !, takže dynamická print area ako OFFSET('Print Data'!$A$1,0,0,2,2) sa vrátila ako $A$1,0,0,2,2) a kvalifikovaná únia ako 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 stratila prefix len na svojom prvom segmente. Pri exporte writer pridal meno hárku raz pred celú uloženú PrintArea, takže obyčajná únia $A$1:$B$2,$D$1:$E$2 nechala knižnicu s kvalifikovaným prvým segmentom a holým druhým, čo Excel neprijíma ako definíciu _xlnm.Print_Area podľa ECMA-376 Part 1 §18.2.5

// Import: prefix strhni len vtedy, keď to, čo zostane, je obyčajný 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(...) sa vracia nedotknuté

// Export: kvalifikuj každý segment oddelený čiarkou, alebo žiadny
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 doslovne
  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árovania je na oboch stranách rovnaké: print area je holý rozsah len vtedy, keď sa každý segment parsuje ako rozsah, inak je to formula a cestuje doslovne. PrintArea_FormulaDefinitionSurvivesRoundTrip pokrýva pomenovanú bázu, bázu kvalifikovanú hárkom aj úniu cez dva cykly uloženia a znovuotvorenia. Ako print areas interagujú s page setup a zvyškom tlačového modelu, pokrýva článok o ochrane hárku, page setup a tlači

Ako zistíte, proti ktorému pravidlu Excel namieta?

Vychádzajte z predpokladu, že váš vlastný validátor sa mýli, pretože prešiel. Validátor z Open XML SDK pomenuje porušenie schémy, ako chýbajúce fontId, spolu s partom a XPath, a packaging vrstva pod ním odmietne otvoriť package s duplicitnými content-type záznamami úplne, takže ho púšťajte pred všetkým ostatným. Keď mlčí a Excel aj tak opravuje, bisekujte package: rozzipujte, zmažte časť aj jej relationship aj jej Override, zzipujte a znova otvorte, a kandidátsku množinu každým krokom polte, kým výzva nezmizne. Tri defekty odtiaľto vypadli presne v tomto poradí a ani jeden z nich by nebol viditeľný v opravenom súbore, ktorý Excel ponúkne uložiť, pretože oprava problematické záznamy ticho zahodí alebo prečísluje. Hranice opravy vo v2.382.5 stoja za to povedať rovnako priamo. Deduplikácia je first-wins s modelom vpredu, takže ak zdrojový package deklaroval iný content type pre časť, ktorú model tiež generuje, vyhrá deklarácia modelu a zdrojová sa zahodí, čo je správne pre časti, ktoré HotXLS regeneruje, a nie je to všeobecný merge. verify_opc_uniqueness kontroluje len jedinečnosť; nevaliduje schémy, takže budúci povinný atribút by stále potreboval Excel alebo schema validátor, aby sa vynoril. A extra prechod TXMLReader cez vygenerovaný content types stream beží pri každom uložení so zapnutým PreserveUnsupportedParts, čo je malá cena proti streamu, ktorý málokedy presiahne pár kilobajtov. S tým všetkým sa obe buildy úverovej šablóny, Win32 aj Win64, teraz otvárajú v Exceli bez výzvy, prepočítajú všetkých 4805 overených formúl s nulovými nezhodami a hlásia rovnakú print area ako originál

Ak píšete XLSX z Delphi sami, checklist je krátky: emitujte každý atribút, ktorý schéma označuje za povinný, bez ohľadu na jeho hodnotu, deklarujte každé meno partu raz a držte jeden zoznam použitých identifikátorov na relationships part naprieč všetkými writermi, ktoré sa ho dotknú. Ak by ste radšej mali ten zoznam už hotový a testovaný proti Excelu a nie len proti vlastnému readeru, package writer opísaný v tomto článku vychádza v HotXLS Delphi spreadsheet component, spolu s opaque-part round-tripom, ktorý spravil ten švík hodným stráženia