Teknisk artikkel

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

Excel viser «Vi fant et problem med noe av innholdet» på en XLSX som LibreOffice og enhver egenutviklet leser åpner uten å klage, fordi Excel håndhever to ting de leserne ser bort fra: attributter som skjemaet krever, og unikhetsreglene i Open Packaging Conventions. HotXLS, den native Excel-regnearkkomponenten for Delphi og C++Builder, traff nøyaktig dette i v2.382.5 første gang utdataene gikk gjennom en ekte Excel COM-instans, og de tre årsakene var en <phoneticPr> uten fontId, en duplisert Override i [Content_Types].xml og to rotrelasjoner som delte rId4

Hvorfor avviser Excel en pakke som alle andre lesere godtar?

Fordi reparasjonsvarselet er en skjema- og pakkevalidator, ikke en parserfeil. HotXLS-korpuset hadde kjørt en låmemal med 4805 formler tur-retur gjennom biblioteket, gjennom LibreOffice og gjennom XML-validatorene i testsettet i ukevis. Den lagrede filen var strukturelt sunn i den OPC-forstanden som brukes i artikkelen om OPC-relasjonsoppslag i XLSX: hver del kunne nås, hvert mål kunne løses opp. Så ble en Windows-maskin med Excel 16.0 build 20326 tilgjengelig, korpuskjøreren åpnet den lagrede malen gjennom Workbooks.Open i en isolert COM-instans med DisplayAlerts av, og kallet feilet tvert. Interaktivt gir den samme filen den velkjente dialogen som tilbyr reparasjon, og reparasjonsloggen, når Excel gidder å skrive en, navngir delen men ikke regelen. Tre uavhengige feil gjemte seg i det ene varselet, og Excel rapporterer dem ikke én om gangen; den avviser arbeidsboken og overlater til deg å finne og analysere dem. Det som følger, er hver regel, linjen i HotXLS som brøt den, og rettelsen som ble levert, fordi hver av dem er en regel enhver XLSX-skriver for Delphi kan snuble i

Regel 1: phoneticPr fontId er påkrevd, også når den er null

Elementet <phoneticPr> bærer et fontId-attributt som er deklarert use="required" i ECMA-376 Part 1 §18.4.3, og verdien 0 er en lovlig fontindeks, ikke en manglende verdi. Den gamle regnearkarkskriveren i HotXLS behandlet null som «ikke satt» og skrev bare ut attributtet når Sheet.PhoneticFontId > 0. Det er en naturlig Delphi-refleks, siden heltallsfelt har null som standard, men den gir <phoneticPr type="noConversion"/> for enhver arbeidsbok der den fonetiske fonten tilfeldigvis er den første fonten i styles.xml, som er nøyaktig det lånemalen i HotXLS-korpuset hadde. Excel avviser da på vei inn igjen en verdi den selv har skrevet

Hvorfor Excel krevde reparasjon av regnearkarkdelen fra HotXLS: elementet phoneticPr deklarerer fontId med use required i ECMA-376 Part 1, fontindeksen 0 er en lovlig verdi, og den gamle skriveren som utelot attributtet når PhoneticFontId var null, produserte phoneticPr type noConversion, mens skjemaet gir type og alignment standardverdier og fontId ingen
Å utelate et attributt når det er lik standardverdien, er bare trygt når skjemaet deklarerer den standardverdien, og lånemalen hadde sin fonetiske font helt først i styles.xml
// lxHandleX.pas, regnearkarkskriveren — før v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — attributtet er påkrevd, null inkludert
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLS skriver fortsatt bare ut elementet når TXLSXWorksheet.PhoneticType ikke er tom, så arbeidsbøker som aldri har hatt fonetiske innstillinger, er upåvirket. Regresjonstesten PhoneticSettings_DefaultFontIsExplicit setter PhoneticFontId til null på et nytt ark, lagrer og hevder at <phoneticPr fontId="0" finnes i xl/worksheets/sheet1.xml. Den bredere lærdommen er at «utelat når det er standard» bare er trygt når skjemaet deklarerer en standardverdi; type og alignment har standardverdier i det elementet, fontId har det ikke

Regel 2: én Override per delnavn i [Content_Types].xml

Innholdsstrømmen for innholdstyper kan deklarere hvert delnavn høyst én gang, og Excel behandler en andre Override for samme PartName som korrupsjon, selv når begge oppføringene bærer samme ContentType. HotXLS har to skrivere som mater den strømmen. BuildContentTypesXml deklarerer hver del objektmodellen genererer: arbeidsbok, stiler, delte strenger, tema, regnearkark og, når TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Når PreserveUnsupportedParts er på, legger TXLSXOpaquePackage deretter til en Override for hver del den fanget ordrett fra kildepakken, slik at de bytene forblir deklarert på vei ut. Sammenstøtet er en del som lever på begge sider. Egendefinerte dokumentegenskaper parses inn i modellen, men kildepakkens docProps/custom.xml ble også fanget opakt, så den sammenslåtte strømmen deklarerte den to ganger, og diagram- og pivot-cache-deler kan havne på samme sted når modellen regenererer en del som det opake laget også beholdt. Før v2.382.5 hadde ContentTypeOverridesXml ingen oversikt over hva modellen allerede hadde skrevet, og kunne derfor ikke vite det

