Műszaki cikk

Excel-javítás érvényes XLSX-nél: OPC-szabályok Delphiben

Az Excel a „We found a problem with some content" üzenetet mutatja egy olyan XLSX-nél, amelyet a LibreOffice és minden házi fejlesztésű olvasó panasz nélkül megnyit, mert az Excel két olyan dolgot kényszerít ki, amelyeket ezek az olvasók figyelmen kívül hagynak: a séma által kötelezővé tett attribútumokat és az Open Packaging Conventions egyediségi szabályait. A HotXLS, a natív Excel-táblázatkezelő komponens Delphihez és C++Builderhez, pontosan ebbe futott bele a v2.382.5-ben, amikor a kimenete először ment át egy valódi Excel COM-példányon, és a három ok egy <phoneticPr> fontId nélkül, egy duplikált Override a [Content_Types].xml-ben, valamint két gyökérreláció volt, amelyek ugyanazt az rId4-et használták

Miért utasítja el az Excel azt a csomagot, amelyet minden más olvasó elfogad?

Mert a javítási párbeszédablak egy séma- és csomagérvényesítő, nem elemzőhiba. A HotXLS korpusza hetekig járatta körbe a 4805 képletű hitelsablont a könyvtáron, a LibreOffice-on és a tesztsor XML-érvényesítőin keresztül. A mentett fájl szerkezetileg rendben volt abban az OPC-értelemben, amelyet az XLSX OPC-relációfeloldásról szóló cikk használ: minden rész elérhető, minden cél feloldható. Aztán elérhetővé vált egy Windows-os gép Excel 16.0 build 20326-tal, a korpuszfuttató megnyitotta a mentett sablont a Workbooks.Open hívásával egy elkülönített COM-példányban, kikapcsolt DisplayAlerts mellett, és a hívás egyszerűen elbukott. Interaktívan ugyanez a fájl a megszokott, javítást felkínáló párbeszédablakot hozza fel, a javítási napló pedig, amikor az Excel egyáltalán ír ilyet, megnevezi a részt, a szabályt viszont nem. Három önálló hiba bújt meg abban az egy párbeszédablakban, az Excel pedig nem egyenként jelenti őket; visszautasítja a munkafüzetet, és magára hagyja Önt, hogy megtalálja és elemezze őket. A következőkben jön minden szabály, a HotXLS azon sora, amely megsértette, és a javítás, amely megjelent, mert mindegyik olyan szabály, amelybe bármely Delphi XLSX-író belefuthat

1. szabály: a phoneticPr fontId kötelező, akkor is, ha nulla

A <phoneticPr> elem egy fontId attribútumot hordoz, amelyet az ECMA-376 Part 1 §18.4.3 use="required" beállítással deklarál, a 0 érték pedig legális fontindex, nem pedig a hiány jelölése. A régi HotXLS munkalapíró a nullát „nincs beállítva" jelentéssel kezelte, és csak akkor írta ki az attribútumot, ha Sheet.PhoneticFontId > 0 volt. Ez természetes Delphi-reflex, hiszen az egész szám típusú mezők alapértéke nulla, de <phoneticPr type="noConversion"/> lesz belőle minden olyan munkafüzetnél, amelynek a fonetikai fontja épp a styles.xml első fontja — pontosan ezt hordozta a HotXLS korpuszában lévő hitelsablon is. Az Excel aztán a visszafelé úton visszautasít egy olyan értéket, amelyet maga írt

Miért kért az Excel javítást a HotXLS munkalaprészéhez: a phoneticPr elem a fontId-t use required beállítással deklarálja az ECMA-376 Part 1-ben, a 0 fontindex legális érték, a régi író pedig, amely nulla PhoneticFontId esetén elhagyta az attribútumot, phoneticPr type noConversion elemet állított elő, míg a séma a type és az alignment számára ad alapértéket, a fontId számára nem
Egy attribútum elhagyása akkor biztonságos, ha az egyenlő az alapértékkel, és a séma deklarálja is ezt az alapértéket; a hitelsablon pedig a fonetikai fontját a styles.xml legelső bejegyzéseként hordozta
// lxHandleX.pas, munkalapíró — a v2.382.5 előtt
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — az attribútum kötelező, a nulla is beleértve
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

