技術記事

Delphi の REGEXTEST・REGEXEXTRACT は静的 PCRE2 で

HotXLS は、Microsoft 365 の正規表現関数 REGEXTEST、REGEXEXTRACT、REGEXREPLACE を、自前の Delphi 数式エンジンの中で評価します。Excel のインストールもシステムの regex DLL も要りません。HotXLS の計算機は、固定された 16 ビット PCRE2 10.49 ビルドを静的リンクし、UTF-16 テキストを直接マッチし、配列へブロードキャストし、バックトラッキングの各ステップを数式のリソース予算に請求します

最後の部分こそ、この記事が存在する理由です。顧客から届くワークブックは信頼されていない入力であり、数式の中の正規表現は顧客が書いたコードです。パターンとテキストだけで、壊滅的バックトラッキングにより CPU コアを数分間釘付けにできます。末尾に b を付けた長い a の連なりに対する古典的な ReDoS 形状 (a+)+$ です。アップロードされたワークブックを再計算するサーバーはこれに耐えられません。だから HotXLS 2.384.112 の興味深い仕事は PCRE2 の呼び出しではなく、PCRE2 を停止可能に、説明責任を持たせ、すべてのターゲットで同一にすることでした

HotXLS で REGEXTEST、REGEXEXTRACT、REGEXREPLACE は何を返すのか

HotXLS の数式エンジンは Microsoft が公開しているシグネチャに従い、引数のセマンティクスは Excel で既に知っているものです。マッチングは既定で大文字小文字を区別します。最後の case_sensitivity 引数は、区別するなら 0、しないなら 1 を取り、それ以外の値は #VALUE! を返します。挙動をパターンの中に閉じ込みたいなら、(?i) や (*UCP) のような PCRE2 のインラインオプションも使えます

REGEXTEST(text, pattern, [case_sensitivity]) はブール値を返し、マッチしなかったことは単に FALSE であって、エラーではありません。REGEXEXTRACT(text, pattern, [return_mode], [case_sensitivity]) には 3 つのモードがあります。0 は最初の完全マッチを返し、1 はすべての完全マッチを縦配列で返し、2 は最初のマッチのキャプチャグループを横配列で返します。マッチなしは #N/A を返し、キャプチャグループのないパターンでのモード 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. future-function 接頭辞付きで格納し、再オープンされたワークブックは再度再計算します。クラシックな BIFF8 エンジンは文脈的呼び出しでこれらの関数を評価できますが、ネイティブの .xls 数式トークンとして書き出すことはできません。regex 数式は XLSX 出力に留めてください

regex 数式では配列ブロードキャストがどう働くのか

REGEXTEST と REGEXREPLACE、そして REGEXEXTRACT のモード 0 は、互換性のあるオペランドにまたがってブロードキャストします。実務の列で役に立つのはこのためです。各引数はスカラー、行、列、矩形のいずれでもよく、HotXLS は最大の行数と列数を結果の形として取ります。寸法が 1 でもその最大でもないオペランドは、呼び出し全体を #VALUE! にします。いつもの Excel の配列不一致です。したがって REGEXTEST(A2:A500, "^[A-Z]{2}\d{9}$") は 499 個のブール値を生み、単一のテキストセルに対するパターンの列も逆方向で同じように働きます

REGEXEXTRACT のモード 1 と 2 はブロードキャストを拒みます。各要素が長さの違うベクトルを生み、ギザギザのベクトルの集合は矩形の数式結果を占められないからです。黙って切り詰めたりパディングしたりする代わりに、HotXLS は呼び出しを未対応として報告します。多数のセルからの全マッチが必要なら、セルを繰り返し処理して 1 個ずつ抽出するか、HotXLS 数式エンジンの LET と LAMBDA クロージャで問題を組み替えて、各クロージャ呼び出しが 1 つのテキスト値を見るようにしてください

ブロードキャスト結果の内部でのエラーの置かれ方は意図的です。ある 1 要素に対する Excel エラー、たとえば 37 行目で何も見つけられなかった抽出の #N/A は、結果のその 1 セルに着地し、他のセルは値を保ちます。リソース上限やキャンセルは別物です。呼び出し全体を停止し、部分的に埋まった配列を決して公開しません。完全に見える半分の結果は、明示的な失敗より悪いからです

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;

