技术文章

在 Delphi 中的 XLSX 工作表保护:15 种允許选项

你将一份完成的工作簿交給同事,并要求他们对其進行篩選,而不是重写。因此,你保护了工作表。在較旧的 HotXLS 組建中,这个动作会在文件中写入一件事:<sheetProtection sheet="1" objects="1" scenarios="1"/>,每次都是写死的。工作表被锁定了,密码杂湊被加上了,使用者什么都不能做,甚至連你实际想开放的排序和篩選都不行。Excel 自带的“保护工作表”对话框正是为了这个原因而有 15 個核取方塊,但引擎卻无法表達其中任何一个。这正是 v2.91.0 保护模型所要填補的落差

HotXLS 是一个适用於 Delphi 和 C++Builder 的原生 VCL 試算表元件,能在未安裝 Excel 的情況下读写 XLS 和 XLSX。本文討論的是工作表保护在 XLSX 方面的内容:新的 TXLSXSheetProtectionOption 列舉、切换各项权限的 AllowOption 属性,以及那個让每个手写 <sheetProtection> 元素的开发者都会絆倒的 OOXML 編码规则

工作表保护实际上防护了什么

首先是边界問题,因为它決定了你应该对这一切有多信任。OOXML 試算表格式 (ECMA-376) 中的工作表保护是一种互动原则,而不是加密。它告訴遵循标準的应用程式,在工作表受保护时应拒絕哪些編輯动作。保存格的值仍然以純文字形式存在於 xl/worksheets/sheetN.xml 中;解压缩 .xlsx,它们就在那里。選用的密码会保存为一个短的旧式杂湊值,而不是一个能打亂任何东西的密鑰。任何将文件重新命名、打开该部分并删除 <sheetProtection> 该行的人,都能读取和編輯所有内容

因此,保护功能回答的是“阻止我的同事不小心破壞公式”,而不是“对有心人士保密这份数据”。这是用不同工具解決的不同問题。如果你需要機密性,你需要的是受 AES 保护的 XLSX 输出中所涵蓋的工作簿层級加密,那才会真正加密套件。工作表保护和工作簿加密可以乾淨地組合在一起,但只有第二個才是真正的锁。保持这条界线清晰,这页剩下的内容就只是管线配置而已

15 种选项与 AllowOption 属性

现在每个工作表都带有一組 TXLSXSheetProtectionOption 值,描述在工作表受保护时,使用者仍然可以做什么。这些成員一对一对应到 OOXML 属性以及 Excel 对话框的核取方塊:

  • xlsxSpoEditObjectsxlsxSpoEditScenarios — 編輯繪图对象与假设状況分析情境
  • xlsxSpoFormatCellsxlsxSpoFormatColumnsxlsxSpoFormatRows — 设置保存格、栏、列的格式
  • xlsxSpoInsertColumnsxlsxSpoInsertRowsxlsxSpoInsertHyperlinks — 插入栏、列、超連結
  • xlsxSpoDeleteColumnsxlsxSpoDeleteRows — 删除栏、列
  • xlsxSpoSelectLockedCellsxlsxSpoSelectUnlockedCells — 将選取范围移至锁定或未锁定的保存格上
  • xlsxSpoSortxlsxSpoAutoFilterxlsxSpoPivotTables — 排序范围、使用自动篩選下拉式選單、操作樞紐分析表

你可以透过 AllowOption 上带有索引的 TXLSXWorksheet 属性來读写個別位元。AllowOption[Opt] = True 表示允許该动作;将其设为 False 则禁止。整個集合也可以透过 SheetProtectionOptions(一个純 Pascal 的 TXLSXSheetProtectionOptions,型別为 set of)一次存取,这样你就可以保存它、还原它或整批替换它

