워드 파일을 전용으로 개발된 officer
팩키지와 워드 파일에서 표를 추출하는 docxtractr
팩키지도 있는 반면 텍스트 마이닝이나 자연어 처리 분야에서 다양한 텍스트 데이터를 읽어들이기 위해 개발된 textreadr
, readtext
팩키지도 있다.
officer
: officeverse
를 이루는 핵심 팩키지docxtractr
: 워드 문서에서 데이터 표(Data Table)을 추출
xml2
: XML 파일을 파싱하여 XML 표를 추출하는 방식2trinker/textreadr
:quanteda/readtext
officer
officer
팩키지를 사용해서 워드 파일에 담긴 내용을 이해하고 필요한 정보를 추출해 보자. 먼저, officer
팩키지 docx_summary()
함수를 활용하여
library(tidyverse)
library(officer)
finstmt_docx <- officer::read_docx("data/word_files/word_20190102000008_01211649.docx")
finstmt_content <- docx_summary(finstmt_docx) %>% as_tibble()
finstmt_content
# A tibble: 1,104 x 11
doc_index content_type style_name text level num_id row_id is_header cell_id
<int> <chr> <chr> <chr> <dbl> <int> <int> <lgl> <dbl>
1 1 paragraph <NA> "목차"… NA NA NA NA NA
2 2 paragraph <NA> "" NA NA NA NA NA
3 3 paragraph <NA> "에이오… NA NA NA NA NA
4 4 paragraph Body Text "" NA NA NA NA NA
5 5 paragraph <NA> "재 무… NA NA NA NA NA
6 6 paragraph Heading 1 "감사보… NA NA NA NA NA
7 7 paragraph Body Text "" NA NA NA NA NA
8 8 paragraph Body Text "" NA NA NA NA NA
9 9 paragraph Body Text "" NA NA NA NA NA
10 10 paragraph Body Text "" NA NA NA NA NA
# … with 1,094 more rows, and 2 more variables: col_span <dbl>, row_span <dbl>
문서에서 표와 문단이 각각 차지하는 비율을 각각 계산해 보자. 이를 통해 문단이 얼마를 구성하고 있고 표는 몇개가 있는지 등등을 파악할 수 있다.
# tapply(finstmt_content$doc_index, finstmt_content$content_type,
# function(x) length(unique(x)))
finstmt_content %>%
count(doc_index, content_type) %>%
select(-n) %>%
distinct_all() %>%
count(content_type)
# A tibble: 2 x 2
content_type n
<chr> <int>
1 paragraph 343
2 table cell 20
문서에 포함된 표에 대한 기본 통계량을 계산하기 위해 다음과 같이 구성을 일부 변경한다. docxtractr
팩키지에 내장된 docx_describe_tbls()
함수가 있지만 화면에 쭉 뿌리고 없어져 재사용을 위해서 docx_describe_stat_tbls()
함수를 별도 제작해서 복잡도가 높은 표를 문서내에서 검출해 낸다.
library(docxtractr)
finstmt_docx_tbl <- docxtractr::read_docx("data/word_files/word_20190102000008_01211649.docx")
docx_describe_stat_tbls <- function(docx) {
ns <- docx$ns
tbls <- docx$tbls
res_tbl <- list()
for (i in 1:length(tbls)) {
tbl <- tbls[[i]]
cells <- xml2::xml_find_all(tbl, "./w:tr/w:tc", ns=ns)
rows <- xml2::xml_find_all(tbl, "./w:tr", ns=ns)
cell_count_by_row <- purrr::map_int(rows, ~{ length(xml2::xml_find_all(.x, "./w:tc", ns)) })
row_counts <- paste0(unique(cell_count_by_row), collapse=", ")
max_cell_count <- max(cell_count_by_row)
# simplistic test for whether table is uniform rows x cells == cell count
uniform_test <- NULL
if ((max_cell_count * length(rows)) == length(cells)) {
# cat(" uniform : likely!\n")
uniform_test <- TRUE
} else {
# cat(sprintf(
# " uniform : unlikely => found differing cell counts (%s) across some rows\n",
# row_counts))
uniform_test <- FALSE
}
one_tbl <- tibble(tbl_no = i,
total_cell = length(cells),
total_row = length(rows),
is_uniform = uniform_test)
res_tbl[[i]] <- one_tbl
}
res_df <- map_df(res_tbl, rbind)
return(res_df)
}
tbl_info_df <- docx_describe_stat_tbls(finstmt_docx_tbl)
tbl_info_df %>%
arrange(desc(total_cell))
# A tibble: 22 x 4
tbl_no total_cell total_row is_uniform
<int> <int> <int> <lgl>
1 1 148 30 FALSE
2 4 108 22 FALSE
3 21 92 7 FALSE
4 2 78 16 FALSE
5 22 76 17 FALSE
6 16 32 8 FALSE
7 3 28 7 TRUE
8 13 25 4 FALSE
9 11 23 6 FALSE
10 12 18 6 TRUE
# … with 12 more rows
상기 작업을 통해서 1, 4, 21 번 표가 복잡도가 높은 표로 확인이 된다.
금감원 DART에서 제공되는 재무제표에서 몇가지 재무제표가 표 중에서 특히 중요하다.
문서에 포함된 수많은 표를 다음 5가지 범주로 구분하는 분류기를 만든 후에 해당 표를 상기 5가지 유형의 표로 분기한다.
먼저 표에 포함된 텍스트를 추출하는 스크립트를 작성한다. 표를 특정(1
)하여 docx_extract_tbl()
함수를 사용해서 데이터프레임으로 변환시킨다. 그리고 무조건 첫번째 칼럼을 특정하여 한글만 추출하여 텍스트를 구성한다.
bs_df <- docx_extract_tbl(finstmt_docx_tbl, 1, header=TRUE)
bs_df %>%
janitor::clean_names() %>%
pull(var = 1) %>%
str_trim() %>%
str_remove_all(" ") %>%
str_extract(pattern = "[가-힣]+") %>%
str_c(collapse = " ")
[1] "자산 유동자산 당좌자산 현금및현금성자산 선급비용 당기법인세자산 비유동자산 투자자산 매도가능증권 투자채권 자산총계 부채 유동부채 미지급금 미지급비용 단기차입금 비유동부채 장기차입금 전환사채 상환할증금 사채할인발행차금 부채총계 자본 자본금 보통주자본금 결손금 미처리결손금 자본총계 부채및자본총계"
다음 단계로 함수로 만들어 표 번호를 입력하게 되면 표에 포함된 텍스트가 자동으로 추출되도록 작업한다.
extract_text_from_tbl <- function(docx, tbl_no) {
tbl_df <- docx_extract_tbl(docx, tbl_no, header=TRUE) %>%
janitor::clean_names()
coa_txt <- tbl_df %>%
pull(var = 1) %>%
str_trim() %>%
str_remove_all(" ") %>%
str_extract(pattern = "[가-힣]+") %>%
str_c(collapse = " ")
return(coa_txt)
}
extract_text_from_tbl(finstmt_docx_tbl, 3)
[1] "전기초 당기순이익 전기말 당기초 당기순이익 당기말"
마지막으로 모든 표에서 텍스트를 추출한다. 결과를 데이터프레임으로 만들어 후속 작업에 활용한다.
tbl_text <- map_chr(1:docx_tbl_count(finstmt_docx_tbl), extract_text_from_tbl, docx = finstmt_docx_tbl)
tbl_text_df <- tibble(tbl_no = 1:docx_tbl_count(finstmt_docx_tbl),
tbl_text = tbl_text)
tbl_text_df
# A tibble: 22 x 2
tbl_no tbl_text
<int> <chr>
1 1 자산 유동자산 당좌자산 현금및현금성자산 선급비용 당기법인세자산 비유동자산 투자자산 매도가능증권 투자채권 자산총계 부채 유동부…
2 2 매출액 매출원가 매출총이익 판매비와관리비 지급수수료 세금과공과 영업손실 영업외수익 이자수익 잡이익 영업외비용 이자비용 법인세…
3 3 전기초 당기순이익 전기말 당기초 당기순이익 당기말
4 4 영업활동으로인한현금흐름 당기순이익 현금의유출이없는비용등의가산 이자비용 현금의유입이없는수익등의차감 영업활동으로인한자산부채의변동…
5 5 구분 미래에셋대우증권 국민은행 계
6 6 피투자회사명 에이오엔인베스트먼트
7 7 피투자회사명 에이오엔인베스트먼트
8 8 피투자회사명 랜드마크타워
9 9 피투자회사명 랜드마크타워
10 10 차입처 에이오엔비지엔
# … with 12 more rows
정규표현식을 사용해서 각종 재무제표를 앞서 정의한 5가지 유형으로 분류한다.
tbl_text_df <- tbl_text_df %>%
mutate(classify = case_when(str_detect(tbl_text, "자산총계") ~ "재무상태표",
str_detect(tbl_text, "매출액") ~ "손익계산서",
str_detect(tbl_text, "전기초") ~ "자본변동표",
str_detect(tbl_text, "현금흐름") ~ "현금흐름표",
TRUE ~ "기타"
)) %>%
select(tbl_no, classify, tbl_text)
tbl_text_df %>%
mutate(tbl_text = str_sub(tbl_text, start = 1L, end = 30L))
# A tibble: 22 x 3
tbl_no classify tbl_text
<int> <chr> <chr>
1 1 재무상태표 자산 유동자산 당좌자산 현금및현금성자산 선급비용 당기법
2 2 손익계산서 매출액 매출원가 매출총이익 판매비와관리비 지급수수료 세
3 3 자본변동표 전기초 당기순이익 전기말 당기초 당기순이익 당기말
4 4 현금흐름표 영업활동으로인한현금흐름 당기순이익 현금의유출이없는비용등
5 5 기타 구분 미래에셋대우증권 국민은행 계
6 6 기타 피투자회사명 에이오엔인베스트먼트
7 7 기타 피투자회사명 에이오엔인베스트먼트
8 8 기타 피투자회사명 랜드마크타워
9 9 기타 피투자회사명 랜드마크타워
10 10 기타 차입처 에이오엔비지엔
# … with 12 more rows
먼저 재무상태표를 추출하여 내용을 꺼내서 확인해본다.
bs_tbl_no <- tbl_text_df %>%
filter(classify == "재무상태표") %>%
pull(tbl_no)
bs_df <- docx_extract_tbl(finstmt_docx_tbl, bs_tbl_no, header=TRUE)
bs_df %>% DT::datatable()
두번째로 손익계산서를 꺼내서 내용을 확인해본다.
is_tbl_no <- tbl_text_df %>%
filter(classify == "손익계산서") %>%
pull(tbl_no)
is_df <- docx_extract_tbl(finstmt_docx_tbl, is_tbl_no, header=TRUE)
is_df %>% DT::datatable()
마지막으로 현금흐름표를 추출해서 관련 내용을 확인해보자
가장 먼저 워드 템플릿을 생성한다. R마크다운을 사용해서 워드 템플릿을 만들어도 관계없다. 먼저 IEEE에서 워드 템플릿을 다운로드 받아 이를 기본 서식으로 사용한다.
rdocx document with 2 element(s)
* styles:
Normal heading 1 heading 2
"paragraph" "paragraph" "paragraph"
heading 3 heading 4 heading 5
"paragraph" "paragraph" "paragraph"
heading 6 heading 7 heading 8
"paragraph" "paragraph" "paragraph"
heading 9 Default Paragraph Font Normal Table
"paragraph" "character" "table"
No List Abstract Authors
"numbering" "paragraph" "paragraph"
MemberType Title footnote text
"character" "paragraph" "paragraph"
References IndexTerms footnote reference
"paragraph" "paragraph" "character"
footer Text Figure Caption
"paragraph" "paragraph" "paragraph"
Table Title Reference Head header
"paragraph" "paragraph" "paragraph"
Equation Hyperlink FollowedHyperlink
"paragraph" "character" "character"
Body Text Indent Default Paragraph Font1 abs-title
"paragraph" "paragraph" "paragraph"
body-text table-figure-caption footnote
"paragraph" "paragraph" "paragraph"
subsection-title Body Text 본문 Char
"paragraph" "paragraph" "character"
bullet list sponsors paper subtitle
"paragraph" "paragraph" "paragraph"
equation figure caption table col head
"paragraph" "paragraph" "paragraph"
table col subhead table copy table footnote
"paragraph" "paragraph" "paragraph"
table head
"paragraph"
* Content at cursor location:
level num_id text style_name content_type
1 NA NA NA paragraph
재무제표에 큰 제목으로 넣고 나서 재무상태표, 손익계산서, 현금흐름표를 순차적으로 채워넣고자 틀을 잡아둔다.
finstmt_word <- word_template %>%
body_add_par(value = "특정회사 재무제표", style = "heading 1") %>%
body_add_par(value = "재무상태표", style = "heading 2") %>%
body_add_par(value = "손익계산서", style = "heading 2") %>%
body_add_par(value = "현금흐름표", style = "heading 2")
finstmt_word
rdocx document with 6 element(s)
* styles:
Normal heading 1 heading 2
"paragraph" "paragraph" "paragraph"
heading 3 heading 4 heading 5
"paragraph" "paragraph" "paragraph"
heading 6 heading 7 heading 8
"paragraph" "paragraph" "paragraph"
heading 9 Default Paragraph Font Normal Table
"paragraph" "character" "table"
No List Abstract Authors
"numbering" "paragraph" "paragraph"
MemberType Title footnote text
"character" "paragraph" "paragraph"
References IndexTerms footnote reference
"paragraph" "paragraph" "character"
footer Text Figure Caption
"paragraph" "paragraph" "paragraph"
Table Title Reference Head header
"paragraph" "paragraph" "paragraph"
Equation Hyperlink FollowedHyperlink
"paragraph" "character" "character"
Body Text Indent Default Paragraph Font1 abs-title
"paragraph" "paragraph" "paragraph"
body-text table-figure-caption footnote
"paragraph" "paragraph" "paragraph"
subsection-title Body Text 본문 Char
"paragraph" "paragraph" "character"
bullet list sponsors paper subtitle
"paragraph" "paragraph" "paragraph"
equation figure caption table col head
"paragraph" "paragraph" "paragraph"
table col subhead table copy table footnote
"paragraph" "paragraph" "paragraph"
table head
"paragraph"
* Content at cursor location:
level num_id text style_name content_type
1 NA NA 현금흐름표 heading 2 paragraph
이제 주요 재무제표를 데이터프레임으로 만들었는데 이를 워드 파일에 끼워넣어 재무제표 워드 파일을 목적에 맞춰 깔끔하게 다시 제작한다. cursor_reach()
함수로 문서 내 커서의 위치를 특정하고 난 후 body_add_table()
함수로 데이터프레임을 표로 변환시켜 워드 문서에 포함시킨다.
finstmt_word <- finstmt_word %>%
cursor_reach(keyword = "재무상태표") %>%
body_add_table(bs_df, first_column = TRUE) %>%
body_add_break() %>%
cursor_reach(keyword = "손익계산서") %>%
body_add_table(is_df, first_column = TRUE) %>%
body_add_break() %>%
cursor_reach(keyword = "현금흐름표") %>%
body_add_table(cf_df, first_column = TRUE) %>%
body_add_break()
finstmt_word %>%
docx_show_chunk()
데이터 과학자 이광춘 저작
kwangchun.lee.7@gmail.com