一份試算表帶著兩層身分。一層是儲存格格線,另一層是與它並行的文件中繼資料:標題、作者、公司、關鍵字、時間戳記。Excel 從不在格線裡顯示第二層,然而它正是 Windows Search 編製索引的那一層、是 SharePoint 讀來為文件命名的那一層,也是紀錄管理系統據以歸檔的那一層。當一份生成的活頁簿從它建置來源的範本繼承了 Author 與 Title,每一個下游系統就會把範本設計師記錄為四千份客戶對帳單的作者。中繼資料在哪裡都不正確,卻到處被查詢
HotXLS 把這一層呈現為兩個引擎上普通的活頁簿層級屬性:用於 .xls 的 BIFF 外觀,與用於 .xlsx 的 OOXML 外觀。你在開啟檔案後讀取欄位,並在儲存檔案前寫入欄位。數值落在哪個實體容器裡,由函式庫決定。在你寫一個產生器之前值得理解的,是每種格式實際支援哪些欄位、這些欄位實際住在哪裡,以及那一條治理著一份 .xlsx 是否記錄任何中繼資料的閘門規則
兩種格式,兩種儲存模型
試算表函式庫之所以需要兩套中繼資料實作,以及半調子工具之所以能正確蓋好一種格式卻忘了另一種,原因在於 .xls 與 .xlsx 把屬性放在互不相干的地方。BIFF 活頁簿把它們寫進 OLE 複合檔案資料流,主要是那套比 Excel 本身更古老的 SummaryInformation 屬性集,以及一條資料流內的 WRITEACCESS 紀錄,它記下最後儲存檔案的人是誰。OOXML 活頁簿則把它們保留為 zip 封包內的 XML 部件,依用途拆開:docProps/core.xml 持有 Dublin Core 欄位(標題、建立者、主旨、關鍵字、日期),而 docProps/app.xml 持有公司與產生應用程式這類應用程式層級欄位,依 ECMA-376 Part 1
HotXLS 把這兩種儲存模型都攤平成活頁簿物件的直接屬性。你絕不手動開啟屬性集資料流,也絕不手動編輯 XML 部件。你把字串與日期指派給活頁簿,無論你存成哪種格式,正確的容器就會現身
從業務紀錄為生成的活頁簿蓋章
在 XLSX 那一側,TXLSXWorkbook 把 Title、Subject、Author、Keywords、Description、Category、LastModifiedBy、Company、Application 與 AppVersion 公開為字串,加上 Created 與 Modified 作為 TDateTime 數值,其中零表示未設定。關閉繼承漏洞的那條規則只有一句話:每一次執行都指派每一個欄位,從業務紀錄取值,而不是信任範本剛好帶了什麼
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open('statement-template.xlsx') <> 1 then
raise Exception.Create('Template not available');
// 覆寫每個欄位:任何未處理的欄位都會
// 繼承範本設計者留下的內容
Book.Title := 'Account Statement 2026-06 / ACME Corp';
Book.Subject := 'Monthly account statement';
Book.Author := 'Billing Service 4.2';
Book.LastModifiedBy := 'Billing Service 4.2';
Book.Company := 'Northwind Financial';
Book.Category := 'Customer Delivery';
Book.Keywords := 'statement;billing;2026-06;acct-10024';
Book.Description := 'Generated document - manual edits are not retained';
Book.Created := Now;
Book.Modified := Now;
Book.SaveAs('statement-10024.xlsx');
finally
Book.Free;
end;
end;
Keywords 欄位值得比它通常得到的更多思索。搜尋基礎結構逐字編製它的索引,Windows Search、SharePoint 與多數 DMS 產品皆然,所以一套以分號分隔、帶著帳號與週期的慣例,能把每一份交付的活頁簿變成一筆無需資料庫往返就能找到的紀錄。同樣的觸及範圍也正是陷阱所在。屬性會跟著檔案的每一份副本移動,遠遠超過寫下它們那個系統的存取控制之外,所以個人資料不該出現在裡面
這對時間戳記帶著值得用政策定下來、而非交給習慣的語意。Created 應該標記你的管線產生文件的這一刻,然後保持凍結。Modified 則是 Excel 在收件者每次儲存檔案時更新的欄位,所以兩者在交付後出現分歧,就是有人下游編輯了這份活頁簿的明確證據,這能擺平不止一場關於一張轉寄試算表裡的數字究竟是誰的爭論。有一個陷阱藏在未設定的狀態裡:它是字面數值零,不是例外,也不是 null,所以審計程式碼必須明確地測試零。少了這層守衛就去格式化一個未設定的 TDateTime,你的日誌就會塞滿一個信心滿滿卻錯誤的 1899 年 12 月日期
DocPropsTouched:不帶 docProps 就出貨的活頁簿
一個唯讀旗標,DocPropsTouched,為 XLSX 屬性寫入器把關。一份從未指派過任何屬性的活頁簿,完全不產生任何 docProps 部件;HotXLS 拒絕寫出一副空的中繼資料骨架。這個行為很整潔,而它有兩個值得為之設計的後果
消費端的進件程式碼絕不能假設每個封包裡都存在 core.xml。一個硬性要求它的工具,會拒絕掉完全合法的精簡檔案。而如果你的合規立場要求每一份對外文件至少帶著一個產生器身分,這個要求就變成程式碼,而不是格式的一項屬性:在儲存路徑裡無條件指派 Application 與 Author,因為一份原封不動的活頁簿在規範下完全合法,卻悄悄違反了你的政策
舊式 XLS 介面與 Comments 陷阱
BIFF 外觀帶著較舊、較小的欄位集:Title、Subject、Author、Keywords、Comments、Company 與 Manager,再加上 LastSavedBy,它是 UserName 的別名,會寫入 Excel 在另一個使用者鎖住檔案時顯示的 WRITEACCESS 紀錄
var
Legacy: IXLSWorkbook; // reference-counted interface: no manual Free
begin
Legacy := TXLSWorkbook.Create;
if Legacy.Open('archive-1999.xls') <= 0 then
raise Exception.Create('Cannot open archive file');
Legacy.Title := 'FY1999 ledger (migrated copy)';
Legacy.Author := 'Archive Migration Batch';
Legacy.Company := 'Northwind Financial';
Legacy.Comments := 'Migrated 2026-06-11; source retained in cold storage';
Legacy.LastSavedBy := 'migration-svc'; // BIFF WRITEACCESS 記錄
Legacy.SaveAs('archive-1999-stamped.xls');
end;
一個命名碰撞造成了反覆出現的混淆。這裡的文件層級 Comments 屬性,是顯示在檔案屬性對話方塊裡的自由文字註解。它跟儲存格註解毫無關係——後者是透過一套完全分開的 API 附著在範圍上的繪圖層物件。一場接受了「我們已經寫了 Comments」卻沒查清楚指的是哪一個的程式碼審查,等於接受了一項關於錯誤功能的宣稱,而這發生的頻率比那個共用名稱所暗示的更高。兩者共用四個字母,卻不共用一個位元組的儲存
在進件時讀取中繼資料,以及探測缺口
讀取是對稱的。在 Open 之後,相同的屬性會從檔案填入回來,這把一場對內送活頁簿的中繼資料審計變成了一個簡短的迴圈
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open(FileName) = 1 then
begin
Writeln(Format('%s | title="%s" author="%s" created=%s',
[ExtractFileName(FileName), Book.Title, Book.Author,
FormatDateTime('yyyy-mm-dd', Book.Created)]));
if Book.Created = 0 then
Writeln(' no creation date recorded');
end;
finally
Book.Free;
end;
end;
在做的時候要為一個限制做規劃。沒有只讀屬性的探測。GetSheetNames 能在不載入活頁簿的情況下列出工作表,但讀取 Title 或 Author 意味著一次完整的 Open,所以對一個大型檔案庫做中繼資料分診,每個檔案都要付出完整的解析成本。在 BIFF 那一側,你可以在開啟前把 _DisableGraphics 設為 true,為唯讀審計削減那份成本,這會徹底跳過繪圖層。它適合一個只讀屬性與儲存格統計的迴圈,而在同一個實例可能會儲存的那一刻,它就完全錯了,因為被跳過的繪圖內容會被丟掉。當光是工作表結構就能預先篩選集合時——單一工作表匯出顯然是該跳過的——我們的工作表清單與輕量檢視文章裡的便宜技巧,能削減有多少檔案會抵達昂貴的那一階段。而在大量蓋章工作上——其中寫的是數千份輸出而非受檢——我們的批次工作串流寫入文章裡的寫入端吞吐量 pattern 原封不動地適用,因為屬性指派對儲存時間不增加任何可量測的東西
跨越格式,並圍堵洩漏
屬性在單一外觀內乾淨地來回轉換:開啟一份 .xlsx、編輯它、儲存它,這個集合會完整回來。跨越格式正是對等假設崩潰的地方,因為 BIFF 與 OOXML 的欄位集無法一對一對齊。BIFF 有 Manager 而沒有時間戳記;OOXML 有 Category、Description,以及 Created/Modified 這一對。一個盲目複製的轉換器會弄丟目的格式裝不下的任何東西,所以要把欄位明確對應,並把這份對應放進你的轉換檢查清單,與其他所有活不過這趟旅程的東西擺在一起
範本繼承所開的洩漏,往另一個方向跑:你從沒打算送出去的資訊。作者名稱、停放在關鍵字裡的內部專案標籤、一個沒人清掉的草稿標題。上面產生器那套「全部覆寫」的紀律就是全部的防禦,而它值得用一個外人的方式來驗證:開啟任何客戶都搆得到的「屬性」對話方塊,或是解開 .xlsx 的 zip 並直接從封包裡讀 docProps/core.xml。你在那裡看到的,正是每一個下游索引器所看到的
那種下游可見性,也正是少數幾個欄位比其餘欄位值得更多照顧的原因。Title、Author、Keywords(以標籤浮現)以及 Comments 或 Description,在 SharePoint 與 Windows Search 裡承擔了大部分的索引權重。一個每份文件都真正與眾不同、帶著週期與帳號的 Title,對可尋性的貢獻比疊在它上面的任何資料夾命名方案都大,而它的成本只是每次儲存一次指派
文件屬性是一份生成的活頁簿所能帶著、最便宜的專業潤飾,也是沒人擁有它們時最常被出貨的缺陷。這裡描述的兩個屬性介面都屬於HotXLS Delphi Component,它能為 XLS 與 XLSX 原生寫入它們,不需 Excel 自動化