Teknik Makale

Excel Geçerli XLSX'i Neden Onarıyor: OPC Paket Kuralları

LibreOffice'in ve her el yapımı okuyucunun sorunsuz açtığı bir XLSX dosyasında Excel "We found a problem with some content" iletisini gösterir; çünkü Excel, o okuyucuların görmezden geldiği iki şeyi zorunlu tutar: şemanın zorunlu kıldığı öznitelikler ve Open Packaging Conventions'ın tekillik kuralları. Delphi ve C++Builder için yerel Excel tablo bileşeni olan HotXLS tam da bununla v2.382.5'te, çıktısı ilk kez gerçek bir Excel COM örneğinden geçtiğinde karşılaştı; üç neden fontId içermeyen bir <phoneticPr>, [Content_Types].xml içinde yinelenen bir Override ve rId4 paylaşan iki kök relationship'ti

Excel neden her okuyucunun kabul ettiği bir paketi reddediyor?

Çünkü onarım istemi bir parser hatası değil, şema ve paket doğrulayıcısıdır. HotXLS corpus'u haftalardır 4805 formüllü bir kredi şablonunu kütüphaneden, LibreOffice'ten ve test takımındaki XML doğrulayıcılarından geçirip round-trip ediyordu. Kaydedilen dosya, XLSX OPC relationship çözümlemesi makalesinde kullanılan OPC anlamında yapısal olarak sağlamdı: her parçaya ulaşılabiliyor, her hedef çözümlenebiliyordu. Sonra Excel 16.0 build 20326 kurulu bir Windows makinesi devreye girdi, corpus koşucusu kaydedilen şablonu DisplayAlerts kapalı izole bir COM örneğinde Workbooks.Open ile açtı ve çağrı doğrudan başarısız oldu. Etkileşimli olarak aynı dosya, onarım teklif eden bilinen iletiyi çıkarır; Excel'in yazmaya zahmet ettiği onarım günlüğü ise parçayı adlandırır ama kuralı söylemez. Bu tek istemin arkasında üç bağımsız kusur saklanıyordu ve Excel onları teker teker raporlamaz; workbook'u reddeder ve onları bulup analiz etmeyi size bırakır. Aşağıda her kuralı, onu ihlal eden HotXLS satırını ve yayınlanan düzeltmeyi bulacaksınız; çünkü bunların her biri herhangi bir Delphi XLSX yazıcısının takılabileceği türden kurallar

Kural 1: phoneticPr fontId zorunlu, sıfırken de

<phoneticPr> öğesi, ECMA-376 Part 1 §18.4.3'te use="required" olarak bildirilmiş bir fontId özniteliği taşır ve 0 değeri yokluk değil, geçerli bir font indeksidir. Eski HotXLS worksheet yazıcısı sıfırı ayarlanmamış sayıyor ve özniteliği yalnızca Sheet.PhoneticFontId > 0 olduğunda yazıyordu. Tam sayı alanlarının varsayılanı sıfır olduğu için bu doğal bir Delphi refleksi, ama fonetik fontu styles.xml içindeki ilk font olan her workbook için <phoneticPr type="noConversion"/> üretiyor; HotXLS corpus'undaki kredi şablonunun taşıdığı da tam olarak buydu. Excel böylece kendi yazdığı bir değeri geri alırken reddediyor

Excel'in HotXLS worksheet parçası için neden onarım istediği: phoneticPr öğesi fontId değerini ECMA-376 Part 1 içinde use required olarak bildiriyor, 0 font indeksi geçerli bir değer ve PhoneticFontId sıfırken özniteliği atlayan eski yazıcı phoneticPr type noConversion üretiyordu; şema type ve alignment için varsayılan verirken fontId için hiç vermiyor
Bir özniteliği varsayılana eşit olduğunda atlamak yalnızca şema o varsayılanı bildirdiğinde güvenlidir; kredi şablonu ise fonetik fontunu styles.xml içindeki ilk girdi olarak taşıyordu
// lxHandleX.pas, worksheet yazıcısı — v2.382.5 öncesi
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — öznitelik zorunlu, sıfır da dahil
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLS öğeyi yalnızca TXLSXWorksheet.PhoneticType boş değilse yazmaya devam ediyor, yani hiç fonetik ayar taşımayan workbook'lar etkilenmiyor. PhoneticSettings_DefaultFontIsExplicit regresyon testi yeni bir sayfada PhoneticFontId değerini sıfıra ayarlar, kaydeder ve xl/worksheets/sheet1.xml içinde <phoneticPr fontId="0" bulunduğunu doğrular. Daha geniş ders şu: varsayılansa yazma yaklaşımı yalnızca şema bir varsayılan bildirdiğinde güvenlidir; o öğede type ve alignment varsayılana sahiptir, fontId ise sahip değildir