A HotXLS továbbra is csak akkor írja ki az elemet, ha a TXLSXWorksheet.PhoneticType nem üres, így azok a munkafüzetek, amelyek soha nem hordoztak fonetikai beállításokat, érintetlenek maradnak. A PhoneticSettings_DefaultFontIsExplicit regressziós teszt egy friss munkalapon nullára állítja a PhoneticFontId értékét, ment, és megköveteli, hogy a <phoneticPr fontId="0" ott legyen az xl/worksheets/sheet1.xml fájlban. A tágabb tanulság az, hogy az „alapérték esetén elhagyni" csak akkor biztonságos, ha a séma deklarálja az alapértéket; a type és az alignment rendelkezik alapértékkel ebben az elemben, a fontId nem

2. szabály: résznevenként egyetlen Override a [Content_Types].xml-ben

A content types stream minden résznévhez legfeljebb egyszer deklarálhat bejegyzést, az Excel pedig ugyanannak a PartName-nek a második Override bejegyzését is sérülésként kezeli, még akkor is, ha mindkettő ugyanazt a ContentType értéket hordozza. A HotXLS-ben két író táplálja ezt a streamet. A BuildContentTypesXml minden olyan részt deklarál, amelyet az objektummodell előállít: munkafüzet, stílusok, megosztott sztringek, téma, munkalapok, valamint a /docProps/custom.xml, ha TXLSXWorkbook.CustomProperties.Count > 0. Ha a PreserveUnsupportedParts be van kapcsolva, a TXLSXOpaquePackage ezután minden olyan részhez hozzáfűz egy Override bejegyzést, amelyet szó szerint átvett a forráscsomagból, hogy ezek a bájtok a kimeneten is deklarálva maradjanak. Az ütközés az a rész, amely mindkét oldalon él. Az egyéni dokumentumtulajdonságok bekerülnek a modellbe, a forráscsomag docProps/custom.xml fájlját viszont átlátszatlan módon is átvette a kód, így az összefésült stream kétszer deklarálta, a diagram- és pivot cache-részek pedig ugyanerre a sorsra juthatnak, amikor a modell újragenerál egy olyan részt, amelyet az átlátszatlan réteg is megtartott. A v2.382.5 előtt a ContentTypeOverridesXml nem látta, mit írt már meg a modell, így nem is tudhatta

Hogyan ütközött két HotXLS-író a [Content_Types].xml-ben: a BuildContentTypesXml az objektummodellből deklarálta a docProps/custom.xml fájlt, a TXLSXOpaquePackage viszont hozzáfűzött egy Override bejegyzést ugyanahhoz, szó szerint átvett részhez, és a v2.382.5 óta az átlátszatlan réteg először beolvassa a generált streamet, az OpcLowerPartName-nel normalizálja a neveket, és minden ütközésnél a modellt hagyja nyerni
Mindkét író önmagában konzisztens volt, az viszont, hogy minden résznév csak egyszer szerepelhet, kizárólag ott megkötés, ahol a kimeneteik összefűződnek, ezért adja át a javítás a modell streamjét
<!-- Ezt látta az Excel a v2.382.5 előtt -->
<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"/>

A javítás átadja a generált XML-t a ContentTypeOverridesXml metódusnak, és hagyja, hogy az átlátszatlan író minden kibocsátás előtt beolvassa. Két részlet hordozza a helyességet. Az OpcLowerPartName kisbetűsít, a visszaperjeleket perjelre váltja, és az összehasonlítás előtt lehántja a vezető perjeleket, mert az OPC-részneveket kis-nagybetűre érzéketlenül hasonlítják össze, a modell pedig vezető perjellel írja őket, míg az átlátszatlan réteg a ZIP-elemek nevét az nélkül tárolja. A BuildContentTypesXml-ben lévő hívó pedig a Result + '</Types>' értéket adja át, lezárva a félig felépített dokumentumot, így a TXMLReader jól formált bemenetet lát csonkolt stream helyett. A kirajzolódó szabály az első nyer, a modell áll az élen: amit az objektummodell deklarál, az a mérvadó, az átlátszatlan visszajátszás pedig csak a hézagokat tölti ki

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // A modell által generált stream beolvasása és minden deklarált PartName összegyűjtése.
    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;                       // már deklarálva, vagy rels rész
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

3. szabály: a reláció-azonosítók egyediek egy relationships részen belül

