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 відкидає на зворотному шляху значення, яке сам же й записав
// 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 не бачив, що модель уже написала, тож і не міг знати
<!-- Що бачив 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 тримає окрему мапу ідентифікаторів
// 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 непрозорих частин, який і зробив той шов вартим охорони