パターンが自分で管理していないところから来るなら、正しいエントリポイントは TXLSXWorksheet.EvaluateFormulaAt です。数式を、指定の行と列に住んでいるものとして評価し、単一のキャッシュセル値の代わりに完全な配列を返し、ステップ、配列メモリ、キャンセルの明示的な制御を持つ TXLSFormulaEvaluationOptions を取ります。この評価 API の残りはHotXLS 数式エンジンとカスタム関数ガイドがカバーします

HotXLS は壊滅的バックトラッキングのパターンをどう止めるのか

HotXLS は、PCRE2 に毎ステップ数式コンテキストへ報告させることで、暴走パターンを止めます。バックエンドはすべてのパターンを PCRE2_AUTO_CALLOUT 付きでコンパイルします。マッチング中、パターンアイテムごとに PCRE2 が Pascal のコールバックを呼ぶようにするものです。コールバックは、アクティブな評価状態に 1 単位の仕事を請求し、キャンセルフラグを確認し、常駐メモリを確認します。どれかが停止を告げれば、コールバックは PCRE2 の callout-abort コードを返し、マッチは PCRE2 の内部できれいに巻き戻ります。数式は、ハングしたスレッドではなく、xlfeResourceLimit かキャンセルステータスとして現れます

メモリも同じやり方で計量されます。すべての PCRE2 コンテキストは、アロケータが Pascal 関数であるインスタンスごとの general context と共に作られるため、コンパイル済みパターン、マッチデータ、PCRE2 の内部バックトラッキングフレームは、すべて HotXLS を通じて確保されます。各ブロックは小さなヘッダを運び、16 バイトにアラインされ、そのサイズは、予算チェックが数式の配列メモリ上限と比較する実行中合計に加算されます。出力バッファ、抽出ベクトル、置換文字列も同じ合計に加わるため、何百万もの小さな文字列にマッチするパターンは、永遠にバックトラックするパターンと同じ確実さで天井に当たります

予算コールバックは、見た目より重要なガードの下でも動きます。PCRE2 が呼んだコールバックの中で生じた Delphi の例外は、上へ伝播しては決してなりません。コールバックと自分のコードの間には、Delphi の例外テーブルを一切知らずにコンパイルされた C のスタックフレームが座っているからです。それらを通じた巻き戻しは、最善でも未定義、最悪ではスタック破壊です。HotXLS のブリッジはコールバック内のすべての例外を捕捉し、インスタンスをアボート状態に固定し、PCRE2 へは普通のエラーコードを返します。自分のコードが目にするのは失敗した数式であって、他国のフレームを越えた例外では決してありません

コストは実在し、明言する価値があります。auto callout は素の PCRE2 インタプリタよりマッチングを遅くし、HotXLS は評価される要素ごとにパターンをコンパイルします。5 万セルへのブロードキャストは 5 万回のコンパイルを払います。信頼できるデータに対するバルクテキスト処理なら、ワークブックの外の専用 Delphi regex パスのほうが速いでしょう。見知らぬ人が送ってくるワークブックの中では、有限で予測可能なことが速さに勝ります

JIT なしの静的 UTF-16 PCRE2 ビルドなのはなぜか

Excel の文字列は UTF-16 であり、Delphi の WideString 値もそうです。だから 16 ビット PCRE2 ライブラリが唯一の sensible な選択でした。HotXLS は数式テキストを PCRE2_UTF を立てて pcre2_match_16 に渡し、返ってくるすべてのオフセットは UTF-16 のコードユニットインデックスです。UTF-8 の往復は発生しないため、サロゲートペアの上でずれることはありません。空のマッチが走査の前進を強制する場所では、HotXLS は PCRE2 の global-match 規則に従います。同じ位置で空でない anchored マッチを再試行し、それから Unicode スカラー値を 1 つ進めます。サロゲートペアを分割する代わりに 2 コードユニット動く、という意味です

JIT は意図的にオフです。JIT コンパイラは実行可能メモリと独自の JIT スタックを必要とし、それらはプラットフォーム固有のコードと、数式ごとのアロケータの外側の確保を引き込みます。インタプリタなら、すべてのバイトを HotXLS 予算の下に置き、すべてのマッチをコールバックの下に置き、5 つのターゲットすべてで同じ振る舞いをします。これらの数式の背後にある内部クラス TXLSRegex の各インスタンスは自分のコンテキストを所有するため、並列の計算ワーカが可変の PCRE2 状態を共有することはありません

