Rustで学術論文からテキストを抽出する #6 - 表を検出する方法

:::note
## Summary
- pdftotext，pdftohtmlの情報では表の座標を特定できない
- PDFを画像に変換して力業で表の位置を特定する
- Popplerのツールで変換する画像やテキスト，HTMLの縮尺の違いに注意する必要がある

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] セクションのタイトルを識別する
- [ ] 図表に含まれるテキストを除外する
    - [ ] 表を除外する

## 今日のファイル
```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で学術論文からテキストを抽出する #5](https://qiita.com/akitenkrad/items/0a4c8ddcabaa4e55c7a5)

前回までで，セクションタイトルを抽出することができました．残りは図，表，アルゴリズム，数式といったあたりの処理になりますが，今回は表の対処にチャレンジします．

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

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

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

    subgraph 今日はここ
    D[図表の処理]
    end
    
    E[テキスト抽出PoC]
    
    A-->B
    B1-->C
    B3-->C
    C1-->D
    D-->E
```

## 表を検出するにはどうしたら良いか

PDFからのテキスト抽出においては，図・表のうちpngやjpegなどの図はあまり問題になりません．というのも，画像ファイルはpdftotextで変換されないので，図のキャプションだけが残る形になるためです．そして，図のキャプションであれば，論文の分析に使用するテキストとしては含まれていても良い文字列かなと思います．
問題は図の形式がSVGだった場合ですが，これはまた後ほど考えることにします．

さて，PDFにおける表の検出ですが，フォントのときと同じく，pdftotextの出力では太刀打ちできません．
HTMLなら`<table></table>`で出力してくれるんじゃないか？とも期待していたのですが，残念ながらpdftohtmlで出力されるテキストはすべて`<p></p>`です．
何のためのpdftohtmlやねん，と思いました．

ちなみに，pdftotextで出力される表部分は以下のようになります．
みていただければわかりますが，ほぼ表の体をなしていません．
ここからそれぞれのブロックが表に属するかどうかを判定するのは難しいでしょう．

```html
      <block xMin="210.011000" yMin="405.546357" xMax="401.990302" yMax="414.452922">
        <line xMin="210.011000" yMin="405.546357" xMax="401.990302" yMax="414.452922">
          <word xMin="210.011000" yMin="405.546357" xMax="236.023349" yMax="414.452922">Figure</word>
          <word xMin="238.513999" yMin="405.546357" xMax="246.264901" yMax="414.452922">1:</word>
          <word xMin="249.353307" yMin="405.546357" xMax="264.845150" yMax="414.452922">The</word>
          <word xMin="267.335800" yMin="405.546357" xMax="316.780184" yMax="414.452922">Transformer</word>
          <word xMin="319.270834" yMin="405.546357" xMax="322.588380" yMax="414.452922">-</word>
          <word xMin="325.079030" yMin="405.546357" xMax="349.985530" yMax="414.452922">model</word>
          <word xMin="352.476180" yMin="405.546357" xMax="401.990302" yMax="414.452922">architecture.</word>
        </line>
      </block>
      <block xMin="107.691000" yMin="437.598357" xMax="505.247118" yMax="468.322922">
        <line xMin="107.691000" yMin="437.598357" xMax="504.353075" yMax="446.504922">
          <word xMin="107.691000" yMin="437.598357" xMax="123.368745" yMax="446.504922">The</word>
          <word xMin="125.838872" yMin="437.598357" xMax="175.886671" yMax="446.504922">Transformer</word>
          <word xMin="178.356798" yMin="437.598357" xMax="208.351198" yMax="446.504922">follows</word>
          <word xMin="210.831407" yMin="437.598357" xMax="225.400115" yMax="446.504922">this</word>
          <word xMin="227.870242" yMin="437.598357" xMax="255.565912" yMax="446.504922">overall</word>
          <word xMin="258.036039" yMin="437.598357" xMax="305.623792" yMax="446.504922">architecture</word>
          <word xMin="308.104002" yMin="437.598357" xMax="329.952023" yMax="446.504922">using</word>
          <word xMin="332.422150" yMin="437.598357" xMax="362.557700" yMax="446.504922">stacked</word>
          <word xMin="365.027827" yMin="437.598357" xMax="418.231339" yMax="446.504922">self-attention</word>
          <word xMin="420.711548" yMin="437.598357" xMax="435.270175" yMax="446.504922">and</word>
          <word xMin="437.740302" yMin="437.598357" xMax="482.827682" yMax="446.504922">point-wise,</word>
          <word xMin="485.307891" yMin="437.598357" xMax="504.353075" yMax="446.504922">fully</word>
        </line>
        <line xMin="108.000000" yMin="448.507357" xMax="505.247118" yMax="457.413922">
          <word xMin="108.000000" yMin="448.507357" xMax="149.196148" yMax="457.413922">connected</word>
          <word xMin="151.889039" yMin="448.507357" xMax="176.155541" yMax="457.413922">layers</word>
          <word xMin="178.848432" yMin="448.507357" xMax="190.697152" yMax="457.413922">for</word>
          <word xMin="193.390042" yMin="448.507357" xMax="211.457815" yMax="457.413922">both</word>
          <word xMin="214.150706" yMin="448.507357" xMax="226.568489" yMax="457.413922">the</word>
          <word xMin="229.271542" yMin="448.507357" xMax="261.433803" yMax="457.413922">encoder</word>
          <word xMin="264.126694" yMin="448.507357" xMax="278.800408" yMax="457.413922">and</word>
          <word xMin="281.493299" yMin="448.507357" xMax="315.799712" yMax="457.413922">decoder,</word>
          <word xMin="318.543412" yMin="448.507357" xMax="344.821961" yMax="457.413922">shown</word>
          <word xMin="347.514852" yMin="448.507357" xMax="355.420772" yMax="457.413922">in</word>
          <word xMin="358.123825" yMin="448.507357" xMax="370.541608" yMax="457.413922">the</word>
          <word xMin="373.234499" yMin="448.507357" xMax="386.780248" yMax="457.413922">left</word>
          <word xMin="389.473139" yMin="448.507357" xMax="404.146853" yMax="457.413922">and</word>
          <word xMin="406.839744" yMin="448.507357" xMax="426.035482" yMax="457.413922">right</word>
          <word xMin="428.728373" yMin="448.507357" xMax="454.539477" yMax="457.413922">halves</word>
          <word xMin="457.232368" yMin="448.507357" xMax="465.697190" yMax="457.413922">of</word>
          <word xMin="468.400243" yMin="448.507357" xMax="494.932839" yMax="457.413922">Figure</word>
          <word xMin="497.625729" yMin="448.507357" xMax="505.247118" yMax="457.413922">1,</word>
        </line>
        <line xMin="108.000000" yMin="459.416357" xMax="157.583860" yMax="468.322922">
          <word xMin="108.000000" yMin="459.416357" xMax="157.583860" yMax="468.322922">respectively.</word>
        </line>
      </block>
```

