Teknisk artikel

Hvorfor Excel reparerer en gyldig XLSX: OPC-regler i Delphi

Excel viser "We found a problem with some content" på en XLSX, som LibreOffice og enhver hjemmelavet læser åbner uden brok, fordi Excel håndhæver to ting, de læsere ignorerer: skema-påkrævede attributter og entydighedsreglerne i Open Packaging Conventions. HotXLS, den native Excel-regnearkskomponent til Delphi og C++Builder, ramte præcis det i v2.382.5, første gang dets output gik gennem en ægte Excel COM-instans, og de tre årsager var et <phoneticPr> uden fontId, en duplikeret Override i [Content_Types].xml og to root-relationships, der delte rId4

Hvorfor afviser Excel en pakke, som alle andre læsere accepterer?

Fordi reparationsdialogen er en skema- og pakkvalidator, ikke en parserfejl. HotXLS-korpuset havde round-trippet en låneskabelon med 4805 formler gennem biblioteket, gennem LibreOffice og gennem XML-validatorerne i test-suitten i ugevis. Den gemte fil var strukturelt sund i den OPC-forstand, der bruges i artiklen om XLSX OPC relationship resolution: hver part nåbar, hver target opløselig. Så blev en Windows-maskine med Excel 16.0 build 20326 tilgængelig, corpus-runneren åbnede den gemte skabelon gennem Workbooks.Open i en isoleret COM-instans med DisplayAlerts slået fra, og kaldet fejlede fuldstændigt. Interaktivt producerer samme fil den velkendte dialog, der tilbyder at reparere, og reparationsloggen, når Excel gider skrive en, nævner parten, men ikke reglen. Tre uafhængige defekter gemte sig i den ene dialog, og Excel rapporterer dem ikke én ad gangen; den afviser arbejdsbogen og efterlader dig med at finde og analysere dem. Det følgende er hver regel, den linje i HotXLS, der brød den, og det fix, der shippede, fordi hver enkelt af dem er en regel, enhver Delphi XLSX-writer kan snuble over

Regel 1: phoneticPr fontId er påkrævet, selv når den er nul

Elementet <phoneticPr> bærer en fontId-attribut, der er deklareret use="required" i ECMA-376 Part 1 §18.4.3, og værdien 0 er et lovligt fontindeks, ikke et fravær. Den gamle HotXLS-regnearkswriter behandlede nul som "ikke sat" og emitterede kun attributten, når Sheet.PhoneticFontId > 0. Det er en naturlig Delphi-refleks, eftersom integer-felter som standard er nul, men det producerer <phoneticPr type="noConversion"/> for enhver arbejdsbog, hvis fonetiske font tilfældigvis er den første font i styles.xml, hvilket er præcis, hvad låneskabelonen i HotXLS-korpuset bar. Excel afviser altså på vejen tilbage en værdi, den selv havde skrevet

Hvorfor Excel krævede en reparation af HotXLS regnearks-part: phoneticPr-elementet deklarerer fontId med use required i ECMA-376 Part 1, et fontindeks på 0 er en lovlig værdi, og den gamle writer, der udelod attributten, når PhoneticFontId var nul, producerede phoneticPr type noConversion, mens skemaet giver type- og alignment-defaults, men ikke fontId
At udelade en attribut, når den er lig med defaulten, er kun sikkert, når skemaet deklarerer den default, og låneskabelonen bar sin fonetiske font som den allerførste post i styles.xml
// lxHandleX.pas, regnearkswriter — før v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — attributten er påkrævet, nul inkluderet
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLS emitter stadig kun elementet, når TXLSXWorksheet.PhoneticType er ikke-tom, så arbejdsbøger, der aldrig bar fonetiske indstillinger, er upåvirkede. Regressionstesten PhoneticSettings_DefaultFontIsExplicit sætter PhoneticFontId til nul på et friskt ark, gemmer og asserter, at <phoneticPr fontId="0" er til stede i xl/worksheets/sheet1.xml. Den bredere lektie er, at "udelad ved default" kun er sikkert, når skemaet deklarerer en default; type og alignment har defaults i det element, fontId har ikke

Regel 2: én Override pr. partnavn i [Content_Types].xml

Content types-streamen må deklarere hvert partnavn højst én gang, og Excel behandler en anden Override for det samme PartName som korruption, selv når begge poster bærer samme ContentType. HotXLS har to writere, der føder den stream. BuildContentTypesXml deklarerer hver part, objektmodellen genererer: workbook, styles, shared strings, theme, worksheets og, når TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Når PreserveUnsupportedParts er slået til, appenderer TXLSXOpaquePackage derefter en Override for hver part, den har fanget ordret fra kildepakken, så de bytes forbliver deklareret på vejen ud. Kollisionen er en part, der lever på begge sider. Brugerdefinerede dokumentegenskaber parses ind i modellen, men kildepakkens docProps/custom.xml blev også fanget opaqt, så den flettede stream deklarede den to gange, og chart- og pivot cache-parts kan lande samme sted, når modellen regenererer en part, det opakte lag også beholdt. Før v2.382.5 havde ContentTypeOverridesXml intet syn for, hvad modellen allerede havde skrevet, så den kunne ikke vide det

