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

Почему Excel чинит валидный XLSX: правила OPC-пакета

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: fontId в phoneticPr обязателен, даже когда он равен нулю

Элемент <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 обязательным в 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 в ноль на новом листе, сохраняет и проверяет, что в xl/worksheets/sheet1.xml присутствует <phoneticPr fontId="0". Урок шире: «пропускай, если значение по умолчанию» безопасно только тогда, когда схема это умолчание объявляет; у type и alignment в этом элементе умолчания есть, у fontId — нет

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

Поток типов содержимого может объявлять каждое имя части не более одного раза, и Excel считает второй Override для того же PartName повреждением, даже когда обе записи несут одинаковый ContentType. В этот поток пишут два писателя HotXLS. BuildContentTypesXml объявляет каждую часть, которую генерирует объектная модель: книгу, стили, общие строки, тему, рабочие листы и, при TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Когда включён 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, rId2 и rId3. Про rId4 он не знал, и не знал, что модель вот-вот выдаст собственную связь настраиваемых свойств, поэтому исходный пакет, чья связь настраиваемых свойств тоже была rId4, — а именно это Excel пишет по умолчанию, — выходил с двумя записями rId4, указывающими на одну цель. Вызывающий код, BuildRootRelsXml, теперь передаёт Workbook.FCustomProps.Count > 0 вторым аргументом, так что и резервирование, и пропуск управляются тем же условием, которое решает, выдаст ли модель rId4 вообще. Перенумерация в корне пакета безопасна: ничто внутри книги не ссылается на корневые идентификаторы связей по имени. Тот же приём был бы ошибкой на уровень ниже, где атрибуты r:id в workbook.xml привязаны к идентификаторам в части связей книги, поэтому MergeWorkbookRelationshipsXml и держит отдельную карту идентификаторов

Столкновение идентификаторов связей в корне пакета HotXLS: модель пишет rId1, rId2, rId3 и rId4, где rId4 зарезервирован под настраиваемые свойства; непрозрачный слой воспроизвёл исходную связь, которая тоже пришла как rId4, потому что UsedIds знал только rId1, rId2 и 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;

Что общего у трёх отказов?

Все три — симптомы писателя с двумя источниками и без единого хозяина инвариантов пакета. Объектная модель генерирует части, которые понимает; непрозрачный слой воспроизводит те, которых не понимает, чтобы круговой рейс сохранял диаграммы, кеши сводных таблиц, настраиваемый XML и всё остальное, о чём говорит заметка про рейс без потерь для 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, а слой упаковки под ним вообще откажется открыть пакет с дублирующимися записями типов содержимого, так что запускайте его первым делом. Когда он молчит, а Excel всё равно восстанавливает, делите пакет пополам: распакуйте, удалите часть, её связь и её Override, запакуйте заново и снова откройте, каждый раз вдвое сужая набор кандидатов, пока запрос не исчезнет. Три здешних дефекта выпали именно в таком порядке, и ни один из них не был бы виден в восстановленном файле, который Excel предлагает сохранить, потому что восстановление молча выбрасывает или перенумеровывает проблемные записи. Границы фикса v2.382.5 стоит назвать так же прямо. Дедупликация — это победа первого с моделью впереди, поэтому если исходный пакет объявлял другой тип содержимого для части, которую модель тоже генерирует, побеждает объявление модели, а исходное отбрасывается: для частей, которые HotXLS перегенерирует, это правильно и вовсе не является общим слиянием. verify_opc_uniqueness проверяет только уникальность; схемы он не валидирует, так что будущий обязательный атрибут всё равно потребует Excel или валидатора схем, чтобы всплыть. А дополнительный проход TXMLReader по сгенерированному потоку типов содержимого выполняется при каждом сохранении с включённым PreserveUnsupportedParts — небольшая цена за поток, который редко бывает больше нескольких килобайт. Со всем этим и сборки книги кредита под Win32 и Win64 теперь открываются в Excel без запроса, пересчитывают все 4805 проверенных формул без единого расхождения и показывают ту же область печати, что и оригинал

Если вы сами пишете XLSX из Delphi, чек-лист короткий: выдавайте каждый атрибут, помеченный в схеме обязательным, независимо от его значения; объявляйте каждое имя части один раз; и держите один список использованных идентификаторов на каждую часть связей во всех писателях, которые к ней прикасаются. Если вам удобнее, чтобы такой список уже существовал и проверялся против Excel, а не только против вашего собственного читателя, описанный здесь писатель пакетов поставляется в составе компонента HotXLS Delphi для работы с электронными таблицами — вместе с круговым рейсом непрозрачных частей, из-за которого стык и стал стоить охраны