预设值很重要且是刻意的:一个新建立的工作表一开始会允許所有选项。建构函式会以完整范围 SheetProtectionOptions[Low(TXLSXSheetProtectionOption)..High(TXLSXSheetProtectionOption)] 提供种子 (seed)。你从那里开始,透过排除你想禁止的动作來缩小范围,而不是从无到有建立一个权限集合。这个选择使得写入器的編码规则(如下所示)能与 Excel 的行为一致

保护工作表但开放排序和篩選

以下是一个端到端 (end to end) 的常見案例:保护一份完成的报表,使其版面配置无法被重塑,但让读者可以進行排序和篩選。请注意,Protect 和选项是独立的。Protect 会将工作表切换到受保护的状態,并保存選用的密码杂湊;它不会动到选项集合。你要分开调整 AllowOption,而这些切换会在工作表被保护并保存后生效

var
  wb: TXLSXWorkbook;
  sh: TXLSXWorksheet;
begin
  wb := TXLSXWorkbook.Create;
  try
    sh := wb.Sheets.Add('Protected');
    sh.Cells[1, 1].Value := 'Region'; sh.Cells[1, 2].Value := 'Units';
    sh.Cells[2, 1].Value := 'North';  sh.Cells[2, 2].Value := 120;
    sh.Cells[3, 1].Value := 'South';  sh.Cells[3, 2].Value := 98;

    // Protect with a password. This only sets the protected state + hash;
    // the option set is left at its all-permitted default.
    sh.Protect('HotXLS-2026');

    // Narrow: keep sort + AutoFilter, forbid reshaping and reformatting.
    sh.AllowOption[xlsxSpoSort]          := True;
    sh.AllowOption[xlsxSpoAutoFilter]    := True;
    sh.AllowOption[xlsxSpoFormatCells]   := False;
    sh.AllowOption[xlsxSpoFormatColumns] := False;
    sh.AllowOption[xlsxSpoFormatRows]    := False;
    sh.AllowOption[xlsxSpoInsertRows]    := False;
    sh.AllowOption[xlsxSpoDeleteRows]    := False;

    if wb.SaveAs('protection.xlsx') <> 1 then
      Writeln('SaveAs failed');
  finally
    wb.Free;
  end;
end;

从该程式码片段可以读出两件事。儘管 SortAutoFilter 的预设值都是 True,但这两行仍然被明確写出;这是給下一个維护者的文件,不是功能性需求。且因为预设值是寬容的,唯一会改變输出文件的行,是将选项设为 False 的那几行。这不是这个 API 的偶然,而是 OOXML 传输格式 (wire format) 的展现,这也是下一節要談的

編码规则:省略代表允許,attr=0 代表禁止

这是整個功能中唯一違反直觉的事实,而这正是手写 <sheetProtection> 通常出错的地方。在 OOXML 中,每个針对各個动作的属性都是一个禁止旗标,而它的缺席代表允許。遗失的属性表示允許该动作。写为 "0" 的属性表示在工作表受保护时禁止该动作。在格式正確的文件中,没有 formatCells="1" 來表示“允許设置格式”;你只要省略该属性即可。(缺席属性的预设值为 OOXML 的布林值预设值 true,而且这些属性的命名,使得“true”表示相应的編輯动作是允許的。)

HotXLS 写入器完全反映了这一点。它会发出 sheet="1" 以打开保护,接著走訪选项集合,只为你设置为 attr="0" 的选项写入 False。允許的动作对输出没有任何貢獻。因此,上一節的工作簿在序列化后会變成类似这样,只带有被禁止的动作加上密码杂湊:

// Conceptual output for the snippet above (attributes elided for brevity):
// <sheetProtection sheet="1"
//   formatCells="0" formatColumns="0" formatRows="0"
//   insertRows="0" deleteRows="0"
//   password="...4-hex..."/>
// Note what is NOT there: no sort, no autoFilter, no selectLockedCells.
// Their absence is exactly what tells Excel those actions stay allowed.