A .rels részben minden Relationship elemhez olyan Id kell, amely egyedi az adott részen belül, és az Excel visszautasítja a csomagot, ha kettő ugyanazt használja. A HotXLS a csomagszintű _rels/.rels fájlt rögzített azonosítókkal írja: rId1 a munkafüzethez, rId2 és rId3 az alap- és kiterjesztett dokumentumtulajdonságokhoz, rId4 pedig az egyéni tulajdonságokhoz, ha a modellnek vannak ilyenjei. Az átlátszatlan csomag ezután hozzáfűzi mindazokat a gyökérrelációkat, amelyeket megtartott a forrásból, újraszámozva minden olyan azonosítót, amely már szerepel a UsedIds listában. A lista az rId1-től rId3-ig ismerte a helyzetet. Az rId4-et nem ismerte, és azt sem tudta, hogy a modell mindjárt kiírja a saját egyéni tulajdonságok relációját, így egy olyan forráscsomag, amelynek az egyéni tulajdonságok relációja szintén rId4 volt — az Excel alapértelmezés szerint ezt írja —, két rId4 bejegyzéssel jött ki, ugyanarra a célra mutatva. A hívó, a BuildRootRelsXml, mostantól a Workbook.FCustomProps.Count > 0 értéket adja át második argumentumként, így a foglalást és a kihagyást ugyanaz a feltétel vezérli, amely azt is eldönti, hogy a modell egyáltalán kiírja-e az rId4-et. Az újraszámozás a csomag gyökerében biztonságos, mert a munkafüzeten belül semmi nem hivatkozik név szerint a gyökérrelációk azonosítóira; ugyanez a fogás egy szinttel lejjebb hibás volna, ahol a workbook.xml r:id attribútumai a munkafüzet relationships részének azonosítóihoz kötődnek, ezért tart külön azonosítótérképet a MergeWorkbookRelationshipsXml

A relációazonosítók ütközése a HotXLS-csomag gyökerében: a modell az rId1-től az rId4-ig ír, az rId4-et az egyéni tulajdonságok számára fenntartva, az átlátszatlan réteg viszont visszajátszott egy olyan forrásrelációt, amely szintén rId4-ként érkezett, mert a UsedIds csak az rId1-től az rId3-ig ismerte a helyzetet, a javítás pedig előre lefoglalja az rId4-et, valahányszor az EmitCustomProps teljesül, és újraszámozza a többit
Az újraszámozás a csomag gyökerében biztonságos, mert a munkafüzeten belül semmi nem hivatkozik név szerint a gyökér-azonosítókra, ugyanez a fogás egy szinttel lejjebb viszont minden r:id kötést eltörne a workbook.xml-ben
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // a modellíró foglalja le
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // Az egyéni tulajdonságok mostantól a modellé; ne játsszuk vissza a forrásból származó másolatot.
  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);      // a legkisebb szabad rIdN
  UsedIds.Add(String(Id));
  ...
end;

Mi a közös a három hibában?

Mindhárom annak a tünete, hogy az írónak két forrása van, és a csomaginvariánsoknak nincs egyetlen gazdája. Az objektummodell az általa értett részeket generálja, az átlátszatlan réteg pedig azokat játssza vissza, amelyeket nem ért meg, így a körbejáratás megtartja a diagramokat, a pivot cache-eket, az egyéni XML-t és minden mást, amit a téma, extLst és calcChain veszteségmentes körbejáratásáról szóló jegyzet leír. Mindkét oldal önmagában konzisztens volt. Azok a megkötések, amelyeket az OPC a teljes csomagra ró — egyedi Override résznevek és részenként egyedi relációazonosítók —, kizárólag ott léteznek, ahol a kettő összefűződik, és a v2.382.5-ig senki nem ellenőrizte ezt a varratot. A fontId hiba ugyanilyen alakú egy szinttel lejjebb: az író tudta, mit akar elhagyni, de soha nem nézte meg a sémát, amely szerint nem hagyhatja el. A javítás, amelyre a HotXLS végül jutott, rögzített elsőbbség összefésülő heurisztika helyett. A modell ír először, az átlátszatlan réteg látja, mi íródott meg, és minden ütközésnél enged, a korpuszfuttató pedig mostantól kívülről kényszeríti ki az invariánsokat a verify_opc_uniqueness hívással, amely egy mentett csomagban beolvassa a [Content_Types].xml fájlt és minden .rels elemet, és elbukik minden duplikált PartName, Extension vagy Id esetén. Ez az ellenőrzés olcsó, nem kell hozzá Excel, és a három hiba közül kettőt már az első korpuszfutáson elcsípett volna

Ugyanabból a körből: nyomtatási területek, amelyek képletek, nem tartományok

