Excel hiện dòng “We found a problem with some content” trên một XLSX mà LibreOffice và mọi reader tự viết đều mở ngon lành, vì Excel thực thi hai thứ mà những reader kia bỏ qua: các attribute mà schema đánh dấu bắt buộc và quy tắc duy nhất của Open Packaging Conventions. HotXLS, component bảng tính Excel thuần cho Delphi và C++Builder, gặp đúng chuyện đó ở v2.382.5 trong lần đầu đầu ra của nó đi qua một COM instance Excel thật, và ba nguyên nhân là một <phoneticPr> thiếu fontId, một Override lặp trong [Content_Types].xml, và hai relationship gốc dùng chung rId4
Vì sao Excel từ chối một package mà mọi reader khác đều chấp nhận?
Vì lời nhắc sửa chữa là một validator về schema và package, chứ không phải một lần parser thất bại. Corpus của HotXLS đã round-trip một template cho vay 4805 công thức qua thư viện, qua LibreOffice và qua các validator XML trong bộ test suốt nhiều tuần. Tệp được lưu hoàn toàn vững về cấu trúc theo nghĩa OPC mà bài về phân giải relationship OPC của XLSX dùng: mọi part đều tiếp cận được, mọi target đều phân giải được. Rồi một máy Windows với Excel 16.0 build 20326 trở nên sẵn có, bộ chạy corpus mở template đã lưu qua Workbooks.Open trong một COM instance biệt lập với DisplayAlerts tắt, và lời gọi thất bại thẳng. Ở chế độ tương tác, cùng tệp đó sinh ra hộp thoại quen thuộc mời sửa chữa, và log sửa chữa, khi Excel chịu ghi, chỉ nêu tên part chứ không nêu quy tắc. Ba lỗi độc lập nấp trong đúng một lời nhắc đó, và Excel không báo từng cái một; nó từ chối workbook rồi để bạn tự tìm và phân tích. Phần dưới đây là từng quy tắc, dòng HotXLS vi phạm nó, và bản vá đã phát hành, vì mỗi cái trong số đó là quy tắc mà bất kỳ writer XLSX Delphi nào cũng có thể vấp
Quy tắc 1: phoneticPr bắt buộc có fontId, kể cả khi bằng 0
Phần tử <phoneticPr> mang attribute fontId được khai báo use="required" trong ECMA-376 Phần 1 §18.4.3, và giá trị 0 là một font index hợp lệ, không phải sự vắng mặt. Writer worksheet cũ của HotXLS coi số 0 là “chưa đặt” và chỉ phát attribute khi Sheet.PhoneticFontId > 0. Đó là phản xạ tự nhiên của Delphi, vì các trường integer mặc định bằng 0, nhưng nó sinh ra <phoneticPr type="noConversion"/> cho bất kỳ workbook nào có font phiên âm tình cờ là font đầu tiên trong styles.xml, đúng như template cho vay trong corpus HotXLS mang theo. Excel sau đó từ chối ngay khi đọc lại chính giá trị mà nó đã ghi ra
// lxHandleX.pas, writer worksheet — trước v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — attribute là bắt buộc, kể cả số 0
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS vẫn chỉ phát phần tử này khi TXLSXWorksheet.PhoneticType khác rỗng, nên những workbook chưa bao giờ mang thiết lập phiên âm không bị ảnh hưởng. Bài regression PhoneticSettings_DefaultFontIsExplicit đặt PhoneticFontId bằng 0 trên một sheet mới, lưu, rồi assert rằng <phoneticPr fontId="0" có mặt trong xl/worksheets/sheet1.xml. Bài học rộng hơn là “bỏ qua khi bằng mặc định” chỉ an toàn khi schema có khai báo mặc định đó; type và alignment có mặc định trong phần tử này, còn fontId thì không
Quy tắc 2: mỗi tên part chỉ một Override trong [Content_Types].xml
Stream content types chỉ được khai báo mỗi tên part nhiều nhất một lần, và Excel coi một Override thứ hai cho cùng PartName là hỏng, kể cả khi cả hai entry mang cùng ContentType. HotXLS có hai writer cùng đổ vào stream đó. BuildContentTypesXml khai báo mọi part mà object model sinh ra: workbook, styles, shared strings, theme, các worksheet, và khi TXLSXWorkbook.CustomProperties.Count > 0 thì thêm /docProps/custom.xml. Khi PreserveUnsupportedParts bật, TXLSXOpaquePackage lại nối thêm một Override cho mọi part mà nó đã sao chép nguyên văn từ package nguồn, để các byte đó vẫn được khai báo khi ghi ra. Đụng độ xảy ra ở một part nằm cả hai phía. Custom document properties được parse vào model, nhưng docProps/custom.xml của package nguồn cũng đã được sao chép opaque, nên stream sau khi gộp khai báo nó hai lần, và các part chart cùng pivot cache cũng có thể rơi vào tình huống tương tự khi model sinh lại một part mà tầng opaque cũng giữ. Trước v2.382.5, ContentTypeOverridesXml không hề biết model đã ghi gì, nên nó không thể biết
<!-- Điều Excel nhìn thấy trước 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"/>
Bản vá truyền XML đã sinh vào ContentTypeOverridesXml và để writer opaque parse nó trước khi phát ra bất cứ thứ gì. Hai chi tiết gánh phần đúng đắn. OpcLowerPartName chuyển về chữ thường, đổi dấu gạch chéo ngược thành gạch chéo xuôi, và bỏ dấu gạch chéo đầu trước khi so, vì tên part OPC được so không phân biệt hoa thường và model ghi chúng kèm dấu gạch chéo đầu trong khi tầng opaque lưu tên item ZIP không có nó. Và phía gọi trong BuildContentTypesXml truyền Result + '</Types>', đóng tài liệu đang dựng dở để TXMLReader nhìn thấy đầu vào well-formed thay vì một stream bị cắt. Quy tắc rút ra là ai đến trước thắng, với model đứng đầu: bất cứ thứ gì object model khai báo đều có thẩm quyền, còn phần phát lại opaque chỉ lấp chỗ trống
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// Parse stream do model sinh ra và thu mọi PartName đã được khai báo.
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; // đã được khai báo, hoặc là một part rels
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
Quy tắc 3: Id relationship là duy nhất trong một part relationships
Mọi Relationship trong một part .rels cần một Id duy nhất trong chính part đó, và Excel từ chối package khi hai cái dùng chung một Id. HotXLS ghi _rels/.rels cấp package với các định danh cố định: rId1 cho workbook, rId2 và rId3 cho core và extended document properties, và rId4 cho custom properties khi model có. Tầng package opaque sau đó nối thêm bất cứ relationship gốc nào nó giữ được từ nguồn, đánh số lại mọi định danh đã có trong danh sách UsedIds. Danh sách này biết rId1 tới rId3. Nó không biết rId4, và cũng không biết rằng model sắp phát relationship custom properties của chính nó, nên một package nguồn có relationship custom properties cũng là rId4, đúng như Excel ghi mặc định, cho ra hai entry rId4 cùng trỏ vào một target. Phía gọi, BuildRootRelsXml, giờ truyền Workbook.FCustomProps.Count > 0 làm tham số thứ hai, nên việc dành chỗ và việc bỏ qua cùng do một điều kiện quyết định là model có phát rId4 hay không. Đánh số lại an toàn ở gốc package vì không có gì bên trong workbook tham chiếu định danh relationship gốc bằng tên; cùng mẹo đó sẽ sai ở tầng dưới, nơi các attribute r:id trong workbook.xml gắn với định danh trong part relationships của workbook, và đó là lý do MergeWorkbookRelationshipsXml giữ một bản đồ định danh riêng
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // do writer của model dành trước
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 giờ sở hữu custom properties; đừng phát lại bản sao từ nguồn.
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 nhỏ nhất còn trống
UsedIds.Add(String(Id));
...
end;
Ba lỗi này có điểm gì chung?
Cả ba đều là triệu chứng của một writer có hai nguồn và không có ai làm chủ duy nhất các bất biến của package. Object model sinh những part nó hiểu; tầng opaque phát lại những part nó không hiểu, để một vòng round-trip giữ được chart, pivot cache, custom XML và mọi thứ khác được mô tả trong bài về round-trip mất mát bằng không của theme, extLst và calcChain. Mỗi phía khi đứng riêng đều nhất quán. Những ràng buộc mà OPC đặt lên toàn package, tên part Override duy nhất và định danh relationship duy nhất trong từng part, chỉ tồn tại ở đường nối nơi hai phía được ghép lại, và cho tới v2.382.5 không ai kiểm tra đường nối đó. Lỗi fontId có cùng hình dạng ở tầng dưới: writer biết nó muốn bỏ qua cái gì nhưng chưa bao giờ tra schema để biết rằng mình không được phép bỏ. Bản vá mà HotXLS chọn là một thứ tự ưu tiên cố định thay vì một heuristic trộn. Model ghi trước, tầng opaque nhìn thấy những gì đã ghi và nhường ở mọi va chạm, và bộ chạy corpus giờ cưỡng chế các bất biến từ bên ngoài bằng verify_opc_uniqueness, thứ đọc [Content_Types].xml cùng mọi item .rels trong một package đã lưu và fail ca test nếu có PartName, Extension hay Id trùng. Phép kiểm tra đó rẻ, không cần Excel, và sẽ bắt được hai trong ba lỗi ngay lần chạy corpus đầu tiên
Cùng đợt: print area là công thức, không phải dải ô
Lượt chạy Excel cũng đánh dấu _xlnm.Print_Area của template cho vay, thứ Excel báo là $A$1:$J$29 trên bản gốc và phải báo y hệt trên bản đã lưu. Hai lỗi riêng biệt nằm sau một assertion đó. Lúc import, XlsxStripSheetPrefix cắt mọi thứ tính tới dấu ! đầu tiên không nằm trong ngoặc, nên một print area động như OFFSET('Print Data'!$A$1,0,0,2,2) quay về thành $A$1,0,0,2,2), và một union có định danh sheet như 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 mất tiền tố chỉ ở đoạn đầu. Lúc export, writer thêm tên sheet một lần vào toàn bộ PrintArea đã lưu, nên một union trần $A$1:$B$2,$D$1:$E$2 để lại cho thư viện một đoạn đầu có tên sheet và một đoạn sau trần trụi, thứ mà Excel không chấp nhận là định nghĩa _xlnm.Print_Area theo ECMA-376 Phần 1 §18.2.5
// Import: chỉ bóc tiền tố khi phần còn lại là một sqref trần
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(...) được trả về nguyên trạng
// Export: gắn tên sheet cho mọi đoạn ngăn bằng dấu phẩy, hoặc không đoạn nào
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; // là công thức: phát nguyên văn
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;
Quy tắc đi theo cặp giống nhau ở cả hai phía: một print area chỉ là dải ô trần nếu mọi đoạn của nó đều parse ra dải ô, còn nếu không thì nó là công thức và đi nguyên văn. PrintArea_FormulaDefinitionSurvivesRoundTrip phủ trường hợp base có tên, base có tên sheet, và union, qua hai vòng lưu rồi mở lại. Cách print area tương tác với page setup và phần còn lại của model in ấn được trình bày trong bài về bảo vệ sheet, page setup và in ấn
Làm sao tìm ra quy tắc nào Excel đang phản đối?
Hãy bắt đầu từ giả định rằng validator của chính bạn sai, vì nó đã cho qua. Validator của Open XML SDK sẽ nêu tên một vi phạm schema như thiếu fontId kèm cả part và XPath, còn tầng packaging bên dưới nó từ chối mở hẳn một package có entry content-type trùng, nên hãy chạy nó trước mọi thứ khác. Khi nó im lặng mà Excel vẫn sửa chữa, hãy chia đôi package: giải nén, xóa một part cùng relationship và Override của nó, nén lại rồi mở lại, mỗi lần giảm một nửa tập ứng viên cho tới khi lời nhắc biến mất. Ba lỗi ở đây lộ ra theo đúng thứ tự đó, và không cái nào nhìn thấy được trong tệp đã sửa mà Excel mời bạn lưu, vì bản sửa âm thầm bỏ hoặc đánh số lại các entry gây lỗi. Ranh giới của bản vá v2.382.5 cũng đáng nói thẳng như vậy. Việc khử trùng lặp là ai đến trước thắng với model đứng đầu, nên nếu package nguồn khai báo một content type khác cho một part mà model cũng sinh ra, khai báo của model thắng và của nguồn bị bỏ, điều đúng cho các part mà HotXLS sinh lại và không phải một phép trộn tổng quát. verify_opc_uniqueness chỉ kiểm tra tính duy nhất; nó không validate schema, nên một attribute bắt buộc phát sinh về sau vẫn cần Excel hoặc một schema validator để lộ ra. Và lượt TXMLReader thêm trên stream content types đã sinh chạy ở mọi lần lưu khi bật PreserveUnsupportedParts, một chi phí nhỏ so với một stream hiếm khi vượt vài kilobyte. Với những thứ đó, cả bản Win32 lẫn Win64 của template cho vay giờ mở trong Excel không còn lời nhắc, tính lại đủ 4805 công thức đã kiểm chứng với 0 sai lệch, và báo print area y như bản gốc
Nếu bạn tự viết XLSX từ Delphi, danh sách kiểm tra khá ngắn: phát mọi attribute mà schema đánh dấu bắt buộc bất kể giá trị của nó, khai báo mỗi tên part đúng một lần, và giữ một danh sách định danh đã dùng cho từng part relationships xuyên suốt mọi writer đụng vào nó. Nếu bạn muốn danh sách đó đã tồn tại sẵn và được kiểm chứng với Excel chứ không chỉ với reader của chính bạn, bộ ghi package mô tả ở đây có trong component bảng tính Delphi của HotXLS, cùng với round-trip part opaque — thứ khiến đường nối kia đáng được canh gác ngay từ đầu