Kural 2: [Content_Types].xml içinde parça adı başına tek Override

İçerik tipi akışı her parça adını en fazla bir kez bildirebilir ve Excel, aynı PartName için ikinci bir Override girdisini, iki girdi aynı ContentType değerini taşısa bile bozulma sayar. HotXLS'te o akışı besleyen iki yazıcı var. BuildContentTypesXml, nesne modelinin ürettiği her parçayı bildirir: workbook, styles, shared strings, theme, worksheet'ler ve TXLSXWorkbook.CustomProperties.Count > 0 olduğunda /docProps/custom.xml. PreserveUnsupportedParts açıkken TXLSXOpaquePackage, kaynak paketten birebir yakaladığı her parça için bir Override ekler; böylece o baytlar çıkışta da bildirilmiş kalır. Çakışma, her iki tarafta da bulunan bir parçadan doğar. Özel belge özellikleri modele ayrıştırılır ama kaynak paketin docProps/custom.xml dosyası da opak biçimde yakalanır, bu yüzden birleşik akış onu iki kez bildiriyordu; model, opak katmanın da koruduğu bir parçayı yeniden ürettiğinde chart ve pivot cache parçaları da aynı noktaya düşebilir. v2.382.5 öncesinde ContentTypeOverridesXml modelin neleri yazdığını göremiyordu, dolayısıyla bilemezdi

HotXLS'te iki yazıcının [Content_Types].xml içinde nasıl çakıştığı: BuildContentTypesXml docProps/custom.xml dosyasını nesne modelinden bildirirken TXLSXOpaquePackage birebir yakalanan aynı parça için bir Override ekliyordu; v2.382.5'ten beri opak katman önce üretilen akışı ayrıştırıyor, adları OpcLowerPartName ile normalleştiriyor ve her çakışmayı modelin kazanmasına izin veriyor
Her yazıcı kendi içinde tutarlıydı; her parça adının bir kez görünmesi kısıtı ise yalnızca çıktılarının birleştirildiği dikişte var olduğu için düzeltme model akışını içeri geçiriyor
<!-- Excel'in v2.382.5 öncesinde gördüğü -->
<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"/>

Düzeltme, üretilen XML'i ContentTypeOverridesXml fonksiyonuna geçiriyor ve opak yazıcının bir şey yazmadan önce onu ayrıştırmasını sağlıyor. Doğruluğu iki ayrıntı taşıyor. OpcLowerPartName karşılaştırmadan önce adları küçük harfe çevirir, ters bölüleri düz bölüye döndürür ve baştaki bölüleri kırpar; çünkü OPC parça adları büyük-küçük harf duyarsız karşılaştırılır, model onları baştaki bölüyle yazar, opak katman ise ZIP öğe adlarını bölüsüz saklar. Bir de BuildContentTypesXml içindeki çağıran, Result + '</Types>' geçirerek kısmen kurulmuş belgeyi kapatır, böylece TXMLReader kesilmiş bir akış yerine well-formed girdi görür. Buradan çıkan kural, modelin önde olduğu ilk-gelen-kazanır kuralıdır: nesne modelinin bildirdiği her şey otoritedir, opak replay yalnızca boşlukları doldurur

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Modelin ürettiği akışı ayrıştır ve bildirilen her PartName değerini topla.
    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;                       // zaten bildirilmiş ya da bir rels parçası
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Kural 3: relationship Id değerleri bir relationships parçası içinde tekil olmalı

