요약
이 글은 HWPX 문서 포맷의 내부 구조를 “한/글 문서 파일 형식 : HWPX 포맷 구조 살펴보기”와 KS X 6101 표준 문서를 바탕으로 이해하고, 이를 통해 문서의 일부 데이터를 추출하여 Python 객체(Document)로 구조화하는 과정을 실제 문서와 예제 코드를 통해 살펴봅니다. ZIP 기반 XML 파일에서 메타정보, 커서 위치, 폰트 정보, 바이너리 데이터 목록 등을 추출하는 방법을 상세히 설명하며, HWPX 문서 내부 구조 분석의 기초를 제공합니다.
서론
“한/글 문서 파일 형식 : HWPX 포맷 구조 살펴보기”를 통해 HWPX 문서의 내부 구조를 이해했다면, 이제 실제 문서에서 데이터를 추출할 수 있습니다.
“한/글 문서 파일 형식 : HWPX 포맷 구조 살펴보기“에 대한 자세한 내용은 아래 블로그를 참고 부탁드리겠습니다.
🔗 한/글 문서 파일 형식 : HWPX 포맷 구조 살펴보기
✍ 작성자 : 한글개발팀 김규리 님
이번 글에서는 HWPX 표준 문서(KS X 6101)를 포함해 공개된 HWPX 포맷 정보를 바탕으로, 문서 데이터를 Python 코드 예제를 통해 추출하는 방법을 알아보겠습니다.
* 표준 문서는 e-나라표준인증에서 확인 가능합니다.
* 수록된 예제 코드는 Python 내장 라이브러리만을 사용하여 작성되었으며, 데이터 추출 과정을 설명하기 위해 간단하게 작성된 점 참고 부탁드립니다.
사용할 라이브러리
이전 글에서 설명했듯이, HWPX는 ZIP 파일 구조를 가진 XML 기반 포맷입니다.
따라서 파일의 내용을 확인하려면 (1) 압축을 해제하고, (2) 내부 XML 문서를 분석하는 과정이 필요합니다.
다행히 이 모든 기능은 Python에 기본적으로 내장되어 있어 별도의 라이브러리를 설치하지 않아도 됩니다.
예제 코드에서는 아래의 두 가지 표준 라이브러리를 사용합니다.
import zipfile #(1)
import xml.etree.ElementTree as ET #(2)
추출 데이터 모델 구조
HWP의 ‘DocInfo’에 해당하는 정보들이 HWPX에서는 metadata.xml이나 settings.xml 등 여러 파일에 분산 저장되어 있습니다.
이렇게 분산된 정보를 매번 파일에서 직접 읽어오는 것은 비효율적이므로, 필요한 정보를 미리 추출해 하나로 모아둘 데이터 모델(Data Model)을 먼저 정의하겠습니다.
# 커서(Caret)의 위치 정보
@dataclass
class CaretPosition:
list_id: int
paragraph_id: int
char_pos: int
# 문서의 메타 정보 및 설정값을 담을 모델
@dataclass
class Document:
sectionCount: int = 0
pageStartNum: int = 0
footnoteStartNum: int = 0
endnoteStartNum: int = 0
pictureStartNum: int = 0
tableStartNum: int = 0
equationStartNum: int = 0
caretPos: CaretPosition = field(default_factory=lambda: CaretPosition(0, 0, 0))
binaryDataCount: int = 0
hangulFontDataCount: int = 0
englishFontDataCount: int = 0
이제 각 파일에서 정보를 읽어와 위 Document 모델의 필드를 채우는 과정을 살펴보겠습니다.
데이터 추출
1. ZIP 파일 읽기
우선, zipfile 라이브러리를 사용해 HWPX 파일을 ZIP 파일 형태로 읽어옵니다.
@classmethod
def read_hwpx_document(cls, file_path: str) -> bool:
# HWPX 파일 읽기
zipf = zipfile.ZipFile(file_path, 'r')
# ... 생략 ...
2. XML Namespace 추출
표준 문서에 설명되어 있듯이, 문서 버전에 따라 XML Namespace가 달라질 수 있습니다.
따라서 별도로 구현한 extract_namespaces 함수를 통해 현재 문서에 저장된 Namespace를 추출하여 파싱 과정에 사용하도록 코드를 작성했습니다.
# namespace 추출
def extract_namespaces(xml_str):
events = ('start-ns',)
namespaces = {}
for _, elem in ET.iterparse(xml_str, events):
prefix, uri = elem
namespaces[prefix] = uri
return namespaces
3. 파일 구조 확인
표준 문서나 실제 파일을 참고해 ZIP 파일 내에서 데이터를 꺼내올 XML 파일 위치를 파악합니다.

