Rustで学術論文からテキストを抽出する #11 - rsrppの全体像

:::note
## Summary
- rsrppのメイン関数である`parse()`を解説

GiHub -> https://github.com/akitenkrad/rsrpp
crates.io -> https://crates.io/crates/rsrpp

## ToDo
- [x] pdftotextで論文から単語単位のテキストと位置情報を取得する (`Word`,`Line`,`Block`,`Page`)
- [x] テキストの属性 (本文, タイトル, 脚注, etc.) を判定する
    - [x] テキストが含まれるエリアを抽出する
        - [x] 2段組みを扱えるようにする
    - [x] セクションのタイトルを識別する
- [x] 図表に含まれるテキストを除外する
    - [x] 表を除外する
        - [x] PDFを画像に変換
        - [x] 画像処理で表の位置を特定
## 今日のファイル
```text
rsrpp
├── Cargo.toml
├── rsrpp
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── parser
│           ├── mod.rs <-- today
│           ├── structs.rs
│           └── tests.rs
└── rsrpp-cli
    ├── Cargo.toml
    └── src
        └── main.rs
```
:::

## 前回までのあらすじ

[前回：Rustで学術論文からテキストを抽出する #10](https://qiita.com/akitenkrad/items/57104dfe83905dede6de)

前回までで学術論文からテキストを抽出するためのパーツは全て揃ったので，今回はPDFをパースする関数本体を実装します．

```mermaid
graph LR
    A[要件定義]

    subgraph Popplerの観察
    B[Popperの観察]
    B1[pdftotext]
    B2[pdfinfo]
    B3[pdftohtml]
    B4[pdftocairo]
    end

    subgraph テキストの属性判定
    C[テキストの属性判定]
    C1[テキストエリアの抽出]
    end

    subgraph 図表の処理
    D[図表の処理]
    subgraph 表の検出

    D1[表の検出]
    end
    end
 
    E[JSON変換]
    
    subgraph 今日はここ
    F[parse関数の実装]
    end
    
    A-->B
    B1-->C
    B3-->C
    C1-->D
    D-->E
    B4-->D1
    E-->F
```

## `parse()`関数

### 1. HTMLのパース
`parse()`関数の本体は，`pdf2html()`でPDFをHTMLに構造化したテキストから`page` > `block` > `line` > `word` を順に取り出していくループがメインになります．

表領域の抽出は`page`単位で実行するので，該当するPDFのページを画像に変換したものから表を抽出し`Page`構造体に格納しています．

表の抽出にはなかなか苦労させられたので，この数行には思い入れがあります．

```rust
let fig_path = config.pdf_figures.get(&page_number).unwrap();
extract_tables(
    fig_path,
    &mut _page.tables,
    _page.width as i32,
    _page.height as i32,
);
```

抽出した表領域は`line`のループにおいて，`line`が表に含まれるテキストであるかどうかを判定するために使用されます．
また，表の矩形に`line`の矩形が含まれているかどうかを判定するために，[第3回](https://qiita.com/akitenkrad/items/8b00674f13ceabe787d2)で実装した `Coordinate::is_contained_in()`を使用しています．

```rust
for table in _page.tables.iter() {
    let line_coord = Coordinate::from_object(_line.x, _line.y, _line.width, _line.height);
    if line_coord.is_contained_in(&table) {
        continue 'line_iter;
    }
}
```

### 2. テキストエリア外の`block`の除外

HTMLをパースして基本的なPDF構造を把握したら，脚注などテキストエリア外の`block`を除外します．
なお，HTMLのパースとテキストエリアの算出の処理を分離している理由ですが，テキストエリアを算出するためには，すべてのページにわたる`block`の情報が必要になり，HTMLのパースとテキストエリアの抽出を並列に実行することができないためです．

ここでPDFの段組の判定も同時に行います．

```rust
let text_area = get_text_area(&pages);
```

表領域の処理のときは`Coordiante::is_contained_in()`を使いましたが，テキストエリアの判定では`block`の一部がエリア外にはみ出しているケースも存在するので，`iou`を用いて "大体テキストエリア内に存在する`block`"を残すようにしています．

```rust
if (iou - 0.0).abs() < 1e-6 {
    remove_indices.push(i);
} else if !section_titles.contains(&block_text.to_lowercase())
    && (block.width / width < 0.3 && block.lines.len() < 4)
{
    remove_indices.push(i);
}
```

その後，`adjust_columns()`を実行し，2段組PDFの場合の`block`の順序を調整しています．

```rust
adjst_columns(&mut pages, config);
```

### 3. セクション情報の抽出
最後に，論文のセクションのテキストを抽出して`Block`にセクションの情報を付与しています．
以上で，PDFからテキスト抽出した結果を`Vec::<Page>`に格納することができました．
あとは，[第9回](https://qiita.com/akitenkrad/items/4acca22b04c277baf1e8)で実装した通り，`Page`を`Section`に変換してJSONにするなり，`Page`のままさらにPDFの情報の解析を行うなりできます．

```rust
let mut current_section = "Abstract".to_string();
let mut page_number = 1;
let title_regex = regex::Regex::new(r"\d+\.").unwrap();
for page in pages.iter_mut() {
    for block in page.blocks.iter_mut() {
        for line in block.lines.iter_mut() {
            let text = line.get_text();
            let text = title_regex.replace(&text, "").trim().to_string();
            if config.sections.iter().any(|(pg, section)| {
                text.to_lowercase() == *section.to_lowercase() && pg == &page_number
            }) {
                current_section = text;
            }
            block.section = current_section.clone();
        }
    }
    page_number += 1;
}
```

### プログラム全体

`parse()`の全体です．

```rust: rsrpp > rsrpp > src > parser > mod.rs
pub async fn parse(path_or_url: &str, config: &mut ParserConfig) -> Result<Vec<Page>> {
    let html = pdf2html(path_or_url, config).await?;

    // ① HTMLのパース
    let mut pages = Vec::new();
    let page_selector = scraper::Selector::parse("page").unwrap();
    let _pages = html.select(&page_selector);
    for (_page_number, page) in _pages.enumerate() {
        let page_number = (_page_number + 1) as PageNumber;
        let page_width = page.value().attr("width").unwrap().parse::<f32>().unwrap();
        let page_height = page.value().attr("height").unwrap().parse::<f32>().unwrap();
        let mut _page = Page::new(page_width, page_height, page_number);

        // extract tables
        let fig_path = config.pdf_figures.get(&page_number).unwrap();
        extract_tables(
            fig_path,
            &mut _page.tables,
            _page.width as i32,
            _page.height as i32,
        );

        let block_selector = scraper::Selector::parse("block").unwrap();
        let _blocks = page.select(&block_selector);
        for block in _blocks {
            let block_xmin = block.value().attr("xmin").unwrap().parse::<f32>().unwrap();
            let block_ymin = block.value().attr("ymin").unwrap().parse::<f32>().unwrap();
            let block_xmax = block.value().attr("xmax").unwrap().parse::<f32>().unwrap();
            let block_ymax = block.value().attr("ymax").unwrap().parse::<f32>().unwrap();
            let mut _block = Block::new(
                block_xmin,
                block_ymin,
                block_xmax - block_xmin,
                block_ymax - block_ymin,
            );

            let line_selector = scraper::Selector::parse("line").unwrap();
            let _lines = block.select(&line_selector);
            'line_iter: for line in _lines {
                let line_xmin = line.value().attr("xmin").unwrap().parse::<f32>().unwrap();
                let line_ymin = line.value().attr("ymin").unwrap().parse::<f32>().unwrap();
                let line_xmax = line.value().attr("xmax").unwrap().parse::<f32>().unwrap();
                let line_ymax = line.value().attr("ymax").unwrap().parse::<f32>().unwrap();
                let mut _line = Line::new(
                    line_xmin,
                    line_ymin,
                    line_xmax - line_xmin,
                    line_ymax - line_ymin,
                );

                for table in _page.tables.iter() {
                    let line_coord =
                        Coordinate::from_object(_line.x, _line.y, _line.width, _line.height);
                    if line_coord.is_contained_in(&table) {
                        continue 'line_iter;
                    }
                }

                let word_selector = scraper::Selector::parse("word").unwrap();
                let _words = line.select(&word_selector);
                for word in _words {
                    let word_xmin = word.value().attr("xmin").unwrap().parse::<f32>().unwrap();
                    let word_ymin = word.value().attr("ymin").unwrap().parse::<f32>().unwrap();
                    let word_xmax = word.value().attr("xmax").unwrap().parse::<f32>().unwrap();
                    let word_ymax = word.value().attr("ymax").unwrap().parse::<f32>().unwrap();
                    let text = word.text().collect::<String>();
                    _line.add_word(
                        text.clone(),
                        word_xmin,
                        word_ymin,
                        word_xmax - word_xmin,
                        word_ymax - word_ymin,
                    );
                }
                if _line.get_text().trim().len() > 0 {
                    _block.lines.push(_line);
                }
            }
            if _block.lines.len() > 0 {
                _page.blocks.push(_block);
            }
        }
        if _page.blocks.len() > 0 {
            pages.push(_page);
        }
    }

    // ② テキストエリア外のBlockの除外
    let section_titles =
        config.sections.iter().map(|(_, section)| section.to_lowercase()).collect::<Vec<String>>();
    let text_area = get_text_area(&pages);
    let title_index_regex = regex::Regex::new(r"\d+\.").unwrap();
    for page in pages.iter_mut() {
        let mut remove_indices: Vec<usize> = Vec::new();
        let width = if page.number_of_columns == 2 {
            page.width / 2.2
        } else {
            page.width / 1.1
        };
        for (i, block) in page.blocks.iter_mut().enumerate() {
            let block_coord = Coordinate::from_object(block.x, block.y, block.width, block.height);
            let iou = text_area.iou(&block_coord);
            let block_text = block.get_text();
            let block_text = title_index_regex.replace(&block_text, "").trim().to_string();

            if (iou - 0.0).abs() < 1e-6 {
                remove_indices.push(i);
            } else if !section_titles.contains(&block_text.to_lowercase())
                && (block.width / width < 0.3 && block.lines.len() < 4)
            {
                remove_indices.push(i);
            }
        }
        for i in remove_indices.iter().rev() {
            page.blocks.remove(*i);
        }
    }

    adjst_columns(&mut pages, config);

    // ③ セクション情報の抽出
    let mut current_section = "Abstract".to_string();
    let mut page_number = 1;
    let title_regex = regex::Regex::new(r"\d+\.").unwrap();
    for page in pages.iter_mut() {
        for block in page.blocks.iter_mut() {
            for line in block.lines.iter_mut() {
                let text = line.get_text();
                let text = title_regex.replace(&text, "").trim().to_string();
                if config.sections.iter().any(|(pg, section)| {
                    text.to_lowercase() == *section.to_lowercase() && pg == &page_number
                }) {
                    current_section = text;
                }
                block.section = current_section.clone();
            }
        }
        page_number += 1;
    }

    return Ok(pages);
}
```


## 次回

以上で，`rsrpp`が完成しました．
ただし，このままではPDFをパースするために毎回プログラムを書かなくてはいけないので，次回はこれをcliで利用できるようにコマンドラインツールを整備します．

[次回：Rustで学術論文からテキストを抽出する #12](https://qiita.com/akitenkrad/items/14c68808b6cce6c9152e)