Технічна стаття

Діалог відновлення Excel: три правила OPC пакета в Delphi

Excel показує «We found a problem with some content» на XLSX, який LibreOffice і будь-який саморобний читач відкривають без жодних скарг, бо Excel вимагає двох речей, які ті читачі ігнорують: обов'язкових за схемою атрибутів і правил унікальності Open Packaging Conventions. HotXLS — нативний компонент таблиць Excel для Delphi і C++Builder — натрапив рівно на це у v2.382.5 першого ж разу, коли його вивід пішов через справжній COM-екземпляр Excel, і три причини були такі: <phoneticPr> без fontId, дубльований Override у [Content_Types].xml і два кореневих зв'язки, що ділили rId4

Чому Excel відкидає пакет, який приймає будь-який інший читач?

Бо запит на відновлення — це валідатор схеми й пакета, а не збій парсера. Корпус HotXLS тижнями ганяв шаблон позики на 4805 формул через бібліотеку, через LibreOffice і через XML-валідатори в тестовому наборі. Збережений файл був структурно здоровий у тому сенсі OPC, який використовується в статті про розв'язання зв'язків OPC в XLSX: кожна частина досяжна, кожна ціль розв'язується. А потім з'явилася Windows-машина з Excel 16.0 build 20326, прогін корпусу відкрив збережений шаблон через Workbooks.Open в ізольованому COM-екземплярі з вимкненими DisplayAlerts — і виклик упав остаточно. Інтерактивно той самий файл видає знайомий діалог із пропозицією відновити, а журнал відновлення, коли Excel узагалі зволить його записати, називає частину, але не правило. У тому одному запиті ховалися три незалежні дефекти, і Excel не повідомляє про них по одному: він відкидає книгу й лишає вам їх шукати й аналізувати. Далі — кожне правило, рядок HotXLS, який його порушив, і виправлення, що вийшло, бо кожне з них — це правило, об яке може спіткнутися будь-який записувач XLSX на Delphi

Правило 1: phoneticPr вимагає fontId, навіть коли той нульовий

Елемент <phoneticPr> несе атрибут fontId, оголошений як use="required" в ECMA-376 Part 1 §18.4.3, і значення 0 — це легальний індекс шрифту, а не його відсутність. Старий записувач аркушів HotXLS трактував нуль як «не задано» й видавав атрибут лише тоді, коли Sheet.PhoneticFontId > 0. Це природний рефлекс Delphi, бо цілочисельні поля типово нульові, але він дає <phoneticPr type="noConversion"/> для будь-якої книги, чий фонетичний шрифт випадково виявився першим шрифтом у styles.xml, — а саме це й ніс шаблон позики з корпусу HotXLS. Тож Excel відкидає на зворотному шляху значення, яке сам же й записав

Чому Excel вимагав відновлення для частини аркуша HotXLS: елемент phoneticPr оголошує fontId як use required в ECMA-376 Part 1, індекс шрифту 0 — легальне значення, а старий записувач, який пропускав атрибут при нульовому PhoneticFontId, видавав phoneticPr type noConversion, тоді як схема дає типові значення для type і alignment, а для fontId — ні
Пропускати атрибут, коли він дорівнює типовому значенню, безпечно лише тоді, коли схема це типове значення оголошує, а шаблон позики ніс свій фонетичний шрифт найпершим записом у styles.xml
// lxHandleX.pas, записувач аркушів — до 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 непорожній, тож книги, які ніколи не мали фонетичних налаштувань, не зачеплені. Регресійний тест PhoneticSettings_DefaultFontIsExplicit ставить PhoneticFontId у нуль на свіжому аркуші, зберігає й перевіряє, що <phoneticPr fontId="0" присутній у xl/worksheets/sheet1.xml. Ширший урок: «пропускати типове» безпечно лише тоді, коли схема оголошує типове значення; type і alignment мають типові значення в цьому елементі, а fontId — ні

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

Потік content types може оголошувати кожне ім'я частини щонайбільше один раз, і Excel вважає другий Override для того самого PartName пошкодженням, навіть коли обидва записи несуть однаковий ContentType. У HotXLS у цей потік пишуть два записувачі. BuildContentTypesXml оголошує кожну частину, яку генерує об'єктна модель: книгу, стилі, спільні рядки, тему, аркуші, а також /docProps/custom.xml, коли TXLSXWorkbook.CustomProperties.Count > 0. Коли увімкнено PreserveUnsupportedParts, TXLSXOpaquePackage потім додає по Override на кожну частину, яку він захопив дослівно з вихідного пакета, щоб ці байти лишалися оголошеними на виході. Колізія — це частина, що живе з обох боків. Властивості документа, задані користувачем, розбираються в модель, але docProps/custom.xml вихідного пакета теж був захоплений як непрозорий, тож об'єднаний потік оголосив його двічі; а частини діаграм і кешів зведених таблиць можуть потрапити в ту саму точку, коли модель перегенеровує частину, яку непрозорий шар теж зберіг. До v2.382.5 ContentTypeOverridesXml не бачив, що модель уже написала, тож і не міг знати