## 画像からのアプローチ

そこで，力業にでます．
論文における表というのは，ある程度フォーマットが決まっていて，横の罫線が必ず入ります[^1]．

![table.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/c46586ac-b4de-c4ee-8819-274b28d73d55.png)

画像処理の技術の一つにHough変換という直線を検出する変換手法があります[^2]．
表を検出するということは，要するに，横方向の直線を検出し，その直線で挟まれたテキストエリアを表の領域とみなすという処理に言い換えられます．

というわけで突然の画像処理になりますが，まずはPDFを画像に変換し，それをHough変換して直線を検出します．

## PDFを画像に変換

Popplerのツール群の中に，pdftocaitoというコマンドがあります．
これを使ってPDFを画像に変換することができます．
オプションがたくさんありますが，`jpeg`でPDFを画像として保存したいだけなので，コマンドはシンプルに `pdftocairo -jpeg PDF.pdf`で事足ります．

```text
pdftocairo version 24.02.0
Copyright 2005-2024 The Poppler Developers - http://poppler.freedesktop.org
Copyright 1996-2011, 2022 Glyph & Cog, LLC
Usage: pdftocairo [options] <PDF-file> [<output-file>]
  -png                     : generate a PNG file
  -jpeg                    : generate a JPEG file
  -jpegopt <string>        : jpeg options, with format <opt1>=<val1>[,<optN>=<valN>]*
  -tiff                    : generate a TIFF file
  -tiffcompression <string>: set TIFF compression: none, packbits, jpeg, lzw, deflate
  -ps                      : generate PostScript file
  -eps                     : generate Encapsulated PostScript (EPS)
  -pdf                     : generate a PDF file
  -svg                     : generate a Scalable Vector Graphics (SVG) file
  -f <int>                 : first page to print
  -l <int>                 : last page to print
  -o                       : print only odd pages
  -e                       : print only even pages
  -singlefile              : write only the first page and do not add digits
  -r <fp>                  : resolution, in PPI (default is 150)
  -rx <fp>                 : X resolution, in PPI (default is 150)
  -ry <fp>                 : Y resolution, in PPI (default is 150)
  -scale-to <int>          : scales each page to fit within scale-to*scale-to pixel box
  -scale-to-x <int>        : scales each page horizontally to fit in scale-to-x pixels
  -scale-to-y <int>        : scales each page vertically to fit in scale-to-y pixels
  -x <int>                 : x-coordinate of the crop area top left corner
  -y <int>                 : y-coordinate of the crop area top left corner
  -W <int>                 : width of crop area in pixels (default is 0)
  -H <int>                 : height of crop area in pixels (default is 0)
  -sz <int>                : size of crop square in pixels (sets W and H)
  -cropbox                 : use the crop box rather than media box
  -mono                    : generate a monochrome image file (PNG, JPEG)
  -gray                    : generate a grayscale image file (PNG, JPEG)
  -transp                  : use a transparent background instead of white (PNG)
  -antialias <string>      : set cairo antialias option
  -icc <string>            : ICC color profile to use
  -level2                  : generate Level 2 PostScript (PS, EPS)
  -level3                  : generate Level 3 PostScript (PS, EPS)
  -origpagesizes           : conserve original page sizes (PS, PDF, SVG)
  -paper <string>          : paper size (letter, legal, A4, A3, match)
  -paperw <int>            : paper width, in points
  -paperh <int>            : paper height, in points
  -nocrop                  : don't crop pages to CropBox
  -expand                  : expand pages smaller than the paper size
  -noshrink                : don't shrink pages larger than the paper size
  -nocenter                : don't center pages smaller than the paper size
  -duplex                  : enable duplex printing
  -struct                  : enable logical document structure
  -opw <string>            : owner password (for encrypted files)
  -upw <string>            : user password (for encrypted files)
  -q                       : don't print any messages or errors
  -v                       : print copyright and version info
  -h                       : print usage information
  -help                    : print usage information
  --help                   : print usage information
  -?                       : print usage information
```