如果你習慣了旧的写死字串,并期望看到每个属性都被拼写出來,这看起來很稀疏,几乎像是错的。但它是正確的。一个列出 sort="1"autoFilter="1" 的文件对遵循标準的读取器來说意思是一样的,但 Excel 本身会写入最小化的純禁止形式,而与之匹配能保持差異 (diffs) 最小,并让双向转换 (round-trips) 變得无聊。objectsscenarios 属性遵循相同的规则:预设允許,因此它们只有在你禁止它们时才会顯示为 "0",这与旧版无條件发出的 objects="1" scenarios="1" 正好相反

读回保护设置:双向转换的保真度

一个只能写但不能读的权限模型是一扇單向门,常見的症状就是“加载-編輯-保存”循環会默默地放寬权限。HotXLS 解決了这个問题。当 ParseWorksheetXml 遇到 <sheetProtection> 元素时,它会将工作表设为受保护状態、擷取密码杂湊(如果存在),然后反向使用相同的慣例,将每个动作属性解码回 AllowOption:存在且等於 "0" 的属性会禁止该动作;缺席的属性则将选项保留在其允許的预设值

var
  wb: TXLSXWorkbook;
  sh: TXLSXWorksheet;
begin
  wb := TXLSXWorkbook.Create;
  try
    wb.LoadFromFile('protection.xlsx');
    sh := wb.Sheets[1];                  // XLSX sheets are 1-based
    if sh.IsProtected then
    begin
      Writeln('Protected; password hash present: ',
        sh.SheetProtectHash <> '');
      Writeln('Sort allowed:       ', sh.AllowOption[xlsxSpoSort]);
      Writeln('AutoFilter allowed: ', sh.AllowOption[xlsxSpoAutoFilter]);
      Writeln('FormatCells allowed:', sh.AllowOption[xlsxSpoFormatCells]);
    end;
  finally
    wb.Free;
  end;
end;

加载写入器产生的文件,你会看到 SortAutoFilter 返回为 TrueFormatCells 返回为 False — 你保存的集合原封不动。这种对稱性就是重点:在受保护、部分允許的工作表中編輯一个保存格并重新保存,你没有动到的那 14 個权限会存活下來,而不是崩塌回旧版全有或全无的预设值

实务注意事项与限制

在你将此功能接入报表管线之前,有几件事值得了解:

  • 密码在设計上很弱。 XLSX 工作表保护保存了一个 16 位元的旧式杂湊值(也就是 Excel 使用了数十年的同一个),保留在这里是为了互通性。它能阻止意外的編輯;它无法抵禦攻擊者。请勿将其視为機密守护者。为了獲得真正的保护,请加密工作簿
  • 在保护前设置选项是可以的。 无論工作表目前是否受保护,都可以指派 AllowOption;这些切换只是描述一旦 Protect 生效时,保护功能会允許什么。UnProtect 会清除保护状態和杂湊,但会为下一次保留你的选项集合
  • 锁定保存格 (Locked) 語义依然适用。 保护功能只会阻擋对设置了 Locked 属性(工作簿预设值)之保存格的編輯。保留输入区域的可編輯性是保存格样式 (cell-style) 的工作,而不是保护选项;这两个层面在 Excel 中的組合方式与此相同
  • 这是 XLSX 引擎。 选项模型反映了 XLS 引擎較旧的 Allow* 属性,但这里的列舉和属性名稱(xlsxSpo*AllowOption)属於 TXLSXWorksheet 中的 lxHandleX。如果你也在相同的工作表上驅动列印版面配置,那麼保护与版面设置列印的演練涵蓋了这些设置如何与列印范围和页首并存,而数据验证、自动篩選和表格则自然地与锁定报表上維持开放的 xlsxSpoAutoFilter 搭配

細緻的保护模型以及其餘的 XLSX 读写引擎皆隨附於适用於 Delphi 与 C++Builder 的 HotXLS 元件 中;产品网页包含了完整的工作表 API,包含完整的保护选项参考