技術文章

Delphi 靜態 PCRE2 實現 REGEXTEST/REGEXEXTRACT

HotXLS 在自己的 Delphi 公式引擎裡求值 Microsoft 365 的正規表示式函式 REGEXTEST、REGEXEXTRACT 與 REGEXREPLACE,不需要安裝 Excel,也不依賴系統的 regex DLL。HotXLS 計算器靜態連結一個釘死版本的 16 位元 PCRE2 10.49,直接對 UTF-16 文字比對、跨陣列廣播,並把每一步回溯都記在公式的資源預算帳上

最後那一點就是這篇文章存在的原因。客戶寄來的工作簿是不受信任的輸入,而公式裡的正規表示式是客戶寫的程式碼。一個 pattern 加一段文字,就足以用災難性回溯把一顆 CPU 核心釘住幾分鐘——經典的 ReDoS 形狀 (a+)+$ 對上一長串 a 字元加結尾的 b。重算上傳工作簿的伺服器經不起這個,所以 HotXLS 2.384.112 裡有意思的工作不是呼叫 PCRE2,而是讓 PCRE2 可停、可計帳、而且在每個目標平台上行為一致

REGEXTEST、REGEXEXTRACT 與 REGEXREPLACE 在 HotXLS 裡回傳什麼?

HotXLS 公式引擎遵循 Microsoft 公布的簽名,引數語意就是您在 Excel 裡已經熟悉的那套。比對預設區分大小寫。最後的 case_sensitivity 引數取 0 為區分大小寫、1 為不區分,其他任何值回傳 #VALUE!。想讓行為留在 pattern 裡的話,(?i) 或 (*UCP) 這類內嵌 PCRE2 選項也能用

REGEXTEST(text, pattern, [case_sensitivity]) 回傳布林值,比不中就只是 FALSE,絕不報錯。REGEXEXTRACT(text, pattern, [return_mode], [case_sensitivity]) 有三種模式:0 回傳第一個完整符合、1 把每個完整符合以垂直陣列回傳、2 把第一個符合的擷取群組以水平陣列回傳。比不中回傳 #N/A,pattern 沒有擷取群組時模式 2 回傳 #VALUE!,未參與比對的選擇性群組則產出空字串而非錯誤

REGEXREPLACE(text, pattern, replacement, [occurrence], [case_sensitivity]) 用 PCRE2 的取代語法,$1 與 ${name} 都能參照擷取群組。occurrence 為 0 時取代每個符合,正值從頭挑第 n 個符合,負值從尾端往回數。選中的出現位置不存在時,HotXLS 原樣傳回原文。數字選擇器向零截斷,跟 Excel 處理帶小數的整數引數同一套規矩

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Orders');
    Sheet.Cells[2, 1].Value := 'PO-2026-00417 shipped to Graz';

    // 儲存格帶採購單編號時為 TRUE
    Sheet.Cells[2, 2].Formula := 'REGEXTEST(A2, "PO-\d{4}-\d{5}")';

    // 第一個完整符合:"PO-2026-00417"
    Sheet.Cells[2, 3].Formula := 'REGEXEXTRACT(A2, "PO-\d{4}-\d{5}")';

    // 用編號擷取群組對調年份與序號:"PO-00417-2026 ..."
    Sheet.Cells[2, 4].Formula :=
      'REGEXREPLACE(A2, "PO-(\d{4})-(\d{5})", "PO-$2-$1")';

    // 用最後一個引數做不區分大小寫的測試
    Sheet.Cells[2, 5].Formula := 'REGEXTEST(A2, "graz", 1)';

    Book.Recalculate;
    Book.SaveAs('orders.xlsx');
  finally
    Book.Free;
  end;
end;

Delphi 裡的公式文字就是普通字串字面值,所以 \d 在 Pascal 這一側不必跳脫。只有公式裡的 Excel 雙引號需要在意,它們照一般 Excel 規則處理。存檔時,XLSX 寫出器會把這些名稱加上 Excel 期望的 _xlfn. 前瞻函式前綴存放,重新打開的工作簿會再算一遍。經典的 BIFF8 引擎能在情境式呼叫中求值這些函式,但無法把它們寫成原生 .xls 公式 token,所以 regex 公式請留在 XLSX 輸出裡

regex 公式的陣列廣播是怎麼運作的?

REGEXTEST、REGEXREPLACE 與 REGEXEXTRACT 的模式 0 會跨相容運算元廣播,這正是它們在真實欄位上好用的原因。每個引數可以是純量、一列、一欄或一個矩形,HotXLS 取最大的列數與欄數當結果形狀。某個運算元的維度既不是 1 也不是那個最大值,整個呼叫就回傳 #VALUE!,也就是常見的 Excel 陣列不匹配。所以 REGEXTEST(A2:A500, "^[A-Z]{2}\d{9}$") 產出 499 個布林值,一欄 pattern 對單一文字儲存格,反方向也一樣成立