Sådan kolliderede to HotXLS-writere i [Content_Types].xml: BuildContentTypesXml deklarede docProps/custom.xml fra objektmodellen, mens TXLSXOpaquePackage appenderede en Override for samme part fanget ordret, og siden v2.382.5 parser det opakte lag først den genererede stream, normaliserer navne med OpcLowerPartName og lader modellen vinde hver kollision
Hver writer var hver for sig konsistent, og begrænsningen om, at hvert partnavn kun må optræde én gang, findes kun ved sømmen, hvor deres outputs konkateneres, og det er derfor, fixet sender model-streamen ind
<!-- Hvad Excel så før 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"/>

Fixet sender den genererede XML ind i ContentTypeOverridesXml og lader den opakte writer parse den, før den emitterer noget. To detaljer bærer korrektheden. OpcLowerPartName konverterer til små bogstaver, vender backslashes til skråstreger og stripper indledende skråstreger før sammenligningen, fordi OPC-partnavne sammenlignes case-insensitivt, og modellen skriver dem med indledende skråstreg, mens det opakte lag gemmer ZIP-elementnavne uden. Og calleren i BuildContentTypesXml sender Result + '</Types>' og lukker dermed det delvist byggede dokument, så TXMLReader ser velformet input frem for en trunkeret stream. Reglen, der opstår, er first-wins med modellen forrest: Uanset hvad objektmodellen deklarerer, er autoritativt, og den opakte replay udfylder kun huller

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Parse den modelgenererede stream og saml hver deklareret 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;                       // allerede deklareret, eller en rels-part
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Regel 3: relationship-Id'er er unikke inden for en relationships-part

Hver Relationship i en .rels-part behøver en Id, der er unik inden for den part, og Excel nægter pakken, når to deler én. HotXLS skriver pakke-niveau _rels/.rels med faste identifikatorer: rId1 for arbejdsbogen, rId2 og rId3 for core og extended document properties og rId4 for brugerdefinerede egenskaber, når modellen har nogen. Den opakte pakke appenderer derefter de root-relationships, den beholdt fra kilden, og omnummererer enhver identifikator, der allerede er i en UsedIds-liste. Listen kendte rId1 til og med rId3. Den kendte ikke rId4, og den vidste ikke, at modellen var ved at emitte sin egen custom-properties-relationship, så en kildepakke, hvis custom-properties-relationship også var rId4, hvilket er det, Excel skriver som default, kom ud med to rId4-poster, der pegede på samme target. Calleren, BuildRootRelsXml, sender nu Workbook.FCustomProps.Count > 0 som andet argument, så reservationen og springet styres af samme betingelse, der afgør, om modellen overhovedet emitterer rId4. Omnummerering er sikker ved pakkeroden, fordi intet inde i arbejdsbogen refererer root-relationship-identifikatorer ved navn; samme trick ville være forkert ét niveau nede, hvor r:id-attributter i workbook.xml binder til identifikatorer i arbejdsbogens relationships-part, hvilket er grunden til, at MergeWorkbookRelationshipsXml fører et separat identifikator-kort

Identifikatorkollisionen på roden af en HotXLS-pakke: Modellen skriver rId1 til rId4 med rId4 reserveret til brugerdefinerede egenskaber, det opakte lag replayede en kilde-relationship, der også ankom som rId4, fordi UsedIds kun kendte rId1 til rId3, og fixet reserverer rId4 på forhånd, når EmitCustomProps gælder, og nummererer resten om
Omnummerering er sikker ved pakkeroden, fordi intet inde i arbejdsbogen refererer root-identifikatorer ved navn, og samme trick ét niveau nede ville knække hver r:id-binding i workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // reserveret af model-writeren
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // Modellen ejer de brugerdefinerede egenskaber nu; replay ikke kildekopien.
  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);      // laveste ledige rIdN
  UsedIds.Add(String(Id));
  ...
end;

Hvad har de tre fejl tilfælles?

