# -*- 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 : 표 () - image : 이미지 () - 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) # 엘리먼트 수집 — 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) # ★ 인라인 아이콘이면 텍스트와 합쳐서 paragraph로 처리 if img_info.get('is_inline_icon') and img_info.get('text'): content_order.append({ **base, 'type': 'paragraph', 'text': img_info['text'].strip(), 'charPrIDRef': None, 'has_icon': True, }) continue # 일반 이미지 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 # ================================================================ # 본문 수집 — secPr 내부 제외 # ================================================================ def _collect_body_paragraphs(root, ns): """ 직계 만 수집한다. secPr, headerFooter 내부의 는 본문이 아니므로 제외. 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: 전체에서 를 찾되, 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): """ 에서 기본 메타 정보 추출""" 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): """ 에서 이미지 참조 정보 추출""" info = { 'binaryItemIDRef': None, 'text': '', 'is_inline_icon': False, # ★ 추가 } # img 태그에서 binaryItemIDRef img = _find_element(pic, 'img', ns) if img is not None: info['binaryItemIDRef'] = _get_attr(img, 'binaryItemIDRef') # ★ pos 태그에서 treatAsChar 확인 — 인라인 아이콘 판별 pos = _find_element(pic, 'pos', ns) if pos is not None: treat_as_char = _get_attr(pos, 'treatAsChar') if treat_as_char == '1': info['is_inline_icon'] = True # 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): """ 내 모든 텍스트를 순서대로 합침 주의: t.tail은 XML 들여쓰기 공백이므로 수집하지 않는다. HWPX에서 실제 텍스트는 항상 ... 안에 있다. """ 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): """ 들의 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): """ 직계 만 찾음 (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