REGEXEXTRACT 的模式 1 與 2 拒絕廣播。每個元素會產出長度不一的向量,參差不齊的一堆向量塞不進矩形的公式結果。HotXLS 不悄悄截斷或補齊,而是把呼叫回報為不支援。要從很多儲存格拿全部符合,就逐格迭代、一次擷一個,或用HotXLS 公式引擎的 LET 與 LAMBDA 閉包重塑問題,讓每次閉包呼叫只看到一個文字值

廣播結果裡的錯誤擺放是深思熟慮的。某個元素的 Excel 錯誤——例如第 37 列擷不到東西的 #N/A——落在結果的哪一格就是那一格,其他儲存格保有各自的值。資源限制或取消則不同:它中止整個呼叫,絕不發布裝了一半的陣列,因為一個看起來完整卻只做了一半的結果,比明確的失敗更糟

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Opts: TXLSFormulaEvaluationOptions;
  Eval: TXLSFormulaEvaluationResult;
  I: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Log');
    Sheet.Cells[1, 1].Value := 'GET /api/v2/orders 200 18ms';

    Opts := XLSDefaultFormulaEvaluationOptions;
    Opts.MaxEvaluationSteps := 200000;   // 給這條公式能做的工作設上限

    // 模式 2:第一個符合的擷取群組,作為 1 x 3 陣列
    Eval := Sheet.EvaluateFormulaAt(1, 2,
      'REGEXEXTRACT(A1, "(GET|POST) (\S+) (\d{3})", 2)', xlfrsA1, Opts);

    case Eval.Status of
      xlfeOk:
        if VarIsArray(Eval.Value) then
          for I := VarArrayLowBound(Eval.Value, 2) to
                   VarArrayHighBound(Eval.Value, 2) do
            Log(VarToStr(Eval.Value[0, I]));
      xlfeResourceLimit:
        Log('Pattern exceeded its budget: ' + Eval.Issue.Message);
    else
      Log('Evaluation failed: ' + Eval.Issue.Message);
    end;
  finally
    Book.Free;
  end;
end;

當 pattern 來自您管不到的地方,TXLSXWorksheet.EvaluateFormulaAt 就是正確的進入點。它把公式當成位於指定列與欄來求值,回傳完整陣列而不是單一快取儲存格值,並接受帶明確步數、陣列記憶體與取消控制的 TXLSFormulaEvaluationOptions。那套求值 API 的其餘部分,見HotXLS 公式引擎與自訂函式指南

HotXLS 怎麼停下一個災難性回溯的 pattern?

HotXLS 讓 PCRE2 在每一步都向公式情境回報,藉此停住失控的 pattern。後端用 PCRE2_AUTO_CALLOUT 編譯每個 pattern,比對途中 PCRE2 會在每個 pattern 項目上呼叫一個 Pascal callback。該 callback 向作用中的求值狀態記一單位工作、檢查取消旗標、檢查常駐記憶體。任何一項說停,callback 就回傳 PCRE2 的 callout 中止碼,比對在 PCRE2 內部乾淨地解開。公式最後浮出的是 xlfeResourceLimit 或取消狀態,而不是一條掛死的執行緒

記憶體也用同一套計量。每個 PCRE2 context 都以一個每實例的 general context 建立,其配置器是 Pascal 函式,所以編譯好的 pattern、match data 與 PCRE2 內部的回溯框架全都經由 HotXLS 配置。每個區塊帶一個小標頭、對齊 16 位元組,其大小累加進一個執行總量,預算檢查拿它對照公式的陣列記憶體上限。輸出緩衝、擷取向量與取代字串也記進同一個總量——這意味著一個配對出幾百萬個小字串的 pattern,跟一個永遠回溯的 pattern 一樣穩穩撞上天花板

預算 callback 還罩著一層保護,其重要性超過表面看起來。在 PCRE2 呼叫的 callback 裡丟出的 Delphi 例外,絕不能向上傳播,因為 callback 與您的程式碼之間隔著對 Delphi 例外表一無所知的 C 堆疊框架。穿越它們解開堆疊,最好的情況是未定義行為,最壞是堆疊損毀。HotXLS 的橋接層在 callback 裡接住每一個例外,把實例鎖進已中止狀態,然後回一個普通錯誤碼給 PCRE2。您的程式碼看到的只有一條失敗的公式,絕不會是穿越外來框架的例外

代價是真實的,值得說清楚。Auto callout 讓比對比裸的 PCRE2 直譯器慢,而 HotXLS 對每個被求值的元素都編譯一次 pattern,所以橫跨 50,000 個儲存格的廣播要付 50,000 次編譯。在受信任的資料上做大批文字處理,工作簿外一段專用的 Delphi regex 會更快。在陌生人寄來的工作簿裡,有界且可預測勝過快

為什麼是不帶 JIT 的靜態 UTF-16 PCRE2 版本?

