Техническа статия

Защо Excel поправя валиден XLSX: OPC правила в Delphi

Excel показва „Намерихме проблем с някакво съдържание“ на XLSX, който LibreOffice и всеки собствен четец отварят без оплакване, защото Excel налага две неща, които тези четци игнорират: схемово задължителни атрибути и правилата за уникалност на Open Packaging Conventions. HotXLS, нативният Excel spreadsheet компонент за Delphi и C++Builder, удари точно това в v2.382.5 първия път, когато изходът му мина през истинска Excel COM инстанция, а трите причини бяха <phoneticPr> без fontId, дублиран Override в [Content_Types].xml и две root релации, споделящи rId4

Защо Excel отхвърля пакет, който всеки друг четец приема?

Защото prompt-ът за поправка е schema и package валидатор, а не parser провал. HotXLS corpus-ът от седмици гонеше кредитен шаблон с 4805 формули в round-trip през библиотеката, през LibreOffice и през XML валидаторите в тестовия suite. Записаният файл беше структурно здрав в OPC смисъла, ползвания в статията за XLSX OPC relationship resolution: всяка част достижима, всяка цел разрешима. Тогава стана достъпна Windows машина с Excel 16.0 build 20326, corpus runner-ът отвори записания шаблон чрез Workbooks.Open в изолирана COM инстанция с изключен DisplayAlerts, и извикването се провали тотално. Интерактивно същият файл показва познатия диалог с предложение за поправка, а repair логът, когато Excel си направи труда да напише такъв, назова частта, но не и правилото. Три независими дефекта се криеха в този един prompt, и Excel не ги докладва по един; той отхвърля работната книга и оставя вас да ги намерите и анализирате. Това, което следва, е всяко правило, редът от HotXLS, който го нарушаваше, и поправката, която излезе, защото всяко едно от тях е правило, върху което всеки Delphi XLSX writer може да се спъне

Правило 1: phoneticPr fontId е задължителен, дори когато е нула

Елементът <phoneticPr> носи атрибут fontId, деклариран use="required" в ECMA-376 Part 1 §18.4.3, а стойност 0 е легален font индекс, не липса. Старият worksheet writer в HotXLS третираше нулата като „не е зададено“ и излъчваше атрибута само когато Sheet.PhoneticFontId > 0. Това е естествен Delphi рефлекс, тъй като integer полетата по подразбиране са нула, но произвежда <phoneticPr type="noConversion"/> за всяка работна книга, чи phonetic font случайно е първият шрифт в styles.xml — точно това носеше кредитният шаблон в HotXLS corpus-а. Excel после отхвърля на връщане стойност, която самият той е бил записал

Защо Excel поиска поправка на worksheet частта на HotXLS: елементът phoneticPr декларира fontId с use required в ECMA-376 Part 1, font индекс 0 е легална стойност, а старият writer, пропусквал атрибута при нулев PhoneticFontId, произвеждаше phoneticPr type noConversion, докато схемата дава type и alignment по подразбиране, а на fontId не дава никакво
Пропускането на атрибут, когато той равнява на подразбиращото се, е безопасно само когато схемата декларира това подразбиращо се, а кредитният шаблон носеше phonetic шрифта си като съвсем първия запис в styles.xml
// lxHandleX.pas, worksheet writer — преди v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — атрибутът е задължителен, включително нулата
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLS все още излъчва елемента само когато TXLSXWorksheet.PhoneticType не е празен, така че работни книги, които никога не са носили phonetic настройки, не се засягат. Регресионният тест PhoneticSettings_DefaultFontIsExplicit задава PhoneticFontId на нула върху чист лист, записва и assert-ва, че <phoneticPr fontId="0" присъства в xl/worksheets/sheet1.xml. По-широкият урок е, че „пропусни при подразбиращо се“ е безопасно само когато схемата декларира подразбиращо се; type и alignment имат подразбиращи се в този елемент, fontId — няма

Правило 2: по един Override на име на част в [Content_Types].xml

Content types потокът може да декларира всяко име на част най-много веднъж, а Excel третира втори Override за същия PartName като повреда, дори когато двата записа носят същия ContentType. HotXLS има два writer-а, захранващи този поток. BuildContentTypesXml декларира всяка част, която обектният модел генерира: workbook, styles, shared strings, theme, worksheets и, когато TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Когато PreserveUnsupportedParts е включено, TXLSXOpaquePackage после добавя Override за всяка част, която е уловил дословно от source пакета, така че тези байтове остават декларирани на изхода. Сблъсъкът е част, която живее и в двете страни. Custom document properties се парсват в модела, но docProps/custom.xml на source пакета беше уловен и опаково, така че слетият поток го декларираше два пъти, а chart и pivot cache части могат да кацнат на същото място, когато моделът регенерира част, която opaque слойът също е задържал. Преди v2.382.5 ContentTypeOverridesXml нямаше представа какво моделът вече е написал, така че не можеше да знае

