從 Delphi 生成活頁簿,並使用傳統的註解 API 為儲存格新增註解,Excel 365 會將您的文字歸檔在「附註」下:這是一個舊式的黃色方框,沒有「回覆」欄位、沒有作者大頭貼,也沒有「解決」按鈕;HotXLS 透過 XLSX 引擎上的 AddThreadedComment 與 AddThreadedReply 寫入真正的 Excel 365 對話式註解,為每個工作表發出專用的 xl/threadedComments/threadedCommentN.xml 部分,並在活頁簿層級的 person 部分註冊每一位作者,因此您的程式碼寫入的對話,與審查人員在 Excel 中回覆的物件是完全相同的
此區別很重要,因為這兩項功能只是看起來相似;附註是一個浮動的文字方塊;對話式註解則是一個對話紀錄,具有穩定的識別碼、回覆樹、作者登錄檔和解決旗標——這些是審查工作流程實際運作所需的元素;本篇文章會先介紹 OOXML 結構,因為一旦您並排看到這兩種儲存模型,Excel 中的每項行為差異就都不再顯得神祕了
為什麼我的註解在 Excel 365 中顯示為附註?
簡言之:兩代註解共享同一個網格,且它們存在於套件中完全不同的部分;傳統模型(AddComment 目標指向的模型)將文字儲存在 xl/comments1.xml(內容類型為 application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml)中,並透過隨附的 VML 繪圖部分 xl/drawings/vmlDrawing1.vml 來定位對話方框;這對組合在功能區出現之前就已存在;Excel 365 仍然忠實地讀取它,但將結果轉譯為「附註」且不提供任何對話式使用者介面,因為儲存結構中沒有可用於建立對話的元素;作者是顯示字串,位置是 VML 形狀,這就是整個模型
隨 Excel 365 引入的對話式註解完全忽略了 VML 層;每個帶有對話的工作表都在內容類型 application/vnd.openxmlformats-officedocument.spreadsheetml.threadedComments+xml 下攜帶自己的 xl/threadedComments/threadedCommentN.xml 部分,且活頁簿攜帶一個列出每位參與者一次的 xl/person/person.xml 部分(內容類型為 ...spreadsheetml.people+xml);HotXLS 在同一個工作表上公開了這兩種模型:AddComment 繼續寫入傳統配對,而 AddThreadedComment 則寫入新部分;如果您的輸出顯示為附註,代表您呼叫了第一個 API 但需要的是第二個;傳統模型在某些工作上仍然是正確的工具——先前關於 檢視工作流程中的儲存格註解與超連結 的文章對其進行了深入探討(包括 XLS 端)——但它內部的任何內容都無法升級為對話
HotXLS 寫入套件中的內容
HotXLS 將每個回覆模型化為 TXLSXThreadedComment 物件,攜帶儲存格錨點(Ref)、GUID 樣式識別碼(Id)、選用性 ParentId、透過 person 列表解析的 AuthorId、DisplayName 鏡像、文字內容、Done 旗標以及 ISO-8601 DateTime;儲存時,每個項目都成為 <threadedComment> 元素,其 t 屬性保存識別碼,dT 屬性保存時間戳記,而僅存在於子回覆上的 parentT 屬性則指名父識別碼;Excel 透過將 parentT 與 t 進行比對來重建對話樹;XML 中沒有巢狀結構,只是一個扁平列表加上參照
person 部分是大多數手動實作最常忘記的部分;對於對話式註解中的每個 authorId,都必須解析為 xl/person/person.xml 中的 <person> 項目(該項目已連結至活頁簿關係,並帶有其自有的內容類型覆蓋)——否則 Excel 沒有可顯示的名稱;HotXLS 自動處理註冊:AddThreadedComment 透過顯示名稱在活頁簿的 Persons 列表中尋找作者,並在不存在時以全新的 GUID 建立項目,因此同一作者的連續註解會共用同一個身分識別;工作表關係會取得 threadedComments 關係,活頁簿關係取得 person 關係,且內容類型清單取得這兩個覆蓋,這一切都無須出現在您的程式碼中
使用 AddThreadedComment 與 AddThreadedReply 建立回覆鏈
AddThreadedComment 建立對話根源:其 ParentId 保持空白,這正是將其標記為對話頂層的原因;AddThreadedReply 接受一個 ParentRef 參數(根源所在的儲存格參照),在該錨點尋找根源,並將新回覆的 ParentId 蓋上根源 Id 的印記;兩者皆傳回新的 TXLSXThreadedComment,因此您可以針對剛建立的物件設定時間戳記或解決狀態
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Root: TXLSXThreadedComment;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('forecast.xlsx');
Sheet := Book.Sheets[0];
// Conversation root; ParentId stays empty
Root := Sheet.AddThreadedComment(8, 3,
'Q3 figure looks low against the pipeline export', 'Maria Ortiz');
Root.DateTime := '2026-07-06T09:12:00Z';
// Replies anchor at the same cell; ParentRef names the root cell
Sheet.AddThreadedReply(8, 3, Root.Ref,
'Pipeline export missed the EMEA renewals, re-running', 'Jan Kowalski');
Sheet.AddThreadedReply(8, 3, Root.Ref,
'Confirmed, refreshed figure lands tomorrow', 'Maria Ortiz');
Book.SaveAs('forecast-reviewed.xlsx');
finally
Book.Free;
end;
end;
該清單中的兩個細節值得注意;第一,傳遞的是 Root.Ref 而不是手動建置的字串:函式庫產生的參照保證與 AddThreadedReply 將尋找的內容相符,這消除了一整類偏離一個儲存格的錨定錯誤;第二,兩位作者都以純顯示名稱傳遞——人名登錄檔、GUID 身分識別和 authorId 連結都在呼叫背後自動完成;在 Excel 365 中開啟儲存的檔案,儲存格錨定、回覆順序以及兩位不同的參與者都處於活動狀態:點擊「回覆」回覆,Excel 會附加到您的程式碼所開始的同一個對話中
讀取對話:雙向轉換行為
HotXLS 自 2.122.0 版本起支援對話式註解的雙向轉換:重新開啟儲存的套件,會從組件中重建每個工作表的對話清單和活頁簿參與者清單,因此識別碼、回覆連結與顯示名稱在完整的「讀取-寫入」循環中得以存留;讀取器在讀取任何工作表內容之前先解析 person.xml,這使得每個 authorId 在載入時都能解析為顯示名稱;解析的個人識別碼按原樣保留,而不是重新生成,因此在您的程式碼與 Excel 之間穿梭的活頁簿在任意多次雙向轉換中都能保持穩定的作者身分識別
var
i: Integer;
Cmt: TXLSXThreadedComment;
begin
Book.Open('forecast-reviewed.xlsx');
Sheet := Book.Sheets[0];
for i := 0 to Sheet.ThreadedComments.Count - 1 do
begin
Cmt := Sheet.ThreadedComments[i];
if Cmt.ParentId = '' then
Writeln('Root at ', Cmt.Ref, ' by ', Cmt.DisplayName, ': ', Cmt.Text)
else
Writeln(' reply by ', Cmt.DisplayName, ': ', Cmt.Text);
end;
Writeln('Participants: ', Book.Persons.Count);
end;
「扁平列表加上參照」的儲存方式在此處刻意顯現出來:ThreadedComments 依組件順序列舉回覆,且空白 of ParentId 用於識別每個根源;如果您需要樹狀形狀(例如匯出檢視日誌),請先依 Ref 進行分組,然後在每個儲存格的分組內將 ParentId 連結至 Id;這項「保留您所解析的內容」行為是該函式庫更廣泛原則的一個實例;關於 佈景主題、擴充清單與計算鏈的無損雙向轉換 的文章介紹了相同的原理如何套用至套件的其餘部分
解決狀態、時間戳記與作者身分識別
三個屬性承載了審查語義,且這三個屬性在傳回的物件上都是可寫入的;Done 對應至 done 屬性——Excel 會將已解決的對話顯示為收合狀態,並帶有「重新開啟」連結;DateTime 是 ISO-8601 UTC 建立戳記(dT);當您將其保留為空時,HotXLS 會替換為固定的預留位置以使組件能通過驗證,但真實的時間戳記能使對話歷程更具可讀性,因此請設定它;在身分識別方面,每個 TXLSXPerson 都公開了 ProviderId(針對本機編寫的項目通常為 None)以及 UserId(當您的應用程式知道時,可以用 UPN 或電子郵件填入)
var
Root: TXLSXThreadedComment;
Person: TXLSXPerson;
begin
Root := Sheet.ThreadedComments.FindAt('D9');
if Root <> nil then
Root.Done := True; // saved as done="1"; Excel shows the thread resolved
Person := Book.Persons.FindByDisplayName('Maria Ortiz');
if Person <> nil then
Person.UserId := 'maria.ortiz@example.com';
end;
需明確說明的一項限制:HotXLS 寫入的 <mentions> 元素為空;@-mention 紀錄(在註解文字內部亮起同事姓名並在 Microsoft 365 中觸發通知的機制)並未被建模,因此包含 @ 符號的文字會儲存為純文字;對於產生的審查活頁簿而言,這鮮少算是一種損失,但如果您的工作流程依賴提及通知,請規劃由人員在 Excel 內部新增它們;person 部分中的作者身分識別,自然與在 活頁簿中介資料與檔案屬性 中涵蓋的活頁簿層級來源欄位配對,這也是生成的檔案其餘稽核軌跡所存放的位置
舊版的 Excel 如何處理對話式註解?
Excel 2016 及更早版本在對話式模型出現之前就已存在,它們僅忽略不理解的部分;當 Excel 365 本身儲存對話式交談時,它還會寫入一個傳統的註解陰影——一個閱讀為「[Threaded comment]...」的預留位置附註——這正是為了讓較舊的版本在錨定的儲存格上顯示某些內容;HotXLS 僅發出對話式部分,而沒有傳統的陰影,因此當在 Excel 2016 中開啟您的程式碼寫入的檔案時,根本不會顯示任何註解指標;如果您的受眾包括 365 之前的安裝版本,且註記必須在該處可見,則傳統的 AddComment API 仍然是相容的管道;請避免在同一個儲存格上堆疊這兩種模型,除非測試過您出貨到的每一個 Excel 版本,因為讀取器如何在一個儲存格上調和附註與對話是讀取器的決定,而不是您的決定
另一個硬性界限是檔案格式本身:對話式註解是沒有 BIFF8 對應項的 OOXML 結構,因此 HotXLS 的 .xls 端沒有對話式 API——必須保持傳統二進位格式的活頁簿僅限於傳統的附註;在實務上,決策清單很短:針對 Excel 365 或網頁/行動 Excel 讀取器寫入 .xlsx時,使用 AddThreadedComment 取得真實對話;針對 Excel 2016 或 .xls,使用 AddComment 並接受附註模型;在稽核您收到的檔案時,檢查 ThreadedComments 與 Comments,因為曾經在兩個世界中並存的活頁簿可以合法地同時攜帶兩者;這兩個世代的完整 API 範圍,都記錄在 HotXLS Delphi Excel 元件 產品頁面上