[#5](https://qiita.com/akitenkrad/items/0a4c8ddcabaa4e55c7a5)で実装した`ParserConfig`には`pdf_figures: HashMap<PageNumber, String>`という画像ファイルを参照する変数をすでに用意してあるので，こちらに変換後の画像のパスを格納します．

```rust: rsrpp > rsrpp > src > parser > mod.rs
fn save_pdf_as_figures(config: &mut ParserConfig) -> Result<()> {
    let pdf_path = Path::new(config.pdf_path.as_str());
    let dst_path = pdf_path.parent().unwrap().join(pdf_path.file_stem().unwrap().to_str().unwrap());

    // save pdf as jpeg files
    let res = Command::new("pdftocairo")
        .args(&[
            "-jpeg".to_string(),
            "-r".to_string(),
            "72".to_string(),
            pdf_path.to_str().unwrap().to_string(),
            dst_path.to_str().unwrap().to_string(),
        ])
        .stdout(Stdio::piped())
        .output();
    if let Err(e) = res {
        return Err(Error::msg(format!("Error: {}", e)));
    }

    // get all jpeg files
    let glob_query = dst_path.file_name().unwrap().to_str().unwrap().to_string() + "*.jpg";
    let glob_query = dst_path.parent().unwrap().join(glob_query);
    for entry in glob(glob_query.to_str().unwrap())? {
        match entry {
            Ok(path) => {
                let page_number: PageNumber = path
                    .file_stem()
                    .unwrap()
                    .to_str()
                    .unwrap()
                    .split("-")
                    .last()
                    .unwrap()
                    .parse::<i8>()?;
                config.pdf_figures.insert(page_number, path.to_str().unwrap().to_string());
            }
            Err(e) => return Err(Error::msg(format!("Error: {}", e))),
        }
    }

    return Ok(());
}
```

## PDFの用紙サイズの問題

今検討しているアプローチは，

1. pdftocairoで変換した画像を解析して表の座標を取得
2. pdftotextで変換したテキストの座標の中から表の座標内に存在するテキストを除外

という戦略になっていますが，このためには**pdftocairoとpdftotextの変換結果の縮尺が一致していないけない**ということに気がつきました．

pdftocairoで変換した画像はピクセル単位で処理することになります．
対して，pdftotextで出力されている座標の値...コレ単位は何なんだ？

というわけで，pdftotext，pdftohtml，pdftocairoのそれぞれの出力結果を見比べて，各コマンドに対してリサイズやスケーリングのオプションを与えてやることによってサイズを揃えることができるとわかりました．
具体的には，以下のように`-r`，`-zoom`を指定することでサイズが概ね揃います．

```bash
> pdftotext -nopgbrk -htmlmeta -bbox-layout -r 72 PDF.pdf
> pdftohtml -c -s -dataurls -xml -zoom 1.0 PDF.pdf
> pdftocairo -jpeg -r 72 PDF.pdf
```

これは巧妙に隠蔽された落とし穴で，実は初期の実装のときには最後の方で落とし穴に落ちました．
ともあれ，まずは表を検出するための準備が整ったので，次は実際に画像解析してみます．

## 次回

PDFを画像に変換したので，画像にHough変換をかけて直線の抽出にチャレンジします．

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


[^1]: [Attention Is All You Need (Vaswani et al., 2017)](https://arxiv.org/abs/1706.03762)
[^2]: Ballard, Dana H. "Generalizing the Hough transform to detect arbitrary shapes." Pattern recognition 13.2 (1981): 111-122.