㊗️

【Rust/正規表現と置換】ルビ記法(|《》)をHTMLに自動変換 ~ 正規表現にマッチしたその全てを置換する

2023/09/03に公開

Rustで正規表現に適合した箇所を全て置換する術

そもそも世の中にはルビがあり、ルビ(ruby/ふりがな)を表す記法が複数存在します。私が見たことのある例は、|被ルビ部《ルビ内容》というものです。その他、幾つもの記法が存在するようですが、ここに枚挙するような遑は御座いませんので参考記事を紹介するに留め置きます。

https://zenn.dev/gaqwest/articles/6cb54e348b5557
https://note.com/eveningmoon_lab/n/nb9aba9d97c8f

扨、このようなルビ記法は各小説投稿サイトで用いられているそうですが、このような特殊な記法は、他所では邪魔な記号の混入と成り果ててしまうのが常であります。今回は、比較的どこでも有効なHTMLによる記法に置換することを試みます。
こうした処理の仕方を調べると、JavaScriptで行うものが見つかります。

https://qiita.com/8amjp/items/d7c46d9dee0da4d530ef

しかし、今回はRustを用います。理由は単に速そうだからです。

先に結論

これが本記事の全てです。不備などあればお伝えください。

use regex::Regex;

fn replace_all(regex_model: Regex, text: String) -> String {
    let replaced = regex_model.replace(
        &text,
        "<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>"
    );

    if regex_model.is_match(&replaced) {
        replace_all(regex_model, (&replaced).to_string())
    } else {
        replaced.to_string()
    }
}

fn main() {
    let text = "くれなゐのはつ|花《はな》そめのいろふかくおもひし|心《こゝろ》われわすれめや".to_string();
    let regex_model = Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap();

    let returned_string = replace_all(regex_model, text);
    println!("{}", returned_string);
}
くれなゐのはつ<ruby><rp>(</rp><rt>はな</rt><rp>)</rp></ruby>そめのいろふかくおもひし<ruby><rp>(</rp><rt>こゝろ</rt><rp>)</rp></ruby>われわすれめや

詳細は後述するとして、概要を図示しましょう。

樊籬は示しました。一つずつ漸進して参りましょう。

HTMLによるルビ記法のおさらい

先ずはHTMLで如何にルビを表現するかを明らかにしておきましょう。
こちらを参考にしています。
https://developer.mozilla.org/ja/docs/Web/HTML/Element/ruby

ruby
<ruby>被ルビ部<rp>(</rp><rt>ルビ内容</rt><rp>)</rp></ruby>
タグ名 意味
<ruby></ruby> rubyを表すための要素。
<rp></rp> ruby parenthesesの略。
括弧を示す。
<rt></rt> ruby textの略。
註釈の内容を表す。

<rp></rp>は、一見して存在意義の知り難い要素です。例えば<ruby>被ルビ部<rp>(</rp><rt>ルビ内容</rt><rp>)</rp></ruby>が何らかの要因でルビを形成しなかった場合、被ルビ部(ルビ内容)と表示されます。このような想定の元、ルビに()を付する目的で使用するのが<rp></rp>です。

https://developer.mozilla.org/ja/docs/Web/HTML/Element/rp#ルビ対応なし

ですから、このような配慮を要せぬ場合には、単に<ruby>被ルビ部<rt>ルビ内容</rt></ruby>で充分であるということが言えます。使用意図に応じて使い分けましょう。

Rustでの正規表現

Rustで正規表現(regular expression)を用いるには、Regexcrateを利用します。

次の操作をすることで、図のような最低限の要素が自動で構成されます。茲ではkurenawiの名前でパッケージを生成しました。

cargo new kurenawi

project rootにて次の通り操作することで、Cargo.tomlRegexcrateが追記されます。

cargo add regex
Cargo.toml
[dependencies]
regex = "1.9.5"

今回のバージョンは1.9.5でした。

正規表現を確かめる

「古今和歌集・一四・恋四・七二三」から、一首和歌を引用しましょう。紅の花の色を初恋の心に準えて歌ったものだそうです。
95/165の左面一首目です。

https://emuseum.nich.go.jp/detail?langId=ja&webView=&content_base_id=100406&content_part_id=001&content_pict_id=000&x=-132&y=-12&s=1&img_id=97&page_id=95

以下は、正規表現に適合したらtrueと表示するプログラムです。当然乍ら、適合しなければfalseとなります。

main.rs
use regex::Regex;