Alle tre er symptomer på en writer med to kilder og ingen enig ejer af pakkens invarianter. Objektmodellen genererer de parts, den forstår; det opakte lag replay'er de parts, den ikke gør, så en round trip bevarer charts, pivot caches, custom XML og alt det andet, der er beskrevet i noterne om lossless round-trip af theme, extLst og calcChain. Hver side var hver for sig konsistent. De begrænsninger, OPC lægger på hele pakken, unikke Override-partnavne og unikke relationship-identifikatorer pr. part, findes kun ved sømmen, hvor de to konkateneres, og indtil v2.382.5 tjekkede ingen sømmen. fontId-bugen er samme form ét niveau nede: Writeren vidste, hvad den ville udelade, men konsulterede aldrig det skema, der siger, at den ikke må. Fixet, HotXLS landede på, er en fast præcedens frem for en merge-heuristik. Modellen skriver først, det opakte lag ser, hvad der er skrevet, og viger ved enhver kollision, og corpus-runneren håndhæver nu invarianterne udefra med verify_opc_uniqueness, som læser [Content_Types].xml og hvert .rels-element i en gemt pakke og fejler casen ved enhver duplikeret PartName, Extension eller Id. Det tjek er billigt, behøver ingen Excel og ville have fanget to af de tre defekter ved den første corpus-kørsel

Samme batch: print-områder, der er formler, ikke ranges

Excel-passet flaggede også låneskabelonens _xlnm.Print_Area, som Excel rapporterede som $A$1:$J$29 på originalen og skulle rapportere identisk på den gemte kopi. To separate bugs sad bag den ene assertion. Ved import skar XlsxStripSheetPrefix alt op til det første !, der ikke var i anførselstegn, så et dynamisk print-område som OFFSET('Print Data'!$A$1,0,0,2,2) kom tilbage som $A$1,0,0,2,2), og en kvalificeret union som 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 mistede prefixet på sit første segment alene. Ved eksport satte writeren arkenavnet foran én gang for hele den gemte PrintArea, så en almindelig union $A$1:$B$2,$D$1:$E$2 forlod biblioteket med et kvalificeret første segment og et nøgent andet, hvilket Excel ikke accepterer som en _xlnm.Print_Area-definition under ECMA-376 Part 1 §18.2.5

// Import: Strip kun prefixet, når det, der resterer, er en almindelig 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(...) returneres urørt

// Eksport: Kvalificér hvert kommasepareret segment, eller ingen af dem
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;                           // en formel: emit ordret
  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;

Paringsreglen er den samme på begge sider: Et print-område er et nøgent range kun, hvis hvert segment parser som ét, ellers er det en formel og rejser ordret. PrintArea_FormulaDefinitionSurvivesRoundTrip dækker den navngivne base, den arkekvalificerede base og unionen gennem to gem-og-genåbn-cyklusser. Hvordan print-områder interagerer med sideopsætning og resten af print-modellen er dækket i artiklen om arkbeskyttelse, sideopsætning og udskrivning

Hvordan finder du ud af, hvilken regel Excel indvender mod?

Start fra antagelsen om, at din egen validator tager fejl, fordi den bestod. Open XML SDK-validatoren vil nævne en skemavovertrædelse som den manglende fontId med part og XPath, og pakningslaget under den nægter overhovedet at åbne en pakke med duplikerede content-type-poster, så kør den før noget som helst andet. Når den er tavs, og Excel stadig reparerer, så bisect pakken: unzip, slet en part og dens relationship og dens Override, rezip og genåbn, og halvér kandidatsættet hver gang, indtil dialogen forsvinder. De tre defekter her faldt ud i den rækkefølge, og ingen af dem ville have været synlig i den reparerede fil, Excel tilbyder at gemme, da reparationen lydløst dropper eller omnummererer de problematiske poster. Grænserne for v2.382.5-fixet er det værd at sige lige så klart. Deduplikeringen er first-wins med modellen forrest, så hvis kildepakken deklarede en anden content type for en part, som modellen også genererer, vinder modellens deklaration, og kildens kasseres, hvilket er korrekt for de parts, HotXLS regenererer, og er ikke en generel merge. verify_opc_uniqueness tjekker kun entydighed; den validerer ikke skemaer, så en fremtidig påkrævet attribut ville stadig kræve Excel eller en skemavalidator for at vise sig. Og det ekstra TXMLReader-pass over den genererede content types-stream kører ved hvert gem med PreserveUnsupportedParts slået til, en lille pris for en stream, der sjældent er mere end nogle få kilobytes. Med det på plads åbner både Win32- og Win64-buildene af låneskabelonen nu i Excel uden dialog, genberegner alle 4805 verificerede formler med nul mismatches og rapporterer samme print-område som originalen

Hvis du selv skriver XLSX fra Delphi, er tjeklisten kort: Emitér enhver attribut, skemaet markerer som påkrævet, uanset dens værdi, deklarér hvert partnavn én gang, og hold én liste over brugte identifikatorer pr. relationships-part på tværs af enhver writer, der rører den. Hvis du hellere vil have, at den liste allerede findes og er testet mod Excel frem for kun mod din egen læser, så ships pakkewriteren beskrevet her i HotXLS Delphi-regnearkskomponent, sammen med den opakte part-round-trip, der gjorde sømmen værd at vogte i første omgang