Bài viết kỹ thuật

Chặn việc lưu XLS âm thầm tính lại công thức trong Delphi

HotXLS, thư viện Excel thuần cho Delphi và C++Builder, lưu một workbook BIFF8 .xls cổ điển theo hướng cache trước: TXLSWorksheet.WriteFormula hỏi TXLSWorkbook.TryGetCachedFormulaValue giá trị mà Excel lưu bên cạnh mỗi công thức và chỉ gọi bộ tính giá trị khi cache đó thiếu hoặc đã bị vô hiệu. Một workbook bạn mở ra và chưa từng đụng vào sẽ lưu lại đúng những con số cũ, còn kết quả mới cần một lời gọi Recalculate tường minh thay vì là hệ quả ngầm của SaveAs

Bug buộc phải đưa giao kèo này ra ánh sáng thì nhỏ đến mức ngại. Một tệp corpus tên nested-subtotals.xls chứa một tổng lớn ở R2C4 với giá trị cache là 37. Mở nó bằng HotXLS, hỏi TryGetCachedFormulaValue cho ô đó, nhận 37. Lưu mà không đổi một ô nào, mở bản đã lưu, hỏi đúng câu đó, nhận 67. Không có API nào bị yêu cầu tính toán gì cả, vậy mà một con số trong tệp đã dịch đúng 30 đơn vị — và 30 tình cờ là tổng của hai subtotal nhóm, 10 và 20, nằm trong vùng mà tổng lớn bao phủ

Vì sao lưu một tệp XLS lại làm đổi giá trị công thức?

Hai lỗi độc lập phải trùng khớp thì 37 mới thành 67, và vá riêng một cái sẽ che mất cái kia. Cái thứ nhất mang tính cấu trúc: writer cổ điển tính lại mọi công thức ở mọi lần lưu. Cái thứ hai là một phép kiểm tra kiểu không bao giờ đúng với công thức nạp từ đĩa, khiến bộ tính giá trị đếm các ô SUBTOTAL lồng nhau hai lần. Tệp corpus chỉ đơn giản là đầu vào đầu tiên mà ở đó việc tính lại lúc lưu cho ra đáp án khác Excel và có người đem hai đáp án ra so. Lỗi cấu trúc thì dễ nói: trước v2.382.3, TXLSWorksheet.WriteFormula cùng người anh em dùng chung công thức WriteFormulaWithTExp lấy trường FormulaValue tám byte của mọi record Formula bằng cách gọi TXLSWorkbook.GetFormulaValue, tức chính bộ tính giá trị. Cache mà ParseFormula đã cẩn thận giải mã từ tệp nguồn lúc nạp chưa bao giờ được hỏi tới ở chiều ghi ra. Thực chất mỗi lần lưu là một lần tính lại toàn bộ với API tính lại cấp workbook bị bỏ qua, nên không có gì bạn đặt trên workbook có thể ngăn nó. Bất cứ chỗ nào bộ tính giá trị của HotXLS bất đồng với Excel, dù là một hàm chưa hỗ trợ một cách chính đáng hay chỉ là một bug thường, đều trở thành một thay đổi dữ liệu âm thầm khi lưu

Lỗi thứ hai nằm trong callback xử lý subtotal lồng nhau mà bộ tính giá trị dùng. Excel định nghĩa mọi dạng SUBTOTAL là bỏ qua các ô mà công thức của chính chúng cũng là một SUBTOTAL, nên bộ tính toán trong lxCalc.pas bật cờ FIgnoreSubtotalCells trong lúc tổng hợp và hỏi workbook, qua TXLSWorkbook.GetClassicIsSubtotalCell, xem từng ô trong vùng có phải như vậy không. Callback đó lấy text công thức dưới dạng Variant rồi kiểm tra bằng VarType(f) = varOleStr. Text quay về từ GetUnCompiledFormula dưới dạng String của Delphi, mà một String gán vào Variant là varUString, không bao giờ là varOleStr. Vị từ đó sai với mọi ô trong mọi tệp đã nạp, các subtotal nhóm bị cộng vào tổng lớn lần thứ hai, và trong một lần lưu có tính lại tất cả, 10 + 20 + 7 thành 67

// HotXLS 2.381 và trước đó: một Variant công thức dựng từ String
// là varUString, nên phép so này chưa bao giờ đúng
Result := (VarType(f) = varOleStr) and
  (SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
   SameText(Copy(f, 1, 10), '=SUBTOTAL('));

// HotXLS 2.382.0: VarIsStr chấp nhận varString, varOleStr và varUString,
// và AGGREGATE cũng được loại khỏi subtotal bao ngoài như Excel làm
if VarIsStr(f) then
  Result := SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
    SameText(Copy(f, 1, 10), '=SUBTOTAL(') or
    SameText(Copy(f, 1, 10), 'AGGREGATE(') or
    SameText(Copy(f, 1, 11), '=AGGREGATE(');