fn main() {
    let text = "くれなゐのはつ|花《はな》そめのいろふかくおもひし|心《こゝろ》われわすれめや";
    let regex_model = Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap();

    println!(
        "is match: {}",
        regex_model.is_match(text)
    );
}
プログラムに関する概説

let text = "~";

"~"は文字列を示します。textは宣言する変数の名です。letで始めることで、textの名で変数を定めることができ、その内容は"~"となります。


Regex::new(r"\u{007c}(.+?)《(.+?)》")

Regex::new(r"\u{007c}(.+?)《(.+?)》")で、正規表現の評価型式を定めています。重要な箇所はr"\u{007c}(.+?)《(.+?)》"です。


rrawrです。結論だけ述べると、rを除いたRegex::new("\u{007c}(.+?)《(.+?)》")では機能しません"\u{007c}"が影響を及ぼさないように、raw(生)の状態で保留しておくための記法です。


\u{~}は、16進法で表現される文字コード「Unicode」を示します。例を見てみましょう。

プログラム
fn main() {
    println!("\u{007c}");
}
実行結果
|

U+007C|を示すため、\u{007c}を表示すると|に変換されて出力されます。亦、この例に使用している通り、println!()は文字列を表示するための記述です。;(セミコロン)は一文の結びを示します。
本来ならばr"|(.+?)《(.+?)》"で宜しいと思われますが、|が悪影響を及ぼしているためか、実際には機能しません。そのため、|を排除するため致し方なく\u{007c}の表記を採用しています。


(.+?)は、.+?の三つからなります。

  • .は、何らかの文字が入ることを示します。
  • +は、直前の文字、茲では何らかの文字が1回以上連続することを示します。
  • ?は、+?とすることで、余計なものが入り込まないようにすることができます。
    例:a|b《c》d|e《f》gについて
    |(.+)《(.+)》の場合、|(b《c》d|e)《(f)》が適合します。
    |(.+?)《(.+?)》の場合、|(b)《(c)》が適合します。

unwrap()

失敗し得る操作に付する関数です。unwrap()がなければプログラムを実行できないため、仕方なく付しています。明確な意図を以て付しているのではありません。失敗した場合にはプログラムを停止します。ただし実際には、プログラムの停止はサービス全体の停止を意味します。停止されては困る場合、unwrap_or()のような、失敗を報知しながらもプログラムを継続させる関数を用います


println!("is match: {}", regex_model.is_match(text))

println!()には!が付きます。Rustでは!が付くものをmacroと呼び、!の付かないものを関数と呼び分けます。macroは内部処理が関数と異なるものの、今回敢えて意識する必要はありません。

println!("a = {}", a)とある場合、変数aの内容をa = {}の型に当てはめて表示します。
今回はprintln!("is match: {}", regex_model.is_match(text))とあるため、regex_model.is_match(text)の結果をis match: {}の型に当てはめて表示します。

cargo runでプログラムを実行します。

実行結果
PS C:\・・・\kurenawi> cargo run
   Compiling kurenawi v0.1.0 (C:\・・・\kurenawi)
    Finished dev [unoptimized + debuginfo] target(s) in 1.01s
     Running `target\debug\kurenawi.exe`
is match: true

これでは正規表現に適合したか否かの外に何もわかりませんが、この真理値は後に用いる重要な情報です。次に、適合した箇所を上述したHTMLで置換します。

正規表現で適合した箇所を置換する

main.rs
use regex::Regex;

fn main() {
    let text = "くれなゐのはつ|花《はな》そめのいろふかくおもひし|心《こゝろ》われわすれめや";
    let regex_model = Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap();
    let ruby_html = "<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>";

    println!(
        "is match: {}",
        regex_model.is_match(text)
    );

    println!(
        "{}",
        regex_model.replace(text, ruby_html)
    );
}
プログラムに関する概説

"<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>"

HTMLによるルビ記法を記したものです。$1$2は、正規表現に対応しています。


regex_model.replace(text, ruby_html)

regex_modelに基づいて、textruby_htmlで置換します。結果は次の実行結果の通りです。

実行結果
PS C:\・・・\kurenawi> cargo run
   Compiling kurenawi v0.1.0 (C:\・・・\kurenawi)
    Finished dev [unoptimized + debuginfo] target(s) in 1.62s
     Running `target\debug\kurenawi.exe`
is match: true
くれなゐのはつ<ruby><rp>(</rp><rt>はな</rt><rp>)</rp></ruby>そめのいろふかくおもひし|心《こゝろ》われわすれめや