Как два HotXLS writer-а се сблъскаха в [Content_Types].xml: BuildContentTypesXml декларира docProps/custom.xml от обектния модел, докато TXLSXOpaquePackage добавяше Override за същата част, уловена дословно, а от v2.382.5 opaque слойът парсва първо генерирания поток, нормализира имената с OpcLowerPartName и дава на модела да печели всеки сблъсък
Всеки writer беше поотделно консистентен, а ограничението всяко име на част да се появи най-много веднъж съществува само на шева, където техните изходи се долепят, затова поправката подава model потока навътре
<!-- Какво видя Excel преди 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"/>

Поправката подава генерирания XML в ContentTypeOverridesXml и позволява на opaque writer-ът да го парсне, преди да излъче каквото и да е. Два детайла носят коректността. OpcLowerPartName прави малки букви, обръща backslash-ите на наклонени черти и съблича водещите наклонени черти преди сравнение, защото OPC имена на части се сравняват без значение на регистра, а моделът ги записва с водеща наклонена черта, докато opaque слойът съхранява ZIP item имена без такава. И caller-ът в BuildContentTypesXml подава Result + '</Types>', затваряйки частично изградения документ, така че TXMLReader да види well-formed вход, а не отрязан поток. Правилото, което излиза, е „първият печели с модела отпред“: каквото обектният модел декларира е авторитетно, а opaque replay-ът само запълва празнотите

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Парсвай model-генерирания поток и събери всеки деклариран 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;                       // вече деклариран, или rels част
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Правило 3: relationship Id-та са уникални в рамките на една relationships част

Всеки Relationship в .rels част се нуждае от Id, уникален в рамките на тази част, а Excel отказва пакета, когато две споделят един. HotXLS записва package-level _rels/.rels с фиксирани идентификатори: rId1 за работната книга, rId2 и rId3 за core и extended document properties, и rId4 за custom properties, когато моделът има такива. Opaque пакетът после добавя каквито root релации е задържал от source-а, преименувайки всеки идентификатор, вече попаднал в списък UsedIds. Списъкът знаеше за rId1 до rId3. Не знаеше за rId4, и не знаеше, че моделът ще излъчи собствена custom-properties релация, така че source пакет, чиято custom-properties релация също беше rId4 — което Excel записва по подразбиране — излезе с два записа rId4, сочещи към същата цел. Caller-ът, BuildRootRelsXml, вече подава Workbook.FCustomProps.Count > 0 като втори аргумент, така че резервацията и пропускането се управляват от същото условие, което решава дали моделът излъчва rId4 изобщо. Преименуването е безопасно в корена на пакета, защото нищо вътре в работната книга не реферира root relationship идентификатори по име; същият трик би бил грешен едно ниво надолу, където атрибутите r:id в workbook.xml се връзват към идентификатори в relationships частта на работната книга — затова MergeWorkbookRelationshipsXml държи отделна карта на идентификаторите

Сблъсъкът на relationship идентификатори в корена на HotXLS пакет: моделът записва rId1 до rId4 с rId4, резервиран за custom properties, opaque слойът възпроизведе source релация, пристигнала също като rId4, защото UsedIds знаеше само rId1 до rId3, а поправката резервира rId4 отпред, когато EmitCustomProps пасува, и преименува останалите
Преименуването е безопасно в корена на пакета, защото нищо вътре в работната книга не реферира root идентификатори по име, а същият трик едно ниво надолу би счупил всяка r:id връзка в workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // резервиран от model writer-а
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-ът притежава custom properties сега; не възпроизвеждай source копието.
  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);      // най-ниското свободно rIdN
  UsedIds.Add(String(Id));
  ...
end;

Какво имат общо трите провала?

