技術文章

Excel 儲存格註解與超連結:Delphi 搭配 HotXLS

在一個生成的活頁簿裡,把工作表從「Summary」改名為「Overview」,每個原本指向 Summary!A1 的內部超連結就再也跳不到任何地方。儲存時不會丟出例外,開啟時也不會。連結照樣渲染出來,看起來一樣可以點,卻悄悄地解析不到任何東西。同樣的損壞也會在另存轉換、或 .xls 與 .xlsx 來回轉換之後出現,例如註解錯了一欄、或相對連結弄丟了目標。這兩種功能都帶著真實人員會據以行動的審核狀態,因此一旦損壞,在審核人員點下去卻什麼也沒發生之前,這個失敗完全是隱形的

這正是註解與超連結值得比它們表面上看起來更用心對待的實務理由。HotXLS 讓 Delphi 與 C++Builder 程式碼能直接寫入這兩者,XLS 與 XLSX 皆然,而且整個過程不需要 Excel 自動化。但這份控制權的另一面就是責任:函式庫會原封不動地寫入你交給它的目標,一個也不會驗證,因此維持審核工作流程的完整是你程式碼的工作,而不是 Excel 的工作

作為機器寫入審核記錄的儲存格註解

在 XLSX 的類別模型裡,註解是一個工作表層級的物件:它知道自己所在的列、欄、一位作者,以及一段文字內容。作者欄位的存在是有道理的。當你程式碼生成的活頁簿流經一條審核鏈時,審計人員問的第一個問題就是某則註解是誰寫的,而一則沒有作者的註解會用一片空白來回答這個問題。為生成的註解蓋上服務身分章,來源就永遠不會含糊

HotXLS 在 Delphi 的註解重試圖解:FindAt 探測會更新既有儲存格註解,盲目重試 AddComment 則疊出重複
盲呼叫 AddComment 的重試會在同一儲存格疊出第二則備註,FindAt 探測則編輯已存在的備註
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Note: TXLSXComment;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('reconciliation.xlsx');
    Sheet := Book.Sheets[0];

    // 針對調整後數值的撰寫註解
    Sheet.AddComment(14, 4, 'Manual adjustment: late FX rate, see ticket FIN-2214',
      'recon-service');

    // 更新既有的註解,而不是再疊加一個
    Note := Sheet.Comments.FindAt(14, 4);
    if Note <> nil then
      Note.Text := Note.Text + ' [verified 2026-06-11]';

    Book.SaveAs('reconciliation-reviewed.xlsx');
  finally
    Book.Free;
  end;
end;

FindAt 探測的份量比表面看起來更重。一個在暫時性失敗後重試的批次工作,會毫不猶豫地對它已經標註過的儲存格第二次呼叫 AddComment,結果那個儲存格就疊出了兩則沒人要的註解。先用 FindAt 探測,再更新它回傳的物件。Comments 集合還公開了 DeleteAtDeleteInRange。當你在活頁簿離開大樓之前要清理它時,那個範圍版本正是該用的:清掉一整個區域的內部品保註解只要一次呼叫,而不是手寫一個逐格的迴圈

外部網址與活頁簿內跳轉是不同的 API

OOXML 把這兩種連結放在不同的地方。外部網址會變成工作表 .rels 部件裡的一筆關聯項目,由儲存格以 id 指向該關聯。內部跳轉則完全不碰關聯層,它只是一段單純的位置字串,例如 Summary!A1,直接存放在連結上。HotXLS 在 API 裡讓這個區別保持清楚可見,而不是把單一方法多載掉,這代表你要根據目標住在哪裡來挑對正確的呼叫:

圖解對比 HotXLS 在 Delphi 產生的活頁簿中,外部 URL 存成 rels 組件裡的關聯,內部跳轉則存成純 location 字串
外部 URL 走關係層,內部跳轉只是純文字,兩種連結失敗的方式各異,各需要自己的稽核規則
Sheet.Cells[2, 1].Value := 'Source record';
Sheet.AddHyperlink(2, 1, 'https://intranet.example.com/records/2214',
  'Open record 2214', 'ERP source entry');

