Artykuł techniczny

Excel naprawia poprawny XLSX: reguły pakietu OPC w Delphi

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ł

Dlaczego Excel wymagał naprawy części arkusza HotXLS: element phoneticPr deklaruje fontId z use required w ECMA-376 Part 1, indeks czcionki 0 jest legalną wartością, a stary zapisujący, który pomijał atrybut gdy PhoneticFontId było zerem, produkował phoneticPr type noConversion, podczas gdy schemat daje domyślne wartości type i alignment, a fontId żadnej
Pomijanie atrybutu równego wartości domyślnej jest bezpieczne tylko wtedy, gdy schemat tę wartość domyślną deklaruje, a szablon kredytu trzymał swoją czcionkę fonetyczną jako pierwszy wpis w styles.xml
// 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ć

Jak dwóch autorów HotXLS zderzyło się w [Content_Types].xml: BuildContentTypesXml deklarował docProps/custom.xml z modelu obiektowego, a TXLSXOpaquePackage dopisywał Override dla tej samej części zachowanej dosłownie, a od v2.382.5 warstwa nieprzezroczysta najpierw parsuje wygenerowany strumień, normalizuje nazwy przez OpcLowerPartName i oddaje modelowi zwycięstwo w każdej kolizji
Każdy z autorów był osobno spójny, a wymóg, by nazwa części występowała raz, istnieje tylko na styku, gdzie ich wyniki się skleja, dlatego poprawka przekazuje strumień modelu dalej
<!-- 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

Kolizja identyfikatorów relacji w korzeniu pakietu HotXLS: model zapisuje rId1 do rId4 z rId4 zarezerwowanym dla właściwości niestandardowych, warstwa nieprzezroczysta odtworzyła relację źródłową, która także przyszła jako rId4, bo UsedIds znała tylko rId1 do rId3, a poprawka rezerwuje rId4 z góry, gdy zachodzi EmitCustomProps, i przenumerowuje resztę
Przenumerowanie jest bezpieczne w korzeniu pakietu, bo nic wewnątrz skoroszytu nie odwołuje się do identyfikatorów głównych po nazwie, a ten sam trik o poziom niżej zerwałby każde wiązanie r:id w workbook.xml
// 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ć