この通り、始めに適合した|花《はな》のみが<ruby>花<rp>(</rp><rt>はな</rt><rp>)</rp></ruby>に置換されており、|心《こゝろ》はそのままになっています。

正規表現で適合した箇所を全て置換する

全てのルビ記号を置換できなければ意味がないので、全て置換し尽くすまで繰り返しましょう。

main.rs
use regex::Regex;

fn replace_all(regex_model: Regex, text: String) -> String {
    let replaced = regex_model.replace(
        &text,
        "<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>"
    );

    if regex_model.is_match(&replaced) {
        replace_all(regex_model, (&replaced).to_string())
    } else {
        replaced.to_string()
    }
}

fn main() {
    let text = "くれなゐのはつ|花《はな》そめのいろふかくおもひし|心《こゝろ》われわすれめや".to_string();
    let regex_model = Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap();

    let returned_string = replace_all(regex_model, text);
    println!("{}", returned_string);
}
プログラムに関する概説

"くれなゐのはつ|花《はな》そめのいろふかくおもひし|心《こゝろ》われわすれめや".to_string()

to_string()関数によってString型に変換しています。"くれなゐのはつ|花《はな》そめのいろふかくおもひし|心《こゝろ》われわすれめや"&str型です。意図としては、String型であればうまくいくからというだけです。

概要
&str そのまま使う場合
String 内容が変わる場合
関数から返却する場合

&str型は、比較的単純な操作に用いられます。基本的には、内容を改竄したり、継ぎ足したりすることには用いられません。その意味ではStringよりも厳格です。

対するString型は、内容の改変を良しとしています。内容の改竄も継ぎ足しもできます。&str型は厳格であるため、関数処理の結果ではこちらを用いざるを得ません。実際のところ、&strではできなかったことが、Stringならばうまくいったのです。


fn replace_all(regex_model: Regex, text: String) -> String {}

fnfunctionの略です。replace_allという名の関数を定めています。

  • regex_model: Regex
    Regex型の値をregex_modelの名で受けるように定める。
  • text: String
    String型の値をtextの名で受けるように定める。

-> Stringは、返却する値がString型でなければならないことを示します。


regex_model.replace(&text, "<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>")

&strからStringに型を変更したため、&textとあるように&を付しています。&がないと実行できません。また、逐一変数を作る手間を省く意図で、直接HTMLを載せています。


if regex_model.is_match(&replaced) {} else {}

ifは続く命題の真理値によって処理を分岐するものです。

正規表現に適合する箇所がある場合、regex_model.is_match(&replaced)trueとなります。無い場合はfalseとなります。

これにより、置換すべきルビ記号が残留しているか否かを判断しています

trueの場合 replace_all(regex_model, (&replaced).to_string())

置換すべき箇所が残っているため、再びreplace_all関数で置換します。

扱う文字列型は&strでなくStringであるため、(&replaced).to_string()とすることで整合しています。

falseの場合 replaced.to_string()

置換し尽くしたため、値を返却します。

返却する値はStringでなければならないため、to_string()関数を付しています。

実行結果
PS C:\・・・\kurenawi> cargo run
   Compiling kurenawi v0.1.0 (C:\・・・\kurenawi)
    Finished dev [unoptimized + debuginfo] target(s) in 0.94s
     Running `target\debug\kurenawi.exe`
くれなゐのはつ<ruby><rp>(</rp><rt>はな</rt><rp>)</rp></ruby>そめのいろふかくおもひし<ruby><rp>(</rp><rt>こゝろ</rt><rp>)</rp></ruby>われわすれめや

これで全ての|《》HTMLに置換されました。

以上が正規表現と置換に関する一例です。とはいえ、エラーに従っていたらできたものに過ぎません。冀わくはこんなものより優れた手法が現れんことを。

増補:regex_modelの定数化

定数化の障壁

以上の例では、逐一regex_modelを引数に渡しているため、粗さが拭えません。願わくは、regex_modelは定数として予め定義しておき、replace_all関数の引数で盥回しにする手間を省きたいものです。しかし、単純に関数外に定数として定義しようとすると、以下のように却下されます。

main.rs(不適切)
use regex::Regex;

const regex_model: Regex = Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap();

fn replace_all(text: String) -> String {
    let replaced = regex_model.replace(
        &text,
        "<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>"
    );

    if regex_model.is_match(&replaced) {
        replace_all((&replaced).to_string())
    } else {
        replaced.to_string()
    }
}