Az Excel-kör a hitelsablon _xlnm.Print_Area bejegyzését is megjelölte, amelyet az Excel az eredetinél $A$1:$J$29-ként jelentett, és a mentett másolatnál ugyanígy kellett jelentenie. Két külön hiba állt e mögött az egyetlen állítás mögött. Betöltéskor az XlsxStripSheetPrefix mindent levágott az első idézőjelbe nem tett !-ig, így egy dinamikus nyomtatási terület, például az OFFSET('Print Data'!$A$1,0,0,2,2), $A$1,0,0,2,2) alakban jött vissza, egy minősített unió pedig, mint a 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2, csak az első szegmensén veszítette el az előtagot. Exportáláskor az író a teljes tárolt PrintArea elé egyszer fűzte oda a munkalap nevét, így egy sima unió, a $A$1:$B$2,$D$1:$E$2, úgy hagyta el a könyvtárat, hogy az első szegmens minősített, a második pedig csupasz lett, amit az Excel az ECMA-376 Part 1 §18.2.5 szerint nem fogad el _xlnm.Print_Area definícióként

// Betöltés: csak akkor hántjuk le az előtagot, ha a maradék sima 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;                                  // az OFFSET(...) érintetlenül tér vissza

// Exportálás: minden vesszővel elválasztott szegmenst minősítünk, vagy egyiket sem
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;                           // képlet: szó szerint kiírjuk
  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;

A párosítási szabály mindkét oldalon ugyanaz: a nyomtatási terület csak akkor csupasz tartomány, ha minden szegmense annak elemezhető, különben képlet, és szó szerint utazik. A PrintArea_FormulaDefinitionSurvivesRoundTrip lefedi a névvel jelölt alapot, a munkalappal minősített alapot és az uniót, két mentés-újranyitás cikluson keresztül. Hogy a nyomtatási területek hogyan hatnak kölcsön a lapbeállítással és a nyomtatási modell többi részével, azt a munkalapvédelemről, lapbeállításról és nyomtatásról szóló cikk tárgyalja

Hogyan deríthető ki, melyik szabályba köt bele az Excel?

Induljon ki abból, hogy a saját érvényesítője téved, hiszen az átengedte a fájlt. Az Open XML SDK érvényesítője megnevezi a séma megsértését, például a hiányzó fontId-t, a résszel és az XPath kifejezéssel együtt, az alatta lévő csomagolási réteg pedig egyáltalán nem nyit meg olyan csomagot, amelyben duplikált content-type bejegyzés van, tehát mindenekelőtt ezt futtassa. Ha csendben marad, az Excel mégis javít, akkor bontsa ketté a csomagot: bontsa ki, töröljön egy részt meg a relációját és az Override bejegyzését, tömörítse újra, és nyissa meg újra, minden alkalommal felezve a jelöltek halmazát, amíg a párbeszédablak el nem tűnik. Az itteni három hiba pontosan ebben a sorrendben került elő, és egyik sem lett volna látható abban a javított fájlban, amelyet az Excel felkínál mentésre, mivel a javítás csendben elhagyja vagy újraszámozza a kifogásolt bejegyzéseket. A v2.382.5 javításának határait ugyanilyen egyenesen érdemes kimondani. A deduplikáció első nyer alapon működik, a modell áll az élen, így ha a forráscsomag más content type-ot deklarált egy olyan részhez, amelyet a modell is generál, a modell deklarációja nyer, a forrásé pedig elvész, ami a HotXLS által újragenerált részeknél helyes, általános összefésülés viszont nem. A verify_opc_uniqueness csak az egyediséget ellenőrzi, sémákat nem érvényesít, így egy jövőbeli kötelező attribútumhoz továbbra is Excelre vagy sémaérvényesítőre lesz szükség, hogy előkerüljön. A generált content types stream feletti extra TXMLReader menet pedig minden mentésnél lefut, ha a PreserveUnsupportedParts be van kapcsolva, ami csekély költség egy olyan streamhez képest, amely ritkán több néhány kilobájtnál. Mindezekkel a hitelsablon Win32-es és Win64-es buildje is párbeszédablak nélkül nyílik meg az Excelben, újraszámolja mind az 4805 ellenőrzött képletet nulla eltéréssel, és ugyanazt a nyomtatási területet jelenti, mint az eredeti

Ha Ön maga ír XLSX-et Delphiből, az ellenőrzőlista rövid: írjon ki minden attribútumot, amelyet a séma kötelezőként jelöl meg, az értékétől függetlenül, minden résznévből egyet deklaráljon, és tartson egyetlen listát a felhasznált azonosítókról relationships részenként, minden olyan írón keresztül, amely hozzányúl. Ha inkább azt szeretné, hogy ez a lista már létezzen, és ne csak a saját olvasójával, hanem Excellel szemben is tesztelve legyen, az itt leírt csomagíró a HotXLS Delphi táblázatkezelő komponensében érkezik, azzal az átlátszatlanrész-körbejáratással együtt, amely miatt a varrat egyáltalán őrzésre érdemes lett