529 lines
16 KiB
Python
529 lines
16 KiB
Python
# -*- 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 |