Bir .rels parçasındaki her Relationship öğesi o parça içinde tekil bir Id taşımak zorundadır ve iki tanesi bir Id paylaşırsa Excel paketi reddeder. HotXLS paket düzeyindeki _rels/.rels dosyasını sabit tanımlayıcılarla yazar: workbook için rId1, core ve extended belge özellikleri için rId2 ve rId3, modelde varsa özel özellikler için rId4. Opak paket ise kaynaktan koruduğu kök relationship'leri ekler ve UsedIds listesinde zaten bulunan tanımlayıcıları yeniden numaralandırır. Liste rId1 ile rId3 arasını biliyordu. rId4 hakkında hiçbir şey bilmiyordu ve modelin kendi özel özellik relationship'ini yazmak üzere olduğunu da bilmiyordu; bu yüzden özel özellik relationship'i de rId4 olan — Excel'in varsayılan olarak yazdığı budur — bir kaynak paket, aynı hedefe işaret eden iki rId4 girdisiyle çıktı. Çağıran BuildRootRelsXml artık ikinci argüman olarak Workbook.FCustomProps.Count > 0 geçiriyor, yani hem rezervasyon hem de atlama, modelin rId4 yazıp yazmayacağına karar veren aynı koşulla yürüyor. Paket kökünde yeniden numaralandırma güvenlidir, çünkü workbook içinde hiçbir şey kök relationship tanımlayıcılarına adla başvurmaz; aynı numara bir kat aşağıda yanlış olurdu, orada workbook.xml içindeki r:id öznitelikleri workbook relationships parçasındaki tanımlayıcılara bağlanır; MergeWorkbookRelationshipsXmlin ayrı bir tanımlayıcı haritası tutmasının nedeni de budur

HotXLS paketinin kökündeki relationship tanımlayıcı çakışması: model rId1 ile rId4 arasını yazar ve rId4 özel özellikler için ayrılmıştır, opak katman ise kaynaktan rId4 olarak gelen bir relationship'i yeniden oynattı çünkü UsedIds yalnızca rId1 ile rId3 arasını biliyordu; düzeltme EmitCustomProps geçerli olduğunda rId4 değerini baştan rezerve edip kalanı yeniden numaralandırıyor
Yeniden numaralandırma paket kökünde güvenlidir çünkü workbook içinde hiçbir şey kök tanımlayıcılara adla başvurmaz; aynı numara bir kat aşağıda workbook.xml içindeki her r:id bağını bozardı
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // model yazıcısı tarafından rezerve edildi
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // Özel özellikler artık modele ait; kaynak kopyayı yeniden oynatma.
  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);      // en düşük boş rIdN
  UsedIds.Add(String(Id));
  ...
end;

Üç hatanın ortak noktası ne?

Üçü de iki kaynağı olan ve paket değişmezlerinin tek bir sahibi bulunmayan bir yazıcının belirtileri. Nesne modeli anladığı parçaları üretir; opak katman anlamadığı parçaları yeniden oynatır, böylece bir round-trip theme, extLst ve calcChain üzerine lossless round-trip notlarında anlatılan chart'ları, pivot cache'leri, özel XML'i ve daha fazlasını korur. Her iki taraf da kendi içinde tutarlıydı. OPC'nin tüm pakete koyduğu kısıtlar — tekil Override parça adları ve parça başına tekil relationship tanımlayıcıları — ancak ikisinin birleştirildiği dikişte var olur ve v2.382.5'e kadar o dikişi kimse kontrol etmiyordu. fontId hatası da bir kat aşağıda aynı biçimde: yazıcı neyi atlamak istediğini biliyordu ama atlayamayacağını söyleyen şemaya hiç danışmadı. HotXLS'in oturduğu çözüm, bir birleştirme sezgisi değil sabit bir öncelik. Önce model yazar, opak katman yazılanı görür ve her çakışmada geri çekilir; corpus koşucusu da değişmezleri artık dışarıdan verify_opc_uniqueness ile zorluyor; bu araç kaydedilmiş bir paketteki [Content_Types].xml dosyasını ve her .rels öğesini okur ve yinelenen her PartName, Extension ya da Id için vakayı başarısız sayar. Bu kontrol ucuz, Excel gerektirmiyor ve üç kusurdan ikisini ilk corpus koşusunda yakalardı

Aynı partide: aralık değil formül olan yazdırma alanları

Excel geçişi kredi şablonunun _xlnm.Print_Area tanımını da işaretledi; Excel orijinalde $A$1:$J$29 olarak raporluyordu ve kaydedilen kopyada da aynısını raporlamak zorundaydı. Bu tek assertion'ın arkasında iki ayrı hata vardı. İçe aktarmada XlsxStripSheetPrefix ilk tırnaksız ! işaretine kadar her şeyi kesiyordu, bu yüzden OFFSET('Print Data'!$A$1,0,0,2,2) gibi dinamik bir yazdırma alanı $A$1,0,0,2,2) olarak geri geliyordu ve 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 gibi nitelenmiş bir birleşim öneki yalnızca ilk parçasında kaybediyordu. Dışa aktarmada yazıcı, saklanan PrintArea değerinin tamamına sayfa adını bir kez başa ekliyordu; bu yüzden $A$1:$B$2,$D$1:$E$2 gibi düz bir birleşim kütüphaneden ilk parçası nitelenmiş, ikincisi çıplak hâlde çıkıyordu; Excel bunu ECMA-376 Part 1 §18.2.5 kapsamında bir _xlnm.Print_Area tanımı olarak kabul etmez