И трите са симптоми на writer с два източника и без единствен притежател на инвариантите на пакета. Обектният модел генерира частите, които разбира; opaque слойът възпроизвежда частите, които не разбира, така че един round trip запазва графиките, pivot cache-овете, custom XML и всичко останало, описано в бележките за беззагубен round-trip на theme, extLst и calcChain. Всяка страна беше поотделно консистентна. Ограниченията, които OPC поставя върху целия пакет — уникални Override имена на части и уникални relationship идентификатори на част — съществуват само на шева, където двете се долепят, и до v2.382.5 никой не проверяваше шева. Бъгът с fontId е същата форма едно ниво надолу: writer-ът знаеше какво иска да пропусне, но никога не питаше схемата, която казва, че не бива. Поправката, върху която HotXLS се спря, е фиксиран приоритет, не merge евристика. Моделът пише първи, opaque слойът вижда какво е написано и отстъпва при всеки сблъсък, а corpus runner-ът сега налага инвариантите отвън с verify_opc_uniqueness, който чете [Content_Types].xml и всеки .rels item в записан пакет и проваля случая при всеки дублиран PartName, Extension или Id. Тази проверка е евтина, не се нуждае от Excel и щеше да хване два от трите дефекта още на първия corpus пробег

Същият пакет: print области, които са формули, а не диапазони

Excel пробегът отбеляза и _xlnm.Print_Area на кредитния шаблон, който Excel докладва като $A$1:$J$29 на оригинала и трябваше да докладва еднакво на записаното копие. Два отделни бъга седяха зад това едно assertion. При импорт XlsxStripSheetPrefix режеше всичко до първата некавитирана !, така че динамична print област като OFFSET('Print Data'!$A$1,0,0,2,2) се връщаше като $A$1,0,0,2,2), а квалифицирано обединение като 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 губеше префикса само на първия си сегмент. При експорт writer-ът добавяше името на листа веднъж пред цялото съхранено PrintArea, така че просто обединение $A$1:$B$2,$D$1:$E$2 напускаше библиотеката с квалифициран първи сегмент и гол втори, което Excel не приема като дефиниция _xlnm.Print_Area под ECMA-376 Part 1 §18.2.5

// Импорт: обелвай префикса само когато останалото е обикновен 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(...) се връща недокоснат

// Експорт: квалифицирай всеки сегмент, разделен със запетаи, или нито един
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;                           // формула: излъчи дословно
  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;

Правилото за сдвояване е същото и в двете страни: print област е гол диапазон само ако всеки сегмент се парсва като такъв, иначе е формула и пътува дословно. PrintArea_FormulaDefinitionSurvivesRoundTrip покрива именуваната основа, sheet-квалифицираната основа и обединението през два цикъла запис-и-повторно-отваряне. Как print областите взаимодействат с page setup и останалата част от печатния модел е разгледано в статията за защита на листове, page setup и печатане

Как откривате кое правило Excel възразява срещу?

Започнете от предположението, че собственият ви валидатор греши, защото е минал. Open XML SDK валидаторът ще назове schema нарушение като липсващия fontId с частта и XPath-а, а packaging слойът под него изобщо отказва да отвори пакет с дублирани content-type записи, така че го пуснете преди всичко останало. Когато той мълчи и Excel все още поправя, разделете пакета на две: разархивирайте, изтрийте една част, нейната релация и нейния Override, архивирайте пак и отворете, на половина всеки път, докато prompt-ът изчезне. Трите дефекта тук излязоха в този ред, и нито един не би бил видим във фиксирания файл, който Excel предлага да запише, защото поправката тихо изпуска или преименува нарушаващите записи. Границите на поправката v2.382.5 си заслужават да се кажат също толкова ясно. Дедупликацията е „първият печели с модела отпред“, така че ако source пакетът е декларирал различен content type за част, която моделът също генерира, декларацията на модела печели, а тази на source-а се изхвърля — коректно за частите, които HotXLS регенерира, и не е общ merge. verify_opc_uniqueness проверява само уникалност; той не валидира схеми, така че бъдещ задължителен атрибут пак ще се нуждае от Excel или schema валидатор, за да излезе наяве. И допълнителният проход TXMLReader върху генерирания content types поток върви на всеки запис с включено PreserveUnsupportedParts — малка цена срещу поток, който рядко надхвърля няколко килобайта. С това на място и двата build-а, Win32 и Win64, на кредитния шаблон вече се отварят в Excel без prompt, преизчисляват всичките 4805 проверени формули с нула разминавания и докладват същата print област като оригинала

Ако сами пишете XLSX от Delphi, чеклистът е кратък: излъчвайте всеки атрибут, който схемата маркира като задължителен, независимо от стойността му, декларирайте всяко име на част веднъж и дръжте един списък с ползвани идентификатори на relationships част за всеки writer, който я пипа. Ако предпочитате този списък вече да съществува и да е тестван срещу Excel, а не само срещу собствения ви четец, package writer-ът, описан тук, се доставя в HotXLS Delphi spreadsheet компонента, заедно с opaque-part round-trip-а, който направи шева изобщо заслужаващ пазене