HotXLS Delphi Component chỉ phát lại nguyên byte một chart Excel chưa bị sửa khi hai điều cùng đúng: chart được tiếp cận qua relationship drawing của worksheet chứ không phải qua một tên part đoán mò, và fingerprint 64-bit của model được chụp sau khi model chart parse xong. Bản 2.382.0 vá điều kiện thứ nhất, bản 2.382.3 vá điều kiện thứ hai và bắt đầu round-trip các offset anchor xdr:colOff và xdr:rowOff khác 0 mà writer drawing vẫn hard-code thành 0. Cả hai lỗi đều lộ ra từ một ca corpus cục bộ, two-charts.xlsx: trước tiên một assertion cấu trúc thấy hai chart part biến thành ba, rồi một phép so byte mọi xl/charts/chartN.xml cho thấy những chart không ai đụng vào vẫn bị ghi lại — và không vấn đề nào ném exception hay khiến Excel phàn nàn, đó là lý do chúng sống lâu được như vậy
Vì sao một workbook hai chart trở về với ba chart part?
Vì loader có một nhánh dự phòng đoán mò. Khi một worksheet không có relationship drawing trong part .rels của nó, code cũ giả định drawing nằm ở tên quy ước xl/drawings/drawing{i+1}.xml, trong đó i là vị trí sheet, rồi gắn part đó nếu nó tồn tại trong archive. Trong two-charts.xlsx, sheet đầu không có drawing và cũng không có part .rels nào, trong khi xl/drawings/drawing1.xml lại tồn tại — nó thuộc sheet thứ hai, sheet này tiếp cận nó qua Target="../drawings/drawing1.xml". Sheet 1 vì thế thừa hưởng một chart mà nó chưa bao giờ tham chiếu, chart1.xml bị parse hai lần, và lần lưu ghi workbook ra với ba chart part thay vì hai
Bản vá trong HotXLS v2.382.0 gỡ bỏ hoàn toàn phép đoán. Giờ drawing của worksheet chỉ được nạp qua ParPartTargets[i].Values[XlsxRtDrawing], đích được ghi cho loại relationship drawing trên sheet đó, và một sheet không có relationship như vậy thì không có drawing nào cả. Đó chính là hành vi mà định dạng đòi hỏi: phần tử <drawing r:id="…"/> trong worksheet (ECMA-376 Phần 1 §18.3.1.36) là mối liên kết duy nhất giữa một sheet và drawing của nó, và tên part trong một package OPC không mang ý nghĩa nào ngoài thứ mà đồ thị relationship gán cho chúng. Các archive do Excel ghi tình cờ dùng đúng tên quy ước, và đó là thứ khiến lối tắt này sống được lâu đến vậy; bài phân tích về phân giải relationship OPC trong HotXLS giải thích vì sao đoán tên part chưa bao giờ an toàn, kể cả khi phép đoán thường đúng
// Trước v2.382.0: thiếu relationship drawing thì rơi về một phép đoán
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
LoadDrawing(zip, drawingName); // có thể thuộc về sheet khác
// Từ v2.382.0: có relationship, hoặc không có gì
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
LoadDrawing(zip, drawingName);
Fingerprint của chart bảo đảm điều gì?
Fingerprint quyết định, theo từng chart, liệu lần lưu có thể sao chép part gốc hay phải dựng lại nó. Lúc import, với PreserveUnsupportedParts được bật trước Open, HotXLS giữ byte UTF-8 thô của mỗi chart part trong FRawChartXml, dựng bản tuần tự hóa của chính model có kiểu bằng BuildChartKnownXml, và lưu độ dài của bản tuần tự hóa đó vào FRawChartModelLength cùng hash của nó vào FRawChartModelHash. Hash là FNV-1a trên các code unit UTF-16 của XML được sinh ra, với offset basis 64-bit chuẩn 14695981039346656037 và số nguyên tố 1099511628211. Lúc lưu, XlsxChartRawModelUnchanged dựng lại known XML rồi so độ dài và hash; khớp nghĩa là model có kiểu đúng y như lúc import, nên không có gì mà ứng dụng có thể đã thay đổi lại thay đổi cả
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
const KnownXml: WideString): Boolean;
begin
Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
(Length(KnownXml) = Chart.FRawChartModelLength) and
(XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;
function BuildChartXmlFromKnown(Chart: TXLSXChart;
const KnownXml: WideString): WideString;
begin
if Chart.FRawChartXml = '' then
Result := KnownXml // không giữ được gì
else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
Result := XlsxDecodeChartUtf8(Chart.FRawChartXml) // phát lại nguyên văn
else
Result := XlsxMergeChartXml(
XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // merge theo cấu trúc
end;
Writer XLSX còn đi xa hơn BuildChartXmlFromKnown một bước. Khi model không đổi và StrictOOXML đang tắt, trước tiên nó thử sao chép thẳng entry nén từ archive nguồn sang đầu ra dưới tên part mới của chart, nên byte thậm chí không bị giải mã rồi nén lại. Chỉ khi không sao chép được thì nó mới rơi xuống đường giải mã hoặc merge. Bản thân cơ chế — độ dài cộng hash, khớp thì phát lại, không khớp thì merge — là cơ chế được mô tả trong bài về sửa chart Excel mà không đánh mất ChartML. Còn bài này nói về cách nó âm thầm ngừng hoạt động
Vì sao rốt cuộc mọi chart đều đi đường merge?
Vì fingerprint bị chụp sớm mất một lời gọi. Việc parse chart trong HotXLS là một lượt SAX trên chart part, theo sau là một loạt lượt recovery kéo các chi tiết ra khỏi văn bản thô mà các handler SAX không model trực tiếp: XlsxChartParseSeriesFlags đọc từng khối <c:ser> để lấy cờ <c:smooth> cùng các giá trị srgbClr của marker fill và marker line, rồi khôi phục chế độ cắt trục cùng kiểu tick-mark chính và phụ cho trục category và trục value. Trước v2.382.3, thứ tự ở cuối ParseChartXml là: phân loại các nhóm trục, dựng known XML, chụp độ dài và hash, và chỉ sau đó mới chạy XlsxChartParseSeriesFlags. Fingerprint vì thế mô tả một model vẫn còn thiếu cờ smooth, màu marker và tick mark. Lúc lưu, BuildChartKnownXml chạy trên model đã hoàn tất, model này giờ sinh ra <c:smooth val="1"/> cùng các màu marker đã khôi phục. XML dài hơn, hash khác, XlsxChartRawModelUnchanged trả về False, và chart đi qua XlsxMergeChartXml. Merge là thao tác đúng cho một chart đã bị ai đó sửa, nhưng nó không phải thao tác giữ nguyên byte: nó tuần tự hóa lại cây, và quy tắc sở hữu cho phép model có kiểu thắng ở series, trục và plot group nghĩa là các node được sinh lại thay thế bản gốc. Kết quả nhìn thấy được trong lần chạy corpus là màu series bị lệch trên những chart không ai sửa — mọi chart trong mọi workbook được preserve, ở mọi lần lưu, mà chẳng có chẩn đoán nào ở đâu cả
Bản sửa chỉ là một lần đổi thứ tự: XlsxChartParseSeriesFlags giờ chạy trước khi known XML được dựng, nên fingerprint mô tả model đúng như nó sẽ tồn tại khi ứng dụng nhìn thấy lần đầu. Bài học vượt ra ngoài phạm vi chart. Một fingerprint phát hiện thay đổi chỉ tốt bằng đúng thời điểm nó được chụp, và thời điểm an toàn là sau khi mọi lượt có thể biến đổi model đã chạy xong. HotXLS có một điểm chụp thứ hai cho cùng hai giá trị đó, baseline mà nó thiết lập lại so với tệp đầu ra sau một lần lưu thành công, và điểm đó xưa nay luôn chạy trên một model đã parse đầy đủ; điểm chụp lúc import mới là cái lệch nhịp
Các offset anchor đã đi đâu?
Vào một số 0 đúng nghĩa. Một twoCellAnchor trong part drawing ghim một chart vào giữa hai ô, và mỗi góc mang một chỉ số ô cộng một offset bên trong ô đó: from (ECMA-376 Phần 1 §20.5.2.5) và to (§20.5.2.32) mỗi cái giữ col, colOff (§20.5.2.4), row và rowOff. Các offset tính bằng English Metric Unit, 914400 đơn vị một inch, và Excel ghi giá trị khác 0 mỗi khi chart được đặt hoặc đổi kích thước bằng chuột, tức là phần lớn chart. Chart đầu tiên trong two-charts.xlsx bắt đầu ở row 0 với rowOff là 19049 và kết thúc ở column 8, row 15 với colOff là 247650 cùng rowOff là 66674 — khoảng một phần tư inch vào cột cuối. Parser drawing trong HotXLS xưa nay vẫn đọc bốn giá trị đó — code xử lý ảnh dùng chúng — nhưng writer chart lại phát ra <xdr:colOff>0</xdr:colOff> và <xdr:rowOff>0</xdr:rowOff> cho mọi góc, khiến mỗi chart bám vào lưới ô khi lưu
// Từ v2.382.3 writer anchor phát lại các offset EMU đã import
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
'<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
'<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
'<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
'<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
'<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...
TXLSXChart giờ mang FFromColOff, FFromRowOff, FToColOff và FToRowOff, được điền từ parser drawing và được sao chép cùng các trạng thái anchor khác khi một chart được gán. Chúng cố ý ở mức private: bề mặt anchor công khai vẫn là bốn tọa độ ô FromRow, FromCol, ToRow và ToCol, và một chart tạo từ code Delphi vẫn nằm trên biên ô như trước. Các offset tồn tại để một vòng round-trip trung thực, không phải để phơi ra tính năng định vị dưới mức ô. Lưu ý rằng bản sửa này độc lập với fingerprint: anchor nằm trong part drawing chứ không phải chart part, nên một chart có ChartML phát lại hoàn hảo vẫn sẽ nhảy về lưới nếu thiếu nó. Các phép đổi đơn vị đằng sau những giá trị EMU đó được trình bày trong bài về hình học ảnh và scaling EMU trong HotXLS
Làm sao chứng minh một chart round-trip mà không đổi gì?
Bằng cách so byte, chứ không phải bằng cách mở kết quả trong Excel. Excel sửa chữa và chuẩn hóa nhiều đến mức một chart bị lệch trông vẫn ổn cho tới khi một analyst nhận ra màu marker đã đổi. Bài test corpus bắt được cả hai lỗi làm ba việc sau một lần mở rồi lưu không sửa gì: nó duyệt các relationship của worksheet, drawing và chart rồi fail nếu có tham chiếu chart trùng, mồ côi hay treo lơ lửng; nó so một chữ ký gồm loại chart, công thức series và hình học anchor giữa bản gốc và đầu ra; và với two-charts.xlsx nó đọc từng xl/charts/chartN.xml từ cả hai archive và đòi byte phải giống hệt. Phép kiểm tra tương tự dễ viết trong Delphi với TZipFile của RTL
uses System.Zip, System.SysUtils;
function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
Src, Dst: TZipFile;
Name: string;
A, B: TBytes;
begin
Result := True;
Src := TZipFile.Create;
Dst := TZipFile.Create;
try
Src.Open(Original, zmRead);
Dst.Open(Resaved, zmRead);
for Name in Src.FileNames do
if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
begin
Src.Read(Name, A);
Dst.Read(Name, B); // ném lỗi nếu part đã biến mất
if (Length(A) <> Length(B)) or
((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
begin
Writeln('changed: ', Name);
Result := False;
end;
end;
finally
Dst.Free;
Src.Free;
end;
end;
Ba điều kiện khiến phép so đó có ý nghĩa, và mỗi điều kiện hỏng âm thầm nếu bị quên. PreserveUnsupportedParts phải là True trước Open, nếu không thì chẳng có byte thô nào được chụp và mọi chart đều được dựng lại từ model. StrictOOXML phải là False, vì chế độ strict theo thiết kế buộc phải sinh lại. Và ứng dụng không được đụng vào chart giữa lúc mở và lúc lưu — đọc thuộc tính thì không sao, nhưng bất kỳ setter nào làm đổi model có kiểu đều lật fingerprint và đẩy chart xuống đường merge, đó là hành vi đúng và không phải thứ bài test này nhắm tới. Chart part cũng được đánh số lại từ một counter toàn workbook khi lưu, nên một workbook có thứ tự sheet hay thứ tự chart thay đổi sẽ đặt byte giống hệt dưới một tên chartN.xml khác; vì lý do đó mà bộ kiểm tra corpus đi theo relationship chứ không đi theo tên
Cả hai bản sửa đều đã phát hành trong HotXLS 2.382.0 và 2.382.3, được kiểm chứng trên Win32 và Win64 với corpus cục bộ, và các mẫu chart được lưu lại cũng được render sang PDF qua một bộ office độc lập rồi so từng trang với bản gốc. HotXLS đọc, sửa và ghi chart XLSX từ code Delphi và C++Builder thuần, không cần cài Excel, và chính điều đó khiến mức độ trung thực này là trách nhiệm của thư viện — trang component bảng tính HotXLS cho Delphi có danh sách tính năng và bản tải dùng thử