v2.382.0 phát hành bản vá VarIsStr và, nhân lúc còn trong cùng hàm, dạy callback rằng các ô AGGREGATE cũng được loại khỏi subtotal bao ngoài. Chỉ thế đã đủ làm assertion của corpus qua, vì 37 sau khi tính lại giờ khớp với 37 đã nạp. Nhưng nó không làm thư viện trở nên trung thực: lần lưu vẫn tính lại, và bài test chỉ xanh vì bộ tính giá trị tình cờ đồng ý với Excel trên đúng tệp đó. Các quy tắc về việc SUBTOTAL và AGGREGATE bỏ qua những ô nào, kể cả dòng bị ẩn, được trình bày trong bài về SUBTOTAL, AGGREGATE và dòng ẩn; điều quan trọng ở đây là không bộ tính giá trị nào được có tiếng nói trên một tệp mà bạn không yêu cầu nó tính

Excel bảo đảm điều gì về giá trị cache khi lưu?

Excel coi một lần lưu là một ảnh chụp, không phải một sự kiện tính toán. Giá trị được ghi vào trường FormulaValue của một record Formula ([MS-XLS] §2.4.127, bố cục ở §2.5.133) là bất cứ thứ gì ô đang hiển thị, mà ở chế độ tính toán thủ công thì có thể cũ mèm nhiều năm, và Excel vẫn ghi nó trung thực. Tính lại là một thao tác riêng với trigger riêng. HotXLS giờ theo đúng quy tắc đó cho các lần lưu cổ điển: WriteFormula và WriteFormulaWithTExp gọi TryGetCachedFormulaValue trước, lấy CacheInfo.Value khi trạng thái là xlfcsLoaded hoặc xlfcsCalculated, và chỉ rơi xuống GetFormulaValue với xlfcsMissing cùng xlfcsInvalidated. Nửa còn lại của giao kèo này, ở phía đọc, gồm ý nghĩa từng trạng thái và vì sao một giá trị trống hay False được cache vẫn tính là một giá trị, được trình bày trong Đọc giá trị công thức đã cache trong Excel bằng Delphi mà không cần tính lại

Quyết định lưu theo cache mà mọi lần lưu XLS cổ điển trong HotXLS phải đưa ra: WriteFormula và WriteFormulaWithTExp gọi TryGetCachedFormulaValue, trạng thái xlfcsLoaded hoặc xlfcsCalculated ghi CacheInfo.Value nguyên văn, xlfcsMissing hoặc xlfcsInvalidated rơi xuống bộ tính giá trị GetFormulaValue, và khi bộ tính giá trị thất bại thì ghi payload bằng 0 kèm cờ fAlwaysCalc để Excel tính lại khi mở
Một công thức được gán trong phiên đến mà không có cache còn công thức bị thay thế thì bị vô hiệu, nên cả hai vẫn được tính ở lúc lưu và workbook sinh từ code vẫn mở ra có số, trong khi tệp bạn mở mà không đụng tới giữ nguyên giá trị Excel đã lưu

Đường dự phòng được giữ lại có chủ ý, không bị bỏ đi. Một công thức bạn gán trong phiên này qua Cells[Row, Col].Formula đến mà không có cache, và một công thức bạn thay thế trên một ô đã nạp được đánh dấu xlfcsInvalidated bởi _SetCompiledFormula; cả hai đều được tính ở lúc lưu y như trước, nên một workbook sinh ra từ code vẫn mở trong Excel với đầy số liệu. Khi ngay cả bộ tính giá trị cũng không tạo nổi một giá trị, writer phát ra một payload bằng 0 và bật fAlwaysCalc (bit 0 trong grbit của §2.4.127) để Excel tự tính lại ô đó khi mở thay vì tin vào giá trị đặt chỗ

procedure RoundTripWithoutRecalc(const Source, Target: string);
var
  Book: TXLSWorkbook;
  Before, After: TXLSFormulaCacheInfo;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open(Source);
    // sheet, dòng và cột đều 1-based: R2C4 trên sheet đầu tiên
    if not Book.TryGetCachedFormulaValue(1, 2, 4, Before) then
      raise Exception.Create('R2C4 carries no usable cache');
    Book.SaveAs(Target);        // không có bộ tính giá trị nào tham gia với ô đã cache
  finally
    Book.Free;
  end;

  Book := TXLSWorkbook.Create;
  try
    Book.Open(Target);
    Book.TryGetCachedFormulaValue(1, 2, 4, After);
    // Before.Value = After.Value = 37 với nested-subtotals.xls
    // Một lần lưu có tính lại sẽ ghi 67 ở đây
  finally
    Book.Free;
  end;
end;

Ô gốc của shared formula BIFF giữ giá trị cache ở đâu?

