1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustで学術論文からテキスト抽出するクレートを実装するAdvent Calendar 2024

Day 11

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

Last updated at Posted at 2024-12-10

Summary

  • rsrppのメイン関数であるparse()を解説

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

ToDo

  • pdftotextで論文から単語単位のテキストと位置情報を取得する (Word,Line,Block,Page)
  • テキストの属性 (本文, タイトル, 脚注, etc.) を判定する
    • テキストが含まれるエリアを抽出する
      • 2段組みを扱えるようにする
    • セクションのタイトルを識別する
  • 図表に含まれるテキストを除外する
    • 表を除外する
      • PDFを画像に変換
      • 画像処理で表の位置を特定

今日のファイル

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

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

parse()関数

1. HTMLのパース

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

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

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

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回で実装した Coordinate::is_contained_in()を使用しています.

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の段組の判定も同時に行います.

let text_area = get_text_area(&pages);

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

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の順序を調整しています.

adjst_columns(&mut pages, config);

3. セクション情報の抽出

最後に,論文のセクションのテキストを抽出してBlockにセクションの情報を付与しています.
以上で,PDFからテキスト抽出した結果をVec::<Page>に格納することができました.
あとは,第9回で実装した通り,PageSectionに変換してJSONにするなり,PageのままさらにPDFの情報の解析を行うなりできます.

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()の全体です.

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?