リンクの話は、ビルドが普通でなくなった場所です。Delphi は C オブジェクトファイルを {$L} でリンクし、そのリンカは Pascal ユニットと C オブジェクトの間のシンボルを十分に解決しますが、別々の C オブジェクトファイル間の恣意的な循環参照は解決しません。PCRE2 は 2 ダースの翻訳単位で、あらゆる方向に互いを呼び合っています。修正は unity ビルドです。PCRE2 全体を 1 つの翻訳単位として、ターゲットごとに 1 個の pcre2_unity オブジェクトへコンパイルします。すべての内部参照が単一オブジェクトの内部で解決され、Delphi リンカが目にするのはエクスポートされた _16 関数だけになります

C ランタイムが 2 番目の問題でした。PCRE2 は malloc、memcpy、memcmp とその仲間を呼びますが、同じ実行ファイルにリンクされる他の C バックエンドが、それらの名前の自分のコピーをすでに運んでいることがあります。HotXLS は PCRE2 を前処理し、それらの呼び出しが hotxls_regex_memcpy のようなプライベート名へ行くようにし、Pascal で実装します。Clang は前処理の後でも素の memset と memchr 呼び出しを合成し得るため、この 2 つも同じユニット内に存在します。シンボル装飾は各オブジェクト形式と一致せねばなりません。Win32 OMF オブジェクトは先頭アンダースコア付きの C 名を期待し、Win64 ELF オブジェクト、Win64 COFF オブジェクト、FPC ビルドのための Pascal 宣言は素の名前を結合します

スタックプローブが微妙なやつでした。フレームが 1 ページより大きい C 関数は、最初にプローブルーチンを呼びます。これは 4 KB ページを順に触り、Windows がガードページを通じてスタックをコミットできるようにするものです。単に return するスタブは普通にリンクでき、小さいパターンでのすべてのユニットテストを通ります。そして深いフレームがガードページを飛び越えた最初の時にクラッシュします。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 で動きます。その他のターゲットでは、数式は別のエンジンで違う答えを生む代わりに、文脈的評価を通じて未対応の関数を報告します。それが正しい失敗です。プラットフォームが違えば計算も違うワークブックは、監査になるまで誰も見つけられないバグです

Excel との等価性にも、明示された限界があります。シグネチャと文書化された例は Microsoft の REGEXTEST、REGEXEXTRACT、REGEXREPLACE のページに従いますが、ローカル検証に使った Excel ビルドは、これらの Microsoft 365 関数をまだ公開していません。そのためエッジケースは、すべての Excel ビルドとビット単位で一致するという主張ではなく、上述の明示的な PCRE2 と配列の契約に従います。特定の後方参照の挙動や Unicode プロパティクラスのような、目立たない隅にワークブックが依存しているなら、その隅を直接テストしてください

FAQ

HotXLS が REGEXTEST を計算するのに Excel や regex DLL は要るのか

いいえ。HotXLS は静的な UTF-16 PCRE2 オブジェクトを実行ファイルへリンクし、REGEXTEST、REGEXEXTRACT、REGEXREPLACE を自前の Delphi 数式エンジンで評価します。Excel も COM も外部 DLL も要りません

悪意あるパターンがサーバーをハングさせられるのか

設定された予算を越えては無理です。マッチングのすべてのステップは、数式コンテキストに請求するコールバックを通るため、ReDoS パターンは xlfeResourceLimit かキャンセルステータスで終わり、部分的な結果は出ません

REGEXEXTRACT のモード 1 が範囲引数で失敗するのはなぜか

セルごとにマッチ数が違う可能性があり、それらのギザギザのベクトルは矩形の結果を形成できません。HotXLS はパディングや切り詰めの代わりに、呼び出しを未対応として報告します。セルを 1 個ずつ抽出してください

HotXLS は、Delphi、C++Builder、Lazarus 向けに、regex 関数を LAMBDA、GROUPBY、PIVOTBY、その他の計算エンジンとともに同梱しています。詳細、エディション、トライアルダウンロードはHotXLS Delphi スプレッドシートコンポーネントのページにあります