fn main() {
    let text = "くれなゐのはつ|花《はな》そめのいろふかくおもひし|心《こゝろ》われわすれめや".to_string();

    let returned_string = replace_all(text);
    println!("{}", returned_string);
}
エラー
error[E0015]: cannot call non-const fn `regex::Regex::new` in constants
 --> src\main.rs:3:28
  |
3 | const regex_model: Regex = Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap();
  |                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: calls in constants are limited to constant functions, tuple structs and tuple variants

error[E0015]: cannot call non-const fn `Result::<regex::Regex, regex::Error>::unwrap` in constants
 --> src\main.rs:3:64
  |
3 | const regex_model: Regex = Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap();
  |                                                                  ^^^^^^^^
  |
  = note: calls in constants are limited to constant functions, tuple structs and tuple variants

解決策

once_cell::sync::Lazyを使うことで、定数として関数外に定義することができます。


Cargo.tomlonce_cellcrateを追加する

Cargo.tomlRegexcrateを追加したように、project rootonce_cellcrateを追加します。

cargo add once_cell

プログラムを改訂する

once_cell::sync::Lazyを使って、定数を定義します。プログラム全体では、以下の型式に落ち着きました。

main.rs
use regex::Regex;
use once_cell::sync::Lazy;

const REGEX_MODEL: Lazy<Regex> = Lazy::new(|| Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap());

fn replace_all(text: String) -> String {
    let replaced = (*REGEX_MODEL).replace(
        &text,
        "<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>"
    );

    if (*REGEX_MODEL).is_match(&replaced) {
        replace_all((&replaced).to_string())
    } else {
        replaced.to_string()
    }
}

fn main() {
    let text = "くれなゐのはつ|花《はな》そめのいろふかくおもひし|心《こゝろ》われわすれめや".to_string();
    
    let returned_string = replace_all(text);
    println!("{}", returned_string);
}
変更点の概説
Lazy::new(|| Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap());

once_cell::sync::Lazyは遅延初期化(Lazy Initialization)を提供するものです。遅延初期化とは読んで字の如く、必要になるまで初期化を先延ばしにすることです。

|| Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap()は、所謂無名関数の形を取っています。||が引数部を表し、Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap()が関数としての処理を表します。||の中に何もありませんから、この関数に引数はなく、唯にRegex::new(r"\u{007c}(.+?)《(.+?)》").unwrap()の実行されるのみとなります。


const REGEX_MODEL: Lazy<Regex> = ・・・
  • 定数を定義するときは、可変と不変の二種より一方を選択します。可変ならばstatic、不変ならばconstを使用するとあります。しかし可変にする必要はないどころか、寧ろ可変では困るため、不変のconstを用いています。

https://doc.rust-jp.rs/rust-by-example-ja/custom_types/constants.html

  • 定数には型を指定する必要があります。Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap()の型はRegexですから、単にこれをLazy<>で囲んでLazy<Regex>とします。

  • 以下のように「定数は大文字(upper case)になさい」と誡告されるため、大文字に直しています。分かり易さからくる慣例ですから、それ以上深い意味は御座いません。

warning
warning: constant `regex_model` should have an upper case name
 --> src\main.rs:4:7
  |
4 | const regex_model: Lazy<Regex> = Lazy::new(|| Regex::new(r"\u{007c}(.+?)《(.+?)》").unwrap());
  |       ^^^^^^^^^^^ help: convert the identifier to upper case: `REGEX_MODEL`
  |
  = note: `#[warn(non_upper_case_globals)]` on by default

(*REGEX_MODEL)

REGEX_MODELLazy<Regex>型でした。しかし、実際に必要なものはRegex型です。<>の中が欲しい時には、参照外し演算子*を付加します。

実行結果
PS C:\・・・\kurenawi> cargo run
   Compiling memchr v2.6.2
   Compiling regex-syntax v0.7.5
   Compiling once_cell v1.18.0
   Compiling aho-corasick v1.0.5
   Compiling regex-automata v0.3.8
   Compiling regex v1.9.5
   Compiling kurenawi v0.1.0 (C:\・・・\kurenawi)
    Finished dev [unoptimized + debuginfo] target(s) in 8.10s
     Running `target\debug\kurenawi.exe`
くれなゐのはつ<ruby><rp>(</rp><rt>はな</rt><rp>)</rp></ruby>そめのいろふかくおもひし<ruby><rp>(</rp><rt>こゝろ</rt><rp>)</rp></ruby>われわすれめや

正常に動作しています。

Discussion