// İçe aktarma: öneki yalnızca kalan şey düz bir sqref ise kırp
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(...) dokunulmadan döner

// Dışa aktarma: virgülle ayrılmış her parçayı nitele, ya da hiçbirini
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;                           // bir formül: olduğu gibi yaz
  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;

Eşleştirme kuralı iki tarafta da aynı: bir yazdırma alanı ancak her parçası düz aralık olarak ayrıştırılabiliyorsa çıplak aralıktır, aksi hâlde bir formüldür ve olduğu gibi taşınır. PrintArea_FormulaDefinitionSurvivesRoundTrip adlandırılmış tabanı, sayfa nitelenmiş tabanı ve birleşimi iki kaydet-aç döngüsü boyunca kapsıyor. Yazdırma alanlarının sayfa yapısı ve baskı modelinin geri kalanıyla nasıl etkileştiği sayfa koruması, sayfa yapısı ve yazdırma makalesinde ele alınıyor

Excel'in hangi kurala itiraz ettiğini nasıl bulursunuz?

Kendi doğrulayıcınızın yanlış olduğu varsayımıyla başlayın, çünkü o geçti. Open XML SDK doğrulayıcısı eksik fontId gibi bir şema ihlalini parçasıyla ve XPath'iyle adlandırır; altındaki paketleme katmanı ise yinelenen içerik tipi girdileri olan bir paketi hiç açmaz, bu yüzden her şeyden önce onu çalıştırın. Sessiz kaldığı ve Excel yine de onardığı durumda paketi ikiye bölerek arayın: arşivi açın, bir parçayı relationship'i ve Override girdisiyle birlikte silin, yeniden sıkıştırın ve açın; istem kaybolana kadar aday kümesini her seferinde yarıya indirin. Buradaki üç kusur da bu sırayla ortaya çıktı ve hiçbiri Excel'in kaydetmeyi teklif ettiği onarılmış dosyada görünmez olurdu, çünkü onarım sorunlu girdileri sessizce düşürür ya da yeniden numaralandırır. v2.382.5 düzeltmesinin sınırlarını da aynı açıklıkla söylemek gerekir. Tekilleştirme, modelin önde olduğu ilk-gelen-kazanır biçimindedir; yani kaynak paket, modelin de ürettiği bir parça için farklı bir içerik tipi bildirdiyse modelin bildirimi kazanır ve kaynağınki atılır. Bu, HotXLS'in yeniden ürettiği parçalar için doğrudur ve genel bir birleştirme değildir. verify_opc_uniqueness yalnızca tekilliği kontrol eder; şemaları doğrulamaz, yani gelecekte zorunlu kılınacak bir özniteliği yine Excel'in ya da bir şema doğrulayıcının ortaya çıkarması gerekir. Bir de üretilen içerik tipleri akışı üzerindeki ek TXMLReader geçişi, PreserveUnsupportedParts etkinken her kayıtta çalışır; nadiren birkaç kilobaytı geçen bir akış için küçük bir maliyettir. Bunlar yerindeyken kredi şablonunun hem Win32 hem Win64 derlemeleri artık Excel'de istem olmadan açılıyor, doğrulanmış 4805 formülün tamamını sıfır uyuşmazlıkla yeniden hesaplıyor ve orijinalle aynı yazdırma alanını raporluyor

XLSX'i Delphi'den kendiniz yazıyorsanız kontrol listesi kısa: şemanın zorunlu işaretlediği her özniteliği değerine bakmadan yazın, her parça adını bir kez bildirin ve ona dokunan her yazıcı için relationships parçası başına tek bir kullanılmış tanımlayıcı listesi tutun. O listenin zaten var olmasını ve yalnızca kendi okuyucunuza değil Excel'e karşı da test edilmiş olmasını isterseniz, burada anlatılan paket yazıcısı HotXLS Delphi spreadsheet component içinde, dikişi korumaya değer kılan opak parça round-trip'iyle birlikte geliyor