アナログCPU:5108843109

ゲームと音楽とプログラミング(酒と女とロックンロールのノリで)

('ω') < イザユケエンジニャー

回数を指定して文字列置換

文字列を置換するのはふつうstr_replace関数を使用しますが
PHP: str_replace - Manual
これは文中の特定文字をすべて置換することしかできません。

最初の1回だけ置換したいような場合はpreg_replaceで回数指定することができます。
第1~第3引数はいつも通りに「置換前のパターン」「置換後の文字列」「置換対象の文字列」で、
第4引数に回数を入れておくだけでOK。

<?php
// 置換前の文字列
$str  = "abcabcabc";

// 1回だけ置換
$str1 = preg_replace("/a/", "A", $str, 1); // Abcabcabc

// 2回だけ置換
$str2 = preg_replace("/a/", "A", $str, 2); // AbcAbcabc

先読み後読みと併用することで、「あるパターンより前の特定文字を1回だけ置換」「○文字目より後の特定文字を1回だけ置換」なんてことも。

<?php
// 置換前の文字列
$str  = "abcabcabc";

// あるパターンより前にある特定文字を1回だけ置換
// ※この例では存在しないパターンとなるので実際は置換されない
$str3 = preg_replace("/a(?=bcd)/", "A", $str, 1) // abcabcabc

// ○文字目より後の特定文字を1回だけ置換
$str4 = preg_replace("/(?<=.{3})a/", "A", $str, 1) // abcAbcabc

(先読み後読みについてはこちら)
正規表現の先読み・後読み - アナログCPU:5108843109

さらに、preg_replaceのパターンは配列でも指定できるので、配列指定かつ回数指定する場合の挙動はどうなってるんだろう…? と思い試してみました。

<?php
$str  = "abcabcabc";
var_dump( preg_replace(array("/a/", "/b/"), array("A", "B"), $str, 2) ); // ABcABcabc

$str = "aaabbbccc";
var_dump( preg_replace(array("/a/", "/b/"), array("A", "B"), $str, 2) ); // AAaBBbccc
var_dump( preg_replace(array("/a/", "/b/"), "X", $str, 2) ); // XXaXXbccc

配列で複数パターンを指定した場合、パターンごとに回数が適用されるようですね。

蛇足な雑談

いや、今回ハマったのが、「後読みには文字数が可変になるようなパターンを指定できない」というものでして、
具体的には、HTMLエスケープされたある文字列について、特定のタグのみエスケープを解除したいという事案でした。

例えばdivタグのみエスケープ解除するとして、

&lt;div&gt;
&lt;p&gt;
&lt;/p&gt;
&lt;/div&gt;
&lt;div class="hoge"&gt;
&lt;p&gt;
&lt;/p&gt;
&lt;/div&gt;

<div>
&lt;p&gt;
&lt;/p&gt;
</div>
<div class="hoge">
&lt;p&gt;
&lt;/p&gt;
</div>

としたい、ということですね。

これを一発

<?php
$str = " ... "; //(省略。エスケープ後文字列)

$pattern_before = array(
    "/&lt;(?=\/?div[\s\/>&])/",
    "/(?<=(<|\/)div[^&]*)&gt;/",
);
$pattern_after = array(
    "<",
    ">",
);
$str = preg_replace($pattern_before, $pattern_after, $str);

こんな感じで置換してしまいたかったのですが、後読みの方がうまくいかず。

仕方ないので、今回調べた内容を使って以下のように実装しました。

<?php
$str = " ... "; //(省略。エスケープ後文字列)

while (true)
{
    // divタグの位置を探す、なければ終了
    // 「&lt;(<)」と「div」の間はスラッシュのみ許容、「div」と「&gt;(>)」の間は&以外を許容
    if (!preg_match($"/&lt;\/?div[\s\/>&]/", $str, $matches, PREG_OFFSET_CAPTURE))
    {
        break;
    }
    
    // divタグが存在する場合:
    // 見つかった位置以降について、最初に見つかった&lt;と&gt;を置換するパターンを生成
    $pattern_before = array(
        "/(?<=.{" . $matches[0][1] . "})&lt;/",
        "/(?<=.{" . $matches[0][1] . "})&gt;/",
    );
    $pattern_after = array(
        "<",
        ">",
    );
    
    // 置換した文字列で$strを上書き
    $str = preg_replace(array(, ), array("<", ">"), $str, 1);
}

もっと美しい実装があるかもしれません。
あまり厳密ではないですしなんかダサい気もします。
(今回実装していたのはCMSなので厳密さは一応こんなもんで良いのですが)