Το Excel εμφανίζει το «Βρήκαμε πρόβλημα με κάποιο περιεχόμενο» σε ένα XLSX που το LibreOffice και κάθε σπιτικός reader ανοίγουν χωρίς παράπονο, γιατί το Excel επιβάλλει δύο πράγματα που εκείνοι οι readers αγνοούν: τα schema-required attributes και τους κανόνες μοναδικότητας του Open Packaging Conventions. Το HotXLS, το native Excel spreadsheet component για Delphi και C++Builder, χτύπησε ακριβώς αυτό στο v2.382.5 την πρώτη φορά που η έξοδός του πέρασε από πραγματικό Excel COM instance, και οι τρεις αιτίες ήταν ένα <phoneticPr> χωρίς fontId, ένα διπλότυπο Override στο [Content_Types].xml, και δύο root relationships που μοιράζονταν το rId4
Γιατί το Excel απορρίπτει ένα package που κάθε άλλος reader δέχεται;
Γιατί το repair prompt είναι validator σχήματος και package, όχι αποτυχία parser. Το corpus του HotXLS έκανε round-trip ένα template δανείου με 4805 τύπους μέσα από τη βιβλιοθήκη, μέσα από το LibreOffice και μέσα από τους XML validators της σουίτας test επί εβδομάδες. Το saved αρχείο ήταν δομικά υγιές με την OPC έννοια που περιγράφει το άρθρο για το XLSX OPC relationship resolution: κάθε part προσπελάσιμο, κάθε target λύσιμο. Μετά έγινε διαθέσιμο ένα Windows μηχάνημα με Excel 16.0 build 20326, ο corpus runner άνοιξε το saved template μέσω Workbooks.Open σε απομονωμένο COM instance με DisplayAlerts κλειστά, και η κλήση απέτυχε ολωσδιόλου. Διαδραστικά το ίδιο αρχείο παράγει το γνώριμο dialog που προσφέρει repair, και το repair log, όταν το Excel φτιάχνεται να γράψει ένα, κατονομάζει το part αλλά όχι τον κανόνα. Τρία ανεξάρτητα ελαττώματα κρύβονταν σε εκείνο το ένα prompt, και το Excel δεν τα αναφέρει ένα-ένα· απορρίπτει το βιβλίο εργασίας και σε αφήνει να τα βρεις και να τα αναλύσεις. Ακολουθεί ο καθένας από τους κανόνες, η γραμμή του HotXLS που τον παραβίαζε, και το fix που κυκλοφόρησε, γιατί ο καθένας τους είναι κανόνας πάνω στον οποίο μπορεί να σκοντάψει οποιοσδήποτε Delphi XLSX writer
Κανόνας 1: το phoneticPr fontId είναι required, ακόμα κι όταν είναι μηδέν
Το στοιχείο <phoneticPr> κουβαλάει attribute fontId δηλωμένο use="required" στο ECMA-376 Part 1 §18.4.3, και τιμή 0 είναι νόμιμο font index, όχι απουσία. Ο παλιός worksheet writer του HotXLS μετέχειρε το μηδέν ως «unset» και εξέπεμπε το attribute μόνο όταν Sheet.PhoneticFontId > 0. Είναι φυσικό Delphi αντανακλαστικό, αφού τα integer πεδία προεπιλέγονται στο μηδέν, αλλά παράγει <phoneticPr type="noConversion"/> για οποιοδήποτε βιβλίο εργασίας του οποίου η phonetic γραμματοσειρά τυχαίνει να είναι η πρώτη γραμματοσειρά στο styles.xml, που είναι ακριβώς αυτό που κουβαλούσε το template δανείου στο corpus του HotXLS. Το Excel απορρίπτει λοιπόν στην επιστροφή μια τιμή που είχε γράψει ο ίδιος
// lxHandleX.pas, worksheet writer — πριν το v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — το attribute είναι required, και το μηδέν
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 είναι μη κενό, οπότε βιβλία εργασίας που δεν κουβαλούσαν ποτέ phonetic ρυθμίσεις δεν επηρεάζονται. Το regression test PhoneticSettings_DefaultFontIsExplicit θέτει PhoneticFontId στο μηδέν σε φρέσκο sheet, σώζει, και ισχυρίζεται ότι το <phoneticPr fontId="0" υπάρχει στο xl/worksheets/sheet1.xml. Το ευρύτερο μάθημα είναι ότι το «παράλειψέ το όταν είναι στο default» είναι ασφαλές μόνο όταν το schema δηλώνει default· το type και το alignment έχουν defaults σε εκείνο το στοιχείο, το fontId δεν έχει
Κανόνας 2: ένα Override ανά part name στο [Content_Types].xml
Το stream των content types μπορεί να δηλώσει κάθε part name το πολύ μία φορά, και το Excel μεταχειρίζεται δεύτερο Override για το ίδιο PartName ως corruption ακόμα κι όταν και οι δύο εγγραφές κουβαλάνε το ίδιο ContentType. Το HotXLS έχει δύο writers που τρέφουν εκείνο το stream. Το BuildContentTypesXml δηλώνει κάθε part που παράγει το object model: workbook, styles, shared strings, theme, worksheets, και, όταν TXLSXWorkbook.CustomProperties.Count > 0, το /docProps/custom.xml. Όταν το PreserveUnsupportedParts είναι ανοιχτό, το TXLSXOpaquePackage προσθέτει μετά ένα Override για κάθε part που κατέγραψε verbatim από το source package ώστε εκείνα τα bytes να μείνουν δηλωμένα στην έξοδο. Η σύγκρουση είναι ένα part που ζει και στις δύο πλευρές. Τα custom document properties γίνονται parse μέσα στο model, αλλά το docProps/custom.xml του source package είχε επίσης καταγραφεί opaquely, οπότε το συγχωνευμένο stream το δήλωνε δύο φορές, και parts chart και pivot cache μπορούν να προσγειωθούν στο ίδιο σημείο όταν το model ξαναδημιουργεί ένα part που το opaque layer κράτησε επίσης. Πριν το v2.382.5, το ContentTypeOverridesXml δεν είχε καμία εικόνα του τι είχε ήδη γράψει το model, οπότε δεν μπορούσε να ξέρει
<!-- Τι έβλεπε το 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"/>
Το fix περνάει το παραγόμενο XML μέσα στο ContentTypeOverridesXml και αφήνει τον opaque writer να το κάνει parse πριν εκπέμψει οτιδήποτε. Δύο λεπτομέρειες κουβαλάνε την ορθότητα. Το OpcLowerPartName κάνει lowercase, αναποδογυρίζει backslashes σε forward slashes και αφαιρεί τα leading slashes πριν τη σύγκριση, γιατί τα OPC part names συγκρίνονται case-insensitive και το model τα γράφει με leading slash ενώ το opaque layer αποθηκεύει ονόματα ZIP items χωρίς. Και ο caller στο BuildContentTypesXml περνάει Result + '</Types>', κλείνοντας το μισοχτισμένο έγγραφο ώστε το TXMLReader να δει καλοσχηματισμένη είσοδο αντί για κομμένο stream. Ο κανόνας που προκύπτει είναι first-wins με το model μπροστά: ό,τι δηλώνει το object model είναι αυθεντικό, και το opaque replay γεμίζει μόνο κενά
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Parseάρεσε το stream που παρήγαγε το model και μάζεψε κάθε declared 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 part
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Κανόνας 3: τα relationship Ids είναι μοναδικά μέσα σε ένα relationships part
Κάθε Relationship σε ένα .rels part χρειάζεται Id μοναδικό μέσα σε εκείνο το part, και το Excel αρνείται το package όταν δύο μοιράζονται ένα. Το HotXLS γράφει το package-level _rels/.rels με σταθερά identifiers: rId1 για το workbook, rId2 και rId3 για core και extended document properties, και rId4 για custom properties όταν το model έχει τέτοια. Το opaque package προσθέτει μετά όσες root relationships κράτησε από την πηγή, ξανα-αριθμώντας κάθε identifier που βρίσκεται ήδη σε λίστα UsedIds. Η λίστα ήξερε για rId1 έως rId3. Δεν ήξερε για rId4, και δεν ήξερε ότι το model ετοιμαζόταν να εκπέμψει το δικό του custom-properties relationship, οπότε ένα source package του οποίου το custom-properties relationship ήταν κι αυτό rId4, που είναι ό,τι γράφει το Excel από προεπιλογή, βγήκε με δύο εγγραφές rId4 που δείχνουν στον ίδιο target. Ο caller, το BuildRootRelsXml, περνάει πλέον Workbook.FCustomProps.Count > 0 ως δεύτερο όρισμα, ώστε η κράτηση και το skip να κινούνται από την ίδια συνθήκη που αποφασίζει αν το model εκπέμπει καθόλου rId4. Η επαναρίθμηση είναι ασφαλής στη ρίζα του package γιατί τίποτα μέσα στο workbook δεν αναφέρει root relationship identifiers με το όνομά τους· το ίδιο κόλπο ένα επίπεδο πιο κάτω θα ήταν λάθος, όπου attributes r:id στο workbook.xml δένουν με identifiers στο workbook relationships part, γι' αυτό το MergeWorkbookRelationshipsXml κρατάει ξεχωριστό identifier map
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // κρατημένο από τον writer του model
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// Τα custom properties ανήκουν πλέον στο model· μην ξαναπαίξεις το αντίγραφο της πηγής.
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;
Τι έχουν κοινό οι τρεις αποτυχίες;
Και οι τρεις είναι συμπτώματα ενός writer με δύο πηγές και κανέναν ενιαίο owner των invariants του package. Το object model παράγει parts που καταλαβαίνει· το opaque layer αναπαράγει parts που δεν καταλαβαίνει, ώστε ένα round trip να κρατά charts, pivot caches, custom XML και όλα τα υπόλοιπα που περιγράφονται στις σημειώσεις για το lossless round-trip του theme, extLst και calcChain. Κάθε πλευρά ήταν ατομικά συνεπής. Οι περιορισμοί που βάζει το OPC σε όλο το package, μοναδικά part names Override και μοναδικά relationship identifiers ανά part, υπάρχουν μόνο στη ραφή όπου τα δύο συνενώνονται, και μέχρι το v2.382.5 κανείς δεν τεστάριζε τη ραφή. Το bug του fontId είναι το ίδιο σχήμα ένα επίπεδο πιο κάτω: ο writer ήξερε τι ήθελε να παραλείψει αλλά δεν συμβουλεύτηκε ποτέ το schema που λέει ότι δεν επιτρέπεται. Το fix που επέλεξε το HotXLS είναι σταθερή προτεραιότητα κι όχι heuristic συγχώνευσης. Το model γράφει πρώτο, το opaque layer βλέπει τι γράφτηκε και υποχωρεί σε κάθε σύγκρουση, και ο corpus runner πλέον επιβάλλει τα invariants απ' έξω με το verify_opc_uniqueness, που διαβάζει το [Content_Types].xml και κάθε item .rels σε saved package και κόβει την περίπτωση σε οποιοδήποτε διπλότυπο PartName, Extension ή Id. Εκείνος ο έλεγχος είναι φτηνός, δεν χρειάζεται Excel, και θα είχε πιάσει δύο από τα τρία ελαττώματα στην πρώτη εκτέλεση corpus
Ίδιο batch: print areas που είναι τύποι, όχι ranges
Το πέρασμα από το Excel σήμανε επίσης το _xlnm.Print_Area του template δανείου, που το Excel ανέφερε ως $A$1:$J$29 στο πρωτότυπο και έπρεπε να αναφέρει πανομοιότυπα στο saved αντίγραφο. Δύο ξεχωριστά bugs κάθονταν πίσω από εκείνον τον έναν έλεγχο. Στο import, το XlsxStripSheetPrefix έκοβε τα πάντα μέχρι το πρώτο ! εκτός εισαγωγικών, οπότε μια δυναμική print area όπως OFFSET('Print Data'!$A$1,0,0,2,2) γύριζε ως $A$1,0,0,2,2), και ένα qualified union όπως 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 έχανε το πρόθεμα μόνο στο πρώτο του segment. Στο export, ο writer πρόσθετε το sheet name μία φορά μπροστά σε όλο το αποθηκευμένο PrintArea, οπότε ένα σκέτο union $A$1:$B$2,$D$1:$E$2 άφηνε τη βιβλιοθήκη με qualified πρώτο segment και γυμνό δεύτερο, που το Excel δεν δέχεται ως ορισμό _xlnm.Print_Area κάτω από ECMA-376 Part 1 §18.2.5
// Import: αφαίρεσε το prefix μόνο όταν ό,τι απομένει είναι σκέτο 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(...) επιστρέφεται ανέγγιχτο
// Export: βάλε το sheet name σε κάθε segment με κόμμα, ή σε κανένα
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; // τύπος: εξέμπεσέ τον verbatim
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;
Ο κανόνας ζευγαρώματος είναι ο ίδιος και στις δύο πλευρές: μια print area είναι γυμνό range μόνο αν κάθε segment γίνεται parse ως τέτοιο, αλλιώς είναι τύπος και ταξιδεύει verbatim. Το PrintArea_FormulaDefinitionSurvivesRoundTrip καλύπτει τη named βάση, τη sheet-qualified βάση, και το union μέσα από δύο κύκλους save-and-reopen. Πώς οι print areas αλληλεπιδρούν με το page setup και το υπόλοιπο μοντέλο εκτύπωσης καλύπτεται στο άρθρο για το sheet protection, page setup και printing
Πώς βρίσκεις τον κανόνα στον οποίο αντιδρά το Excel;
Ξεκίνα από την υπόθεση ότι ο δικός σου validator έχει άδικο, γιατί πέρασε. Ο Open XML SDK validator θα κατονομάσει μια παραβίαση schema όπως το λείπον fontId με το part και το XPath, και το packaging layer από κάτω του αρνείται καθόλου να ανοίξει package με διπλότυπα content-type entries, οπότε τρέξέ το πριν από οτιδήποτε άλλο. Όταν σωπάσει και το Excel εξακολουθεί να επιδιορθώνει, κάνε bisection του package: unzip, σβήσε ένα part με το relationship και το Override του, rezip, και ξαναάνοιξε, υποδιπλασιάζοντας το σύνολο των υποψηφίων κάθε φορά μέχρι το prompt να εξαφανιστεί. Τα τρία ελαττώματα εδώ βγήκαν με αυτή τη σειρά, και κανένα δεν θα ήταν ορατό στο επιδιορθωμένο αρχείο που το Excel προσφέρει να σώσει, αφού το repair σβήνει ή ξαναριθμεί σιωπηλά τις παραβατικές εγγραφές. Τα όρια του fix v2.382.5 αξίζει να δηλωθούν εξίσου ξερά. Η απομάκρυνση διπλοεγγραφών είναι first-wins με το model μπροστά, οπότε αν το source package δήλωνε διαφορετικό content type για ένα part που το model παράγει επίσης, η δήλωση του model κερδίζει και αυτή της πηγής απορρίπτεται, που είναι σωστό για τα parts που ξαναδημιουργεί το HotXLS και δεν είναι γενική συγχώνευση. Το verify_opc_uniqueness τεστάρει μόνο μοναδικότητα· δεν κάνει schema validation, οπότε ένα μελλοντικό required attribute θα χρειαζόταν πάλι το Excel ή schema validator για να αναδυθεί. Και το επιπλέον πέρασμα TXMLReader πάνω στο παραγόμενο stream των content types τρέχει σε κάθε save με ενεργό PreserveUnsupportedParts, μικρό κόστος για ένα stream που σπάνια ξεπερνά λίγα kilobytes. Με αυτά στη θέση τους, και τα builds Win32 και Win64 του template δανείου ανοίγουν πια στο Excel χωρίς prompt, ξαναϋπολογίζουν και τους 4805 επαληθευμένους τύπους με μηδέν αποκλίσεις, και αναφέρουν την ίδια print area με το πρωτότυπο
Αν γράφεις XLSX από Delphi μόνος σου, η λίστα ελέγχου είναι κοντή: εξέμπεσε κάθε attribute που το schema μαρκάρει required ανεξαρτήτως τιμής, δήλωσε κάθε part name μία φορά, και κράτα μία λίστα των χρησιμοποιημένων identifiers ανά relationships part απέναντι σε κάθε writer που την αγγίζει. Αν προτιμάς η λίστα να υπάρχει ήδη και να έχει τεσταριστεί απέναντι στο Excel και όχι μόνο απέναντι στον δικό σου reader, ο package writer που περιγράφεται εδώ κυκλοφορεί μέσα στο HotXLS Delphi spreadsheet component, μαζί με το opaque-part round-trip που έκανε τη ραφή να αξίζει να φυλάγεται εξαρχής