Excel pokazuje „We found a problem with some content” na pliku XLSX, który LibreOffice i każdy własny czytnik otwierają bez skargi, bo Excel egzekwuje dwie rzeczy, które te czytniki ignorują: atrybuty wymagane przez schemat i reguły jednoznaczności z Open Packaging Conventions. HotXLS, natywny komponent arkusza Excel dla Delphi i C++Buildera, trafił dokładnie na to w v2.382.5, gdy jego wynik po raz pierwszy przeszedł przez prawdziwą instancję COM Excela — a trzy przyczyny to <phoneticPr> bez fontId, zdublowany Override w [Content_Types].xml i dwie relacje główne dzielące rId4
Dlaczego Excel odrzuca pakiet, który każdy inny czytnik akceptuje?
Bo monit o naprawę to walidator schematu i pakietu, a nie błąd parsera. Korpus HotXLS od tygodni przepuszczał szablon kredytu z 4805 formułami przez bibliotekę, przez LibreOffice i przez walidatory XML w zestawie testów. Zapisany plik był poprawny strukturalnie w sensie OPC opisanym w artykule o rozwiązywaniu relacji OPC w XLSX: każda część osiągalna, każdy cel rozwiązywalny. Potem pojawiła się maszyna z Windows i Excelem 16.0 build 20326, runner korpusu otworzył zapisany szablon przez Workbooks.Open w izolowanej instancji COM z wyłączonym DisplayAlerts i wywołanie po prostu padło. W trybie interaktywnym ten sam plik wywołuje znajome okno z propozycją naprawy, a dziennik naprawy, gdy Excel w ogóle raczy go zapisać, wymienia część, ale nie regułę. W tym jednym monicie kryły się trzy niezależne defekty, a Excel nie zgłasza ich po kolei; odrzuca skoroszyt i zostawia ci ich szukanie i analizę. Dalej opisujemy każdą regułę, tę linię HotXLS, która ją łamała, i poprawkę, która wyszła — bo każda z nich to reguła, o którą może się potknąć każdy piszący XLSX w Delphi
Reguła 1: phoneticPr wymaga fontId, nawet gdy jest zerem
Element <phoneticPr> niesie atrybut fontId zadeklarowany jako use="required" w ECMA-376 Part 1 §18.4.3, a wartość 0 to legalny indeks czcionki, nie brak wartości. Stary zapisujący arkusz w HotXLS traktował zero jako „nieustawione” i emitował ten atrybut tylko wtedy, gdy Sheet.PhoneticFontId > 0. To naturalny odruch Delphi, bo pola całkowite domyślnie mają zero, ale daje w wyniku <phoneticPr type="noConversion"/> dla każdego skoroszytu, którego czcionka fonetyczna jest przypadkiem pierwszą czcionką w styles.xml — a dokładnie tak było w szablonie kredytu z korpusu HotXLS. Excel odrzuca potem przy ponownym wczytaniu wartość, którą sam zapisał
// lxHandleX.pas, zapisujący arkusz — przed v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — atrybut jest wymagany, zero też
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS nadal emituje ten element tylko wtedy, gdy TXLSXWorksheet.PhoneticType jest niepuste, więc skoroszyty, które nigdy nie miały ustawień fonetycznych, nie są tym dotknięte. Test regresyjny PhoneticSettings_DefaultFontIsExplicit ustawia PhoneticFontId na zero na świeżym arkuszu, zapisuje i sprawdza, że <phoneticPr fontId="0" występuje w xl/worksheets/sheet1.xml. Szerszy wniosek jest taki, że „pomiń, gdy domyślne” jest bezpieczne tylko wtedy, gdy schemat deklaruje wartość domyślną; type i alignment mają w tym elemencie wartości domyślne, a fontId nie ma
Reguła 2: jeden Override na nazwę części w [Content_Types].xml
Strumień typów zawartości może zadeklarować każdą nazwę części najwyżej raz, a Excel traktuje drugi Override dla tego samego PartName jako uszkodzenie, nawet gdy oba wpisy niosą ten sam ContentType. Do tego strumienia pisze w HotXLS dwóch autorów. BuildContentTypesXml deklaruje każdą część generowaną przez model obiektowy: skoroszyt, style, łańcuchy współdzielone, motyw, arkusze, a gdy TXLSXWorkbook.CustomProperties.Count > 0, także /docProps/custom.xml. Gdy włączone jest PreserveUnsupportedParts, TXLSXOpaquePackage dopisuje potem Override dla każdej części zachowanej dosłownie z pakietu źródłowego, żeby te bajty pozostały zadeklarowane na wyjściu. Kolizja dotyczy części, która żyje po obu stronach. Właściwości niestandardowe dokumentu są parsowane do modelu, ale docProps/custom.xml z pakietu źródłowego został dodatkowo zachowany nieprzezroczyście, więc scalony strumień deklarował go dwa razy, a części wykresów i cache tabel przestawnych mogą wylądować w tym samym miejscu, gdy model regeneruje część, którą warstwa nieprzezroczysta też zachowała. Przed v2.382.5 ContentTypeOverridesXml nie miał wglądu w to, co model już zapisał, więc nie mógł o tym wiedzieć
<!-- Co Excel widział przed 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"/>
Poprawka przekazuje wygenerowany XML do ContentTypeOverridesXml i pozwala nieprzezroczystemu autorowi sparsować go, zanim cokolwiek wyemituje. Poprawność trzymają dwa szczegóły. OpcLowerPartName zamienia na małe litery, odwraca ukośniki wsteczne na zwykłe i usuwa wiodące ukośniki przed porównaniem, bo nazwy części OPC porównuje się bez rozróżniania wielkości liter, a model zapisuje je z wiodącym ukośnikiem, podczas gdy warstwa nieprzezroczysta trzyma nazwy elementów ZIP bez niego. A wołający w BuildContentTypesXml przekazuje Result + '</Types>', domykając częściowo zbudowany dokument, żeby TXMLReader dostał poprawny składniowo tekst, a nie ucięty strumień. Reguła, która z tego wynika, to pierwszeństwo pierwszego zapisu z modelem na przedzie: cokolwiek deklaruje model obiektowy, jest rozstrzygające, a nieprzezroczyste odtworzenie tylko wypełnia luki
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Sparsuj strumień wygenerowany przez model i zbierz każdy zadeklarowany 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; // już zadeklarowane albo część rels
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Reguła 3: identyfikatory relacji są unikalne w obrębie części relacji
Każdy Relationship w części .rels potrzebuje Id unikalnego w obrębie tej części, a Excel odrzuca pakiet, gdy dwa dzielą jedno. HotXLS zapisuje _rels/.rels na poziomie pakietu ze stałymi identyfikatorami: rId1 dla skoroszytu, rId2 i rId3 dla podstawowych i rozszerzonych właściwości dokumentu oraz rId4 dla właściwości niestandardowych, gdy model jakieś ma. Nieprzezroczysty pakiet dopisuje potem relacje główne zachowane ze źródła, przenumerowując każdy identyfikator obecny już na liście UsedIds. Lista znała rId1 do rId3. Nie znała rId4 i nie wiedziała, że model właśnie zamierza wyemitować własną relację właściwości niestandardowych, więc pakiet źródłowy, którego relacja właściwości niestandardowych też miała rId4 — a tak Excel zapisuje domyślnie — wychodził z dwoma wpisami rId4 wskazującymi ten sam cel. Wołający, BuildRootRelsXml, przekazuje teraz Workbook.FCustomProps.Count > 0 jako drugi argument, więc rezerwacja i pominięcie są sterowane tym samym warunkiem, który decyduje, czy model w ogóle emituje rId4. Przenumerowanie jest bezpieczne w korzeniu pakietu, bo nic wewnątrz skoroszytu nie odwołuje się do identyfikatorów relacji głównych po nazwie; ten sam trik byłby błędem o poziom niżej, gdzie atrybuty r:id w workbook.xml wiążą się z identyfikatorami w części relacji skoroszytu, dlatego MergeWorkbookRelationshipsXml trzyma osobną mapę identyfikatorów
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // rezerwowany przez autora modelu
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 jest teraz właścicielem właściwości niestandardowych; nie odtwarzaj kopii źródłowej.
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); // najniższy wolny rIdN
UsedIds.Add(String(Id));
...
end;
Co łączy te trzy awarie?
Wszystkie trzy to objawy autora z dwoma źródłami i bez jednego właściciela niezmienników pakietu. Model obiektowy generuje części, które rozumie; warstwa nieprzezroczysta odtwarza części, których nie rozumie, żeby round-trip zachował wykresy, cache tabel przestawnych, własny XML i wszystko inne opisane w notatkach o bezstratnym round-tripie motywu, bloków extLst i calcChain. Każda ze stron była osobno spójna. Ograniczenia, które OPC nakłada na cały pakiet — unikalne nazwy części w Override i unikalne identyfikatory relacji w obrębie części — istnieją wyłącznie na styku, gdzie oba wyniki się skleja, a do v2.382.5 styk ten nie był przez nikogo sprawdzany. Błąd z fontId ma ten sam kształt o poziom niżej: autor wiedział, co chciał pominąć, ale nie zajrzał do schematu, który mówi, że nie wolno. Poprawka, na którą postawił HotXLS, to sztywne pierwszeństwo, a nie heurystyka scalania. Model zapisuje pierwszy, warstwa nieprzezroczysta widzi, co zostało zapisane, i ustępuje przy każdej kolizji, a runner korpusu egzekwuje teraz niezmienniki z zewnątrz przez verify_opc_uniqueness, który czyta [Content_Types].xml i każdy element .rels w zapisanym pakiecie i przewraca przypadek przy każdym powtórzonym PartName, Extension albo Id. To sprawdzenie jest tanie, nie potrzebuje Excela i wyłapałoby dwa z trzech defektów już przy pierwszym przebiegu korpusu
Ten sam batch: obszary wydruku, które są formułami, a nie zakresami
Przebieg przez Excela podniósł też _xlnm.Print_Area szablonu kredytu, który Excel raportował jako $A$1:$J$29 w oryginale i musiał zaraportować identycznie w zapisanej kopii. Za tą jedną asercją stały dwa osobne błędy. Przy imporcie XlsxStripSheetPrefix ucinał wszystko do pierwszego niecytowanego !, więc dynamiczny obszar wydruku taki jak OFFSET('Print Data'!$A$1,0,0,2,2) wracał jako $A$1,0,0,2,2), a kwalifikowana suma jak 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 traciła prefiks tylko w pierwszym segmencie. Przy eksporcie autor doklejał nazwę arkusza raz do całego zapisanego PrintArea, więc zwykła suma $A$1:$B$2,$D$1:$E$2 wychodziła z biblioteki z kwalifikowanym pierwszym segmentem i gołym drugim, czego Excel nie akceptuje jako definicji _xlnm.Print_Area zgodnie z ECMA-376 Part 1 §18.2.5
// Import: zdejmij prefiks tylko wtedy, gdy to, co zostaje, jest zwykłym 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(...) jest zwracany bez zmian
// Eksport: kwalifikuj każdy segment rozdzielony przecinkami albo żaden
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; // formuła: emituj dosłownie
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;
Reguła doboru jest po obu stronach taka sama: obszar wydruku jest gołym zakresem tylko wtedy, gdy każdy segment daje się sparsować jako zakres, w przeciwnym razie jest formułą i wędruje dosłownie. PrintArea_FormulaDefinitionSurvivesRoundTrip pokrywa nazwaną podstawę, podstawę kwalifikowaną nazwą arkusza i sumę przez dwa cykle zapisu i ponownego otwarcia. Jak obszary wydruku współdziałają z ustawieniami strony i resztą modelu drukowania, omawia artykuł o ochronie arkusza, ustawieniach strony i drukowaniu
Jak ustalić, której regule Excel się sprzeciwia?
Zacznij od założenia, że twój własny walidator się myli, bo przecież przepuścił plik. Walidator z Open XML SDK wskaże naruszenie schematu, na przykład brakujący fontId, wraz z częścią i ścieżką XPath, a leżąca pod nim warstwa pakowania w ogóle odmawia otwarcia pakietu ze zdublowanymi wpisami typów zawartości, więc uruchom go przed czymkolwiek innym. Gdy milczy, a Excel wciąż naprawia, dziel pakiet po połowie: rozpakuj, usuń część oraz jej relację i jej Override, spakuj ponownie i otwórz, za każdym razem zmniejszając zbiór kandydatów o połowę, aż monit zniknie. Trzy opisane tu defekty wypadły właśnie w tej kolejności i żadnego z nich nie byłoby widać w pliku naprawionym, który Excel proponuje zapisać, bo naprawa po cichu wyrzuca albo przenumerowuje problematyczne wpisy. Granice poprawki z v2.382.5 warto powiedzieć równie wprost. Deduplikacja działa na zasadzie pierwszeństwa pierwszego zapisu z modelem na przedzie, więc jeśli pakiet źródłowy deklarował inny typ zawartości dla części, którą model też generuje, wygrywa deklaracja modelu, a deklaracja źródła jest odrzucana — co jest słuszne dla części regenerowanych przez HotXLS i nie jest ogólnym scalaniem. verify_opc_uniqueness sprawdza tylko unikalność; nie waliduje schematów, więc przyszły wymagany atrybut nadal musiałby wypłynąć przez Excela albo walidator schematu. A dodatkowy przebieg TXMLReader po wygenerowanym strumieniu typów zawartości wykonuje się przy każdym zapisie z włączonym PreserveUnsupportedParts — drobny koszt wobec strumienia, który rzadko przekracza kilka kilobajtów. Z tym wszystkim obie kompilacje szablonu kredytu, Win32 i Win64, otwierają się teraz w Excelu bez monitu, przeliczają wszystkie 4805 zweryfikowanych formuł bez ani jednej niezgodności i raportują ten sam obszar wydruku co oryginał
Jeśli sam piszesz XLSX z Delphi, lista kontrolna jest krótka: emituj każdy atrybut oznaczony w schemacie jako wymagany, niezależnie od jego wartości, deklaruj każdą nazwę części raz i trzymaj jedną listę użytych identyfikatorów na część relacji we wszystkich autorach, które jej dotykają. Jeśli wolisz, żeby ta lista już istniała i była testowana przeciw Excelowi, a nie tylko przeciw twojemu własnemu czytnikowi, opisany tu autor pakietu jest w komponencie HotXLS Delphi spreadsheet component razem z round-tripem części nieprzezroczystych, przez który ten styk w ogóle warto było chronić