Hvordan to HotXLS-skrivere kolliderte i [Content_Types].xml: BuildContentTypesXml deklarerte docProps/custom.xml fra objektmodellen mens TXLSXOpaquePackage la til en Override for den samme delen fanget ordrett, og fra og med v2.382.5 parser det opake laget den genererte strømmen først, normaliserer navn med OpcLowerPartName og lar modellen vinne hver kollisjon
Hver skriver var for seg selv konsistent, og kravet om at hvert delnavn bare får forekomme én gang, finnes bare i skjøten der utdataene deres settes sammen, og derfor sender rettelsen inn modellstrømmen
<!-- Dette så Excel 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"/>

Rettelsen sender den genererte XML-en inn i ContentTypeOverridesXml og lar den opake skriveren parse den før den skriver noe som helst. To detaljer bærer korrektheten. OpcLowerPartName gjør om til små bokstaver, snur omvendte skråstreker til skråstreker og fjerner ledende skråstreker før sammenligning, fordi OPC-delnavn sammenlignes uten hensyn til store og små bokstaver, og modellen skriver dem med ledende skråstrek mens det opake laget lagrer ZIP-elementnavn uten. Og kalleren i BuildContentTypesXml sender Result + '</Types>', som lukker det delvis bygde dokumentet slik at TXMLReader ser velformet input og ikke en avkuttet strøm. Regelen som vokser fram, er at første vinner med modellen foran: det objektmodellen deklarerer, er autoritativt, og den opake avspillingen fyller bare hull

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Parse den modellgenererte strømmen og samle inn hvert deklarerte 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 deklarert, eller en rels-del
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Regel 3: relasjons-ID-er er unike innenfor en relasjonsdel

Hver Relationship i en .rels-del trenger en Id som er unik innenfor den delen, og Excel avviser pakken når to deler den. HotXLS skriver pakkenivåets _rels/.rels med faste identifikatorer: rId1 for arbeidsboken, rId2 og rId3 for kjerne- og de utvidede dokumentegenskapene, og rId4 for egendefinerte egenskaper når modellen har noen. Den opake pakken legger deretter til de rotrelasjonene den beholdt fra kilden, og nummererer om enhver identifikator som allerede står i en UsedIds-liste. Listen kjente rId1 til rId3. Den kjente ikke rId4, og den visste ikke at modellen var i ferd med å skrive sin egen relasjon for egendefinerte egenskaper, så en kildepakke der relasjonen for egendefinerte egenskaper også var rId4, som er det Excel skriver som standard, kom ut med to rId4-oppføringer som pekte på samme mål. Kalleren, BuildRootRelsXml, sender nå Workbook.FCustomProps.Count > 0 som andre argument, så reservasjonen og hoppet over styres av den samme betingelsen som avgjør om modellen i det hele tatt skriver rId4. Omnummerering er trygt i pakkeroten fordi ingenting inne i arbeidsboken refererer til rotrelasjonsidentifikatorer ved navn; det samme trikset ville være galt ett nivå ned, der r:id-attributter i workbook.xml binder seg til identifikatorer i arbeidsbokens relasjonsdel, og derfor holder MergeWorkbookRelationshipsXml et eget identifikatorkart

Kollisjonen mellom relasjonsidentifikatorer i roten av en HotXLS-pakke: modellen skriver rId1 til rId4 med rId4 reservert for egendefinerte egenskaper, det opake laget spilte av en kilderelasjon som også kom inn som rId4 fordi UsedIds bare kjente rId1 til rId3, og rettelsen reserverer rId4 på forhånd hver gang EmitCustomProps holder og nummererer om resten
Omnummerering er trygt i pakkeroten fordi ingenting inne i arbeidsboken refererer til rotidentifikatorer ved navn, og det samme trikset ett nivå ned ville bryte hver r:id-binding i workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // reservert av modellskriveren
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 eier de egendefinerte egenskapene nå; ikke spill av 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;

Hva har de tre feilene til felles?