Як два записувачі HotXLS зіткнулися в [Content_Types].xml: BuildContentTypesXml оголошував docProps/custom.xml з об'єктної моделі, тоді як TXLSXOpaquePackage додавав Override для тієї самої частини, захопленої дослівно, а від v2.382.5 непрозорий шар спершу розбирає згенерований потік, нормалізує назви через OpcLowerPartName і дозволяє моделі перемагати в кожній колізії
Кожен записувач окремо був несуперечливий, а обмеження, що кожне ім'я частини може з'явитися один раз, існує лише на шві, де їхні виводи склеюються, саме тому виправлення й передає потік моделі всередину
<!-- Що бачив 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 і дозволяє непрозорому записувачу розібрати його, перш ніж щось видавати. Коректність тримається на двох деталях. OpcLowerPartName зводить до нижнього регістру, міняє зворотні слеші на прямі й зрізає провідні слеші перед порівнянням, бо імена частин OPC порівнюються без урахування регістру, а модель пише їх із провідним слешем, тоді як непрозорий шар зберігає імена елементів ZIP без нього. І виклик у BuildContentTypesXml передає Result + '</Types>', закриваючи частково побудований документ, щоб TXMLReader побачив коректний вхід, а не обрізаний потік. Правило, яке з цього випливає, — перемагає перший, і модель попереду: що б не оголосила об'єктна модель, воно авторитетне, а непрозоре відтворення лише заповнює прогалини

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Розібрати згенерований моделлю потік і зібрати кожне оголошене 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 у частині .rels потребує Id, унікального в межах цієї частини, і Excel відкидає пакет, коли двоє ділять один. HotXLS пише кореневий _rels/.rels рівня пакета з фіксованими ідентифікаторами: rId1 для книги, rId2 і rId3 для основних і розширених властивостей документа, і rId4 для властивостей, заданих користувачем, коли вони в моделі є. Потім непрозорий пакет додає всі кореневі зв'язки, які зберіг із джерела, перенумеровуючи будь-який ідентифікатор, що вже є у списку UsedIds. Список знав про rId1–rId3. Він не знав про rId4 і не знав, що модель збирається видати власний зв'язок властивостей, заданих користувачем, тож вихідний пакет, чий зв'язок властивостей теж був rId4 — а саме це Excel пише за замовчуванням, — виходив із двома записами rId4, що вказують на ту саму ціль. Виклик, BuildRootRelsXml, тепер передає Workbook.FCustomProps.Count > 0 другим аргументом, тож і резервування, і пропуск керуються тією самою умовою, яка вирішує, чи модель узагалі видає rId4. Перенумерація безпечна в корені пакета, бо ніщо всередині книги не посилається на ідентифікатори кореневих зв'язків за іменем; той самий трюк був би хибний на рівень нижче, де атрибути r:id у workbook.xml прив'язані до ідентифікаторів у частині зв'язків книги, саме тому MergeWorkbookRelationshipsXml тримає окрему мапу ідентифікаторів

Колізія ідентифікаторів зв'язків у корені пакета HotXLS: модель пише rId1–rId4, причому rId4 зарезервовано під властивості користувача, непрозорий шар відтворив зв'язок із джерела, який теж прийшов як rId4, бо UsedIds знав лише rId1–rId3, а виправлення резервує rId4 наперед щоразу, коли чинне EmitCustomProps, і перенумеровує решту
Перенумерація безпечна в корені пакета, бо ніщо всередині книги не посилається на кореневі ідентифікатори за іменем, а той самий трюк на рівень нижче зламав би всі прив'язки r:id у workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // зарезервовано записувачем моделі
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // Властивості користувача тепер належать моделі; не відтворювати копію з джерела.
  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;

Що спільного в цих трьох збоїв?

