v8:문서유형 분석등록 및 추출_20260206

This commit is contained in:
2026-02-20 11:46:52 +09:00
parent db6532b33c
commit c3e9e29205
57 changed files with 22138 additions and 1421 deletions

View File

@@ -0,0 +1,529 @@
# -*- coding: utf-8 -*-
"""
content_order.py — HWPX section*.xml 본문 콘텐츠 순서 추출
기존 12개 tool이 header.xml의 "정의(definition)"를 추출하는 반면,
이 tool은 section0.xml의 "본문(content)" 순서를 추출한다.
추출 결과는 template_manager._build_body_html()이
원본 순서 그대로 HTML을 조립하는 데 사용된다.
콘텐츠 유형:
- paragraph : 일반 텍스트 문단
- table : 표 (<hp:tbl>)
- image : 이미지 (<hp:pic>)
- empty : 빈 문단 (줄바꿈 역할)
참조: hwpx_domain_guide.md §6(표), §7(본문 구조)
"""
import re
import logging
logger = logging.getLogger(__name__)
# ================================================================
# 네임스페이스
# ================================================================
# HWPX는 여러 네임스페이스를 사용한다.
# section*.xml: hp: (본문), ha: (속성)
# header.xml: hh: (헤더 정의)
# 실제 파일에서 네임스페이스 URI가 다를 수 있으므로 로컬명 기반 탐색도 병행한다.
DEFAULT_NS = {
'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph',
'ha': 'http://www.hancom.co.kr/hwpml/2011/attributes',
'hh': 'http://www.hancom.co.kr/hwpml/2011/head',
'hc': 'http://www.hancom.co.kr/hwpml/2011/core',
}
# ================================================================
# 공개 API
# ================================================================
def extract(raw_xml, parsed, ns=None):
"""section*.xml에서 본문 콘텐츠 순서를 추출한다.
Args:
raw_xml (dict): 원본 XML 문자열 딕셔너리.
raw_xml.get("section0") 등으로 section XML에 접근.
parsed (dict): processor.py가 HWPX를 파싱한 전체 결과 dict.
parsed.get("section_xml") 등으로 parsed Element에 접근.
ns (dict, optional): 네임스페이스 매핑. None이면 자동 감지.
Returns:
list[dict]: 콘텐츠 순서 리스트. 각 항목은 다음 키를 포함:
- type: "paragraph" | "table" | "image" | "empty"
- index: 전체 순서 내 인덱스 (0부터)
- paraPrIDRef: 문단모양 참조 ID (str or None)
- styleIDRef: 스타일 참조 ID (str or None)
+ type별 추가 키 (아래 참조)
추출 실패 시 None 반환 (analyzer가 결과에서 제외함).
"""
# ── section XML 찾기 ──
# raw_xml dict에서 section 원본 문자열 추출
section_raw = None
if isinstance(raw_xml, dict):
# 키 이름은 프로젝트마다 다를 수 있음: section0, section_xml 등
for key in ['section0', 'section_xml', 'section0.xml']:
if key in raw_xml:
section_raw = raw_xml[key]
break
# 못 찾으면 "section"으로 시작하는 첫 번째 키
if section_raw is None:
for key, val in raw_xml.items():
if key.startswith('section') and isinstance(val, str):
section_raw = val
break
elif isinstance(raw_xml, str):
section_raw = raw_xml
# parsed dict에서 section Element 또는 문자열 추출
section_parsed = None
if isinstance(parsed, dict):
for key in ['section_xml', 'section0', 'section_parsed', 'section0_parsed']:
val = parsed.get(key)
if val is None:
continue
if isinstance(val, str):
# 문자열이면 section_raw로 활용 (table.py와 동일)
if section_raw is None:
section_raw = val
elif not isinstance(val, dict):
# Element 객체로 추정
section_parsed = val
break
# fallback: raw_xml 문자열을 직접 파싱
if section_parsed is None and section_raw:
import xml.etree.ElementTree as ET
try:
section_parsed = ET.fromstring(section_raw)
except ET.ParseError:
logger.warning("section XML 파싱 실패")
return None
else:
# parsed 자체가 Element일 수 있음 (직접 호출 시)
section_parsed = parsed
if section_parsed is None:
logger.warning("section XML을 찾을 수 없음 — content_order 추출 생략")
return None
if ns is None:
ns = _detect_namespaces(section_raw or '', section_parsed)
# <hp:p> 엘리먼트 수집 — secPr 내부는 제외
paragraphs = _collect_body_paragraphs(section_parsed, ns)
content_order = []
table_idx = 0
image_idx = 0
for p_elem in paragraphs:
para_pr_id = _get_attr(p_elem, 'paraPrIDRef')
style_id = _get_attr(p_elem, 'styleIDRef')
base = {
'index': len(content_order),
'paraPrIDRef': para_pr_id,
'styleIDRef': style_id,
}
# ── (1) 표 확인 ──
tbl = _find_element(p_elem, 'tbl', ns)
if tbl is not None:
tbl_info = _extract_table_info(tbl, ns)
content_order.append({
**base,
'type': 'table',
'table_idx': table_idx,
**tbl_info,
})
table_idx += 1
continue
# ── (2) 이미지 확인 ──
pic = _find_element(p_elem, 'pic', ns)
if pic is not None:
img_info = _extract_image_info(pic, p_elem, ns)
content_order.append({
**base,
'type': 'image',
'image_idx': image_idx,
**img_info,
})
image_idx += 1
continue
# ── (3) 텍스트 문단 / 빈 문단 ──
text = _collect_text(p_elem, ns)
runs_info = _extract_runs_info(p_elem, ns)
if not text.strip():
content_order.append({
**base,
'type': 'empty',
})
else:
content_order.append({
**base,
'type': 'paragraph',
'text': text,
'charPrIDRef': runs_info.get('first_charPrIDRef'),
'runs': runs_info.get('runs', []),
})
logger.info(
"content_order 추출 완료: %d items "
"(paragraphs=%d, tables=%d, images=%d, empty=%d)",
len(content_order),
sum(1 for c in content_order if c['type'] == 'paragraph'),
table_idx,
image_idx,
sum(1 for c in content_order if c['type'] == 'empty'),
)
return content_order
# ================================================================
# 본문 <hp:p> 수집 — secPr 내부 제외
# ================================================================
def _collect_body_paragraphs(root, ns):
"""<hp:sec> 직계 <hp:p> 만 수집한다.
secPr, headerFooter 내부의 <hp:p>는 본문이 아니므로 제외.
subList 내부(셀 안 문단)도 제외 — 표는 통째로 하나의 항목.
"""
paragraphs = []
# 방법 1: sec 직계 자식 중 p 태그만
sec = _find_element(root, 'sec', ns)
if sec is None:
# 루트 자체가 sec일 수 있음
sec = root
for child in sec:
tag = _local_tag(child)
if tag == 'p':
paragraphs.append(child)
# 직계 자식에서 못 찾았으면 fallback: 전체 탐색 (but secPr/subList 제외)
if not paragraphs:
paragraphs = _collect_paragraphs_fallback(root, ns)
return paragraphs
def _collect_paragraphs_fallback(root, ns):
"""fallback: 전체에서 <hp:p>를 찾되, secPr/headerFooter/subList 내부는 제외"""
skip_tags = {'secPr', 'headerFooter', 'subList', 'tc'}
result = []
def _walk(elem, skip=False):
if skip:
return
tag = _local_tag(elem)
if tag in skip_tags:
return
if tag == 'p':
# 부모가 sec이거나 루트 직계인 경우만
result.append(elem)
return # p 내부의 하위 p는 수집하지 않음
for child in elem:
_walk(child)
_walk(root)
return result
# ================================================================
# 표 정보 추출
# ================================================================
def _extract_table_info(tbl, ns):
"""<hp:tbl> 에서 기본 메타 정보 추출"""
info = {
'rowCnt': _get_attr(tbl, 'rowCnt'),
'colCnt': _get_attr(tbl, 'colCnt'),
'borderFillIDRef': _get_attr(tbl, 'borderFillIDRef'),
}
# 열 너비
col_sz = _find_element(tbl, 'colSz', ns)
if col_sz is not None:
width_list_elem = _find_element(col_sz, 'widthList', ns)
if width_list_elem is not None and width_list_elem.text:
info['colWidths'] = width_list_elem.text.strip().split()
return info
# ================================================================
# 이미지 정보 추출
# ================================================================
def _extract_image_info(pic, p_elem, ns):
"""<hp:pic> 에서 이미지 참조 정보 추출"""
info = {
'binaryItemIDRef': None,
'text': '', # 이미지와 같은 문단에 있는 텍스트 (캡션 등)
}
# img 태그에서 binaryItemIDRef
img = _find_element(pic, 'img', ns)
if img is not None:
info['binaryItemIDRef'] = _get_attr(img, 'binaryItemIDRef')
# imgRect에서 크기 정보
img_rect = _find_element(pic, 'imgRect', ns)
if img_rect is not None:
info['imgRect'] = {
'x': _get_attr(img_rect, 'x'),
'y': _get_attr(img_rect, 'y'),
'w': _get_attr(img_rect, 'w'),
'h': _get_attr(img_rect, 'h'),
}
# 같은 문단 내 텍스트 (pic 바깥의 run들)
info['text'] = _collect_text_outside(p_elem, pic, ns)
return info
# ================================================================
# 텍스트 수집
# ================================================================
def _collect_text(p_elem, ns):
"""<hp:p> 내 모든 <hp:t> 텍스트를 순서대로 합침
주의: t.tail은 XML 들여쓰기 공백이므로 수집하지 않는다.
HWPX에서 실제 텍스트는 항상 <hp:t>...</hp:t> 안에 있다.
"""
parts = []
for t in _find_all_elements(p_elem, 't', ns):
if t.text:
parts.append(t.text)
return ''.join(parts)
def _collect_text_outside(p_elem, exclude_elem, ns):
"""p_elem 내에서 exclude_elem(예: pic) 바깥의 텍스트만 수집"""
parts = []
def _walk(elem):
if elem is exclude_elem:
return
tag = _local_tag(elem)
if tag == 't' and elem.text:
parts.append(elem.text)
for child in elem:
_walk(child)
_walk(p_elem)
return ''.join(parts)
# ================================================================
# Run 정보 추출
# ================================================================
def _extract_runs_info(p_elem, ns):
"""<hp:p> 내 <hp:run> 들의 charPrIDRef와 텍스트 추출
Returns:
{
'first_charPrIDRef': str or None,
'runs': [
{'charPrIDRef': '8', 'text': '1. SamanPro...'},
{'charPrIDRef': '24', 'text': '포장설계...'},
]
}
"""
runs = []
first_char_pr = None
for run_elem in _find_direct_runs(p_elem, ns):
char_pr = _get_attr(run_elem, 'charPrIDRef')
if first_char_pr is None and char_pr is not None:
first_char_pr = char_pr
text_parts = []
for t in _find_all_elements(run_elem, 't', ns):
if t.text:
text_parts.append(t.text)
if text_parts:
runs.append({
'charPrIDRef': char_pr,
'text': ''.join(text_parts),
})
return {
'first_charPrIDRef': first_char_pr,
'runs': runs,
}
def _find_direct_runs(p_elem, ns):
"""<hp:p> 직계 <hp:run>만 찾음 (subList 내부 제외)"""
results = []
for child in p_elem:
tag = _local_tag(child)
if tag == 'run':
results.append(child)
return results
# ================================================================
# 네임스페이스 감지
# ================================================================
def _detect_namespaces(raw_xml, parsed):
"""XML에서 실제 사용된 네임스페이스 URI를 감지한다.
HWPX 버전에 따라 네임스페이스 URI가 다를 수 있다:
- 2011 버전: http://www.hancom.co.kr/hwpml/2011/paragraph
- 2016 버전: http://www.hancom.co.kr/hwpml/2016/paragraph (일부)
"""
ns = dict(DEFAULT_NS)
if raw_xml:
# xmlns:hp="..." 패턴으로 실제 URI 추출
for prefix in ['hp', 'ha', 'hh', 'hc']:
pattern = rf'xmlns:{prefix}="([^"]+)"'
match = re.search(pattern, raw_xml)
if match:
ns[prefix] = match.group(1)
return ns
# ================================================================
# XML 유틸리티 — 네임스페이스 불가지론적 탐색
# ================================================================
def _local_tag(elem):
"""'{namespace}localname''localname'"""
tag = elem.tag
if '}' in tag:
return tag.split('}', 1)[1]
return tag
def _get_attr(elem, attr_name):
"""속성값 가져오기. 네임스페이스 유무 모두 시도."""
# 직접 속성명
val = elem.get(attr_name)
if val is not None:
return val
# 네임스페이스 접두사가 붙은 속성 시도
for full_attr in elem.attrib:
if full_attr.endswith(attr_name):
return elem.attrib[full_attr]
return None
def _find_element(parent, local_name, ns):
"""자식 중 로컬명이 일치하는 첫 번째 엘리먼트를 찾는다.
네임스페이스 prefix 시도 후, 실패하면 로컬명 직접 비교.
"""
# 1차: 네임스페이스 prefix로 탐색
for prefix in ['hp', 'hh', 'hc', 'ha']:
uri = ns.get(prefix, '')
found = parent.find(f'{{{uri}}}{local_name}')
if found is not None:
return found
# 2차: 직계 자식 로컬명 비교
for child in parent:
if _local_tag(child) == local_name:
return child
# 3차: 재귀 탐색 (1단계만)
for child in parent:
for grandchild in child:
if _local_tag(grandchild) == local_name:
return grandchild
return None
def _find_all_elements(parent, local_name, ns):
"""하위 전체에서 로컬명이 일치하는 모든 엘리먼트를 찾는다."""
results = []
def _walk(elem):
if _local_tag(elem) == local_name:
results.append(elem)
for child in elem:
_walk(child)
_walk(parent)
return results
# ================================================================
# 편의 함수
# ================================================================
def summarize(content_order):
"""content_order 리스트를 사람이 읽기 쉬운 요약으로 변환"""
lines = []
for item in content_order:
idx = item['index']
t = item['type']
if t == 'paragraph':
text_preview = item['text'][:50]
if len(item['text']) > 50:
text_preview += '...'
lines.append(
f"[{idx:3d}] P paraPr={item['paraPrIDRef']:<4s} "
f"charPr={item.get('charPrIDRef', '-'):<4s} "
f"\"{text_preview}\""
)
elif t == 'table':
lines.append(
f"[{idx:3d}] T table_idx={item['table_idx']} "
f"({item.get('rowCnt', '?')}×{item.get('colCnt', '?')})"
)
elif t == 'image':
ref = item.get('binaryItemIDRef', '?')
caption = item.get('text', '')[:30]
lines.append(
f"[{idx:3d}] I image_idx={item['image_idx']} "
f"ref={ref} \"{caption}\""
)
elif t == 'empty':
lines.append(f"[{idx:3d}] _ (empty)")
return '\n'.join(lines)
def get_stats(content_order):
"""content_order 통계 반환"""
type_map = {
'paragraph': 'paragraphs',
'table': 'tables',
'image': 'images',
'empty': 'empty',
}
stats = {
'total': len(content_order),
'paragraphs': 0,
'tables': 0,
'images': 0,
'empty': 0,
}
for item in content_order:
key = type_map.get(item['type'])
if key:
stats[key] += 1
return stats