패키징 정보가 담겨있는 Contents/content.hpf 파일에서 spine에 저장된 순서대로 파일을 읽어야하지만, 이번 예시에서는 각 파일에 접근하여 정보를 추출하는 과정 위주로 설명하려고 합니다.
4. Contents/header.xml 파일 추출
HWPX의 여러 파일 중에서도 Contents/header.xml은 문서의 전반적인 정보와 모양 속성을 담고 있는 파일입니다.
이번 장에서는 이 파일을 열어 구조를 살펴보고, 앞서 정의한 Document 데이터 모델을 채우기 위한 정보를 어떻게 추출하는지 알아보겠습니다.
4.1 헤더 구조
head 코드 예제
@classmethod
def read_header(cls, zipf:zipfile) -> bool:
# Contents/header.xml 파일 읽기
if XML_FILENAME_HEADER not in zipf.namelist(): # (1)
return False
# XML_FILENAME_HEADER : 'Contents/header.xml'
ns = extract_namespaces(BytesIO(zipf.read(XML_FILENAME_HEADER))) # (2)
if ns == None:
return False
xml_header = zipf.open(XML_FILENAME_HEADER) # (3)
header = ET.parse(xml_header).getroot() # (4)
먼저,
(1) zipf.namelist()를 통해 ZIP 파일에 저장된 파일 목록에 Contents/header.xml이 존재하는지 확인하고,
(2) Namespace를 추출합니다.
그 후,
(3) ZipFile에서 해당 파일을 꺼내오고,
(4) ElementTree로 파싱하면 header 변수에는 최상위 요소가 저장됩니다.
이제 이 header 요소에서 직접 정보를 추출할 수 있습니다.
가장 먼저 문서의 전체 구역 수를 나타내는 secCnt 속성부터 가져와 보겠습니다.
# 구역 정보
cls.sectionCount = header.get('secCnt')
최상위 요소의 속성을 먼저 가져왔으니, 이제 하위 요소들을 하나씩 탐색해 보겠습니다.
head 실제 저장 내용과 표준 문서
실제 파일에서 보면 Contents/header.xml은 다음과 같은 형태로 저장되어 있습니다.
<hh:head xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" xmlns:hp10="http://www.hancom.co.kr/hwpml/2016/paragraph" xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section" xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core" xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" xmlns:hhs="http://www.hancom.co.kr/hwpml/2011/history" xmlns:hm="http://www.hancom.co.kr/hwpml/2011/master-page" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf/" xmlns:ooxmlchart="http://www.hancom.co.kr/hwpml/2016/ooxmlchart" xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar" xmlns:epub="http://www.idpf.org/2007/ops" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0" version="1.5" secCnt="1">
<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>
<hh:refList>
<hh:fontfaces itemCnt="7">...</hh:fontfaces>
<hh:borderFills itemCnt="2">...</hh:borderFills>
<hh:charProperties itemCnt="7">...</hh:charProperties>
<hh:tabProperties itemCnt="3">...</hh:tabProperties>
<hh:numberings itemCnt="1">...</hh:numberings>
<hh:paraProperties itemCnt="20">...</hh:paraProperties>
<hh:styles itemCnt="22">...</hh:styles>
</hh:refList>
<hh:compatibleDocument targetProgram="HWP201X">
<hh:layoutCompatibility/>
</hh:compatibleDocument>
<hh:docOption>
<hh:linkinfo path="" pageInherit="0" footnoteInherit="0"/>
</hh:docOption>
<hh:trackchageConfig flags="56"/>
<hh:metaTag name="TestFile"/>
</hh:head>
각 요소와 속성의 의미를 알기 위해서는 표준 문서를 확인해야 합니다.
header 하위 요소 이름 | 설명 |
---|---|
beginNum | 문서 내에서 각종 객체들의 시작 번호 정보를 가지고 있는 요소 |
refList | 본문에서 사용될 각종 데이터에 대한 맵핑 정보를 가지고 있는 요소 |
forbiddenWordList | 금칙 문자 목록을 가지고 있는 요소 |
compatibleDocument | 문서 호환성 설정 |
trackchangeConfig | 변경 추적 정보와 암호 정보를 가지고 있는 요소 |
docOption | 연결 문서 정보와 저작권 관련 정보를 가지고 있는 요소 |
metaTag | 메타태그 정보를 가지고 있는 요소 |
이런 구조를 파악하면, 필요한 정보의 위치를 알고 해당 데이터에 접근할 수 있습니다.
우리는 Document 모델을 채워야 하기 때문에 beginNum과 refList가 필요합니다.
4.2 beginNum 요소
beginNum 실제 저장 내용과 표준 문서
저장된 beginNum과 각 속성의 의미를 먼저 확인해 보면, beginNum 요소에는 문서 내에서 사용되는 각 객체들의 시작 번호가 담겨있다는 것을 알 수 있습니다.
<hh:beginNum page=“1” footnote=“1” endnote=“1” pic=“1” tbl=“1” equation=“1”/>
beginNum 속성 이름 | 설명 |
---|---|
page | 페이지 시작 번호 |
footnote | 각주 시작 번호 |
endnote | 미주 시작 번호 |
pic | 그림 시작 번호 |
tbl | 표 시작 번호 |
equation | 수식 시작 번호 |
beginNum 코드 예제
확인된 정보를 바탕으로 코드를 작성하여 데이터를 채워줍니다.
# 시작 번호
# ns : extract_namespaces 함수로 추출된 namespace가 담겨있는 변수
begin_num = header.find('hh:beginNum', ns)
# beginNum 속성 추출
cls.pageStartNum = begin_num.get('page')
cls.footnoteStartNum = begin_num.get('footnote')
cls.endnoteStartNum = begin_num.get('endnote')
cls.pictureStartNum = begin_num.get('pic')
cls.tableStartNum = begin_num.get('tbl')
cls.equationStartNum = begin_num.get('equation')
4.3 refList 요소
refList 실제 저장 내용과 표준 문서
저장된 refList에는 문서 내에서 사용되는 모양 정보 등이 담겨있습니다.
<hh:refList>
<hh:fontfaces itemCnt="7">...</hh:fontfaces>
<hh:borderFills itemCnt="2">...</hh:borderFills>
<hh:charProperties itemCnt="7">...</hh:charProperties>
<hh:tabProperties itemCnt="3">...</hh:tabProperties>
<hh:numberings itemCnt="1">...</hh:numberings>
<hh:paraProperties itemCnt="20">...</hh:paraProperties>
<hh:styles itemCnt="22">...</hh:styles>
</hh:refList>
refList 하위 요소 이름 | 설명 |
---|---|
fontfaces | 글꼴 정보 목록 |
borderFills | 테두리/배경/채우기 정보 목록 |
charProperties | 글자 모양 목록 |
tabProperties | 탭 정의 목록 |
numberings | 번호 문단 모양 목록 |
bullets | 글머리표 문단 모양 목록 |
paraProperties | 문단 모양 목록 |
styles | 스타일 목록 |
memoProperties | 메모 모양 목록 |
trackChanges | 변경 추적 정보 목록 |
trackChangeAuthors | 변경 추적 검토자 목록 |
refList의 fontface에 폰트 정보가 있고, 이를 실제 파일에서 다시 확인해보면 아래와 같은 내용을 확인할 수 있습니다.
<hh:fontfaces itemCnt="7">
<hh:fontface lang="HANGUL" fontCnt="2">
<hh:font id="0" face="함초롬돋움" type="TTF" isEmbedded="0">
<hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/>
</hh:font>
<hh:font id="1" face="함초롬바탕" type="TTF" isEmbedded="0">
<hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/>
</hh:font>
</hh:fontface>
<hh:fontface lang="LATIN" fontCnt="2">
<hh:font id="0" face="함초롬돋움" type="TTF" isEmbedded="0">
<hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/>
</hh:font>
<hh:font id="1" face="함초롬바탕" type="TTF" isEmbedded="0">
<hh:typeInfo familyType="FCAT_GOTHIC" weight="6" proportion="4" contrast="0" strokeVariation="1" armStyle="1" letterform="1" midline="1" xHeight="1"/>
</hh:font>
</hh:fontface>
<!--...생략...-->
</hh:fontfaces>
fontface 코드 예제
# 폰트 정보
ref_list = header.find('hh:refList', ns)
font_faces = ref_list.find('hh:fontfaces', ns)
for item in font_faces.findall('hh:fontface', ns):
font_lang = item.get('lang')
font_count = item.get('fontCnt')
if font_lang == 'HANGUL':
cls.hangulFontDataCount = font_count
elif font_lang == 'LATIN':
cls.englishFontDataCount = font_count
현재 코드 상으로는 저장된 폰트 개수만을 읽어왔지만, 하위 요소에서는 언어별로 어떤 폰트가 저장되어 있는지도 확인할 수 있습니다. 이 폰트 목록은 charProperties와 연결되어 본문 텍스트에 글자 모양으로 적용됩니다.
5. settings.xml 파일 추출
위에 설명한 것과 동일한 방법으로, settings.xml 파일의 최상위 요소를 확인합니다.
5.1 CaretPosition 요소
CaretPosition 실제 저장 내용과 표준 문서
<ha:HWPApplicationSetting xmlns:ha=“http://www.owpml.org/owpml/2023/app” xmlns:config=“urn:oasis:names:tc:opendocument:xmlns:config:1.0”>
<ha:CaretPosition listIDRef=“0” paraIDRef=“12” pos=“6”/>
</ha:HWPApplicationSetting>
CaretPosition 속성 이름 | 설명 |
---|---|
listIDRef | 리스트 아이디 |
paraIDRef | 문단 아이디 |
pos | 문단 내의 글자 위치 |
문서 내용을 바탕으로 CaretPosition 요소에서 저장된 커서 위치 정보를 추출할 수 있습니다.
CaretPosition 코드 예제
@classmethod
def read_settings(cls, zipf:zipfile) -> bool:
# settings.xml 파일 읽기
# ... 생략 ...
# 커서 정보
caret_pos = settings.find('ha:CaretPosition', ns)
list_id = caret_pos.get('listIDRef')
para_id = caret_pos.get('paraIDRef')
char_pos = caret_pos.get('pos')
cls.caretPos = CaretPosition(list_id, para_id, char_pos)
return True
6. Contents/content.hpf 파일 추출
Contents/content.hpf 파일은 OPF 표준을 따르며, 패키징 주요 파일 목록을 담고 있습니다.
특히 manifest 요소에서 문서에 포함된 이미지, OLE 객체와 같은 바이너리 데이터의 목록을 얻을 수 있습니다.
6.1 Manifest 요소
manifest 실제 저장 내용
<opf:manifest>
<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>
<opf:item id="image1" href="BinData/image1.PNG" media-type="image/png" isEmbeded="1"/>
<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>
<opf:item id="settings" href="settings.xml" media-type="application/xml"/>
</opf:manifest>
manifest 코드 예제
문서에 포함된 바이너리 데이터 목록을 확인하고, 해당 데이터에 접근할 수 있도록 binary_data_list를 채우는 예제입니다.
@classmethod
def read_binarydata(cls, zipf:zipfile) -> bool:
# Contents/content.hpf 파일 읽기
# ... 생략 ...
# 파일 목록
manifest = content.find('opf:manifest', ns)
binary_data_list = []
for item in manifest.findall('opf:item', ns):
href = item.get('href')
# 바이너리 데이터 파일 목록
if href.startswith('BinData/'):
binary_data_list.append(href)
cls.binaryDataCount = len(binary_data_list)
return True
현재 코드에서는 binary_data_list를 채우고 Document 모델을 채우기 위해 이 목록의 전체 개수를 세어 binaryDataCount에 저장하는 역할만 수행했지만, 해당 경로에 접근하여 실제 이미지를 별도의 이미지 파일로 저장하는 것도 가능합니다.
예를 들어, 리스트에 ‘BinData/image1.png’라는 경로가 있다면, zipfile 객체에서 이 경로로 실제 이미지의 바이너리 데이터를 읽어와 파일로 쓸 수 있습니다.
바이너리 데이터들은 본문 텍스트 내의 특정 태그와 BinData 폴더 안의 실제 파일로 연결되어, 문서 내용을 구성하게 됩니다.
마치며
지금까지 “한/글 문서 파일 형식 : HWPX 포맷 구조 살펴보기”와 KS X 6101 표준 문서에서 확인한 HWPX 파일의 내부 구조를 통해, 문서의 일부 데이터를 추출하고 Python 객체(Document)로 구조화하는 과정을 실제 문서와 예시 코드로 살펴보았습니다.
다음 글에서는 문서에서 가장 중요한 본문 내용이 담긴 Section 파일을 분석하여 단락과 텍스트, 표의 내용을 추출하는 방법을 알아보겠습니다.
HWPX 파일의 내부를 이해하고 다루는 데 도움이 되었길 바랍니다. 감사합니다.