前回の振り返り
前回は、MarkdownReaderクラスを作成し、Markdownファイルの全行を配列として読み込む処理を実装しました。
今回は、読み込んだ行から見出し(#、##、###など)を抽出していきます。
今回のゴール
Markdownの見出し行を正規表現で検出し、見出しレベル(H1=1、H2=2…)とテキストを抽出するHeadingExtractorクラスを作成します。
Markdownの見出し構文
Markdownでは、#の数で見出しレベルを表現します。
| 記法 | HTMLタグ | レベル |
|---|
# タイトル | <h1> | 1 |
## 章 | <h2> | 2 |
### セクション | <h3> | 3 |
#### 小見出し | <h4> | 4 |
##### 小項目 | <h5> | 5 |
###### 最小見出し | <h6> | 6 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| package HeadingExtractor;
use v5.36;
use Moo;
has 'lines' => (
is => 'ro',
required => 1,
);
has 'headings' => (
is => 'lazy',
builder => '_build_headings',
);
sub _build_headings ($self) {
my @headings;
for my $line ($self->lines->@*) {
if ($line =~ /^(#{1,6})\s+(.+)$/) {
my $level = length($1);
my $text = $2;
push @headings, {
level => $level,
text => $text,
};
}
}
return \@headings;
}
sub heading_count ($self) {
return scalar $self->headings->@*;
}
1;
|
コードの解説
正規表現パターン
1
| if ($line =~ /^(#{1,6})\s+(.+)$/) {
|
この正規表現を分解すると:
| パターン | 意味 |
|---|
^ | 行頭 |
(#{1,6}) | #が1〜6個(キャプチャグループ1) |
\s+ | 1つ以上の空白 |
(.+) | 1文字以上の任意の文字列(キャプチャグループ2) |
$ | 行末 |
キャプチャグループを使うことで、マッチした部分を変数に取り出せます。
レベルの取得
1
| my $level = length($1);
|
$1には#{1,6}でマッチした部分(#の連続)が入ります。length()で文字数(#の数)を取得すると、それがそのまま見出しレベルになります。
テキストの取得
$2には.+でマッチした部分(見出しテキスト)が入ります。
ハッシュで保存
1
2
3
4
| push @headings, {
level => $level,
text => $text,
};
|
抽出した情報を匿名ハッシュにして配列に追加します。各要素はlevelとtextキーを持つハッシュリファレンスです。
使用例
前回作成したMarkdownReaderと組み合わせて使ってみましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| #!/usr/bin/env perl
use v5.36;
use lib '.';
use MarkdownReader;
use HeadingExtractor;
my $reader = MarkdownReader->new(
filepath => 'sample.md',
);
my $extractor = HeadingExtractor->new(
lines => $reader->lines,
);
say "見出し数: " . $extractor->heading_count;
say "";
say "抽出結果:";
for my $h ($extractor->headings->@*) {
say " レベル$h->{level}: $h->{text}";
}
|
実行結果
テスト用のMarkdownファイル(前回と同じ)で実行すると:
1
2
3
4
5
6
| 見出し数: 4
抽出結果:
レベル1: はじめに
レベル2: 第1章
レベル3: セクション1.1
レベル2: 第2章
|
見出しが正しく抽出され、レベルとテキストが取得できています。
補足:正規表現のエッジケース
今回の正規表現は基本的なケースをカバーしていますが、以下のようなエッジケースには対応していません。
マッチしないケース
1
2
| #タイトル <!-- #の後にスペースがない -->
####### 見出し <!-- #が7個以上 -->
|
マッチするが意図と異なる可能性
1
| ## タイトル <!-- 複数スペース:マッチするが末尾スペースも含む -->
|
実用的なツールを作る場合は、これらのケースも考慮する必要がありますが、本シリーズでは基本的なパターンに集中します。
完成コード
今回作成したHeadingExtractorクラスの完成コードです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| package HeadingExtractor;
use v5.36;
use Moo;
has 'lines' => (
is => 'ro',
required => 1,
);
has 'headings' => (
is => 'lazy',
builder => '_build_headings',
);
sub _build_headings ($self) {
my @headings;
for my $line ($self->lines->@*) {
if ($line =~ /^(#{1,6})\s+(.+)$/) {
my $level = length($1);
my $text = $2;
push @headings, {
level => $level,
text => $text,
};
}
}
return \@headings;
}
sub heading_count ($self) {
return scalar $self->headings->@*;
}
1;
|
まとめ
今回は、正規表現を使ってMarkdownの見出しを抽出するHeadingExtractorクラスを作成しました。
学んだこと:
- 正規表現のキャプチャグループ
()で部分文字列を取得 $1、$2でキャプチャした値を参照length()で文字数を取得し、見出しレベルを算出- 匿名ハッシュ
{}で構造化データを配列に格納
次回は、抽出した見出しをフラットな配列のままインデント付きで表示する処理を実装します。まだ階層構造(ツリー構造)は扱わず、単純なループ処理で目次を表示してみましょう。