Trong record Formula của chính nó, như mọi ô công thức khác, và đó đúng là thứ khiến ô gốc của một nhóm shared formula thành chỗ duy nhất mà việc lưu theo cache vẫn đánh rơi. Một shared formula trong BIFF8 được lưu dưới dạng record ShrFmla ([MS-XLS] §2.4.260) nằm sau record Formula của ô trên cùng bên trái, và mọi ô thành viên, kể cả ô gốc, đều mang một rgce chỉ gồm một token PtgExp duy nhất (§2.5.198): byte đầu của biểu thức đã parse là $01, tiếp theo là dòng và cột của ô gốc. Các ô follower thì tự đủ — HotXLS đọc FormulaValue của từng ô và phân giải biểu thức bằng cách tra công thức đã biên dịch của ô gốc. Ô gốc thì khác, vì khi record Formula của nó được parse thì biểu thức chưa tồn tại; nó đến muộn hơn một record

Khoảng cách một record đó chính là chỗ cache biến mất. TXLSReader.ParseFormula giải mã giá trị cache và, khi thấy một PtgExp có tọa độ trùng với chính ô đó, ghi nhớ ô vào FSharedFormulaRow và FSharedFormulaCol rồi công bố cache cho ô. Khi record ShrFmla ($04BC) đến, ParseSharedFormula biên dịch biểu thức rồi cài nó bằng _SetCompiledFormula, và _SetCompiledFormula làm điều nó buộc phải làm với mọi thay đổi công thức: xóa FCachedFormulaValue và đặt trạng thái về xlfcsMissing. Vì thế con 37 đã nạp của ô gốc bị ném đi trước khi ai kịp đọc nó, TryGetCachedFormulaValue báo ô gốc là không có cache, và writer lưu theo cache ngoan ngoãn rơi xuống bộ tính giá trị đúng ở ô mà mọi người đang nhìn. Record Array (§2.4.4) có cùng thứ tự đó và cũng có cùng lỗ hổng

Bản vá ở v2.382.3 thêm trường thứ ba, FSharedFormulaCachedValue, bên cạnh tọa độ ô gốc đang chờ. ParseFormula cất giá trị cache đã giải mã vào đó khi nhận ra một ô gốc, và cả ParseSharedFormula lẫn ParseArrayFormula phát lại nó qua _SetCellCachedFormulaValue ngay sau khi cài biểu thức đã biên dịch, rồi đặt lại chỗ cất về Unassigned. Biến thể String của cache không bị ảnh hưởng bởi tất cả chuyện này vì payload của nó đến trong một record String riêng và được định tuyến theo tọa độ ô, không theo thứ tự record. Nếu bạn làm việc với phía OOXML của cùng khái niệm này, bài về khai triển si của shared formula trong XLSX giải thích vì sao định dạng package không có vấn đề thứ tự tương đương nhưng lại có những cái bẫy khai triển riêng

Vì sao ô gốc của một shared formula BIFF đánh mất giá trị cache 37 trong HotXLS: record Formula mang một token PtgExp cùng cache đã giải mã, biểu thức ShrFmla đến muộn hơn một record, và việc cài nó qua _SetCompiledFormula đặt trạng thái về xlfcsMissing cho tới khi bản 2.382.3 bắt đầu cất FSharedFormulaCachedValue và phát lại nó qua _SetCellCachedFormulaValue
Record Array cũng có khoảng cách một record đó và ParseArrayFormula phát lại chỗ cất theo cùng cách, còn biến thể cache String được định tuyến theo tọa độ ô và chưa bao giờ phụ thuộc vào thứ tự record

Vì sao các ô follower của shared formula cần một phép dịch tương đối?

Vì biểu thức được lưu trong ShrFmla được viết tương đối so với ô gốc, và một follower dùng lại nó nguyên văn sẽ tính các tham chiếu của ô gốc thay vì tham chiếu của chính nó. Reader cũ cài Value.GetCopy() lên từng follower, một bản sao sâu không dịch chuyển gì, nên một nhóm gốc ở B1 với =A1*3 cho mọi follower cũng =A1*3. Việc lưu theo cache thật ra che bug này với các tệp đã nạp, vì các follower có FormulaValue riêng và không cần biểu thức để lưu cho đúng; nó lộ ra ngay khi có gì đó tính lại. Reader giờ cài TXLSCompiledFormula.GetCopy(row - srow, col - scol), đi qua cây cú pháp và dịch mọi tham chiếu tương đối theo khoảng cách từ follower tới ô gốc, nên follower ở B2 sở hữu một =A2*3 thật sự