Sheet.Cells[3, 1].Value := 'Totals';
Sheet.AddHyperlinkToCell(3, 1, 'Overview!B12', 'Jump to totals');

在產生的 TXLSXHyperlink 物件上,UrlLocation 是互斥的,而 IsInternal 會告訴你兩者中哪一個有值。當你盤點一個開啟的活頁簿裡的連結,且需要用不同規則對待「離開這個檔案」與「留在這個檔案裡」時,你檢查的就是這個旗標:一個外部主機可能要面對一份允許清單,而內部目標只需要指向一個存在的工作表。內部連結背後不帶任何關聯部件,這也讓它們在大量重寫時更便宜

開頭那種損壞完全發生在內部這一側,而且它源自一個事實:位置字串不是一個經過解析的參照。HotXLS 會原封不動寫入你交給它的文字,而當工作表後來被重新命名時,沒有任何東西會重新指向那段文字。有兩道防線在實務上行得通。第一道是排序上的紀律:在生成任何一個連結之前,先重新命名所有工作表,然後把工作表名稱當成凍結的識別碼。第二道更穩固,能在事後重新命名後存活下來。把連結指向活頁簿層級的定義名稱,而不是原始的 Sheet!Cell 位址,因為當底層工作表變動時,Excel 會重寫名稱的定義,於是連結就跟著自動過去。第二種做法自然地與HotXLS 的定義名稱與跨工作表公式裡的技巧搭配

XLS 這一側:相同概念、較舊的管線

BIFF8 外觀把註解掛在範圍上,而不是一個工作表層級的集合。你在 IXLSRange 上呼叫 AddComment 並取回一個 TXLSComment;該範圍的 Comment 屬性讀取既有註解,而 ClearComments 則清除它們。這裡的尖角在於位置。TXLSComment 並未公開它自己的列與欄,所以那種很自然的迴圈「走過每則註解並回報它坐在哪裡」會跟 API 反向運作。你必須從儲存格出發。要不是從你標註過的位址清單來驅動這次審計,就是一邊寫一邊保留你自己的位置日誌,因為註解物件事後不會告訴你它住在哪裡

var
  Book: IXLSWorkbook;
  Sheet: IXLSWorksheet;
  Remark: TXLSComment;
begin
  Book := TXLSWorkbook.Create;
  Sheet := Book.Sheets.Add;
  Sheet.Name := 'Review';
  Sheet.Cells.Item[5, 2].Value := 4821.50;

  Remark := Sheet.Cells.Item[5, 2].AddComment('Awaiting sign-off from controller');
  Remark.Visible := True;   // 第一次檢視時就彈開註解

  Sheet.AddHyperlink(7, 2, 'https://intranet.example.com/signoff/4821',
    'Sign-off form', 'Opens the controller queue');
  Book.SaveAs('review.xls');
end;

Visible 設為 True 是讓一則註解無法被忽略的傳統做法:黃色方塊會停在工作表上保持開啟,而不是等著被滑鼠懸停。TXLSComment 比它的 XLSX 對應版本更進一步,公開了 TextRuns,於是一則註解可以在一段樸素的說明旁邊帶著一段粗體警告,這是 XLSX 註解 API 無法以同樣方式提供的格式化能力。這一側的超連結透過三個漸進的多載版本抵達(只有位址、接著附顯示文字、再附螢幕提示),並透過工作表的 HyperLinks 集合讀回,其中每個連結會浮現 AddressSubAddressDisplayTextScreenTip

一份審核索引工作表勝過散落的註解