Усі три — симптоми записувача з двома джерелами й без єдиного власника інваріантів пакета. Об'єктна модель генерує частини, які розуміє; непрозорий шар відтворює ті, яких не розуміє, щоб round-trip зберігав діаграми, кеші зведених таблиць, custom XML і все інше, описане в замітках про round-trip без втрат для theme, extLst і calcChain. Кожен бік окремо був несуперечливий. Обмеження, які OPC накладає на пакет загалом — унікальні імена частин в Override і унікальні ідентифікатори зв'язків у межах частини, — існують лише на шві, де ці два склеюються, і до v2.382.5 ніхто той шов не перевіряв. Баг із fontId — та сама форма на рівень нижче: записувач знав, що хоче пропустити, але жодного разу не спитав схему, яка каже, що пропускати не можна. Виправлення, на якому спинився HotXLS, — це фіксований пріоритет, а не евристика злиття. Модель пише першою, непрозорий шар бачить написане й поступається за будь-якої колізії, а прогін корпусу тепер забезпечує інваріанти ззовні через verify_opc_uniqueness, який читає [Content_Types].xml і кожен елемент .rels у збереженому пакеті та завалює кейс на будь-якому дубльованому PartName, Extension чи Id. Ця перевірка дешева, не потребує Excel і впіймала б два з трьох дефектів уже на першому прогоні корпусу

Той самий батч: області друку, які є формулами, а не діапазонами

Прохід через Excel ще зачепив _xlnm.Print_Area шаблону позики, який Excel показав як $A$1:$J$29 на оригіналі й мусив показати так само на збереженій копії. За тим одним твердженням сиділи два окремі баги. При імпорті XlsxStripSheetPrefix зрізав усе до першого неекранованого !, тож динамічна область друку на кшталт 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 втрачало префікс лише в першому сегменті. При експорті записувач додавав ім'я аркуша один раз до всієї збереженої 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;

Правило парування з обох боків те саме: область друку є голим діапазоном лише тоді, коли кожен сегмент розбирається як такий, інакше це формула й подорожує дослівно. PrintArea_FormulaDefinitionSurvivesRoundTrip покриває іменовану базу, базу з іменем аркуша й об'єднання через два цикли збереження та повторного відкриття. Про те, як області друку взаємодіють із параметрами сторінки й рештою моделі друку, розказано в статті про захист аркуша, параметри сторінки й друк

Як знайти, на яке правило скаржиться Excel?

Почніть із припущення, що ваш власний валідатор помиляється, бо він же пропустив. Валідатор Open XML SDK назве порушення схеми на кшталт відсутнього fontId разом із частиною та XPath, а шар пакета під ним узагалі відмовляється відкривати пакет із дубльованими записами content type, тож запустіть його найпершим. Коли він мовчить, а Excel усе одно відновлює, бісектуйте пакет: розпакуйте, видаліть частину, її зв'язок і її Override, запакуйте назад і відкрийте знову, щоразу вдвічі скорочуючи набір кандидатів, доки діалог не зникне. Три дефекти звідси випали саме в такому порядку, і жоден із них не був би видимий у відновленому файлі, який Excel пропонує зберегти, бо відновлення мовчки викидає або перенумеровує проблемні записи. Межі виправлення v2.382.5 варто назвати так само прямо. Дедуплікація — це перемагає перший, і модель попереду, тож якщо вихідний пакет оголошував для частини, яку модель теж генерує, інший content type, перемагає оголошення моделі, а оголошення джерела відкидається, що правильно для частин, які HotXLS перегенеровує, і не є загальним злиттям. verify_opc_uniqueness перевіряє лише унікальність; він не валідує схеми, тож якийсь майбутній обов'язковий атрибут усе одно доведеться виявляти через Excel або валідатор схеми. А додатковий прохід TXMLReader по згенерованому потоці content types виконується при кожному збереженні з увімкненим PreserveUnsupportedParts — невелика ціна за потік, який рідко коли перевищує кілька кілобайтів. З усім цим на місці обидві збірки шаблону позики, Win32 і Win64, тепер відкриваються в Excel без жодного діалогу, перераховують усі 4805 перевірених формул без розбіжностей і показують ту саму область друку, що й оригінал

Якщо ви самі пишете XLSX з Delphi, чекліст короткий: видавайте кожен атрибут, який схема позначає як обов'язковий, незалежно від його значення, оголошуйте кожне ім'я частини один раз і тримайте один список використаних ідентифікаторів на частину зв'язків — для всіх записувачів, які до неї торкаються. Якщо ж ви волієте, щоб цей список уже існував і був перевірений проти Excel, а не лише проти вашого власного читача, описаний тут записувач пакетів постачається в Delphi spreadsheet component від HotXLS — разом із round-trip непрозорих частин, який і зробив той шов вартим охорони