Rustで学術論文からテキストを抽出する #5 - セクションタイトル識別の試行錯誤

:::note
## Summary
- 座標やテキストの情報だけではセクションタイトルを抽出できない
- HTML/XMLのフォントサイズの情報を使うことによってセクションタイトルの抽出に成功
- ついでに，メタ情報を管理する`ParserConfig`構造体を実装した

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

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

## 前回までのあらすじ

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

ここまでで，PDFから本文+$\alpha$を含むエリアを抽出することができました．このエリアに含まれるのは，本文だけではなく，セクションタイトル，図，表，アルゴリズム，数式，...などなど様々な要素がありますが，まずは最も重要であるセクションタイトルを識別することを考えます．

```mermaid
graph LR
    A[要件定義]
    
    subgraph Popplerの観察
    B[Popperの観察]
    B1[pdftotext]
        subgraph 今回はここ
    B2[pdfinfo]
    B3[pdftohtml]
    end
    end
    
    subgraph テキストの属性判定
    C[テキストの属性判定]
    C1[テキストエリアの抽出]
    end
    
    D[テキスト抽出PoC]
    
    A-->B
    B1-->C
    C1-->D
```

## Popplerツール群再訪

[#2](https://qiita.com/akitenkrad/items/a61c51f8dabccc16d150)で見たようにpdftotextだけではあるテキストが本文なのかセクションタイトルなのかを判別するのは困難です．

そこで，pdftotextと似たような機能を持つpdftohtmlを試してみました．
pdftohtmlの使い方は以下の通り．

```text
pdftohtml version 24.02.0
Copyright 2005-2024 The Poppler Developers - http://poppler.freedesktop.org
Copyright 1999-2003 Gueorgui Ovtcharov and Rainer Dorsch
Copyright 1996-2011, 2022 Glyph & Cog, LLC

Usage: pdftohtml [options] <PDF-file> [<html-file> <xml-file>]
  -f <int>              : first page to convert
  -l <int>              : last page to convert
  -q                    : don't print any messages or errors
  -h                    : print usage information
  -?                    : print usage information
  -help                 : print usage information
  --help                : print usage information
  -p                    : exchange .pdf links by .html
  -c                    : generate complex document
  -s                    : generate single document that includes all pages
  -dataurls             : use data URLs instead of external images in HTML
  -i                    : ignore images
  -noframes             : generate no frames
  -stdout               : use standard output
  -zoom <fp>            : zoom the pdf document (default 1.5)
  -xml                  : output for XML post-processing
  -noroundcoord         : do not round coordinates (with XML output only)
  -hidden               : output hidden text
  -nomerge              : do not merge paragraphs
  -enc <string>         : output text encoding name
  -fmt <string>         : image file format for Splash output (png or jpg)
  -v                    : print copyright and version info
  -opw <string>         : owner password (for encrypted files)
  -upw <string>         : user password (for encrypted files)
  -nodrm                : override document DRM settings
  -wbt <fp>             : word break threshold (default 10 percent)
  -fontfullname         : outputs font full name
```

オプションが色々ありますが，関係ありそうなところで下記のように変換を実行してみたところ，

```bash
pdftohtml -c -s -dataurls PDF.pdf
├── PDF-html.html
└── PDF-outline.html
```

それっぽいものが！ **PDF-outline.html**
中身もまさに求めていたものです．

```html
<html xmlns="http://www.w3.org/1999/xhtml" lang="" xml:lang="">

<head>
  <title>Document Outline</title>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>

<body>
  <a name="outline"></a>
  <h1>Document Outline</h1>
  <ul>
    <li>
      <a href="PDF-2.html">Introduction</a>
    </li>
    <li>
      <a href="PDF-2.html">Background</a>
    </li>
    <li>
      <a href="PDF-2.html">Model Architecture</a>
      <ul>
        <li>
          <a href="PDF-3.html">Encoder and Decoder Stacks</a>
        </li>
        <li>
          <a href="PDF-3.html">Attention</a>
          <ul>
            <li>
              <a href="PDF-4.html">Scaled Dot-Product Attention</a>
            </li>
            <li>
              <a href="PDF-4.html">Multi-Head Attention</a>
            </li>
            <li>
              <a href="PDF-5.html">Applications of Attention in our Model</a>
            </li>
          </ul>
        </li>
        <li>
          <a href="PDF-5.html">Position-wise Feed-Forward Networks</a>
        </li>
        <li>
          <a href="PDF-5.html">Embeddings and Softmax</a>
        </li>
        <li>
          <a href="PDF-6.html">Positional Encoding</a>
        </li>
      </ul>
    </li>
    <li>
      <a href="PDF-6.html">Why Self-Attention</a>
    </li>
    <li>
      <a href="PDF-7.html">Training</a>
      <ul>
        <li>
          <a href="PDF-7.html">Training Data and Batching</a>
        </li>
        <li>
          <a href="PDF-7.html">Hardware and Schedule</a>
        </li>
        <li>
          <a href="PDF-7.html">Optimizer</a>
        </li>
        <li>
          <a href="PDF-7.html">Regularization</a>
        </li>
      </ul>
    </li>
    <li>
      <a href="PDF-8.html">Results</a>
      <ul>
        <li>
          <a href="PDF-8.html">Machine Translation</a>
        </li>
        <li>
          <a href="PDF-8.html">Model Variations</a>
        </li>
        <li>
          <a href="PDF-9.html">English Constituency Parsing</a>
        </li>
      </ul>
    </li>
    <li>
      <a href="PDF-10.html">Conclusion</a>
    </li>
  </ul>
</body>

</html>
```

メインセクションだけではなく，サブセクションの情報も出力されており，それぞれどのページから始まっているかもわかるようです．
勝ったと思いました．

## タイトル回収
この辺りで先にタイトルを回収しておくと，残念ながら`-outline.html`に頼ることはできませんでした．
pdftohtmlを色々な論文に対して試してみると，`-outline.html`が出力されるものとされないものがあることがわかってきました．
さらに詳しく調べてみると，この`-outline.html'はPDFにアウトラインの情報が含まれている場合に生成されるもののようで，要するに今回対象としているすべての論文にもれなく適用することができません．
この方法が使えるのは全体の何割あるかわかりませんが，一部のアウトライン情報を含んだ優秀なPDFだけです．

この他にも，
- `Block`の上位である`Flow`はセクション単位では？ → そんなことはない
- セクションタイトルが含まれる`Block`は`Line`が少ないのでは？ → 確かに少ないが，数式や表の一部など他にも例がある
- セクションの`Line`は他の分よりも幅が短いのでは？ → 上と同様他も例があるのでNG
- もういっそのことLLMに投げちゃう？ → 実行速度とメモリ使用量の観点でNG

と，それはもう色々考えました．
あれこれ試行錯誤していたところ，pdftohtmlの本体の方でフォントが定義されているのを見つけました．

```html
<style type="text/css">
  <!--
          p {margin: 0; padding: 0;}
          .ft10{font-size:18px;font-family:AECCXO+NimbusRomNo9L-Regu;color:#ff0000;}
          .ft11{font-size:26px;font-family:RCUMTF+NimbusRomNo9L-Medi;color:#000000;}
          .ft12{font-size:15px;font-family:RCUMTF+NimbusRomNo9L-Medi;color:#000000;}
          .ft13{font-size:10px;font-family:HPHIWG+CMSY7;color:#000000;}
          .ft14{font-size:15px;font-family:AECCXO+NimbusRomNo9L-Regu;color:#000000;}
          .ft15{font-size:15px;font-family:XEXHSJ+SFTT1000;color:#000000;}
          .ft16{font-size:18px;font-family:RCUMTF+NimbusRomNo9L-Medi;color:#000000;}
          .ft17{font-size:9px;font-family:EHCAGL+CMSY6;color:#000000;}
          .ft18{font-size:13px;font-family:AECCXO+NimbusRomNo9L-Regu;color:#000000;}
          .ft19{font-size:30px;font-family:Times;color:#7f7f7f;-moz-transform: matrix(         0,         -1,          1,          0, 0, 0);-webkit-transform: matrix(         0,         -1,          1,          0, 0, 0);-o-transform: matrix(         0,         -1,          1,          0, 0, 0);-ms-transform: matrix(         0,         -1,          1,          0, 0, 0);-moz-transform-origin: left 75%;-webkit-transform-origin: left 75%;-o-transform-origin: left 75%;-ms-transform-origin: left 75%;}
          .ft110{font-size:15px;line-height:16px;font-family:AECCXO+NimbusRomNo9L-Regu;color:#000000;}
          .ft111{font-size:13px;line-height:14px;font-family:AECCXO+NimbusRomNo9L-Regu;color:#000000;}
-->
</style>
```
コレ，この中からセクションタイトルに該当するフォントのサイズを特定して，そのクラスがつけられているHTML要素を辿ればセクションタイトルを特定できるんじゃないか？
試しに，セクションタイトルの要素をいくつか抜き出してみました．

```html
<p class="ft16">Abstract</p>
<p class="ft20">Introduction</p>
<p class="ft20">1</p>
<p class="ft20">2</p>
<p class="ft20">Background</p>
<p class="ft20">3</p>
<p class="ft20">Model&#160;Architecture</p>
```

思っていたのとちょっとチガウ．
よくよくみてみると，このftXXというクラスはページごとに定義されているらしく，ft1から始まるものはp.1，ft2はp.2という風になっているみたい．
ダメじゃん．

いや，`ft16`のサイズが`18px`であることはわかっているのだから，フォントサイズが`18px`という条件ではどうだろうか？

というわけで，この方針で再度セクションを抽出してみると．

```html
<p class="ft10">Provided&#160;proper&#160;attribution&#160;is&#160;provided,&#160;Google&#160;hereby&#160;grants&#160;permission&#160;to</p>
<p class="ft10">reproduce&#160;the&#160;tables&#160;and&#160;figures&#160;in&#160;this&#160;paper&#160;solely&#160;for&#160;use&#160;in&#160;journalistic&#160;or</p>
<p class="ft10">scholarly&#160;works.</p>
<p class="ft16">Abstract</p>
<p class="ft20">Introduction</p>
<p class="ft20">1</p>
<p class="ft20">2</p>
<p class="ft20">Background</p>
<p class="ft20">3</p>
<p class="ft20">Model&#160;Architecture</p>
<p class="ft69">4</p>
<p class="ft69">Why&#160;Self-Attention</p>
<p class="ft76">5</p>
<p class="ft76">Training</p>
<p class="ft811">6</p>
<p class="ft811">Results</p>
<p class="ft104">7</p>
<p class="ft104">Conclusion</p>
<p class="ft104">References</p>
```

素晴らしい．
冒頭にちょっと余計なやつも入ってますが，フォントサイズを条件にすればセクションタイトルを抽出できそうです．
ちなみに冒頭のやつは表紙の一番上に出てきてる文字列で，テキストエリアの処理で弾けそう．

したがって，セクションタイトルを識別する処理は以下のようになりました．
1. pdftotextでPDFをXMLに変換 (オプションでHTMLをXMLに変更できます)
1. XMLからフォントの情報を抽出してセクションに該当するフォントサイズを取得
1. 同じフォントサイズのテキストを全て抽出してきて，数字などを弾く

最後の課題は，どのフォントサイズがセクションタイトルのものなのか？という点ですが，これはおよそすべての論文に含まれているであろうセクションのタイトルから引っ張ってくることにしました．「Abstract」さんです．なので，このライブラリではAbstractに設定されているフォントサイズがセクションタイトルのフォントサイズということになりました．

## Rustでの実装

この辺りで，Popplerを使った変換を何回か行わなければいけないことが見えてきたので，これもデータ構造に落としてしまうことにしました．
今回のライブラリに必要なメタ情報をまとめて管理するConfigクラスを実装しています．
これまでのところで，たまに顔を出していた`config`はすべてこいつです．

```rust:rsrpp > rsrpp > src > parser > structs.rs
pub struct ParserConfig {
    pub pdf_path: String,
    pub pdf_text_path: String,
    pub pdf_figures: HashMap<PageNumber, String>,
    pub pdf_xml_path: String,
    pub sections: Vec<(PageNumber, String)>,
    pub pdf_info: HashMap<String, String>,
}

impl ParserConfig {
    pub fn new() -> ParserConfig {
        let mut rng = rand::thread_rng();
        let random_value = rng.gen_range(10000..99999);
        let mut pdf_path = String::new();
        pdf_path.push_str("/tmp/pdf_");
        pdf_path.push_str(&random_value.to_string());
        pdf_path.push_str(".pdf");

        let pdf_figures = HashMap::new();
        let pdf_html_path = pdf_path.clone().replace(".pdf", ".text.html");
        let pdf_raw_html_path = pdf_path.clone().replace(".pdf", ".xml");
        let sections = Vec::new();
        ParserConfig {
            pdf_path: pdf_path,
            pdf_text_path: pdf_html_path,
            pdf_figures: pdf_figures,
            pdf_xml_path: pdf_raw_html_path,
            sections: sections,
            pdf_info: HashMap::new(),
        }
    }

    pub fn pdf_width(&self) -> i32 {
        return self.pdf_info.get("page_width").unwrap().parse::<i32>().unwrap();
    }

    pub fn pdf_height(&self) -> i32 {
        return self.pdf_info.get("page_height").unwrap().parse::<i32>().unwrap();
    }

    pub fn clean_files(&self) -> Result<()> {
        if Path::new(&self.pdf_path).exists() {
            std::fs::remove_file(&self.pdf_path)?;
        }
        if Path::new(&self.pdf_text_path).exists() {
            std::fs::remove_file(&self.pdf_text_path)?;
        }
        if Path::new(&self.pdf_xml_path).exists() {
            std::fs::remove_file(&self.pdf_xml_path)?;
        }
        for figure in self.pdf_figures.values() {
            if Path::new(figure).exists() {
                std::fs::remove_file(figure)?;
            }
        }
        return Ok(());
    }
}
```

フォントサイズを取得する処理は，pdftohtmlでファイルを変換するタイミングで実行します．
Popplerのコマンドを利用してPDFファイルを変換する関数に`Parserconfig`をmutableで渡してあげることで，変換と同時にフォントサイズを取得できるようにしました．

ちなみに，ここではxmlの処理に[quick-xml](https://crates.io/crates/quick-xml)というクレートを採用しています．
htmlパーサである[scraper](https://github.com/rust-scraper/scraper)を使う手もあったのですが，quick-xmlの方が低レイヤを扱うことができ，大きなファイルの場合では処理効率も高いのでこちらを採用しました．
ちなみに，今回扱うサイズのXMLではどちらを使ってもほとんど性能に差は出ないと思われます．
すみません，使ってみたかったので．

とにかく，これにてセクションタイトルを抽出することができました．

```rust:rsrpp > rsrpp > src > parser > mod.rs
fn save_pdf_as_xml(config: &mut ParserConfig) -> Result<()> {
    let xml_path = Path::new(&config.pdf_xml_path);

    Command::new("pdftohtml")
        .args(&[
            "-c".to_string(),
            "-s".to_string(),
            "-dataurls".to_string(),
            "-xml".to_string(),
            "-zoom".to_string(),
            "1.0".to_string(),
            config.pdf_path.as_str().to_string(),
            xml_path.to_str().unwrap().to_string(),
        ])
        .stdout(Stdio::piped())
        .output()?;

    // get title font size
    let mut font_number = 0;
    let xml_text = std::fs::read_to_string(xml_path)?;
    let mut reader = quick_xml::Reader::from_str(&xml_text);
    reader.config_mut().trim_text(true);
    loop {
        match reader.read_event() {
            Ok(Event::Start(e)) => {
                if e.name().as_ref() == b"text" {
                    for attr in e.attributes() {
                        let attr = attr?;
                        if attr.key.as_ref() == b"font" {
                            font_number = String::from_utf8_lossy(attr.value.as_ref())
                                .parse::<i32>()
                                .unwrap();
                        }
                    }
                }
            }
            Ok(Event::Text(e)) => {
                if String::from_utf8_lossy(e.as_ref()).to_lowercase() == "abstract" {
                    break;
                }
            }
            Err(_e) => {
                break;
            }
            _ => {}
        }
    }

    // get sections
    let mut page_number = 0;
    let mut is_title = false;
    let regex_is_number = regex::Regex::new(r"^\d+$").unwrap();
    let regex_trim_number = regex::Regex::new(r"\d\.").unwrap();
    let mut reader = quick_xml::Reader::from_str(&xml_text);
    reader.config_mut().trim_text(true);
    loop {
        match reader.read_event() {
            Ok(Event::Start(e)) => {
                if e.name().as_ref() == b"page" {
                    for attr in e.attributes() {
                        let attr = attr?;
                        if attr.key.as_ref() == b"number" {
                            page_number =
                                String::from_utf8_lossy(attr.value.as_ref()).parse::<i8>().unwrap();
                        }
                    }
                } else if e.name().as_ref() == b"text" {
                    let _font_number = String::from_utf8_lossy(
                        e.attributes()
                            .find(|attr| attr.clone().unwrap().key.as_ref() == b"font")
                            .unwrap()
                            .unwrap()
                            .value
                            .as_ref(),
                    )
                    .parse::<i32>()
                    .unwrap();

                    if font_number == _font_number {
                        is_title = true;
                    } else {
                        is_title = false;
                    }
                    continue;
                }
            }
            Ok(Event::Text(e)) => {
                let text = String::from_utf8_lossy(e.as_ref());
                if regex_is_number.is_match(&text) {
                    continue;
                }
                let text = regex_trim_number.replace(&text, "").to_string().trim().to_string();
                if is_title {
                    config.sections.push((page_number, text.to_string()));
                    if text.to_lowercase() == "references" {
                        break;
                    }
                }
            }
            Err(_e) => {
                break;
            }
            _ => {}
        }
    }

    return Ok(());
}

```

## 次回

本文のテキストエリアの中からセクションタイトルを抽出することができました．次は，図表の処理にチャレンジします．

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