超過十來則註解之後,滑鼠懸停閱讀就悄悄地停止擴充了。註解會堆積在審核人員永遠不會開啟的工作表上,而最重要的那些註解,恰恰是最容易被錯過的那些。一直以來最站得住腳的結構,是一張生成的索引工作表:每個標註位置一列,列出它的工作表名稱、儲存格位址、作者,以及註解的一小段摘要。最後一欄帶著一個用 AddHyperlinkToCell 建立的內部超連結,直接跳到被標註的儲存格。這麼一來,審核人員是順著一張清單往下讀,而不是在格線裡四處搜尋,而那張索引的列數同時也充當下方審核階段的註解清單

建立這張索引很便宜,因為你的產生器已經知道它碰過的每個位置。在你寫每則註解時,把一個(工作表、列、欄、作者、摘要)序組追加到一個清單裡,最後才輸出索引工作表,好讓它在儲存前的列數是定稿的。兩個改良值得做:依照嚴重度或工作表排序索引,而不是依插入順序;並在索引標頭放一個返回連結,讓審核人員在每個項目之後能跳回頂端。由於內部連結只是單純的位置字串,背後的關聯層裡什麼也沒有,就算是一千列的索引,對檔案大小或儲存時間也幾乎沒有影響

同一張工作表在回程時又會派上用場。當審核過的活頁簿回來時,你的程式碼讀取的是打在索引列旁邊儲存格裡的狀態值,而不是重新掃描每張工作表去尋找可能變動過的註解。一欄結構化的狀態儲存格能乾淨地解析;散落的自由文字註解則不能

一道確實抓得到損壞的交付前審核階段

這些 API 沒有一個會驗證目標。一個指向你刪掉的工作表的連結、一個拼錯的內部網路主機、一個上季已停用的檔案共用:全部都會毫無聲息地儲存下來。ECMA-376 規範的是連結如何被儲存,而不是它會解析到任何東西。因此,一份帶著審核中繼資料的活頁簿,值得加上一段屬於你自己的簡短審核階段,在 SaveAs 之前執行:

HotXLS 交付前稽核圖解:在 Delphi 的 SaveAs 之前,檢查內部目標、URL 允許清單、註解數量與收件者清理
四項檢查趕在 SaveAs 前執行,每一項都抓得到程式庫自己永遠不會引發的失敗
  • 收集生成期間寫入的每個內部位置,並確認驚嘆號前的工作表名稱仍然存在於活頁簿的工作表集合裡
  • 對照一份配置與主機的允許清單檢查外部網址。單純的 file:// 與 UNC 路徑會洩漏環境細節,並在檔案一離開你的網路時就損壞
  • 統計每張工作表的註解數量,並與你的產生器打算寫入的數量比較。一次把註解加倍的重試會在這裡浮現,而不是在審核人員的收件匣裡
  • 每當收件者位於組織之外,就用 DeleteInRange 剝除僅供內部的註解

從資料層建置活頁簿的團隊,可以把這個階段摺進同一個已經在驗證資料的管線步驟裡,讓中繼資料檢查免費順便搭車。其機制就是將資料庫查詢結果匯出成 Excel 報表裡所描述的那套,只是把方向轉向連結與註解,而非資料列

當人們手工建構位置字串時,有一個引號細節會絆倒他們。一個名稱裡含有空白的工作表,必須在位置裡被加上引號,就跟公式列加引號的方式一模一樣:'Quarterly Totals'!A1,而不是 Quarterly Totals!A1。HotXLS 套用的是公式引擎對跨工作表參照所使用的同一套規則,因此如果一個連結在工作表公式裡行得通,它的引號在這裡也行得通。交給它一個帶空白又沒加引號的名稱,你就會得到開頭警告過的那種悄然死連結

註解與超連結是一份生成活頁簿裡,審核人員會不假思索就行動的那些部分,而這正是一個指向虛無的目標,會在任何人察覺之前造成實質損害的原因。把驗證階段建一次起來,在每份活頁簿出貨之前都執行它,審核工作流程就能在重新命名與轉換之間保持完整。XLS 與 XLSX 兩個外觀的完整 API 介面都記錄在HotXLS Delphi Component 產品頁上