Excel 字串是 UTF-16,Delphi 的 WideString 也是,16 位元 PCRE2 函式庫因此成了唯一合理的選擇。HotXLS 把公式文字交給設了 PCRE2_UTF 的 pcre2_match_16,回來的每個偏移都是 UTF-16 code unit 索引。全程沒有 UTF-8 轉一圈,代理對上不可能漂移。空符合迫使掃描前進時,HotXLS 遵循 PCRE2 的全域比對規則:在同一位置重試一次非空錨定比對,然後前進一個 Unicode 純量值——跨過代理對時移動兩個 code unit,而不是把它拆開

JIT 是刻意關掉的。JIT 編譯器需要可執行記憶體與自己的 JIT 堆疊,這會把平台專屬程式碼與每公式配置器之外的配置拖進來。直譯器讓每個位元組都在 HotXLS 預算內、每次比對都過 callout,而且在全部五個目標上行為一致。這些公式背後的內部類別 TXLSRegex,每個實例擁有自己的 context,平行計算的工作者之間不共用任何可變的 PCRE2 狀態

連結這一段是建置變得不尋常的地方。Delphi 用 {$L} 連結 C 目標檔,它的連結器在 Pascal 單元與 C 目標檔之間解析符號綽綽有餘,卻解析不了各個 C 目標檔之間任意的循環參照。PCRE2 有二十幾個翻譯單元,彼此四面八方互相呼叫。解法是 unity build:整個 PCRE2 編譯成單一翻譯單元,每個目標平台產出一個 pcre2_unity 目標檔,所有內部參照都在單一目標檔內解析完畢,Delphi 連結器只看得到匯出的 _16 函式

C 執行階段是第二個問題。PCRE2 會呼叫 malloc、memcpy、memcmp 這些傢伙,而連進同一個執行檔的其他 C 後端可能已經帶著自己的同名符號。HotXLS 對 PCRE2 做前置處理,讓這些呼叫改道 hotxls_regex_memcpy 之類的私有名稱,並用 Pascal 實作。前置處理之後 Clang 仍可能合成普通的 memset 與 memchr 呼叫,所以這兩個也存在於同一單元裡。符號修飾必須跟各目標檔格式對上:Win32 OMF 目標檔期待帶前導底線的 C 名稱,Win64 ELF 目標檔、Win64 COFF 目標檔與 FPC 建置的 Pascal 宣告則綁裸名稱

堆疊探測是最微妙的一個。框架大過一頁的 C 函式會先呼叫探測常式,按順序摸過每個 4 KB 頁面,讓 Windows 能透過防護頁逐步提交堆疊。一個直接 return 的 stub 連結得過、在每個用小 pattern 的單元測試裡都好好的,然後在深框架第一次跳過防護頁時崩潰。HotXLS 為每種 Windows 目標檔格式用組合語言實作了真正的探測:Win32 目標檔用 __chkstk_noalloc,Win64 ELF 格式目標檔用 __chkstk,Win64 COFF 目標檔用 ___chkstk_ms。在 Linux x64 與 macOS ARM64 上,FPC 改連系統 C 函式庫;其餘 FPC 目標則把後端回報為不可用,而不是悄悄退回另一種 regex 方言

邊界在哪裡?

regex 函式在 Delphi 與 C++Builder 的 Windows 建置、以及 FPC 的 Linux x64 與 macOS ARM64 上執行。其他任何目標上,公式會透過情境式求值回報不支援的函式,而不是用另一顆引擎產出不同的答案。這才是正確的失敗方式:在不同平台上算出不同結果的工作簿,是稽核之前沒人找得到的 bug

與 Excel 的等效性也有明說的限制。簽名與文件範例遵循 Microsoft 的 REGEXTEST、REGEXEXTRACT 與 REGEXREPLACE 頁面,但本地驗證用的 Excel 版本還沒暴露這些 Microsoft 365 函式。因此邊角案例遵循的是上文明確寫出的 PCRE2 與陣列契約,而不是「與每個 Excel 版本逐位元一致」的宣稱。如果您的工作簿依賴某個冷僻角落——特定的反向參照行為或 Unicode 屬性類——請直接測那個角落

FAQ

計算 REGEXTEST 需要 Excel 或 regex DLL 嗎?

不需要。HotXLS 把靜態 UTF-16 PCRE2 目標檔連進您的執行檔,在自己的 Delphi 公式引擎裡求值 REGEXTEST、REGEXEXTRACT 與 REGEXREPLACE,不需要 Excel、COM 或任何外部 DLL

惡意的 pattern 能把我的伺服器掛住嗎?

過不了設定的預算。每個比對步驟都要過一個向公式情境計帳的 callout,所以 ReDoS pattern 最終以 xlfeResourceLimit 或取消狀態收場,不會留下部分結果

REGEXEXTRACT 模式 1 為什麼遇到範圍引數會失敗?

每個儲存格可能回傳不同數量的符合,參差不齊的向量拼不出矩形結果。HotXLS 把呼叫回報為不支援,而不是補齊或截斷,所以請一次擷一個儲存格

HotXLS 把這些 regex 函式與 LAMBDA、GROUPBY、PIVOTBY 及其餘計算引擎一起,提供給 Delphi、C++Builder 與 Lazarus。細節、版本與試用下載,見 HotXLS Delphi spreadsheet component 產品頁