Alle tre er symptomer på en skriver med to kilder og ingen enkelt eier av pakkeinvariantene. Objektmodellen genererer de delene den forstår; det opake laget spiller av de delene den ikke forstår, slik at en tur-retur beholder diagrammer, pivot-cacher, egendefinert XML og alt annet som er beskrevet i notatene om tapfri tur-retur for theme, extLst og calcChain. Hver side var for seg selv konsistent. Begrensningene OPC legger på hele pakken, unike Override-delnavn og unike relasjonsidentifikatorer per del, finnes bare i skjøten der de to settes sammen, og fram til v2.382.5 sjekket ingen den skjøten. fontId-feilen har samme form ett nivå ned: skriveren visste hva den ville utelate, men så aldri i skjemaet som sier at den ikke får. Rettelsen HotXLS landet på, er en fast presedens og ikke en fletteheuristikk. Modellen skriver først, det opake laget ser hva som ble skrevet og viker ved enhver kollisjon, og korpuskjøreren håndhever nå invariantene fra utsiden med verify_opc_uniqueness, som leser [Content_Types].xml og hvert .rels-element i en lagret pakke og feiler saken på enhver duplikat PartName, Extension eller Id. Den sjekken er billig, trenger ingen Excel, og ville ha fanget to av de tre feilene i den første korpuskjøringen

Samme batch: utskriftsområder som er formler, ikke områder

Excel-gjennomkjøringen flagget også _xlnm.Print_Area i lånemalen, som Excel rapporterte som $A$1:$J$29 på originalen og måtte rapportere identisk på den lagrede kopien. To separate feil lå bak den ene påstanden. Ved import kuttet XlsxStripSheetPrefix alt fram til første uinnpakkede !, så et dynamisk utskriftsområde som OFFSET('Print Data'!$A$1,0,0,2,2) kom tilbake som $A$1,0,0,2,2), og en kvalifisert union som 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 mistet prefikset bare på sitt første segment. Ved eksport la skriveren arknavnet foran hele den lagrede PrintArea én gang, så en ren union $A$1:$B$2,$D$1:$E$2 forlot biblioteket med et kvalifisert første segment og et bart andre, noe Excel ikke godtar som en _xlnm.Print_Area-definisjon under ECMA-376 Part 1 §18.2.5

// Import: fjern bare prefikset når resten er en ren 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: kvalifiser hvert kommadelte segment, eller ingen av 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: skriv ut ordrett
  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;

Parringsregelen er den samme på begge sider: et utskriftsområde er et bart område bare hvis hvert segment parser som ett, ellers er det en formel og går ordrett videre. PrintArea_FormulaDefinitionSurvivesRoundTrip dekker det navngitte grunnlaget, det arkvalifiserte grunnlaget og unionen gjennom to lagre-og-gjenåpne-sykluser. Hvordan utskriftsområder samspiller med sideoppsett og resten av utskriftsmodellen, er dekket i artikkelen om arkbeskyttelse, sideoppsett og utskrift

Hvordan finner du ut hvilken regel Excel protesterer på?

Begynn med å anta at din egen validator tar feil, fordi den slapp gjennom. Validatoren i Open XML SDK navngir et skjemabrud som det manglende fontId-et med delen og XPath-en, og pakkelaget under den nekter i det hele tatt å åpne en pakke med dupliserte innholdstypoppføringer, så kjør den før noe annet. Når den er stille og Excel likevel reparerer, halver pakken: pakk ut, slett en del med relasjonen og Override-en, pakk sammen igjen og åpne på nytt, og halver kandidatsettet hver gang til varselet forsvinner. De tre feilene her falt ut i den rekkefølgen, og ingen av dem ville ha vært synlige i den reparerte filen Excel tilbyr å lagre, siden reparasjonen stille dropper eller nummererer om de kriminelle oppføringene. Grensene for v2.382.5-rettelsen er verdt å si like rett ut. Deduplikeringen er første vinner med modellen foran, så hvis kildepakken deklarerte en annen innholdstype for en del modellen også genererer, vinner modellens deklarasjon og kildens forkastes, noe som er riktig for de delene HotXLS regenererer og ikke er en generell sammenslåing. verify_opc_uniqueness sjekker bare unikhet; den validerer ikke skjemaer, så et framtidig påkrevd attributt ville fortsatt trenge Excel eller en skjemavalidator for å komme fram. Og den ekstra TXMLReader-gjennomgangen av den genererte innholdstypestrømmen kjører ved hver lagring med PreserveUnsupportedParts slått på, en liten kostnad mot en strøm som sjelden er mer enn noen kilobyte. Med det på plass åpner nå både Win32- og Win64-byggene av lånemalen i Excel uten varsel, regner om alle 4805 verifiserte formler med null avvik, og rapporterer det samme utskriftsområdet som originalen

Hvis du selv skriver XLSX fra Delphi, er sjekklisten kort: skriv ut hvert attributt skjemaet merker som påkrevd, uansett verdi, deklarer hvert delnavn én gang, og hold én liste over brukte identifikatorer per relasjonsdel på tvers av hver skriver som rører den. Vil du heller at den listen allerede finnes og er testet mot Excel og ikke bare mot din egen leser, leveres pakkeskriveren som er beskrevet her, i HotXLS Delphi-regnearkkomponenten, sammen med tur-returen for opake deler som gjorde skjøten verdt å vokte i utgangspunktet