Các follower của shared formula cần một phép dịch tương đối trong HotXLS: một nhóm gốc ở B1 với =A1*3 trên các đầu vào 2, 4 và 6 từng cài Value.GetCopy nguyên văn nên B2 tính lại A1*3 và hiện 6 trong khi Excel hiện 12, còn GetCopy dịch theo offset của follower khiến B2 sở hữu =A2*3 và B3 sở hữu =A3*3
Việc lưu theo cache che bug với các tệp đã nạp vì mọi follower đều mang cache riêng, nên chỉ một lần Recalculate tường minh mới phơi ra nó, và bài regression gieo các cache sai 999 và 888 buộc phải sống sót qua lần lưu

Bài regression ghim cả hai hành vi này đáng đọc vì nó từ chối để một sự trùng hợp lọt qua. Nó dựng một workbook với =A1*3 và =A2*3 trên các đầu vào 2 và 4, rồi tiêm các giá trị cache cố tình sai 999 và 888 qua _SetCellCachedFormulaValue, một lần với UseSharedFormulas bật và một lần tắt. Sau một lần lưu rồi nạp lại, cả hai ô vẫn phải báo 999 và 888 — bằng chứng rằng lần lưu không đụng tới cache của ô gốc lẫn cache của follower. Chỉ sau một lời gọi Recalculate tường minh chúng mới được thành 6 và 12, bằng chứng rằng biểu thức đã dịch của follower là đúng. Một bài test gieo các giá trị đúng sẽ qua được cả với writer cũ, và đó chính là lý do phải gieo giá trị sai

var
  Book: TXLSWorkbook;
  Info: TXLSFormulaCacheInfo;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('quarterly-model.xls');
    Book.Sheets[1].Cells[1, 1].Value := 5;   // đổi một đầu vào

    // Cache đã nạp của các công thức phụ thuộc KHÔNG bị vô hiệu bởi
    // một sửa đổi literal, nên một SaveAs thường sẽ giữ các số cũ.
    // Hãy yêu cầu tính lại khi bạn thật sự muốn kết quả mới:
    Book.Recalculate;

    if Book.TryGetCachedFormulaValue(1, 1, 2, Info) then
      Writeln('B1 now ', VarToStr(Info.Value),
        ', state ordinal ', Ord(Info.State));   // xlfcsCalculated
    Book.SaveAs('quarterly-model-updated.xls');
  finally
    Book.Free;
  end;
end;

Giao kèo lưu theo cache không làm giúp bạn điều gì

Việc lưu theo cache giữ nguyên những gì đã nạp; nó không theo dõi xem thứ đã nạp còn đúng hay không. Sửa một literal mà một công thức phụ thuộc vào sẽ đánh dấu đồ thị dependency là bẩn đối với bộ tính giá trị, nhưng nó để nguyên cache xlfcsLoaded của ô phụ thuộc, và writer cổ điển sẽ vui vẻ ghi giá trị cũ đó trừ khi bạn gọi Recalculate hoặc đọc Value của ô trước, việc này tính ra giá trị rồi chuyển trạng thái sang xlfcsCalculated. Đây đúng là đánh đổi mà Excel thực hiện ở chế độ tính toán thủ công, và nó là đánh đổi đúng cho một pipeline mở tệp của bên thứ ba, sửa vài nhãn rồi lưu — nhưng nó có nghĩa một workbook có sửa đầu vào phải tự chịu trách nhiệm về bước tính lại của mình một cách tường minh. Chính sách RecalcBeforeSave của writer XLSX không bị thay đổi bởi công việc này và có chế độ thủ công riêng giữ cache theo cùng tinh thần. Hai ranh giới nhỏ hơn suy ra từ đó: đường lưu theo cache chỉ giúp các ô có trạng thái xlfcsLoaded hoặc xlfcsCalculated; một bộ sinh chỉ ghi công thức mà không bao giờ tính chúng vẫn trả một lần tính cho mỗi ô ở lúc lưu, y như trước. Và bản vá subtotal lồng nhau sửa đúng việc bộ tính giá trị bỏ qua những ô nào, chứ không sửa mọi hàm mà nó cài đặt — một tệp có công thức mà HotXLS không tính giống hệt Excel thì giờ an toàn khi round-trip mà không đụng tới, nhưng một lời gọi Recalculate có chủ ý trên tệp đó vẫn cho ra đáp án của thư viện chứ không phải của Excel, và bạn nên so hai đáp án trước khi tin vào một lần lưu có tính lại

Việc lưu tệp cổ điển theo cache, các cache ô gốc của shared formula và array formula được khôi phục, phép dịch tham chiếu tương đối cho follower dùng chung công thức, cùng các quy tắc lồng SUBTOTAL và AGGREGATE đã sửa đều có trong HotXLS Delphi Spreadsheet Component tiêu chuẩn cho Delphi và C++Builder, không phụ thuộc Excel hay bất kỳ OLE automation server nào; trang sản phẩm có đầy đủ tham chiếu API cho workbook, trình đọc cache và các điểm vào tính lại được dùng ở đây